Let a walk wait for its delivery's word, and serve the delivery's owner (hq ADR 0239)
While the mesh-delivery seat has a holder on record, a merge that moves no core module opens its walk and asks nothing until mesh-delivery or a person says go; nothing of it is registered before its turn, so no other send carries it. The controller keeps the planner, the gate, sending and the walk, and gains the verbs the owner asks with: delivery-plan, -order, -check (a group composed as one future state), deliver, delivery-stop, delivery-walks; every walk kept is said as plan-moved.
This commit is contained in:
@@ -340,6 +340,10 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
for dir, b := range c.Beside {
|
for dir, b := range c.Beside {
|
||||||
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
|
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
|
||||||
}
|
}
|
||||||
|
for _, m := range c.Members {
|
||||||
|
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
|
||||||
|
Ref: m.Ref, Paths: m.Paths})
|
||||||
|
}
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,16 @@ func checkedOf(result link.BuildResult) link.Checked {
|
|||||||
}
|
}
|
||||||
if result.Checked != nil {
|
if result.Checked != nil {
|
||||||
c.Plan = result.Checked.Plan
|
c.Plan = result.Checked.Plan
|
||||||
|
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
||||||
|
if g := result.Checked; g.Group != "" {
|
||||||
|
c.Group = g.Group
|
||||||
|
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
|
||||||
|
Commit: result.Ref})
|
||||||
|
for _, m := range g.Members {
|
||||||
|
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
||||||
|
Commit: m.Ref})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
|
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
|
||||||
if l != nil && l.Verdict == "" {
|
if l != nil && l.Verdict == "" {
|
||||||
|
|||||||
@@ -0,0 +1,643 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The controller's part in a delivery (novox/hq ADR 0239, to-be 47).
|
||||||
|
//
|
||||||
|
// **A delivery is mesh-delivery's; the walk is the controller's.** A delivery is one commit in one
|
||||||
|
// repository from its pull request's head to every machine, and a delivery group a few of them sharing a
|
||||||
|
// branch name; their states, order, holds and record belong to the module holding the `mesh-delivery`
|
||||||
|
// seat. The controller keeps what it already owned — the planner, the gate, registration, sending, the
|
||||||
|
// rollback — and the **walk**: one trunk commit's plan, tier by tier across the machines, one machine
|
||||||
|
// first and judged at the gate (ADR 0236). What changes here is when a walk starts.
|
||||||
|
//
|
||||||
|
// - A walk that moves the controller, the node-engine, the node tools, the bus or mesh-delivery itself is
|
||||||
|
// **on the controller's own path**: started by the merge, as every plan was. mesh-delivery cannot gate
|
||||||
|
// or deliver itself, and nothing the core needs to be repaired may wait for it.
|
||||||
|
// - Any other walk, **while the seat has a holder on record**, is opened by the merge and waits — nothing
|
||||||
|
// asked, nothing registered, nothing sent — until mesh-delivery says `deliver`, or a person says
|
||||||
|
// `plans go`. Nothing of it is registered before then, so no other send can carry it to a machine
|
||||||
|
// before its turn (ADR 0236 §4a carries everything registered).
|
||||||
|
// - With no holder on record, every walk starts at the merge, exactly as before this existed.
|
||||||
|
//
|
||||||
|
// The verbs mesh-delivery asks with are here: `delivery plan` (the planner's one answer for a diffset),
|
||||||
|
// `delivery order` (a group's order from the graph), `delivery check` (a group's heads composed), `delivery
|
||||||
|
// go`, `delivery stop` and `delivery walks`.
|
||||||
|
|
||||||
|
// onTheControllersPath are the modules whose walk never waits for the delivery's owner, and what each is.
|
||||||
|
var onTheControllersPath = map[string]string{
|
||||||
|
"mesh-controller": "the controller",
|
||||||
|
"mesh-host": "the node-engine",
|
||||||
|
"node-tools": "the node tools",
|
||||||
|
"nats": "the bus",
|
||||||
|
catalogue.DeliverySeat: "the delivery's owner",
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverySeatHeld is whether a module claiming the delivery seat is assigned somewhere: the seat has a
|
||||||
|
// holder on record. Read from the catalogue the merge handler already holds; never from whether the
|
||||||
|
// holder answers, so a walk does not start by itself because mesh-delivery is down — that wait is said.
|
||||||
|
func deliverySeatHeld(entries []inventory.Entry) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if len(e.On) > 0 && e.Manifest.ClaimsSeat(catalogue.DeliverySeat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitsFor is what a new walk waits for: nil when it starts at once — no holder on record, or a module
|
||||||
|
// on the controller's own path among those it moves.
|
||||||
|
func awaitsFor(entries []inventory.Entry, modules []string) *inventory.PlanDelivery {
|
||||||
|
if !deliverySeatHeld(entries) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, m := range modules {
|
||||||
|
if _, own := onTheControllersPath[m]; own {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingNote is what a walk waiting for its word says, wherever it is read.
|
||||||
|
func waitingNote(p inventory.Plan) string {
|
||||||
|
return fmt.Sprintf("published; its walk waits for %s's word — `plans go %s --why …` starts it by hand",
|
||||||
|
p.Delivery.Awaits, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// letGo gives a waiting walk its word: by the delivery's owner, or a person. The next advance asks its
|
||||||
|
// first tier. Refused for a walk that does not wait.
|
||||||
|
func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (inventory.Plan, error) {
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Plan{}, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
p, err := inv.PlanByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Plan{}, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !p.Open():
|
||||||
|
return p, fmt.Errorf("%s is %s: there is no walk to start", p.ID, p.State)
|
||||||
|
case p.Delivery == nil || p.Delivery.Awaits == "":
|
||||||
|
return p, fmt.Errorf("%s waits for nobody: it started at its merge", p.ID)
|
||||||
|
case p.Delivery.Go != nil:
|
||||||
|
return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By,
|
||||||
|
p.Delivery.Go.Local().Format("15:04:05"))
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
||||||
|
p.Note = "let go by " + by + "; its first tier is asked next"
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopWalk ends a walk on its delivery's word: failed, said as stopped by whom, why. What it asked still
|
||||||
|
// builds and registers; nothing further is asked or sent.
|
||||||
|
func stopWalk(ctx context.Context, inv *inventory.Inventory, id, by, why string) (inventory.Plan, error) {
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Plan{}, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
p, err := inv.PlanByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return inventory.Plan{}, err
|
||||||
|
}
|
||||||
|
if !p.Open() {
|
||||||
|
return p, fmt.Errorf("%s is already %s", p.ID, p.State)
|
||||||
|
}
|
||||||
|
if p.Delivery == nil {
|
||||||
|
p.Delivery = &inventory.PlanDelivery{}
|
||||||
|
}
|
||||||
|
p.Delivery.Stopped, p.Delivery.StoppedWhy = by, why
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("stopped by %s at tier %d: %s", by, p.Tier, why)
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderMember is one member of a group, as mesh-delivery says it.
|
||||||
|
type orderMember struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Base string `json:"base,omitempty"`
|
||||||
|
Head string `json:"head,omitempty"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Paths []string `json:"paths,omitempty"`
|
||||||
|
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
||||||
|
Removed []string `json:"removed,omitempty"`
|
||||||
|
ModuleDirs []string `json:"module_dirs,omitempty"`
|
||||||
|
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||||
|
CloneURL string `json:"clone_url,omitempty"`
|
||||||
|
// After are the repositories its pull request says it goes after (`after: <repository>`).
|
||||||
|
After []string `json:"after,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull is the member as the forge announced it.
|
||||||
|
func (m orderMember) pull() link.PullUpdated {
|
||||||
|
owner, repo, _ := strings.Cut(m.Repository, "/")
|
||||||
|
base := m.Base
|
||||||
|
if base == "" {
|
||||||
|
base = "main"
|
||||||
|
}
|
||||||
|
return link.PullUpdated{Owner: owner, Repo: repo, Number: m.Number, Base: base, Commit: m.Head,
|
||||||
|
CloneURL: m.CloneURL, Paths: m.Paths, PathsTruncated: m.PathsTruncated, Removed: m.Removed,
|
||||||
|
ModuleDirs: m.ModuleDirs, ModuleDirsSaid: m.ModuleDirsSaid}
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderPair is one "before" among a group's members, and why.
|
||||||
|
type orderPair struct {
|
||||||
|
Before string `json:"before"`
|
||||||
|
After string `json:"after"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderReach is what a member moves, as the planner says.
|
||||||
|
type orderReach struct {
|
||||||
|
Moved []string `json:"moved,omitempty"`
|
||||||
|
Dependents []string `json:"dependents,omitempty"`
|
||||||
|
New []string `json:"new,omitempty"`
|
||||||
|
Manifests []string `json:"manifests,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one.
|
||||||
|
type groupOrder struct {
|
||||||
|
Order []string `json:"order"`
|
||||||
|
Pairs []orderPair `json:"pairs,omitempty"`
|
||||||
|
Cycle []string `json:"cycle,omitempty"`
|
||||||
|
Reach map[string]orderReach `json:"reach,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reasons a pair is ordered, in the words the delivery plan shows.
|
||||||
|
const (
|
||||||
|
orderDeclared = "declared"
|
||||||
|
orderBuiltBy = "built by"
|
||||||
|
orderVersionSkew = "version skew"
|
||||||
|
orderEngineFirst = "engine before controller"
|
||||||
|
)
|
||||||
|
|
||||||
|
// orderOf is a group's order (novox/hq ADR 0239 decision 4), pure. A member goes before another when:
|
||||||
|
//
|
||||||
|
// - its pull request is named in the other's `after:` lines (declared);
|
||||||
|
// - it moves a module the other's moved modules are built by, stand on, package or declare (built by);
|
||||||
|
// - it moves the controller and the other changes any module's manifest (version skew: the newer
|
||||||
|
// controller parses what the newer manifest says) — the node-engine's excepted, which goes first;
|
||||||
|
// - it moves the node-engine and the other moves the controller (the witness reads nothing the controller
|
||||||
|
// does not yet grant, and a controller sends nothing an older engine would refuse — ADR 0236's rollout
|
||||||
|
// order).
|
||||||
|
//
|
||||||
|
// Otherwise, by repository then id, so every reading gives one order. A pair both ways is a cycle: the
|
||||||
|
// members in it are named, and none of them is ordered.
|
||||||
|
func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge) groupOrder {
|
||||||
|
out := groupOrder{Reach: reach}
|
||||||
|
byID := map[string]orderMember{}
|
||||||
|
for _, m := range members {
|
||||||
|
byID[m.ID] = m
|
||||||
|
}
|
||||||
|
add := func(before, after, why string) {
|
||||||
|
if before == after {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range out.Pairs {
|
||||||
|
if p.Before == before && p.After == after {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Pairs = append(out.Pairs, orderPair{Before: before, After: after, Why: why})
|
||||||
|
}
|
||||||
|
named := func(said, repository string) bool {
|
||||||
|
said = strings.TrimSuffix(strings.TrimSpace(said), ".git")
|
||||||
|
if strings.EqualFold(said, repository) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, repo, _ := strings.Cut(repository, "/")
|
||||||
|
return strings.EqualFold(said, repo)
|
||||||
|
}
|
||||||
|
for _, a := range members {
|
||||||
|
for _, b := range members {
|
||||||
|
if a.ID == b.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ra, rb := reach[a.ID], reach[b.ID]
|
||||||
|
for _, said := range b.After {
|
||||||
|
if named(said, a.Repository) {
|
||||||
|
add(a.ID, b.ID, orderDeclared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range edges {
|
||||||
|
if slices.Contains(ra.Moved, e.To) && slices.Contains(rb.Moved, e.From) && e.From != e.To {
|
||||||
|
add(a.ID, b.ID, orderBuiltBy)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slices.Contains(ra.Moved, "mesh-controller") && len(rb.Manifests) > 0 &&
|
||||||
|
!slices.Contains(rb.Moved, "mesh-controller") && !slices.Contains(rb.Moved, "mesh-host") {
|
||||||
|
add(a.ID, b.ID, orderVersionSkew)
|
||||||
|
}
|
||||||
|
if slices.Contains(ra.Moved, "mesh-host") && slices.Contains(rb.Moved, "mesh-controller") &&
|
||||||
|
!slices.Contains(ra.Moved, "mesh-controller") {
|
||||||
|
add(a.ID, b.ID, orderEngineFirst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Pairs, func(i, j int) bool {
|
||||||
|
if out.Pairs[i].Before != out.Pairs[j].Before {
|
||||||
|
return out.Pairs[i].Before < out.Pairs[j].Before
|
||||||
|
}
|
||||||
|
return out.Pairs[i].After < out.Pairs[j].After
|
||||||
|
})
|
||||||
|
// Kahn's walk, the next always the first by repository and id among those with nothing before them.
|
||||||
|
waiting := map[string]int{}
|
||||||
|
for _, m := range members {
|
||||||
|
waiting[m.ID] = 0
|
||||||
|
}
|
||||||
|
for _, p := range out.Pairs {
|
||||||
|
waiting[p.After]++
|
||||||
|
}
|
||||||
|
done := map[string]bool{}
|
||||||
|
for len(done) < len(members) {
|
||||||
|
var ready []orderMember
|
||||||
|
for _, m := range members {
|
||||||
|
if !done[m.ID] && waiting[m.ID] == 0 {
|
||||||
|
ready = append(ready, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ready) == 0 {
|
||||||
|
for _, m := range members {
|
||||||
|
if !done[m.ID] {
|
||||||
|
out.Cycle = append(out.Cycle, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out.Cycle)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
sort.Slice(ready, func(i, j int) bool {
|
||||||
|
if ready[i].Repository != ready[j].Repository {
|
||||||
|
return ready[i].Repository < ready[j].Repository
|
||||||
|
}
|
||||||
|
return ready[i].ID < ready[j].ID
|
||||||
|
})
|
||||||
|
next := ready[0]
|
||||||
|
done[next.ID] = true
|
||||||
|
out.Order = append(out.Order, next.ID)
|
||||||
|
for _, p := range out.Pairs {
|
||||||
|
if p.Before == next.ID {
|
||||||
|
waiting[p.After]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachOfMembers is each member's reach, as the planner says it.
|
||||||
|
func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||||
|
edges []inventory.Edge) map[string]orderReach {
|
||||||
|
out := map[string]orderReach{}
|
||||||
|
for _, m := range members {
|
||||||
|
s := pullScope(m.pull(), entries, read, edges)
|
||||||
|
out[m.ID] = orderReach{Moved: s.Modules, Dependents: s.Dependents, New: s.New, Manifests: s.Manifests}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryCommand is `delivery`, the controller's verbs for the delivery's owner:
|
||||||
|
//
|
||||||
|
// delivery plan --repository owner/repo --head <commit> [--base main] --paths a,b [--module-dirs …] [--removed …]
|
||||||
|
// delivery order --members <json>
|
||||||
|
// delivery check --group <id> --members <json>
|
||||||
|
// delivery go <plan> [--by <who>] [--why <text>]
|
||||||
|
// delivery stop <plan> --why <text> [--by <who>]
|
||||||
|
// delivery walks [-n 50] [--plan <id>]
|
||||||
|
func deliveryCommand(ctx context.Context, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("delivery plan|order|check|go|stop|walks")
|
||||||
|
}
|
||||||
|
sub, rest := args[0], args[1:]
|
||||||
|
set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError)
|
||||||
|
repository := set.String("repository", "", "owner/repository")
|
||||||
|
base := set.String("base", "main", "the branch it merges into")
|
||||||
|
head := set.String("head", "", "the commit at hand")
|
||||||
|
paths := set.String("paths", "", "the files it changes, comma-separated")
|
||||||
|
moduleDirs := set.String("module-dirs", "", "the directories holding a module.json at the head, comma-separated")
|
||||||
|
removed := set.String("removed", "", "the files it deletes, comma-separated")
|
||||||
|
membersJSON := set.String("members", "", "a group's members, as JSON")
|
||||||
|
group := set.String("group", "", "a delivery group's id")
|
||||||
|
by := set.String("by", catalogue.DeliverySeat, "who says it")
|
||||||
|
why := set.String("why", "", "why")
|
||||||
|
limit := set.Int("n", 50, "how many ended walks to answer beside the open ones")
|
||||||
|
planID := set.String("plan", "", "one walk")
|
||||||
|
positionals, err := parseAround(set, rest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var members []orderMember
|
||||||
|
if *membersJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(*membersJSON), &members); err != nil {
|
||||||
|
return fmt.Errorf("the members are not readable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
answer := func(v any) error {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(v)
|
||||||
|
}
|
||||||
|
switch sub {
|
||||||
|
case "plan":
|
||||||
|
if *repository == "" || *head == "" && *paths == "" {
|
||||||
|
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
|
||||||
|
}
|
||||||
|
m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head,
|
||||||
|
Paths: splitList(*paths), Removed: splitList(*removed)}
|
||||||
|
if d := moduleDirsOf(*moduleDirs); d != nil {
|
||||||
|
m.ModuleDirs, m.ModuleDirsSaid = *d, true
|
||||||
|
}
|
||||||
|
entries, read, edges, err := theGraph(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := pullScope(m.pull(), entries, read, edges)
|
||||||
|
plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) {
|
||||||
|
u, err := inv.UpgradeOf(ctx, module)
|
||||||
|
return u, err == nil
|
||||||
|
})
|
||||||
|
return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
|
||||||
|
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()})
|
||||||
|
case "order":
|
||||||
|
if len(members) == 0 {
|
||||||
|
return errors.New("delivery order --members <json>: a group has members")
|
||||||
|
}
|
||||||
|
entries, read, edges, err := theGraph(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges))
|
||||||
|
case "check":
|
||||||
|
if *group == "" && len(members) == 1 {
|
||||||
|
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
||||||
|
// own path for a pull request, run because the delivery's owner asked.
|
||||||
|
if err := sayingOnTheBus(ctx, func() error { return (following{open}).PullUpdated(ctx, members[0].pull()) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return answer(map[string]any{"rechecked": members[0].ID})
|
||||||
|
}
|
||||||
|
if *group == "" || len(members) < 2 {
|
||||||
|
return errors.New("delivery check --group <id> --members <json> (two heads or more), or --members <one head>")
|
||||||
|
}
|
||||||
|
id, err := askGroupCheck(ctx, open, *group, members)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return answer(map[string]any{"asked": id, "group": *group})
|
||||||
|
case "go":
|
||||||
|
if len(positionals) != 1 {
|
||||||
|
return errors.New("delivery go <plan> [--by <who>] [--why <text>]")
|
||||||
|
}
|
||||||
|
var p inventory.Plan
|
||||||
|
if err := sayingOnTheBus(ctx, func() (err error) {
|
||||||
|
p, err = letGo(ctx, inv, positionals[0], *by, strings.TrimSpace(*why))
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s at %s) is let go by %s; its first tier is asked at the next pass\n", p.ID, p.Repository,
|
||||||
|
short(p.Commit), *by)
|
||||||
|
return nil
|
||||||
|
case "stop":
|
||||||
|
if len(positionals) != 1 || strings.TrimSpace(*why) == "" {
|
||||||
|
return errors.New("delivery stop <plan> --why <text> [--by <who>]")
|
||||||
|
}
|
||||||
|
var p inventory.Plan
|
||||||
|
if err := sayingOnTheBus(ctx, func() (err error) {
|
||||||
|
p, err = stopWalk(ctx, inv, positionals[0], *by, strings.TrimSpace(*why))
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s stopped by %s at tier %d of %d; what was asked still builds and registers, nothing further "+
|
||||||
|
"is asked or sent\n", p.ID, *by, p.Tier, len(p.Tiers))
|
||||||
|
return nil
|
||||||
|
case "walks":
|
||||||
|
var walks []inventory.Plan
|
||||||
|
if *planID != "" {
|
||||||
|
p, err := inv.PlanByID(ctx, *planID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
walks = []inventory.Plan{p}
|
||||||
|
} else {
|
||||||
|
if walks, err = inv.OpenPlans(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
recent, err := inv.RecentPlans(ctx, *limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range recent {
|
||||||
|
if !slices.ContainsFunc(walks, func(w inventory.Plan) bool { return w.ID == p.ID }) {
|
||||||
|
walks = append(walks, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return answer(map[string]any{"held": deliverySeatHeld(entries), "walks": walks,
|
||||||
|
"own-path": sortedKeysOf(ownPathWords())})
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delivery %s: plan, order, check, go, stop or walks", sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownPathWords is the controller's own path as words, for an answer.
|
||||||
|
func ownPathWords() map[string]string { return onTheControllersPath }
|
||||||
|
|
||||||
|
// theGraph is what the planner reads.
|
||||||
|
func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry, map[string][]inventory.ReadRepository,
|
||||||
|
[]inventory.Edge, error) {
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
edges, err := inv.Dependencies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return entries, read, edges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupPrimary is the head a group's composed check is run from: the one moving the controller, which then
|
||||||
|
// judges the group by itself; else the one moving the node-engine, whose validator judges; else the first.
|
||||||
|
func groupPrimary(members []orderMember, reach map[string]orderReach) int {
|
||||||
|
for _, want := range []string{"mesh-controller", "mesh-host"} {
|
||||||
|
for i, m := range members {
|
||||||
|
if slices.Contains(reach[m.ID].Moved, want) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// askGroupCheck asks the build seat for a group's composed check: every head laid over the mesh in turn,
|
||||||
|
// judged as one future state (novox/hq ADR 0239). The verdict comes back as `checked` with the group's id,
|
||||||
|
// for mesh-delivery; the forge's holder sets no status from it.
|
||||||
|
func askGroupCheck(ctx context.Context, open *stores, group string, members []orderMember) (string, error) {
|
||||||
|
entries, read, edges, err := theGraph(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
reach := reachOfMembers(members, entries, read, edges)
|
||||||
|
primary := groupPrimary(members, reach)
|
||||||
|
p := members[primary].pull()
|
||||||
|
scope := pullScope(p, entries, read, edges)
|
||||||
|
// The gate runs over what any member moves; a judge from the primary alone.
|
||||||
|
for _, m := range members {
|
||||||
|
r := reach[m.ID]
|
||||||
|
scope.Modules = appendNew(scope.Modules, r.Moved...)
|
||||||
|
scope.Dependents = appendNew(scope.Dependents, r.Dependents...)
|
||||||
|
}
|
||||||
|
request, err := checkRequestFor(ctx, open, p, scope, entries)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
request.Check.Group = group
|
||||||
|
world, err := theRestOfTheMesh(ctx, open.inventory, shelfOf(entries), "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for i, m := range members {
|
||||||
|
if i == primary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mp := m.pull()
|
||||||
|
// As the mesh clones a module built from it; a repository no module is built from, from the forge.
|
||||||
|
source := inventory.Source{Seat: gitSeat, Repository: mp.Owner + "/" + mp.Repo}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Provided && sameRepository(e.Source.Repository, link.SourceMoved{Owner: mp.Owner, Repo: mp.Repo}) {
|
||||||
|
source = e.Source
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url := source.Repository
|
||||||
|
if source.Seat != "" {
|
||||||
|
url, err = clonedFromSeat(world, source.Seat, source.Repository)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err)
|
||||||
|
}
|
||||||
|
request.Check.Members = append(request.Check.Members, link.GroupMember{Owner: mp.Owner, Repo: mp.Repo,
|
||||||
|
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths})
|
||||||
|
}
|
||||||
|
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.Fprintf(os.Stderr, "group %s: asked %s to check %d head(s) composed together, as %s\n", group, seat,
|
||||||
|
len(members), request.ID)
|
||||||
|
return request.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendNew appends what is not there yet.
|
||||||
|
func appendNew(to []string, items ...string) []string {
|
||||||
|
for _, i := range items {
|
||||||
|
if !slices.Contains(to, i) {
|
||||||
|
to = append(to, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
|
||||||
|
// shelfOf is the catalogue's manifests by name.
|
||||||
|
func shelfOf(entries []inventory.Entry) map[string]catalogue.Manifest {
|
||||||
|
shelf := map[string]catalogue.Manifest{}
|
||||||
|
for _, e := range entries {
|
||||||
|
shelf[e.Manifest.Module] = e.Manifest
|
||||||
|
}
|
||||||
|
return shelf
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayPlanMoved says a walk kept in a new state as the controller's `plan-moved`, the plan whole: what the
|
||||||
|
// delivery it walks reads its steps from. Never in the way of the walk: said beside it, and a save that could
|
||||||
|
// not be said is logged — the delivery's owner, which also asks for the walks it follows, finds it by
|
||||||
|
// comparison. Records arriving out of order are told apart by the plan's revision.
|
||||||
|
func sayPlanMoved(ctx context.Context, bus link.Bus, p inventory.Plan) {
|
||||||
|
go publishPlanMoved(ctx, bus, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishPlanMoved(ctx context.Context, bus link.Bus, p inventory.Plan) {
|
||||||
|
body, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
|
defer stop()
|
||||||
|
if err := bus.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyPlanMoved, body); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: kept, and could not be said as plan-moved: %v\n", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayingOnTheBus runs a command that keeps walks or says verdicts with the bus to say them on: the serving
|
||||||
|
// controller's, or a connection of the command's own — a verb runs as a command of its own, and what it kept
|
||||||
|
// would otherwise be said by nobody until the delivery's owner read the walks back. Without a bus the command
|
||||||
|
// still runs; what it did is found by comparison.
|
||||||
|
func sayingOnTheBus(ctx context.Context, f func() error) error {
|
||||||
|
if checkEvents != nil {
|
||||||
|
return f()
|
||||||
|
}
|
||||||
|
ran := false
|
||||||
|
err := onTheBus(func(conn *nats.Conn) error {
|
||||||
|
js, err := conn.JetStream()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bus := link.OverNATS{Conn: conn, JS: js}
|
||||||
|
checkEvents = bus
|
||||||
|
inventory.PlanSaved = func(p inventory.Plan) { publishPlanMoved(ctx, bus, p) }
|
||||||
|
defer func() { checkEvents, inventory.PlanSaved = nil, nil }()
|
||||||
|
ran = true
|
||||||
|
return f()
|
||||||
|
})
|
||||||
|
if !ran {
|
||||||
|
fmt.Fprintf(os.Stderr, "the bus cannot be reached (%v): what this does is not said, and is found by comparison\n", err)
|
||||||
|
return f()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0239 decision 4: a group's order, from the graph and the pull requests, the same on every
|
||||||
|
// reading — the build agent before what it builds, the controller before a manifest that needs it, the
|
||||||
|
// node-engine before the controller, a declared `after:` — and a contradiction named, never ordered.
|
||||||
|
func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
|
||||||
|
members := []orderMember{
|
||||||
|
{ID: "cat", Repository: "novox/mesh-catalog"},
|
||||||
|
{ID: "ctl", Repository: "novox/mesh-controller"},
|
||||||
|
{ID: "host", Repository: "novox/mesh-host"},
|
||||||
|
{ID: "agent", Repository: "novox/build-agent"},
|
||||||
|
{ID: "app", Repository: "novox/app", After: []string{"lab"}},
|
||||||
|
{ID: "lab", Repository: "novox/lab"},
|
||||||
|
}
|
||||||
|
reach := map[string]orderReach{
|
||||||
|
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
|
||||||
|
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
|
||||||
|
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
|
||||||
|
"agent": {Moved: []string{"build-agent"}},
|
||||||
|
"app": {Moved: []string{"app"}},
|
||||||
|
"lab": {Moved: []string{"lab"}},
|
||||||
|
}
|
||||||
|
edges := []inventory.Edge{{From: "app", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
||||||
|
{From: "gitea", To: "postgres", Kind: inventory.EdgeDeclared}}
|
||||||
|
got := orderOf(members, reach, edges)
|
||||||
|
if len(got.Cycle) > 0 {
|
||||||
|
t.Fatalf("a cycle where there is none: %v", got.Cycle)
|
||||||
|
}
|
||||||
|
// By repository among those with nothing before them: the build agent, the lab, then what waited on both.
|
||||||
|
want := []string{"agent", "lab", "app", "host", "ctl", "cat"}
|
||||||
|
if !reflect.DeepEqual(got.Order, want) {
|
||||||
|
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
|
||||||
|
}
|
||||||
|
why := map[string]string{}
|
||||||
|
for _, p := range got.Pairs {
|
||||||
|
why[p.Before+">"+p.After] = p.Why
|
||||||
|
}
|
||||||
|
for pair, reason := range map[string]string{"host>ctl": orderEngineFirst, "ctl>cat": orderVersionSkew,
|
||||||
|
"ctl>host": "", "agent>app": orderBuiltBy, "lab>app": orderDeclared} {
|
||||||
|
if why[pair] != reason {
|
||||||
|
t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The same members in another order read the same.
|
||||||
|
shuffled := []orderMember{members[5], members[3], members[0], members[4], members[2], members[1]}
|
||||||
|
if again := orderOf(shuffled, reach, edges); !reflect.DeepEqual(again.Order, want) {
|
||||||
|
t.Fatalf("another reading ordered %v", again.Order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A declared order against an inferred one is a cycle: named, and nothing ordered.
|
||||||
|
members[1].After = []string{"mesh-catalog"}
|
||||||
|
got = orderOf(members, reach, edges)
|
||||||
|
if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) {
|
||||||
|
t.Fatalf("the cycle is %v, ordered %v", got.Cycle, got.Order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0239 decision 8: a walk waits for the delivery's owner only while the seat has a holder on
|
||||||
|
// record, and never one that moves a module on the controller's own path.
|
||||||
|
func TestAWalkWaitsOnlyWhileTheDeliverySeatIsHeldAndNeverForTheCore(t *testing.T) {
|
||||||
|
holder := inventory.Entry{Manifest: catalogue.Manifest{Module: "mesh-delivery",
|
||||||
|
Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}}, On: []string{"anchor"}}
|
||||||
|
unassigned := holder
|
||||||
|
unassigned.On = nil
|
||||||
|
for _, c := range []struct {
|
||||||
|
entries []inventory.Entry
|
||||||
|
moved []string
|
||||||
|
waits bool
|
||||||
|
}{
|
||||||
|
{nil, []string{"gitea"}, false},
|
||||||
|
{[]inventory.Entry{unassigned}, []string{"gitea"}, false},
|
||||||
|
{[]inventory.Entry{holder}, []string{"gitea", "plex"}, true},
|
||||||
|
{[]inventory.Entry{holder}, []string{"gitea", "mesh-controller"}, false},
|
||||||
|
{[]inventory.Entry{holder}, []string{"mesh-host"}, false},
|
||||||
|
{[]inventory.Entry{holder}, []string{"node-tools"}, false},
|
||||||
|
{[]inventory.Entry{holder}, []string{"nats"}, false},
|
||||||
|
{[]inventory.Entry{holder}, []string{"mesh-delivery", "gitea"}, false},
|
||||||
|
} {
|
||||||
|
d := awaitsFor(c.entries, c.moved)
|
||||||
|
if (d != nil) != c.waits {
|
||||||
|
t.Errorf("held %v, moving %v: waits %v, wanted %v", len(c.entries) > 0 && len(c.entries[0].On) > 0,
|
||||||
|
c.moved, d != nil, c.waits)
|
||||||
|
}
|
||||||
|
if d != nil && d.Awaits != catalogue.DeliverySeat {
|
||||||
|
t.Errorf("waits for %q", d.Awaits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0239: with mesh-delivery on record, a merge opens its walk and asks nothing until the
|
||||||
|
// delivery's word; the word starts it; the core's own merge never waits; a person starts a waiting walk by
|
||||||
|
// hand when the owner is down, and the owner's stop ends one as stopped.
|
||||||
|
func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
asked := asksRecorded(t)
|
||||||
|
withConditionsInMemory(t)
|
||||||
|
for _, name := range []string{"app", "mesh-delivery"} {
|
||||||
|
m := catalogue.Manifest{Module: name, Version: "1"}
|
||||||
|
if name == "mesh-delivery" {
|
||||||
|
m.Claims = []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git",
|
||||||
|
Path: "modules/" + name, Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merge := func(commit string, paths ...string) inventory.Plan {
|
||||||
|
t.Helper()
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit, Paths: paths,
|
||||||
|
ModuleDirs: []string{"modules/app", "modules/mesh-delivery"}, ModuleDirsSaid: true}
|
||||||
|
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recent, err := inv.RecentPlans(ctx, 1)
|
||||||
|
if err != nil || len(recent) != 1 || recent[0].Commit != commit {
|
||||||
|
t.Fatalf("no plan for %s: %v %v", commit, recent, err)
|
||||||
|
}
|
||||||
|
return recent[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish ends a walk as done, so the next merge has nothing of it to take over.
|
||||||
|
finish := func(id string) {
|
||||||
|
t.Helper()
|
||||||
|
w, err := inv.PlanByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.State = inventory.PlanDone
|
||||||
|
if err := inv.SavePlan(ctx, &w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No holder on record: the merge starts its walk, as before.
|
||||||
|
p := merge("c1aaaaaaaa", "modules/app/index.ts")
|
||||||
|
if p.Waiting() || len(*asked) != 1 {
|
||||||
|
t.Fatalf("with no holder on record the walk waited (%v) or asked %v", p.Waiting(), *asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holder on record: the next merge waits, asking nothing, and an advance asks nothing either.
|
||||||
|
if _, err := inv.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p = merge("c2bbbbbbbb", "modules/app/index.ts")
|
||||||
|
if !p.Waiting() || len(*asked) != 1 || !strings.Contains(p.Note, "plans go "+p.ID) {
|
||||||
|
t.Fatalf("the walk did not wait for its word: waiting %v, asked %v, note %q", p.Waiting(), *asked, p.Note)
|
||||||
|
}
|
||||||
|
advanceHeld(ctx, open)
|
||||||
|
if len(*asked) != 1 {
|
||||||
|
t.Fatalf("a waiting walk was advanced into asking: %v", *asked)
|
||||||
|
}
|
||||||
|
if line := planLine(p, p.Created); strings.Contains(line, "LATE") || !strings.Contains(line, "waits for") {
|
||||||
|
t.Errorf("a waiting walk reads %q", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delivery's word starts it.
|
||||||
|
if err := deliveryCommand(ctx, []string{"go", p.ID, "--by", catalogue.DeliverySeat, "--why", "its turn"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := deliveryCommand(ctx, []string{"go", p.ID}); err == nil {
|
||||||
|
t.Fatal("a walk was let go twice")
|
||||||
|
}
|
||||||
|
advanceHeld(ctx, open)
|
||||||
|
if len(*asked) != 2 {
|
||||||
|
t.Fatalf("the word did not start the walk: %v", *asked)
|
||||||
|
}
|
||||||
|
got, err := inv.PlanByID(ctx, p.ID)
|
||||||
|
if err != nil || got.Delivery == nil || got.Delivery.By != catalogue.DeliverySeat || got.Delivery.Why != "its turn" {
|
||||||
|
t.Fatalf("the word was not kept: %+v %v", got.Delivery, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delivery's owner's own merge never waits for it — and takes over what the older walk had not
|
||||||
|
// built (app, folded in: ADR 0218), which goes with it on the controller's own path.
|
||||||
|
p = merge("c3cccccccc", "modules/mesh-delivery/main.go")
|
||||||
|
if p.Waiting() || len(*asked) != 4 {
|
||||||
|
t.Fatalf("mesh-delivery's own walk waited for mesh-delivery: %v %v", p.Waiting(), *asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner down: a person starts a waiting walk, with why.
|
||||||
|
finish(p.ID)
|
||||||
|
p = merge("c4dddddddd", "modules/app/index.ts")
|
||||||
|
if !p.Waiting() {
|
||||||
|
t.Fatal("the walk did not wait")
|
||||||
|
}
|
||||||
|
if err := plansCommand(ctx, []string{"go", p.ID}); err == nil || !strings.Contains(err.Error(), "--why") {
|
||||||
|
t.Fatalf("a walk was started by hand without why: %v", err)
|
||||||
|
}
|
||||||
|
if err := plansCommand(ctx, []string{"go", p.ID, "--why", "mesh-delivery is down"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advanceHeld(ctx, open)
|
||||||
|
got, _ = inv.PlanByID(ctx, p.ID)
|
||||||
|
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 5 {
|
||||||
|
t.Fatalf("a person's word did not start the walk: %+v, asked %v", got.Delivery, *asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner's stop: failed, said as stopped by it.
|
||||||
|
finish(p.ID)
|
||||||
|
p = merge("c5eeeeeeee", "modules/app/index.ts")
|
||||||
|
if err := deliveryCommand(ctx, []string{"stop", p.ID, "--why", "the operator stopped it", "--by",
|
||||||
|
"mesh-delivery for jochen"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ = inv.PlanByID(ctx, p.ID)
|
||||||
|
if got.State != inventory.PlanFailed || got.Delivery.Stopped != "mesh-delivery for jochen" ||
|
||||||
|
!strings.Contains(got.Note, "the operator stopped it") {
|
||||||
|
t.Fatalf("the stop was kept as %s %+v %q", got.State, got.Delivery, got.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigned: the mesh is back on the controller's own path.
|
||||||
|
if err := inv.Unassign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
asks := len(*asked)
|
||||||
|
if p = merge("c6ffffffff", "modules/app/index.ts"); p.Waiting() || len(*asked) != asks+1 {
|
||||||
|
t.Fatalf("with the holder gone the walk waited: %v", p.Waiting())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verbs mesh-delivery asks with become the commands they name, and nothing a caller sends is passed over.
|
||||||
|
func TestTheDeliveryVerbsComposeTheirCommands(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
verb string
|
||||||
|
args map[string]any
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"deliver", map[string]any{"plan": "plan-1", "why": "its turn"},
|
||||||
|
[]string{"delivery", "go", "plan-1", "--by", "mesh-delivery", "--why", "its turn"}},
|
||||||
|
{"delivery-stop", map[string]any{"plan": "plan-1", "why": "w", "by": "jochen"},
|
||||||
|
[]string{"delivery", "stop", "plan-1", "--why", "w", "--by", "mesh-delivery for jochen"}},
|
||||||
|
{"delivery-walks", map[string]any{}, []string{"delivery", "walks"}},
|
||||||
|
{"delivery-walks", map[string]any{"plan": "plan-1"}, []string{"delivery", "walks", "--plan", "plan-1"}},
|
||||||
|
{"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}},
|
||||||
|
{"delivery-check", map[string]any{"group": "feat/x", "members": "[]"},
|
||||||
|
[]string{"delivery", "check", "--group", "feat/x", "--members", "[]"}},
|
||||||
|
{"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x", "head": "c0"},
|
||||||
|
[]string{"delivery", "plan", "--repository", "novox/a", "--paths", "x", "--head", "c0"}},
|
||||||
|
{"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}},
|
||||||
|
} {
|
||||||
|
got, err := argvFor(c.verb, c.args)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("%s %v → %v %v, wanted %v", c.verb, c.args, got, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := argvFor("deliver", map[string]any{}); err == nil {
|
||||||
|
t.Error("deliver without a walk was composed")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("delivery-stop", map[string]any{"plan": "p"}); err == nil {
|
||||||
|
t.Error("a stop without why was composed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A verb runs as a command of its own: what it keeps of a walk is still said as plan-moved, on a bus of the
|
||||||
|
// command's own, so the delivery's owner hears it at once and not only when it reads the walks back.
|
||||||
|
func TestAWalkLetGoByAVerbIsSaidAsPlanMoved(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
url := testbus.URL(t)
|
||||||
|
js, err := broker.Dial(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(js.Close)
|
||||||
|
if err := broker.AssertMeshStreams(js); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := handActConn
|
||||||
|
handActConn = js.Conn()
|
||||||
|
t.Cleanup(func() { handActConn = before })
|
||||||
|
heard := make(chan *nats.Msg, 4)
|
||||||
|
sub, err := js.Conn().ChanSubscribe(link.SeatEventSubject(link.MeshControllerSeat, link.KeyPlanMoved), heard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||||
|
|
||||||
|
waiting := inventory.Plan{ID: "plan-waits", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c7c7c7c7",
|
||||||
|
Created: time.Now().UTC(), State: inventory.PlanBuilding, Tiers: [][]string{{"app"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"app": {}},
|
||||||
|
Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}}
|
||||||
|
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := deliveryCommand(ctx, []string{"go", waiting.ID, "--why", "its turn"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m := <-heard:
|
||||||
|
var said inventory.Plan
|
||||||
|
if err := json.Unmarshal(m.Data, &said); err != nil || said.ID != waiting.ID || said.Delivery == nil ||
|
||||||
|
said.Delivery.Go == nil {
|
||||||
|
t.Fatalf("plan-moved said %s (%v)", m.Data, err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("a walk let go by a verb was not said")
|
||||||
|
}
|
||||||
|
if checkEvents != nil || inventory.PlanSaved != nil {
|
||||||
|
t.Fatal("the command's own bus was left in place")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ var handActVerbs = []handActVerb{
|
|||||||
{Verb: "push"},
|
{Verb: "push"},
|
||||||
{Verb: "plans stop"},
|
{Verb: "plans stop"},
|
||||||
{Verb: "plans close"},
|
{Verb: "plans close"},
|
||||||
|
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
|
||||||
|
// not trusted with it — either is a repair the owner should have made.
|
||||||
|
{Verb: "plans go"},
|
||||||
{Verb: "broker consumer-reset"},
|
{Verb: "broker consumer-reset"},
|
||||||
// Silencing the same condition twice says the condition, or what it watches, wants mending.
|
// Silencing the same condition twice says the condition, or what it watches, wants mending.
|
||||||
{Verb: "conditions silence"},
|
{Verb: "conditions silence"},
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ func run() error {
|
|||||||
return collectionCommand(ctx, args[1:])
|
return collectionCommand(ctx, args[1:])
|
||||||
case "plans":
|
case "plans":
|
||||||
return plansCommand(ctx, args[1:])
|
return plansCommand(ctx, args[1:])
|
||||||
|
case "delivery":
|
||||||
|
// The verbs the delivery's owner asks with (novox/hq ADR 0239).
|
||||||
|
return deliveryCommand(ctx, args[1:])
|
||||||
case "pin":
|
case "pin":
|
||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
|
|||||||
@@ -61,12 +61,43 @@ type mergeCheckInput struct {
|
|||||||
repository, tree string
|
repository, tree string
|
||||||
// changed are the paths the change touches, for the width of its rebuild.
|
// changed are the paths the change touches, for the width of its rebuild.
|
||||||
changed []string
|
changed []string
|
||||||
|
// group are the other heads of a delivery group composed with this one (novox/hq ADR 0239), each laid
|
||||||
|
// over the mesh after the one before: the group judged as one future state.
|
||||||
|
group []groupChange
|
||||||
// admin is a PostgreSQL the gate may create and drop databases in.
|
// admin is a PostgreSQL the gate may create and drop databases in.
|
||||||
admin string
|
admin string
|
||||||
// say is where progress goes; the verdict is returned.
|
// say is where progress goes; the verdict is returned.
|
||||||
say io.Writer
|
say io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupChange is one other head of a delivery group: its repository, its checkout and what it changes.
|
||||||
|
type groupChange struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Tree string `json:"tree"`
|
||||||
|
Changed []string `json:"changed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGroup reads the other heads of a group's composed check from the file the build seat wrote.
|
||||||
|
func readGroup(path string) ([]groupChange, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var g []groupChange
|
||||||
|
if err := json.Unmarshal(raw, &g); err != nil {
|
||||||
|
return nil, fmt.Errorf("the group's heads are not readable: %w", err)
|
||||||
|
}
|
||||||
|
for _, c := range g {
|
||||||
|
if c.Repository == "" || c.Tree == "" {
|
||||||
|
return nil, errors.New("a head of the group names no repository or no checkout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeVerdict is what the gate found.
|
// mergeVerdict is what the gate found.
|
||||||
type mergeVerdict struct {
|
type mergeVerdict struct {
|
||||||
Verdict string `json:"verdict"` // pass, warning or fail
|
Verdict string `json:"verdict"` // pass, warning or fail
|
||||||
@@ -129,6 +160,8 @@ func mergeGateCommand(ctx context.Context, args []string) error {
|
|||||||
repository := set.String("repository", "", "owner/repository of the change")
|
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")
|
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")
|
changed := set.String("changed", "", "the paths the change touches, comma-separated, for its rebuild's width")
|
||||||
|
group := set.String("group", "", "a delivery group's other heads, as JSON [{repository, tree, changed}], composed "+
|
||||||
|
"with this one as one future state (novox/hq ADR 0239)")
|
||||||
asJSON := set.Bool("json", false, "print the verdict as JSON")
|
asJSON := set.Bool("json", false, "print the verdict as JSON")
|
||||||
if _, err := parseAround(set, args); err != nil {
|
if _, err := parseAround(set, args); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -144,12 +177,16 @@ func mergeGateCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
others, err := readGroup(*group)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := io.Writer(os.Stdout)
|
out := io.Writer(os.Stdout)
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
out = os.Stderr
|
out = os.Stderr
|
||||||
}
|
}
|
||||||
v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed),
|
v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed),
|
||||||
admin: *admin, say: out})
|
group: others, admin: *admin, say: out})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,24 +249,37 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
|||||||
// which may differ from what the mesh holds for reasons that are not this change's (a module pinned
|
// which may differ from what the mesh holds for reasons that are not this change's (a module pinned
|
||||||
// at an older commit, a main the mesh has not built yet).
|
// at an older commit, a main the mesh has not built yet).
|
||||||
var reach *mergeReach
|
var reach *mergeReach
|
||||||
if in.repository != "" && len(in.changed) > 0 {
|
// The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one
|
||||||
r, err := reachOfChange(in.facts, in.repository, in.changed, in.tree)
|
// future state of the mesh, judged as the group would leave it.
|
||||||
if err != nil {
|
heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...)
|
||||||
v.Notes = append(v.Notes, "what a merge of the change would rebuild could not be worked out, so every "+
|
for i, h := range heads {
|
||||||
"definition in its tree is judged: "+err.Error())
|
var r *mergeReach
|
||||||
} else {
|
if h.Repository != "" && len(h.Changed) > 0 {
|
||||||
reach = &r
|
got, err := reachOfChange(in.facts, h.Repository, h.Changed, h.Tree)
|
||||||
|
if err != nil {
|
||||||
|
v.Notes = append(v.Notes, "what a merge of "+h.Repository+" would rebuild could not be worked out, so "+
|
||||||
|
"every definition in its tree is judged: "+err.Error())
|
||||||
|
} else {
|
||||||
|
r = &got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
reach = r
|
||||||
|
}
|
||||||
|
if h.Tree == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if in.tree != "" {
|
|
||||||
var failures []string
|
var failures []string
|
||||||
change, failures, err = shelfWithChange(base, sources, in.facts, in.repository, in.tree, v.Modules,
|
change, failures, err = shelfWithChange(change, sources, in.facts, h.Repository, h.Tree, v.Modules,
|
||||||
scopeOfReach(reach, in.repository))
|
scopeOfReach(r, h.Repository))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
v.Failures = append(v.Failures, failures...)
|
v.Failures = append(v.Failures, failures...)
|
||||||
}
|
}
|
||||||
|
if len(in.group) > 0 {
|
||||||
|
say("a delivery group: %d head(s) composed together", len(heads))
|
||||||
|
}
|
||||||
say("judging %d module(s) changed against %d machine(s), as the snapshot of %s says they run",
|
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))
|
len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339))
|
||||||
|
|
||||||
|
|||||||
@@ -212,3 +212,72 @@ func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) {
|
|||||||
t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict)
|
t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A delivery group is judged as one future state** (novox/hq ADR 0239): a catalogue change that needs a
|
||||||
|
// provision only another repository's change adds fails alone and passes composed with it; and a group
|
||||||
|
// whose other head breaks what the first needs fails, naming it.
|
||||||
|
func TestADeliveryGroupsHeadsAreComposedTogether(t *testing.T) {
|
||||||
|
f, manifests := catalogueMeshWithAnApp(t)
|
||||||
|
needsTheApp := withEdit(manifests, "album", `{"module":"album","version":"1","requires":["s3-bucket","app-api"]}`)
|
||||||
|
catTree := aTree(t, needsTheApp)
|
||||||
|
alone := gateJudged(t, f, catTree, "modules/album/module.json")
|
||||||
|
if alone.Verdict != "fail" {
|
||||||
|
t.Fatalf("a requirement nothing provides passed alone:\n%s", alone.Report())
|
||||||
|
}
|
||||||
|
app := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1",
|
||||||
|
"provides":[{"name":"app-api","scope":"mesh","identity":false}]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in := mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), repository: "novox/mesh-catalog",
|
||||||
|
tree: catTree, changed: []string{"modules/album/module.json"},
|
||||||
|
group: []groupChange{{Repository: "novox/app", Tree: app, Changed: []string{"module.json"}}}}
|
||||||
|
together, err := judgeChange(t.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if together.Verdict == "fail" {
|
||||||
|
t.Fatalf("the group composed together fails:\n%s", together.Report())
|
||||||
|
}
|
||||||
|
if together.Modules["app"] != "changed" || together.Modules["album"] != "changed" {
|
||||||
|
t.Fatalf("the group's heads were not both laid over the mesh: %v", together.Modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueMeshWithAnApp is catalogueMesh with an application built from a repository of its own, at its
|
||||||
|
// root, on the anchor.
|
||||||
|
func catalogueMeshWithAnApp(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"}}`,
|
||||||
|
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
|
||||||
|
inventory.Source{Repository: "novox/app", BuiltFrom: "c0ffee"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "app"}, {"laptop", "album"}} {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// And a pull request's merge check, asked when the forge announces its head and said when judged
|
// And a pull request's merge check, asked when the forge announces its head and said when judged
|
||||||
// (novox/hq to-be 45 §9).
|
// (novox/hq to-be 45 §9).
|
||||||
checkEvents = bus
|
checkEvents = bus
|
||||||
|
// And every walk this controller keeps in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
|
inventory.PlanSaved = func(p inventory.Plan) { sayPlanMoved(ctx, bus, p) }
|
||||||
if err := server.Checks(following{open}); err != nil {
|
if err := server.Checks(following{open}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -538,6 +538,14 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
if p.Release != nil {
|
if p.Release != nil {
|
||||||
return advanceRelease(ctx, open, p)
|
return advanceRelease(ctx, open, p)
|
||||||
}
|
}
|
||||||
|
// **A walk that waits for its delivery's word asks nothing** (novox/hq ADR 0239): nothing of it is
|
||||||
|
// registered before its turn, so no other send carries it to a machine.
|
||||||
|
if p.Waiting() {
|
||||||
|
note := waitingNote(*p)
|
||||||
|
changed := p.Note != note
|
||||||
|
p.Note = note
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
if p.Tier >= len(p.Tiers) {
|
if p.Tier >= len(p.Tiers) {
|
||||||
p.State = inventory.PlanDone
|
p.State = inventory.PlanDone
|
||||||
fmt.Printf("%s: done — %s at %s, %d tier(s)\n", p.ID, p.Repository, short(p.Commit), len(p.Tiers))
|
fmt.Printf("%s: done — %s at %s, %d tier(s)\n", p.ID, p.Repository, short(p.Commit), len(p.Tiers))
|
||||||
@@ -1073,6 +1081,10 @@ func planLineWith(p inventory.Plan, now time.Time, pause pauseView) string {
|
|||||||
return fmt.Sprintf("%s %s %s", p.Repository, short(p.Commit), p.Note)
|
return fmt.Sprintf("%s %s %s", p.Repository, short(p.Commit), p.Note)
|
||||||
}
|
}
|
||||||
since := now.Sub(p.Updated).Round(time.Second)
|
since := now.Sub(p.Updated).Round(time.Second)
|
||||||
|
if p.Waiting() {
|
||||||
|
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
|
||||||
|
return fmt.Sprintf("%s %s %s, %s, for %s", p.Repository, short(p.Commit), where, waitingNote(p), since)
|
||||||
|
}
|
||||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||||
return fmt.Sprintf("%s %s %s, %s", p.Repository, short(p.Commit), where, waiting)
|
return fmt.Sprintf("%s %s %s, %s", p.Repository, short(p.Commit), where, waiting)
|
||||||
}
|
}
|
||||||
@@ -1205,7 +1217,7 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(positionals) == 2 && (positionals[0] == "stop" || positionals[0] == "close") {
|
if len(positionals) == 2 && (positionals[0] == "stop" || positionals[0] == "close" || positionals[0] == "go") {
|
||||||
// Refused before anything is opened: a repair by hand says why.
|
// Refused before anything is opened: a repair by hand says why.
|
||||||
if err := why.require("plans " + positionals[0]); err != nil {
|
if err := why.require("plans " + positionals[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1274,6 +1286,21 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
if *whatIf != "" {
|
if *whatIf != "" {
|
||||||
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs))
|
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs))
|
||||||
}
|
}
|
||||||
|
// `go` (novox/hq ADR 0239): a person's word in place of the delivery's owner, for a walk that waits for it
|
||||||
|
// — mesh-delivery down, or a person who will not wait. Recorded in the hand-act log with why.
|
||||||
|
if len(positionals) == 2 && positionals[0] == "go" {
|
||||||
|
why.record(ctx, "plans go", positionals[1:])
|
||||||
|
var p inventory.Plan
|
||||||
|
if err := sayingOnTheBus(ctx, func() (err error) {
|
||||||
|
p, err = letGo(ctx, inv, positionals[1], "a person ("+whoAsked()+")", strings.TrimSpace(*why.why))
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s at %s) is let go by hand; its first tier is asked at the next pass\n", p.ID, p.Repository,
|
||||||
|
short(p.Commit))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// `retry` (novox/hq ADR 0219): a failed plan's failed builds asked again, and the plan goes on.
|
// `retry` (novox/hq ADR 0219): a failed plan's failed builds asked again, and the plan goes on.
|
||||||
if len(positionals) == 2 && positionals[0] == "retry" {
|
if len(positionals) == 2 && positionals[0] == "retry" {
|
||||||
said, err := retryPlan(ctx, open, positionals[1])
|
said, err := retryPlan(ctx, open, positionals[1])
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
}
|
}
|
||||||
for _, act := range []string{"stop", "close", "retry"} {
|
for _, act := range []string{"stop", "close", "retry", "go"} {
|
||||||
if id := str(act); id != "" {
|
if id := str(act); id != "" {
|
||||||
argv := []string{"plans", act, id}
|
argv := []string{"plans", act, id}
|
||||||
if act == "retry" {
|
if act == "retry" {
|
||||||
@@ -313,6 +313,58 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "-n", n)
|
argv = append(argv, "-n", n)
|
||||||
}
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
|
// The delivery's owner's verbs (novox/hq ADR 0239).
|
||||||
|
case "delivery-plan":
|
||||||
|
if err := need("repository", "paths"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"delivery", "plan", "--repository", str("repository"), "--paths", str("paths")}
|
||||||
|
for _, flag := range []string{"head", "base", "module-dirs", "removed"} {
|
||||||
|
if v := str(flag); v != "" {
|
||||||
|
argv = append(argv, "--"+flag, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
case "delivery-order":
|
||||||
|
if err := need("members"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"delivery", "order", "--members", str("members")}, nil
|
||||||
|
case "delivery-check":
|
||||||
|
if err := need("members"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if g := str("group"); g != "" {
|
||||||
|
return []string{"delivery", "check", "--group", g, "--members", str("members")}, nil
|
||||||
|
}
|
||||||
|
return []string{"delivery", "check", "--members", str("members")}, nil
|
||||||
|
case "deliver":
|
||||||
|
if err := need("plan"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"delivery", "go", str("plan"), "--by", catalogue.DeliverySeat}
|
||||||
|
if w := str("why"); w != "" {
|
||||||
|
argv = append(argv, "--why", w)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
case "delivery-stop":
|
||||||
|
if err := need("plan", "why"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"delivery", "stop", str("plan"), "--why", str("why")}
|
||||||
|
if b := str("by"); b != "" {
|
||||||
|
argv = append(argv, "--by", catalogue.DeliverySeat+" for "+b)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
|
case "delivery-walks":
|
||||||
|
argv := []string{"delivery", "walks"}
|
||||||
|
if id := str("plan"); id != "" {
|
||||||
|
return append(argv, "--plan", id), nil
|
||||||
|
}
|
||||||
|
if n := str("limit"); n != "" {
|
||||||
|
argv = append(argv, "-n", n)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
// The build queue (novox/hq ADR 0219).
|
// The build queue (novox/hq ADR 0219).
|
||||||
case "queue":
|
case "queue":
|
||||||
return []string{"queue"}, nil
|
return []string{"queue"}, nil
|
||||||
@@ -657,7 +709,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
|
|
||||||
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
||||||
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "collection": true,
|
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "collection": true,
|
||||||
"hand-acts": true, "durations": true, "conditions": true, "doctor": true, "retire": true, "cleanup": true, "data": true}
|
"hand-acts": true, "durations": true, "conditions": true, "doctor": true, "retire": true, "cleanup": true, "data": true,
|
||||||
|
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||||
|
"delivery": true}
|
||||||
|
|
||||||
// repairingCommand names a command line that repairs by hand, and so says why: a push, a plan stopped
|
// repairingCommand names a command line that repairs by hand, and so says why: a push, a plan stopped
|
||||||
// or closed, a consumer re-made (novox/hq to-be 45 §7). Empty for any other.
|
// or closed, a consumer re-made (novox/hq to-be 45 §7). Empty for any other.
|
||||||
@@ -665,7 +719,7 @@ func repairingCommand(argv []string) string {
|
|||||||
switch {
|
switch {
|
||||||
case argv[0] == "push":
|
case argv[0] == "push":
|
||||||
return "push"
|
return "push"
|
||||||
case argv[0] == "plans" && len(argv) > 1 && (argv[1] == "stop" || argv[1] == "close"):
|
case argv[0] == "plans" && len(argv) > 1 && (argv[1] == "stop" || argv[1] == "close" || argv[1] == "go"):
|
||||||
return "plans " + argv[1]
|
return "plans " + argv[1]
|
||||||
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
||||||
return "broker consumer-reset"
|
return "broker consumer-reset"
|
||||||
|
|||||||
@@ -422,6 +422,9 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
strings.Join(also, ", "), plan.Repository)
|
strings.Join(also, ", "), plan.Repository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a
|
||||||
|
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
|
||||||
|
plan.Delivery = awaitsFor(entries, movedNames)
|
||||||
if hasCycle(plan.Tiers, edges) {
|
if hasCycle(plan.Tiers, edges) {
|
||||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||||
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
||||||
@@ -451,6 +454,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||||
"is left where it is\n", strings.Join(also, ", "))
|
"is left where it is\n", strings.Join(also, ", "))
|
||||||
}
|
}
|
||||||
|
if plan.Waiting() {
|
||||||
|
plan.Note = waitingNote(plan)
|
||||||
|
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s waits for %s's word before its first tier is asked\n", plan.ID, plan.Delivery.Awaits)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if err := askTier(ctx, inv, &plan); err != nil {
|
if err := askTier(ctx, inv, &plan); err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,6 +442,11 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time) (
|
|||||||
pause := buildSeatPause(ctx, inv, plans)
|
pause := buildSeatPause(ctx, inv, plans)
|
||||||
var out []planFacts
|
var out []planFacts
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
|
// A walk waiting for its delivery's word is not late (novox/hq ADR 0239): its owner keeps the bound
|
||||||
|
// of that wait, and a silent owner is its own condition (D3, holder-silent).
|
||||||
|
if p.Waiting() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, paused := pausedWaiting(p, pause, now)
|
_, paused := pausedWaiting(p, pause, now)
|
||||||
out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier,
|
out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier,
|
||||||
tiers: len(p.Tiers), entered: p.TierEntered, bound: max(tierAtLeast, 3*p90(measured[p.Repository])),
|
tiers: len(p.Tiers), entered: p.TierEntered, bound: max(tierAtLeast, 3*p90(measured[p.Repository])),
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func TestTheCataloguesDerivedSubjectsMeet(t *testing.T) {
|
|||||||
for _, want := range c.Consumes {
|
for _, want := range c.Consumes {
|
||||||
emitter, event, named := strings.Cut(want, ".")
|
emitter, event, named := strings.Cut(want, ".")
|
||||||
if named {
|
if named {
|
||||||
if s, isASeat := byName[emitter]; isASeat {
|
if s, isASeat := byName[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
||||||
principal.Watches = append(principal.Watches,
|
principal.Watches = append(principal.Watches,
|
||||||
Seat{Name: s.Name, Emits: []string{event}})
|
Seat{Name: s.Name, Emits: []string{event}})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
|||||||
states = append(states, link.KeyRolledBack)
|
states = append(states, link.KeyRolledBack)
|
||||||
// And a pull request's merge check, judged (novox/hq to-be 45 §9).
|
// And a pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||||
states = append(states, link.KeyChecked)
|
states = append(states, link.KeyChecked)
|
||||||
|
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
|
states = append(states, link.KeyPlanMoved)
|
||||||
for _, event := range states {
|
for _, event := range states {
|
||||||
if !slices.Contains(broker.ControllerStates, event) {
|
if !slices.Contains(broker.ControllerStates, event) {
|
||||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ var ControllerStates = []string{"applied", "refused", "built-before",
|
|||||||
"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
|
// 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.
|
// the pull request's status, and anybody else may read.
|
||||||
"checked"}
|
"checked",
|
||||||
|
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
||||||
|
"plan-moved"}
|
||||||
|
|
||||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
// 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
|
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ 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.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.*"] }
|
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.plan-moved", "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"] }
|
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" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ type CheckSpec struct {
|
|||||||
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
||||||
Toolchain string
|
Toolchain string
|
||||||
Toolchains map[string]string
|
Toolchains map[string]string
|
||||||
|
// Group are a delivery group's other heads (novox/hq ADR 0239), each cloned beside this one at its head
|
||||||
|
// and composed with it by the gate as one future state. The repository's own check is not run for a
|
||||||
|
// group: each member's pull request runs its own.
|
||||||
|
Group []GroupHead
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupHead is one other head of a delivery group's composed check.
|
||||||
|
type GroupHead struct {
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
Repository string
|
||||||
|
Ref string
|
||||||
|
Paths []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gated is whether the change touches the mesh's graph, and so whether the gate runs.
|
// Gated is whether the change touches the mesh's graph, and so whether the gate runs.
|
||||||
@@ -141,6 +154,9 @@ const (
|
|||||||
EnvVerdict = "MESH_CHECK_VERDICT"
|
EnvVerdict = "MESH_CHECK_VERDICT"
|
||||||
EnvBeside = "MESH_CHECK_BESIDE"
|
EnvBeside = "MESH_CHECK_BESIDE"
|
||||||
EnvModules = "MESH_CHECK_MODULES"
|
EnvModules = "MESH_CHECK_MODULES"
|
||||||
|
// EnvGroup is a delivery group's other heads, as JSON, for the gate (novox/hq ADR 0239); empty for a
|
||||||
|
// pull request checked alone.
|
||||||
|
EnvGroup = "MESH_CHECK_GROUP"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckTimeout bounds one check; a check that runs past it is an error, not a pass.
|
// CheckTimeout bounds one check; a check that runs past it is an error, not a pass.
|
||||||
@@ -234,6 +250,32 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
say("check", "beside it %s at %s", dir, short(b.Ref))
|
say("check", "beside it %s at %s", dir, short(b.Ref))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
|
||||||
|
groupFile := ""
|
||||||
|
if len(spec.Group) > 0 {
|
||||||
|
var heads []map[string]any
|
||||||
|
for i, g := range spec.Group {
|
||||||
|
dir := fmt.Sprintf("group-%d", i)
|
||||||
|
if g.Repo != "" && safeName.MatchString(g.Repo) {
|
||||||
|
dir = "group-" + g.Repo
|
||||||
|
}
|
||||||
|
if err := clone(g.Repository, g.Ref, dir); err != nil {
|
||||||
|
return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err)
|
||||||
|
}
|
||||||
|
say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref))
|
||||||
|
heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo,
|
||||||
|
"tree": filepath.Join(root, dir), "changed": g.Paths})
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(heads)
|
||||||
|
if err != nil {
|
||||||
|
return CheckVerdict{}, err
|
||||||
|
}
|
||||||
|
groupFile = filepath.Join(root, "group.json")
|
||||||
|
if err := os.WriteFile(groupFile, raw, 0o644); err != nil {
|
||||||
|
return CheckVerdict{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The facts, and the versions they say the mesh runs.
|
// The facts, and the versions they say the mesh runs.
|
||||||
if registry == "" {
|
if registry == "" {
|
||||||
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
||||||
@@ -316,6 +358,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
||||||
EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root,
|
EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root,
|
||||||
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
||||||
|
EnvGroup + "=" + groupFile,
|
||||||
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
||||||
|
|
||||||
v := CheckVerdict{}
|
v := CheckVerdict{}
|
||||||
@@ -346,6 +389,10 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
|
|
||||||
// **The repository's own check**, in the toolchain it declares.
|
// **The repository's own check**, in the toolchain it declares.
|
||||||
switch {
|
switch {
|
||||||
|
case len(spec.Group) > 0:
|
||||||
|
// A group's composed check judges the heads together; each member's own tests are its pull request's.
|
||||||
|
v.Repo = &Layer{Verdict: "pass", Summary: "a group's composed check: each member's own " + CheckScript +
|
||||||
|
" runs on its pull request"}
|
||||||
case !hasScript:
|
case !hasScript:
|
||||||
v.Repo = &Layer{Verdict: "warning", Summary: noScript}
|
v.Repo = &Layer{Verdict: "warning", Summary: noScript}
|
||||||
case timedOut():
|
case timedOut():
|
||||||
@@ -447,7 +494,8 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
|||||||
// 2. Every machine composed with the change.
|
// 2. Every machine composed with the change.
|
||||||
if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c",
|
if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c",
|
||||||
`"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+
|
`"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+
|
||||||
`--repository "$MESH_CHECK_REPOSITORY" --tree . --changed "$MESH_CHECK_CHANGED" --json > "$MESH_CHECK_VERDICT"`)); err != nil {
|
`--repository "$MESH_CHECK_REPOSITORY" --tree . --changed "$MESH_CHECK_CHANGED" `+
|
||||||
|
`${MESH_CHECK_GROUP:+--group "$MESH_CHECK_GROUP"} --json > "$MESH_CHECK_VERDICT"`)); err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "error", "the check was ended during the merge gate"
|
return "error", "the check was ended during the merge gate"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ var aJudge = map[string]string{
|
|||||||
"cmd/mesh-controller/main.go": `package main
|
"cmd/mesh-controller/main.go": `package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -252,6 +253,24 @@ func main() {
|
|||||||
fmt.Println(m + ": ok")
|
fmt.Println(m + ": ok")
|
||||||
}
|
}
|
||||||
case os.Args[1] == "merge-gate":
|
case os.Args[1] == "merge-gate":
|
||||||
|
for i, a := range os.Args {
|
||||||
|
if a == "--group" && i+1 < len(os.Args) {
|
||||||
|
body, _ := os.ReadFile(os.Args[i+1])
|
||||||
|
var heads []struct {
|
||||||
|
Repository string ` + "`json:\"repository\"`" + `
|
||||||
|
Tree string ` + "`json:\"tree\"`" + `
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &heads)
|
||||||
|
var said []string
|
||||||
|
for _, h := range heads {
|
||||||
|
if _, err := os.Stat(h.Tree + "/module.json"); err == nil {
|
||||||
|
said = append(said, h.Repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"composed with %s\"}\n", strings.Join(said, ","))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Println(` + "`" + `{"verdict":"warning","summary":"a merge rebuilds 14 module(s)"}` + "`" + `)
|
fmt.Println(` + "`" + `{"verdict":"warning","summary":"a merge rebuilds 14 module(s)"}` + "`" + `)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +342,27 @@ func TestTheGateRunsWhenTheGraphIsTouchedBesideTheRepositorysOwnCheck(t *testing
|
|||||||
t.Fatalf("a fault the base already had failed the change: %+v\n%s", v.Gate, v.Report)
|
t.Fatalf("a fault the base already had failed the change: %+v\n%s", v.Gate, v.Report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A delivery group (novox/hq ADR 0239): the other heads cloned beside it at their heads and handed to the
|
||||||
|
// gate to compose with this one; the repository's own check is each pull request's, not the group's.
|
||||||
|
app, appHead := aCheckedRepository(t, map[string]string{"module.json": `{"module":"app"}`})
|
||||||
|
repo2, head2 := aCheckedRepository(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||||
|
CheckScript: "exit 1\n"})
|
||||||
|
gid := fmt.Sprintf("check-group-%d", time.Now().UnixNano())
|
||||||
|
v, err = Check(t.Context(), Command, CheckSpec{ID: gid, Repository: repo2, Ref: head2, Owner: "novox",
|
||||||
|
Repo: "mesh-catalog", Paths: []string{"modules/gitea/module.json"}, Beside: beside, Modules: []string{"gitea"},
|
||||||
|
Manifests: []string{"modules/gitea/module.json"}, Toolchain: goToolchain,
|
||||||
|
Group: []GroupHead{{Owner: "novox", Repo: "app", Repository: app, Ref: appHead, Paths: []string{"module.json"}}}},
|
||||||
|
t.TempDir(), registry, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v.Gate.Verdict != "pass" || v.Gate.Summary != "composed with novox/app" {
|
||||||
|
t.Fatalf("the group's other head was not composed: %+v\n%s", v.Gate, v.Report)
|
||||||
|
}
|
||||||
|
if v.Repo == nil || v.Repo.Verdict != "pass" || !strings.Contains(v.Repo.Summary, "group") {
|
||||||
|
t.Fatalf("a group's check ran a member's own script: %+v", v.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
// A change to the controller judges itself: one that does not build fails its own gate.
|
// A change to the controller judges itself: one that does not build fails its own gate.
|
||||||
v = check(map[string]string{"go.mod": "module x\n\ngo 1.22\n", "cmd/mesh-controller/main.go": "package main\nfunc main() { nope }\n",
|
v = check(map[string]string{"go.mod": "module x\n\ngo 1.22\n", "cmd/mesh-controller/main.go": "package main\nfunc main() { nope }\n",
|
||||||
"modules/gitea/module.json": "{}"}, "self")
|
"modules/gitea/module.json": "{}"}, "self")
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
|
||||||
|
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
|
||||||
|
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
|
||||||
|
// one name whoever holds it, and the controller can tell whether anything holds it: while nothing does,
|
||||||
|
// the controller starts every walk itself, as it did before there was a delivery to own.
|
||||||
|
|
||||||
|
// DeliverySeat is the seat mesh-delivery holds.
|
||||||
|
const DeliverySeat = "mesh-delivery"
|
||||||
|
|
||||||
|
// deliveryVerbs are the delivery seat's tools: five that read, and the acts of a person and of healer H2.
|
||||||
|
func deliveryVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||||||
|
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
||||||
|
"by state, repository or group.",
|
||||||
|
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||||||
|
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||||||
|
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||||||
|
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||||||
|
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||||||
|
"steps of its walk, its group and its order.",
|
||||||
|
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
||||||
|
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
||||||
|
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
||||||
|
"state, derived from its members.",
|
||||||
|
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
||||||
|
{Name: "what-if", Description: "The delivery plan a change would have, asked of the controller's planner " +
|
||||||
|
"and kept nowhere: the modules it moves and their dependents in tiers, what each machine would receive, " +
|
||||||
|
"the steps that are not an ordinary send.",
|
||||||
|
Input: schema(map[string]string{"repository": "owner/repository",
|
||||||
|
"paths": "the files it changes, comma-separated, from the repository's root",
|
||||||
|
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
|
||||||
|
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
||||||
|
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
||||||
|
"lets healer H2 take for it, or none.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
||||||
|
"controller is asked for its check. With why.",
|
||||||
|
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||||
|
[]string{"id", "why"})},
|
||||||
|
{Name: "release", Description: "A person's word that a held delivery goes on: it starts delivering. With why.",
|
||||||
|
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||||
|
[]string{"id", "why"})},
|
||||||
|
{Name: "stop", Description: "Stop a delivery that is not final, with why; its walk is ended through the " +
|
||||||
|
"controller, and in a group every member after it is stopped too, naming it.",
|
||||||
|
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||||
|
[]string{"id", "why"})},
|
||||||
|
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
||||||
|
"past its bound. Refused for any other.",
|
||||||
|
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||||
|
[]string{"id", "why"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
|
||||||
|
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
|
||||||
|
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
|
||||||
|
// and the module holding it, and what the module says it says as itself, through the runtime that launches
|
||||||
|
// it — read as the seat's, a consumer would subscribe a subject nothing may publish.
|
||||||
|
func SeatSays(emits []string, event string) bool {
|
||||||
|
for _, e := range emits {
|
||||||
|
if e == event || topicMatches(e, event) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
|
||||||
|
// `>` or `**` the rest.
|
||||||
|
func topicMatches(pattern, event string) bool {
|
||||||
|
p, e := splitDots(pattern), splitDots(event)
|
||||||
|
for i, part := range p {
|
||||||
|
if part == ">" || part == "**" {
|
||||||
|
return len(e) > i
|
||||||
|
}
|
||||||
|
if i >= len(e) || (part != "*" && part != e[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p) == len(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitDots(s string) []string {
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '.' {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(out, s[start:])
|
||||||
|
}
|
||||||
@@ -93,7 +93,9 @@ var defaultSeats = append([]Seat{
|
|||||||
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
|
// 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).
|
// A pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||||
"checked"},
|
"checked",
|
||||||
|
// A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
|
"plan-moved"},
|
||||||
Serves: ControllerVerbs},
|
Serves: ControllerVerbs},
|
||||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
// 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.
|
// served by whichever module holds the seat with tools of these names.
|
||||||
@@ -121,6 +123,10 @@ var defaultSeats = append([]Seat{
|
|||||||
// image registry.
|
// image registry.
|
||||||
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||||
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
||||||
|
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
|
||||||
|
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
||||||
|
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
||||||
|
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
||||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Thirty-six since node-resolver-config retired into node-uplink (novox/hq ADR 0223); thirty-seven
|
// Thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||||
|
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||||
// thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now
|
// thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now
|
||||||
@@ -56,8 +57,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||||
if len(Seats()) != 36 {
|
if len(Seats()) != 37 {
|
||||||
t.Errorf("the mesh defines %d seats rather than 36; the set is closed, so a change here is "+
|
t.Errorf("the mesh defines %d seats rather than 37; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,19 +102,55 @@ var ControllerVerbs = []Verb{
|
|||||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
||||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
||||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
||||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
||||||
|
"go": "a walk's id that waits for its delivery's word: start it by hand, in place of mesh-delivery — with why, " +
|
||||||
|
"recorded in the hand-act log (novox/hq ADR 0239)",
|
||||||
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
||||||
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
||||||
"modules": "with repository: or the modules it would change, comma-separated",
|
"modules": "with repository: or the modules it would change, comma-separated",
|
||||||
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
|
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
|
||||||
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
|
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
|
||||||
"limit": "how many plans to list (default 10); only when listing",
|
"limit": "how many plans to list (default 10); only when listing",
|
||||||
"why": "with stop or close: why it is ended by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
||||||
"cause": "with stop or close: the cause in a word, or a condition's kind (optional)",
|
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
|
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
|
||||||
|
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
|
||||||
|
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
|
||||||
|
"nothing (novox/hq ADR 0239): what it moves and builds after them in tiers, what each machine receives and " +
|
||||||
|
"what waits there, the steps that are not an ordinary send, and whether the gate would run.",
|
||||||
|
Input: schema(map[string]string{"repository": "owner/repository", "head": "the commit at hand",
|
||||||
|
"base": "the branch it merges into (default main)",
|
||||||
|
"paths": "the files it changes, comma-separated, from the repository's root",
|
||||||
|
"module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them",
|
||||||
|
"removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})},
|
||||||
|
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0239): its members in the order " +
|
||||||
|
"they are delivered, every pair and why — declared, built by, version skew, engine before controller — and " +
|
||||||
|
"the cycle when the pairs contradict each other.",
|
||||||
|
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
|
||||||
|
"module_dirs, module_dirs_said, removed, after}]"}, []string{"members"})},
|
||||||
|
{Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " +
|
||||||
|
"0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " +
|
||||||
|
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
|
||||||
|
"again as its pull request is.",
|
||||||
|
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
|
||||||
|
"members": "the members, as JSON, as delivery-order takes them"}, []string{"members"})},
|
||||||
|
{Name: "deliver", Description: "The delivery's word that a walk waiting for it may start (novox/hq ADR 0239): its " +
|
||||||
|
"first tier is asked at the controller's next pass. Refused for a walk that waits for nobody.",
|
||||||
|
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
|
||||||
|
[]string{"plan"})},
|
||||||
|
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
|
||||||
|
"by whom and why; what it asked still builds and registers, nothing further is asked or sent.",
|
||||||
|
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery"},
|
||||||
|
[]string{"plan", "why"})},
|
||||||
|
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
|
||||||
|
"last ended ones, each whole — its tiers, each module's state, first machines and gate — and whether the " +
|
||||||
|
"delivery seat has a holder on record. Given a plan, that one.",
|
||||||
|
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
|
||||||
|
nil)},
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
||||||
"or, with files, the files it would be given.",
|
"or, with files, the files it would be given.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
for _, c := range m.Consumes {
|
for _, c := range m.Consumes {
|
||||||
emitter, event, named := strings.Cut(c, ".")
|
emitter, event, named := strings.Cut(c, ".")
|
||||||
if named {
|
if named {
|
||||||
if s, isASeat := seats[emitter]; isASeat {
|
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
||||||
|
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
||||||
|
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
||||||
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- A walk waits for its delivery's word (novox/hq ADR 0239).
|
||||||
|
--
|
||||||
|
-- A plan is from now on the walk of one delivery's trunk commit across the machines: tier by tier, one
|
||||||
|
-- machine first and judged at the gate. While the mesh-delivery seat has a holder on record, a walk that
|
||||||
|
-- moves no core module is opened by the merge and waits — nothing asked, nothing registered, nothing sent —
|
||||||
|
-- until the delivery's owner says it may start, or a person does (`plans go`). Kept with the plan, as one
|
||||||
|
-- document: who it waits for, and when, by whom and why it was let go or stopped. Null for every plan
|
||||||
|
-- before this and for a walk on the controller's own path, which waits for nobody.
|
||||||
|
alter table release_plan add column delivery jsonb;
|
||||||
@@ -41,8 +41,35 @@ type Plan struct {
|
|||||||
// Release is set on a release plan (novox/hq ADR 0236): not a merge's, but the builds waiting for a
|
// Release is set on a release plan (novox/hq ADR 0236): not a merge's, but the builds waiting for a
|
||||||
// gate, walked through the machines one at a time.
|
// gate, walked through the machines one at a time.
|
||||||
Release *PlanRelease `json:"release,omitempty"`
|
Release *PlanRelease `json:"release,omitempty"`
|
||||||
|
// Delivery is set on a walk that waits for its delivery's word (novox/hq ADR 0239): nil for a walk on
|
||||||
|
// the controller's own path, which starts at the merge as every plan did before.
|
||||||
|
Delivery *PlanDelivery `json:"delivery,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanDelivery is what a walk waits for and what came of the wait (novox/hq ADR 0239).
|
||||||
|
type PlanDelivery struct {
|
||||||
|
// Awaits is who must say the walk may start: the seat whose holder owns the delivery.
|
||||||
|
Awaits string `json:"awaits"`
|
||||||
|
// Go is when it was let go, By by whom (the seat's holder, or a person's name) and Why.
|
||||||
|
Go *time.Time `json:"go,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
// Stopped is who ended the walk through the delivery's owner, and StoppedWhy why: the walk is failed,
|
||||||
|
// and the delivery reads it as stopped, not as a build that failed.
|
||||||
|
Stopped string `json:"stopped,omitempty"`
|
||||||
|
StoppedWhy string `json:"stopped_why,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting is whether the walk waits for its delivery's word.
|
||||||
|
func (p Plan) Waiting() bool {
|
||||||
|
return p.Delivery != nil && p.Delivery.Awaits != "" && p.Delivery.Go == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanSaved is told every plan this process kept, after it is kept (novox/hq ADR 0239): the serving
|
||||||
|
// controller says it on the bus as `plan-moved`. Nil in a command, which says nothing; whoever follows a
|
||||||
|
// walk also asks for it, so a save made by a command is found by comparison.
|
||||||
|
var PlanSaved func(Plan)
|
||||||
|
|
||||||
// ErrPlanMoved is a save against a plan written by somebody else since it was read.
|
// ErrPlanMoved is a save against a plan written by somebody else since it was read.
|
||||||
var ErrPlanMoved = errors.New("the plan was written by somebody else since it was read")
|
var ErrPlanMoved = errors.New("the plan was written by somebody else since it was read")
|
||||||
|
|
||||||
@@ -174,12 +201,17 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var release []byte
|
var release, delivery []byte
|
||||||
if p.Release != nil {
|
if p.Release != nil {
|
||||||
if release, err = json.Marshal(p.Release); err != nil {
|
if release, err = json.Marshal(p.Release); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.Delivery != nil {
|
||||||
|
if delivery, err = json.Marshal(p.Delivery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
// **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the
|
// **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the
|
||||||
// plan moves, in the same transaction as the move, so no save can move a tier unmeasured or
|
// plan moves, in the same transaction as the move, so no save can move a tier unmeasured or
|
||||||
// measure one twice.
|
// measure one twice.
|
||||||
@@ -195,16 +227,16 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
|||||||
var revision int64
|
var revision int64
|
||||||
err = tx.QueryRow(ctx,
|
err = tx.QueryRow(ctx,
|
||||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
||||||
branch, tier_entered, revision, epoch, release)
|
branch, tier_entered, revision, epoch, release, delivery)
|
||||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14)
|
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15)
|
||||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
||||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
||||||
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
|
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
|
||||||
release = excluded.release
|
release = excluded.release, delivery = excluded.delivery
|
||||||
where release_plan.revision = $12
|
where release_plan.revision = $12
|
||||||
returning revision`,
|
returning revision`,
|
||||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
||||||
p.Revision, epoch, release).Scan(&revision)
|
p.Revision, epoch, release, delivery).Scan(&revision)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
// The row is there and at another revision — moved since this was read, or there already
|
// The row is there and at another revision — moved since this was read, or there already
|
||||||
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
||||||
@@ -223,6 +255,9 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
|||||||
} else {
|
} else {
|
||||||
p.Epoch = 0
|
p.Epoch = 0
|
||||||
}
|
}
|
||||||
|
if PlanSaved != nil {
|
||||||
|
PlanSaved(*p)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +294,7 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
|||||||
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
||||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release
|
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery
|
||||||
from release_plan `+tail)
|
from release_plan `+tail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -268,10 +303,11 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
|||||||
var out []Plan
|
var out []Plan
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Plan
|
var p Plan
|
||||||
var tiers, modules, release []byte
|
var tiers, modules, release, delivery []byte
|
||||||
var epoch int64
|
var epoch int64
|
||||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
||||||
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release); err != nil {
|
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release,
|
||||||
|
&delivery); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(release) > 0 {
|
if len(release) > 0 {
|
||||||
@@ -279,6 +315,11 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(delivery) > 0 {
|
||||||
|
if err := json.Unmarshal(delivery, &p.Delivery); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
p.Epoch = uint64(epoch)
|
p.Epoch = uint64(epoch)
|
||||||
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
|
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -123,6 +123,21 @@ type CheckRequest struct {
|
|||||||
// the controller, judged by itself; JudgeValidator for a change to the node-engine, judged by the
|
// the controller, judged by itself; JudgeValidator for a change to the node-engine, judged by the
|
||||||
// running controller built with the change's validator in place of the one it vendors.
|
// running controller built with the change's validator in place of the one it vendors.
|
||||||
Judge string `json:"judge,omitempty"`
|
Judge string `json:"judge,omitempty"`
|
||||||
|
// Group names a delivery group whose members' heads are composed together with this one (novox/hq ADR
|
||||||
|
// 0239), and Members are those other heads, each cloned beside it and laid over the mesh in turn. The
|
||||||
|
// repository's own check is not run for a group: each member's pull request runs its own.
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Members []GroupMember `json:"members,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMember is one other head of a delivery group's composed check.
|
||||||
|
type GroupMember struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Paths []string `json:"paths,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Who judges a merge check's gate.
|
// Who judges a merge check's gate.
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ const (
|
|||||||
// KeyChecked: a pull request's merge check was judged (novox/hq to-be 45 §9) — the verdict, its
|
// 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.
|
// summary and its report, for the forge's holder to set as the pull request's status.
|
||||||
KeyChecked = "checked"
|
KeyChecked = "checked"
|
||||||
|
// KeyPlanMoved: a walk — the controller's plan of one trunk commit, tier by tier across the machines —
|
||||||
|
// was kept in a new state (novox/hq ADR 0239): the plan whole, for the delivery it walks to read its
|
||||||
|
// steps from. Said after every save of the serving controller; a delivery's holder also asks for the
|
||||||
|
// walks it follows, so a save made elsewhere is found by comparison, never lost.
|
||||||
|
KeyPlanMoved = "plan-moved"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Applied is what a machine now runs, as the mesh states it.
|
// Applied is what a machine now runs, as the mesh states it.
|
||||||
@@ -261,6 +266,19 @@ type Checked struct {
|
|||||||
// Plan is the change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build
|
// Plan is the change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build
|
||||||
// and send, posted with the verdict.
|
// and send, posted with the verdict.
|
||||||
Plan *ChangePlan `json:"plan,omitempty"`
|
Plan *ChangePlan `json:"plan,omitempty"`
|
||||||
|
// Group is set on a delivery group's composed check (novox/hq ADR 0239): every member's head composed
|
||||||
|
// together as one future state. Members are the heads judged. A forge's holder sets no merge-gate
|
||||||
|
// status from it — the group's verdict is its owner's, mesh-delivery, to say on each member's head.
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Members []CheckedMember `json:"members,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckedMember is one head a group's composed check judged.
|
||||||
|
type CheckedMember struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it
|
// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it
|
||||||
|
|||||||
@@ -35,6 +35,12 @@
|
|||||||
"seats",
|
"seats",
|
||||||
"builds",
|
"builds",
|
||||||
"plans",
|
"plans",
|
||||||
|
"delivery-plan",
|
||||||
|
"delivery-order",
|
||||||
|
"delivery-check",
|
||||||
|
"deliver",
|
||||||
|
"delivery-stop",
|
||||||
|
"delivery-walks",
|
||||||
"plan",
|
"plan",
|
||||||
"assign",
|
"assign",
|
||||||
"unassign",
|
"unassign",
|
||||||
|
|||||||
Reference in New Issue
Block a user