diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 0d6bdf1..662a2bf 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -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). // diff --git a/cmd/mesh-controller/checks.go b/cmd/mesh-controller/checks.go new file mode 100644 index 0000000..26b6b6a --- /dev/null +++ b/cmd/mesh-controller/checks.go @@ -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) + } +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index ee74415..85e0873 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -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. diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go new file mode 100644 index 0000000..2bd68c5 --- /dev/null +++ b/cmd/mesh-controller/merge_gate.go @@ -0,0 +1,1095 @@ +package main + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/artifacts" + "github.com/novox/mesh-controller/internal/catalogue" + snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/store" +) + +// The merge gate (novox/hq to-be 45 §9, ADR 0227 rule 9): **every machine of the mesh composed with the +// change, and the node-engine's own validator run over each, before the change merges.** +// +// A change is judged against the mesh as the facts snapshot says it runs, twice: once as it is (the +// base) and once with the change applied, each in a throwaway store raised from the snapshot through +// the controller's own code — the same registration, assignment, seats, pins and settings a running +// controller keeps, and the same composition a push makes. What the change breaks is what composes or +// validates in the base and not with the change, named by the machine's role and the module; what was +// already broken is said and does not fail the change. +// +// What it catches, by the incidents it was written from: +// - a manifest the node-engine refuses whole, found only by assigning it to a live machine (236); +// - an identity a real machine's name makes too long for a provision, which refused the provider's +// whole machine (263) — or, since ADR 0225, leaves the consumer out of its grants: either fails; +// - a manifest the controller the mesh runs cannot read (version skew): a catalogue change is judged +// by that controller, so a field only a newer one knows fails here, not at registration; +// - a module removed from its source while machines still run it (ADR 0236), and two definitions of +// one module name (239); +// - a setting the mesh holds that the changed definition cannot compose with (ADR 0163); +// - and, said rather than failed, how wide the rebuild of the merge would be (278). +// +// It runs where a build runs: the build seat, asked to check a pull request (checks.go), so no CI +// outside the mesh is needed; and by hand, with a snapshot and a throwaway store, by anyone. + +// mergeCheckInput is what one judging needs. +type mergeCheckInput struct { + facts snapshot.Facts + // repository is the change's owner/repository; tree its checkout, empty when the change is to code + // alone and every module stays as the mesh holds it. + repository, tree string + // changed are the paths the change touches, for the width of its rebuild. + changed []string + // admin is a PostgreSQL the gate may create and drop databases in. + admin string + // say is where progress goes; the verdict is returned. + say io.Writer +} + +// mergeVerdict is what the gate found. +type mergeVerdict struct { + Verdict string `json:"verdict"` // pass, warning or fail + Summary string `json:"summary"` + // Judge is the controller build that judged it, and Facts when the snapshot it judged against was taken. + Judge string `json:"judge"` + Facts time.Time `json:"facts"` + Failures []string `json:"failures,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Notes []string `json:"notes,omitempty"` + Machines []mergeMachine `json:"machines"` + Modules map[string]string `json:"modules,omitempty"` // changed module → what changed: added, changed, removed + Width *mergeWidthOf `json:"width,omitempty"` +} + +// mergeMachine is one machine, composed as it is and with the change. +type mergeMachine struct { + Name string `json:"name"` + Described string `json:"described"` + Live bool `json:"live-composes"` + Base mergeComposed `json:"base"` + Change mergeComposed `json:"change"` + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Modules map[string]int `json:"-"` +} + +// mergeComposed is one machine's composition in one store. +type mergeComposed struct { + Composes bool `json:"composes"` + Problems []string `json:"problems,omitempty"` + Withheld []string `json:"withheld,omitempty"` + Unbound []string `json:"unbound,omitempty"` + LeftOut map[string]string `json:"left-out,omitempty"` + Resources []string `json:"resources,omitempty"` +} + +// mergeWidthOf is how much a merge of the change would rebuild. +type mergeWidthOf struct { + Modules []string `json:"modules"` + Tiers int `json:"tiers"` + // Shared is the changed paths read as shared code: in no directory of a module the mesh holds, so + // everything built from the repository is rebuilt for them. + Shared []string `json:"shared,omitempty"` +} + +// wideRebuild is how many modules a rebuild may take before the gate says so as a warning. +var wideRebuild = 12 + +// mergeGateCommand is `gate`: +// +// merge-gate --facts --store [--repository owner/repo --tree ] [--changed a,b] [--json] +// +// --facts may be a file or `store`, the snapshot the artifact store holds (MESH_REGISTRY names it). +// --store is a PostgreSQL the gate may create and drop databases in: a throwaway, never the mesh's. +func mergeGateCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("merge-gate", flag.ContinueOnError) + factsFrom := set.String("facts", "", "the facts snapshot: a file, or `store` for the one the artifact store holds") + admin := set.String("store", os.Getenv("MESH_GATE_POSTGRES"), "a throwaway PostgreSQL the gate may create databases in") + repository := set.String("repository", "", "owner/repository of the change") + tree := set.String("tree", "", "the change's checkout: every module.json in it replaces the mesh's") + changed := set.String("changed", "", "the paths the change touches, comma-separated, for its rebuild's width") + asJSON := set.Bool("json", false, "print the verdict as JSON") + if _, err := parseAround(set, args); err != nil { + return err + } + if *factsFrom == "" || *admin == "" { + return errors.New("merge-gate --facts --store [--repository owner/repo --tree ] " + + "[--changed paths] [--json]") + } + if (*tree == "") != (*repository == "") && *tree != "" { + return errors.New("--tree names the change's checkout; --repository says which repository it is") + } + f, err := readFacts(ctx, *factsFrom) + if err != nil { + return err + } + out := io.Writer(os.Stdout) + if *asJSON { + out = os.Stderr + } + v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed), + admin: *admin, say: out}) + if err != nil { + return err + } + if *asJSON { + body, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + // And the report a person reads, beside it: what the pull request is told comes from here. + fmt.Fprint(os.Stderr, v.Report()) + } else { + fmt.Print(v.Report()) + } + if v.Verdict == "fail" { + return errMergeGateFailed + } + return nil +} + +// errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more. +var errMergeGateFailed = errors.New("the change fails the merge gate") + +// readFacts reads a snapshot from a file, or from the artifact store. +func readFacts(ctx context.Context, from string) (snapshot.Facts, error) { + var body []byte + var err error + if from == "store" { + address := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) + if address == "" { + return snapshot.Facts{}, errors.New("--facts store reads the artifact store MESH_REGISTRY names, and it names none") + } + body, _, err = (artifacts.Store{Address: address}).GetTagged(ctx, snapshot.Repository, snapshot.Tag) + } else { + body, err = os.ReadFile(from) + } + if err != nil { + return snapshot.Facts{}, err + } + return snapshot.Decode(body) +} + +// judgeChange is the gate: both compositions, and what the change did to each machine. +func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error) { + v := mergeVerdict{Judge: version, Facts: in.facts.Taken, Modules: map[string]string{}} + say := func(format string, args ...any) { + if in.say != nil { + fmt.Fprintf(in.say, format+"\n", args...) + } + } + base, err := shelfOfFacts(in.facts) + if err != nil { + return v, err + } + change := base + sources := sourcesOfFacts(in.facts) + if in.tree != "" { + var failures []string + change, failures, err = shelfWithChange(base, sources, in.facts, in.repository, in.tree, v.Modules) + if err != nil { + return v, err + } + v.Failures = append(v.Failures, failures...) + } + say("judging %d module(s) changed against %d machine(s), as the snapshot of %s says they run", + len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339)) + + // What no single machine shows: the rules between manifests, identities on the mesh's own longest + // name, and the data each module keeps. Only what the change adds is the change's. + for _, rule := range []func(catalogue.Shelf) []string{ + catalogue.CatalogueProblems, + func(s catalogue.Shelf) []string { return catalogue.IdentityProblems(s, in.facts.Longest()) }, + catalogue.DataProblems, + } { + was := rule(catalogue.Shelf(base)) + for _, p := range rule(catalogue.Shelf(change)) { + if !slices.Contains(was, p) { + v.Failures = append(v.Failures, p) + } + } + } + + // Both compositions, each in a store of its own. + say("composing every machine as the mesh is") + before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil) + if err != nil { + return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err) + } + v.Notes = append(v.Notes, notes...) + say("composing every machine with the change") + var added []string + for module, how := range v.Modules { + if how == "added" { + added = append(added, module) + } + } + sort.Strings(added) + after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added) + if err != nil { + return v, fmt.Errorf("the mesh with the change could not be raised: %w", err) + } + for _, n := range notes { + if !slices.Contains(v.Notes, n) { + v.Failures = append(v.Failures, n) + } + } + + for _, m := range in.facts.Machines { + gm := mergeMachine{Name: m.Name, Described: m.Described(), Live: m.Declaration.Composes, + Base: before[m.Name], Change: after[m.Name]} + gm.Added, gm.Removed = difference(gm.Base.Resources, gm.Change.Resources) + v.Machines = append(v.Machines, gm) + switch { + case gm.Base.Composes && !gm.Change.Composes: + v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s", + gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems))) + case !gm.Base.Composes && m.Declaration.Composes: + // The mesh composes it and the gate could not raise it as it is: a fact the snapshot does + // not carry. Said, and judged by what the change adds. + v.Notes = append(v.Notes, fmt.Sprintf("%s composes on the mesh and not as the snapshot raised it: %s — "+ + "judged by what the change adds", gm.Described, firstOr(gm.Base.Problems, nil))) + if added := newOnly(gm.Change.Problems, gm.Base.Problems); len(added) > 0 { + v.Failures = append(v.Failures, fmt.Sprintf("%s: the change adds — %s", gm.Described, added[0])) + } + case !gm.Base.Composes: + v.Notes = append(v.Notes, fmt.Sprintf("%s does not compose on the mesh today either: %s", + gm.Described, firstOr(m.Declaration.Problems, gm.Base.Problems))) + } + for _, w := range newOnly(gm.Change.Withheld, gm.Base.Withheld) { + v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a consumer out of its grants — %s", + gm.Described, w)) + } + for _, u := range newOnly(gm.Change.Unbound, gm.Base.Unbound) { + v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", + gm.Described, u)) + } + for module, why := range gm.Change.LeftOut { + if _, was := gm.Base.LeftOut[module]; !was { + v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", + gm.Described, module, why)) + } + } + } + + // A module nobody runs yet, tried on a machine that could run it. + for _, module := range added { + t := tried[module] + for _, r := range t.Refused { + v.Failures = append(v.Failures, fmt.Sprintf("%s, assigned to %s, would be refused by its node-engine — %s", + module, t.Machine, r)) + } + if t.Said != "" { + v.Notes = append(v.Notes, fmt.Sprintf("%s could not be tried on a machine: %s", module, t.Said)) + } + } + + if in.repository != "" && len(in.changed) > 0 { + w, err := rebuildWidth(in.facts, in.repository, in.changed, in.tree) + if err != nil { + v.Notes = append(v.Notes, "the width of the rebuild could not be worked out: "+err.Error()) + } else { + v.Width = &w + if len(w.Shared) > 0 && len(w.Modules) > wideRebuild { + v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s), because %s read as shared "+ + "code: in no directory of a module the mesh holds (issue 278)", len(w.Modules), + readableList(w.Shared))) + } else if len(w.Modules) > wideRebuild { + v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)", + len(w.Modules), w.Tiers)) + } + } + } + + sort.Strings(v.Failures) + v.Failures = slices.Compact(v.Failures) + switch { + case len(v.Failures) > 0: + v.Verdict = "fail" + v.Summary = fmt.Sprintf("%d problem(s) the change brings; the first: %s", len(v.Failures), v.Failures[0]) + case len(v.Warnings) > 0: + v.Verdict = "warning" + v.Summary = v.Warnings[0] + default: + v.Verdict = "pass" + composing := 0 + for _, m := range v.Machines { + if m.Change.Composes { + composing++ + } + } + v.Summary = fmt.Sprintf("every machine composes with the change as it did without (%d of %d compose)", + composing, len(v.Machines)) + } + return v, nil +} + +// Report is the verdict as a person reads it, and as a pull request is told it. +func (v mergeVerdict) Report() string { + var b strings.Builder + fmt.Fprintf(&b, "merge gate: %s — %s\n", strings.ToUpper(v.Verdict), v.Summary) + fmt.Fprintf(&b, "judged by controller %s against the facts of %s\n", v.Judge, v.Facts.Format(time.RFC3339)) + list := func(title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(&b, "\n%s:\n", title) + for _, i := range items { + fmt.Fprintf(&b, " - %s\n", i) + } + } + list("fails", v.Failures) + list("warns", v.Warnings) + if len(v.Modules) > 0 { + var changed []string + for m, how := range v.Modules { + changed = append(changed, m+" ("+how+")") + } + sort.Strings(changed) + list("modules the change moves", changed) + } + var machines []string + for _, m := range v.Machines { + line := m.Described + ": " + switch { + case m.Change.Composes: + line += "composes" + default: + line += "does not compose" + } + if len(m.Added) > 0 || len(m.Removed) > 0 { + line += fmt.Sprintf("; the change adds %d resource(s) and removes %d", len(m.Added), len(m.Removed)) + if len(m.Added)+len(m.Removed) <= 6 { + line += " (" + strings.Join(append(prefixed("+", m.Added), prefixed("-", m.Removed)...), ", ") + ")" + } + } + machines = append(machines, line) + } + list("machines", machines) + if v.Width != nil { + fmt.Fprintf(&b, "\na merge would rebuild %d module(s) in %d tier(s)", len(v.Width.Modules), v.Width.Tiers) + if len(v.Width.Modules) > 0 && len(v.Width.Modules) <= wideRebuild { + fmt.Fprintf(&b, ": %s", strings.Join(v.Width.Modules, ", ")) + } + fmt.Fprintln(&b) + } + list("notes", v.Notes) + return b.String() +} + +func prefixed(p string, items []string) []string { + out := make([]string, len(items)) + for i, s := range items { + out[i] = p + s + } + return out +} + +// shelfOfFacts is every module of the snapshot, as the mesh holds it — but the ones that came with the +// controller: those are this controller's own, provided when its store is raised. +func shelfOfFacts(f snapshot.Facts) (map[string]catalogue.Manifest, error) { + out := map[string]catalogue.Manifest{} + for _, m := range f.Modules { + if m.Provided { + continue + } + var manifest catalogue.Manifest + if err := json.Unmarshal(m.Manifest, &manifest); err != nil { + return nil, fmt.Errorf("the snapshot's %s is not a manifest: %w", m.Name, err) + } + out[m.Name] = manifest + } + return out, nil +} + +// sourcesOfFacts is where each module of the snapshot is built from. +func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source { + out := map[string]inventory.Source{} + for _, m := range f.Modules { + if !m.Provided { + out[m.Name] = inventory.Source{Repository: m.Repository, Path: m.Path, BuiltFrom: m.Commit} + } + } + return out +} + +// ignoredInTree are directories a module.json in is not a module of the repository. +var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true, + "examples": true, "fixtures": true} + +// shelfWithChange is the base shelf with every module of the change's tree in place of the mesh's: each +// read by this controller's strict parser — the one the mesh runs, for a change to a catalogue — resolved +// with stand-in builds, and read again as registration reads a built one. A module the repository held +// and the tree no longer has is removed. Answers the shelf and what the tree itself fails. +func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inventory.Source, f snapshot.Facts, + repository, tree string, moved map[string]string) (map[string]catalogue.Manifest, []string, error) { + out := make(map[string]catalogue.Manifest, len(base)) + for k, m := range base { + out[k] = m + } + var failures []string + found := map[string]string{} // module → its directory in the tree + err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { + return filepath.SkipDir + } + return nil + } + if d.Name() != "module.json" { + return nil + } + dir, err := filepath.Rel(tree, filepath.Dir(path)) + if err != nil { + return err + } + if dir == "." { + dir = "" + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", + orRoot(dir), version, oneLine(err.Error()))) + return nil + } + if was, twice := found[m.Module]; twice { + failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module", + orRoot(was), orRoot(dir), m.Module)) + return nil + } + found[m.Module] = dir + if s, held := sources[m.Module]; held && s.Repository != "" && !sameRepository(s.Repository, + link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { + failures = append(failures, fmt.Sprintf("%s defines %s, which the mesh builds from %s: two definitions "+ + "of one module name (issue 239)", orRoot(dir), m.Module, s.Repository)) + return nil + } + resolved, err := standInBuild(m) + if err == nil { + // As registration reads a built manifest: strictly, again (takeIn). + var raw []byte + if raw, err = json.Marshal(resolved); err == nil { + resolved, err = catalogue.ParseManifest(raw) + } + } + if err == nil { + err = namesNoInstallation(resolved) + } + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %s is not registrable — %s", orRoot(dir), m.Module, + oneLine(err.Error()))) + return nil + } + if was, held := base[m.Module]; !held { + moved[m.Module] = "added" + } else if builtAlike(was) == builtAlike(resolved) { + // Unchanged but for what a build pins: the mesh's own build stands, so the machines that + // run it compose exactly as they do. + return nil + } else { + moved[m.Module] = "changed" + } + out[m.Module] = resolved + return nil + }) + if err != nil { + return nil, nil, err + } + // What the repository held and the tree no longer defines is removed — refused while a machine runs it. + running := map[string][]string{} + for _, mc := range f.Machines { + for _, a := range mc.Assigned { + running[a] = append(running[a], mc.Described()) + } + } + for name, s := range sources { + if s.Repository == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { + continue + } + if _, still := found[name]; still { + continue + } + delete(out, name) + moved[name] = "removed" + if on := running[name]; len(on) > 0 { + failures = append(failures, fmt.Sprintf("%s is removed from %s, and %s run(s) it: unassign it first (ADR 0236)", + name, repository, readableList(on))) + } + } + return out, failures, nil +} + +func orRoot(dir string) string { + if dir == "" { + return "the repository's root" + } + return dir +} + +func ownerOf(repository string) string { + owner, _, _ := strings.Cut(repository, "/") + return owner +} + +func repoOf(repository string) string { + _, repo, _ := strings.Cut(repository, "/") + return repo +} + +// builtAlike is a resolved manifest with what a build pins taken out — the digests and the references +// they are reached by, and the version a path is named for — so a definition the change leaves alone reads +// the same as the build of it the mesh holds. +func builtAlike(m catalogue.Manifest) string { + raw, err := json.Marshal(m) + if err != nil { + return "" + } + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return "" + } + var walk func(any) any + walk = func(v any) any { + switch t := v.(type) { + case map[string]any: + for k, e := range t { + switch strings.ToLower(k) { + case "image", "source", "digest", "launchers": + t[k] = "pinned" + default: + t[k] = walk(e) + } + } + return t + case []any: + for i, e := range t { + t[i] = walk(e) + } + return t + case string: + if before, _, found := strings.Cut(t, "/versions/"); found { + return before + "/versions/pinned" + } + return t + } + return v + } + out, err := json.Marshal(walk(doc)) + if err != nil { + return "" + } + return string(out) +} + +// standInBuild resolves a manifest as a build would, with artifacts nobody built: a digest made from the +// module's and the artifact's names, in the store's own vocabulary. Composition and validation read the +// shape of a reference, never its bytes. +func standInBuild(m catalogue.Manifest) (catalogue.Manifest, error) { + if m.Build == nil { + return m.Resolve(nil) + } + var built []catalogue.Built + for _, a := range m.Build.Artifacts { + sum := sha256.Sum256([]byte("gate\x00" + m.Module + "\x00" + a.Name)) + digest := fmt.Sprintf("sha256:%x", sum) + b := catalogue.Built{Name: a.Name, Kind: a.Kind, Digest: digest} + switch a.Kind { + case catalogue.ArtifactImage, catalogue.ArtifactUpstream: + b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "@" + digest + default: + b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "/blobs/" + digest + } + built = append(built, b) + } + return m.Resolve(built) +} + +// composeEveryMachine raises a throwaway store from the snapshot with this shelf, composes every machine +// twice — the first time as a push does, making each credential, so the second has every consumer's +// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not +// be raised: a refusal to register a module, keep a setting or a pin. +func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest, + sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) { + open, drop, err := throwawayStores(ctx, in.admin) + if err != nil { + return nil, nil, nil, err + } + defer drop() + notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources) + if err != nil { + return nil, nil, notes, err + } + gens, gensErr := generators(ctx, open) + out := map[string]mergeComposed{} + for pass := 0; pass < 2; pass++ { + for _, m := range in.facts.Machines { + if gensErr != nil { + out[m.Name] = mergeComposed{Problems: []string{"the private network cannot be computed: " + + oneLine(gensErr.Error())}} + continue + } + declared, problems, err := composedAndValidated(ctx, open, m.Name, gens, Allocating) + if pass == 0 { + continue + } + c := mergeComposed{} + if err != nil { + c.Problems = []string{oneLine(err.Error())} + out[m.Name] = c + continue + } + c.Composes = len(problems) == 0 + c.Problems = problems + c.Resources = resourceNames(declared.Resources) + c.LeftOut = declared.leftOutWhy + for _, o := range declared.withheld { + c.Withheld = append(c.Withheld, o.String()) + } + for _, u := range declared.unbound { + c.Unbound = append(c.Unbound, u.String()) + } + sort.Strings(c.Withheld) + sort.Strings(c.Unbound) + out[m.Name] = c + } + if ctx.Err() != nil { + return nil, nil, notes, ctx.Err() + } + } + tried := map[string]mergeTrial{} + if gensErr == nil { + for _, module := range trials { + tried[module] = tryAssigning(ctx, open, in.facts, shelf[module], gens) + } + } + return out, tried, notes, nil +} + +// mergeTrial is a module no machine runs yet, assigned for a moment to one that could run it. +type mergeTrial struct { + Machine string `json:"machine,omitempty"` + Refused []string `json:"refused,omitempty"` + Composes bool `json:"composes"` + Said string `json:"said,omitempty"` +} + +// tryAssigning composes a module nobody runs yet on the first machine that has what it needs, and says +// what the node-engine would refuse of it there (issue 236: the refusal came only from the first live +// machine it was assigned to). The assignment is taken back; nothing else in the store moves. +func tryAssigning(ctx context.Context, open *stores, f snapshot.Facts, m catalogue.Manifest, + gens map[string]catalogue.Generator) mergeTrial { + for _, machine := range f.Machines { + has := map[string]bool{} + for _, c := range machine.Capabilities { + has[c.Name] = c.Present + } + fits := true + for _, c := range m.Capabilities { + fits = fits && has[c] + } + if !fits { + continue + } + if _, err := open.inventory.Assign(ctx, machine.Name, m.Module); err != nil { + return mergeTrial{Machine: machine.Described(), Said: oneLine(err.Error())} + } + _, problems, err := composedAndValidated(ctx, open, machine.Name, gens, Allocating) + _ = open.inventory.Unassign(ctx, machine.Name, m.Module) + t := mergeTrial{Machine: machine.Described()} + if err != nil { + t.Said = oneLine(err.Error()) + return t + } + for _, p := range problems { + if strings.Contains(p, `"`+m.Module+".") || strings.Contains(p, m.Module+":") { + t.Refused = append(t.Refused, p) + } + } + t.Composes = len(problems) == 0 + return t + } + return mergeTrial{Said: "no machine of the mesh has what it needs: " + strings.Join(m.Capabilities, ", ")} +} + +// throwawayStores creates fresh databases for every context the controller keeps in the PostgreSQL at +// admin, brings each to this controller's schema, and answers them opened, with what drops them again. +func throwawayStores(ctx context.Context, admin string) (*stores, func(), error) { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000_000) + conn, err := pgx.Connect(ctx, admin) + if err != nil { + return nil, nil, fmt.Errorf("cannot reach the throwaway PostgreSQL: %w", err) + } + defer conn.Close(ctx) + cut := strings.LastIndex(admin, "/") + if cut < 0 { + return nil, nil, fmt.Errorf("%q is not a PostgreSQL URL", admin) + } + query := "" + if q := strings.Index(admin[cut:], "?"); q >= 0 { + query = admin[cut+q:] + } + var names []string + // The stores are found by the environment, so what it said before is what it says after: a gate run + // inside a process with stores of its own — a test, a controller — leaves them as they were. + restore := map[string]*string{} + for _, c := range held { + if was, set := os.LookupEnv(store.Variable(c.name)); set { + restore[c.name] = &was + } else { + restore[c.name] = nil + } + } + drop := func() { + for name, was := range restore { + if was == nil { + _ = os.Unsetenv(store.Variable(name)) + } else { + _ = os.Setenv(store.Variable(name), *was) + } + } + c, err := pgx.Connect(context.Background(), admin) + if err != nil { + return + } + defer c.Close(context.Background()) + for _, n := range names { + _, _ = c.Exec(context.Background(), "drop database if exists "+n+" with (force)") + } + } + for _, c := range held { + name := "gate_" + c.name + "_" + suffix + if _, err := conn.Exec(ctx, "create database "+name); err != nil { + drop() + return nil, nil, fmt.Errorf("cannot create %s: %w", name, err) + } + names = append(names, name) + url := admin[:cut] + "/" + name + query + if err := os.Setenv(store.Variable(c.name), url); err != nil { + drop() + return nil, nil, err + } + migrations, err := c.migrations() + if err != nil { + drop() + return nil, nil, err + } + s, err := store.Open(ctx, c.name) + if err != nil { + drop() + return nil, nil, err + } + _, err = s.Migrate(ctx, migrations) + s.Close() + if err != nil { + drop() + return nil, nil, fmt.Errorf("%s does not migrate: %w", c.name, err) + } + } + open, err := openStores(ctx) + if err != nil { + drop() + return nil, nil, err + } + for _, m := range provided { + if err := open.inventory.Provide(ctx, m); err != nil { + open.Close() + drop() + return nil, nil, err + } + } + if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + open.Close() + drop() + return nil, nil, err + } + return open, func() { open.Close(); drop() }, nil +} + +// raiseFromFacts puts the snapshot's mesh into a fresh store through the controller's own records: +// every module of the shelf registered, every machine added where it is with what it reported, a key +// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its +// settings, and a stand-in for every secret a person gave. +func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest, + sources map[string]inventory.Source) ([]string, error) { + inv := open.inventory + var notes []string + names := make([]string, 0, len(shelf)) + for name := range shelf { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + s := sources[name] + if err := inv.RegisterModule(ctx, shelf[name], inventory.Source{Repository: s.Repository, Path: s.Path, + BuiltFrom: s.BuiltFrom}); err != nil { + notes = append(notes, fmt.Sprintf("%s is not registered: %s", name, oneLine(err.Error()))) + } + } + for i, m := range f.Machines { + node, err := inv.AddNodeAs(ctx, m.Name, m.Adopted) + if err != nil { + return notes, fmt.Errorf("%s: %w", m.Name, err) + } + if m.OnNetwork { + endpoint := "" + if m.Public { + endpoint = fmt.Sprintf("192.0.2.%d:51820", i+1) + } + if err := inv.SetPlace(ctx, m.Name, endpoint, m.Site, m.Hub, fmt.Sprintf("10.99.%d.%d", i/250, i%250+1)); err != nil { + return notes, fmt.Errorf("%s: %w", m.Name, err) + } + } + var caps []map[string]any + for _, c := range m.Capabilities { + caps = append(caps, map[string]any{"name": c.Name, "present": c.Present, "detail": c.Detail}) + } + if err := inv.RecordProfile(ctx, node.ID, map[string]any{"architecture": m.Architecture, + "kernel": m.Kernel, "capabilities": caps}); err != nil { + return notes, err + } + for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} { + key, err := standInKey() + if err != nil { + return notes, err + } + if err := record(ctx, node.ID, key); err != nil { + return notes, err + } + } + if m.Account != "" { + if err := inv.SetAccount(ctx, m.Name, m.Account, m.AccountHome); err != nil { + return notes, err + } + } + if m.PublicDomain != "" { + if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil { + return notes, err + } + } + } + for _, m := range f.Machines { + for _, module := range m.Assigned { + if _, err := inv.Assign(ctx, m.Name, module); err != nil { + notes = append(notes, fmt.Sprintf("%s cannot be assigned to %s: %s", module, m.Described(), + oneLine(err.Error()))) + } + } + } + for _, s := range f.Seats { + for _, h := range s.Holders { + var err error + if s.Scope == catalogue.ScopeMesh && len(s.Holders) > 1 { + err = inv.AddSeatHolder(ctx, s.Name, s.Scope, h.Machine, h.Module) + } else { + err = inv.HoldSeat(ctx, s.Name, s.Scope, h.Machine, h.Module) + } + if err != nil { + notes = append(notes, fmt.Sprintf("%s's hold of %s is not kept: %s", h.Machine, s.Name, oneLine(err.Error()))) + } + } + } + for _, s := range f.Settings { + if err := inv.SetSettings(ctx, "", s.Module, s.Values); err != nil { + notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error()))) + } + } + for _, m := range f.Machines { + for _, p := range m.Pins { + if err := inv.PinProvision(ctx, m.Name, p.Provision, p.Machine, p.Module); err != nil { + notes = append(notes, fmt.Sprintf("%s's pin of %s is not kept: %s", m.Described(), p.Provision, + oneLine(err.Error()))) + } + } + for _, s := range m.Settings { + if err := inv.SetSettings(ctx, m.Name, s.Module, s.Values); err != nil { + notes = append(notes, fmt.Sprintf("%s's settings of %s are not kept: %s", m.Described(), s.Module, + oneLine(err.Error()))) + } + } + for _, a := range m.Accepted { + standIn := fmt.Sprintf("gate-stand-in-%x", sha256.Sum256([]byte(m.Name+a.Module+a.Name+a.Provider+a.Local)))[:40] + var err error + if a.Provider == "" { + err = inv.AcceptSecretForModule(ctx, m.Name, a.Module, a.Name, standIn) + } else { + err = inv.AcceptSecretForPair(ctx, a.Name, m.Name, a.Module, a.Provider, a.Local, standIn) + } + if err != nil { + notes = append(notes, fmt.Sprintf("%s's given %s for %s is not kept: %s", m.Described(), a.Name, + a.Module, oneLine(err.Error()))) + } + } + } + return notes, nil +} + +// standInKey is a key a machine could have reported; its private half is never kept. +func standInKey() (string, error) { + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil +} + +// mergeWidth is what a merge of the change would rebuild, computed the way the merge handler computes +// it, from the modules, sources and edges the snapshot carries. +func rebuildWidth(f snapshot.Facts, repository string, paths []string, tree string) (mergeWidthOf, error) { + owner, repo, found := strings.Cut(repository, "/") + if !found { + return mergeWidthOf{}, fmt.Errorf("%q is not owner/repository", repository) + } + m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "gate", Paths: paths} + // Which changed directories hold a module, read from the change's tree as the forge's announcer reads + // them at the merge commit (issue 278): a file inside one is that module's business, held or not. + if tree != "" { + m.ModuleDirs, m.ModuleDirsSaid = moduleDirsIn(tree, paths), true + } + var entries []inventory.Entry + read := map[string][]inventory.ReadRepository{} + for _, mod := range f.Modules { + var manifest catalogue.Manifest + if err := json.Unmarshal(mod.Manifest, &manifest); err != nil { + return mergeWidthOf{}, err + } + entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided, + Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}}) + for _, r := range mod.Reads { + read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r}) + } + } + var edges []inventory.Edge + for _, e := range f.Edges { + edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind}) + } + var from []inventory.Entry + for _, e := range entries { + if !e.Provided && sourceIs(e.Source, m) { + from = append(from, e) + } + } + touched := whatTheMergeTouched(from, entries, m) + var names []string + for _, e := range touched { + names = append(names, e.Manifest.Module) + } + for _, e := range entries { + if readsFrom(read[e.Manifest.Module], m) && !slices.Contains(names, e.Manifest.Module) { + names = append(names, e.Manifest.Module) + } + } + w := mergeWidthOf{} + if len(names) > 0 { + p := planOfMerge(m, names, edges) + w.Tiers = len(p.Tiers) + for name := range p.Modules { + w.Modules = append(w.Modules, name) + } + sort.Strings(w.Modules) + } + // The paths read as shared: in no directory of a module the mesh holds from this repository. + var dirs []string + for _, e := range from { + if d := strings.Trim(e.Source.Path, "/"); d != "" { + dirs = append(dirs, d+"/") + } + } + for _, d := range m.ModuleDirs { + dirs = append(dirs, strings.Trim(d, "/")+"/") + } + for _, p := range paths { + inModule := false + for _, d := range dirs { + inModule = inModule || strings.HasPrefix(p, d) + } + if !inModule && len(dirs) > 0 { + w.Shared = append(w.Shared, p) + } + } + return w, nil +} + +// moduleDirsIn are the directories above the changed paths, never the root, that hold a module.json in +// the tree. +func moduleDirsIn(tree string, paths []string) []string { + seen := map[string]bool{} + var out []string + for _, p := range paths { + for dir := filepath.Dir(filepath.Clean(p)); dir != "." && dir != "/" && dir != ""; dir = filepath.Dir(dir) { + if seen[dir] { + continue + } + seen[dir] = true + if _, err := os.Stat(filepath.Join(tree, dir, "module.json")); err == nil { + out = append(out, filepath.ToSlash(dir)) + } + } + } + sort.Strings(out) + return out +} + +// difference is what b has that a has not, and what a has that b has not. +func difference(a, b []string) (added, removed []string) { + for _, x := range b { + if !slices.Contains(a, x) { + added = append(added, x) + } + } + for _, x := range a { + if !slices.Contains(b, x) { + removed = append(removed, x) + } + } + return added, removed +} + +// newOnly is what now has that was had not. +func newOnly(now, was []string) []string { + var out []string + for _, x := range now { + if !slices.Contains(was, x) { + out = append(out, x) + } + } + return out +} + +func firstOr(items, otherwise []string) string { + if len(items) > 0 { + return items[0] + } + if len(otherwise) > 0 { + return otherwise[0] + } + return "it says nothing more" +} diff --git a/cmd/mesh-controller/merge_gate_test.go b/cmd/mesh-controller/merge_gate_test.go new file mode 100644 index 0000000..c5280ee --- /dev/null +++ b/cmd/mesh-controller/merge_gate_test.go @@ -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) + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index f238a05..4ad2590 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -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 diff --git a/internal/broker/states_agreement_test.go b/internal/broker/states_agreement_test.go index b838527..0b3bd4f 100644 --- a/internal/broker/states_agreement_test.go +++ b/internal/broker/states_agreement_test.go @@ -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) diff --git a/internal/broker/streams.go b/internal/broker/streams.go index 0da2e4b..62f54ad 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -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 diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index c4cdabe..224f6ea 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -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: { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 21b5c37..589f44b 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -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", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 8b3bb8e..32d53dc 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -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", diff --git a/internal/builder/check.go b/internal/builder/check.go new file mode 100644 index 0000000..17a01b4 --- /dev/null +++ b/internal/builder/check.go @@ -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]) +} diff --git a/internal/builder/check_test.go b/internal/builder/check_test.go new file mode 100644 index 0000000..d3911f1 --- /dev/null +++ b/internal/builder/check_test.go @@ -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) + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 6e12389..b36ee39 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -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. diff --git a/internal/link/build.go b/internal/link/build.go index c866e75..a8b6b77 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -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, diff --git a/internal/link/contracts.go b/internal/link/contracts.go index a2a54b2..f5e759a 100644 --- a/internal/link/contracts.go +++ b/internal/link/contracts.go @@ -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. " + diff --git a/internal/link/events.go b/internal/link/events.go index 397503f..a3eb907 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -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"` diff --git a/internal/link/receive.go b/internal/link/receive.go index bdd0acd..7222b38 100644 --- a/internal/link/receive.go +++ b/internal/link/receive.go @@ -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. diff --git a/internal/link/receive_nats.go b/internal/link/receive_nats.go index 6c330ed..3200d81 100644 --- a/internal/link/receive_nats.go +++ b/internal/link/receive_nats.go @@ -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 diff --git a/internal/link/serve.go b/internal/link/serve.go index d8da197..4a8bac8 100644 --- a/internal/link/serve.go +++ b/internal/link/serve.go @@ -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). // diff --git a/merge-check.sh b/merge-check.sh new file mode 100755 index 0000000..0062bb5 --- /dev/null +++ b/merge-check.sh @@ -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 ./... diff --git a/mesh-controller b/mesh-controller index f1086b8..721ae36 100755 Binary files a/mesh-controller and b/mesh-controller differ