diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index e22a0ee..bbb446a 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -340,6 +340,10 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec { for dir, b := range c.Beside { 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 } diff --git a/cmd/mesh-controller/checks.go b/cmd/mesh-controller/checks.go index ee457a6..863625f 100644 --- a/cmd/mesh-controller/checks.go +++ b/cmd/mesh-controller/checks.go @@ -338,6 +338,16 @@ func checkedOf(result link.BuildResult) link.Checked { } if result.Checked != nil { 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} { if l != nil && l.Verdict == "" { diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go new file mode 100644 index 0000000..cddb694 --- /dev/null +++ b/cmd/mesh-controller/delivery.go @@ -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: `). + 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 [--base main] --paths a,b [--module-dirs …] [--removed …] +// delivery order --members +// delivery check --group --members +// delivery go [--by ] [--why ] +// delivery stop --why [--by ] +// delivery walks [-n 50] [--plan ] +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 --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 : 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 --members (two heads or more), or --members ") + } + 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 [--by ] [--why ]") + } + 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 --why [--by ]") + } + 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 +} diff --git a/cmd/mesh-controller/delivery_test.go b/cmd/mesh-controller/delivery_test.go new file mode 100644 index 0000000..6f68549 --- /dev/null +++ b/cmd/mesh-controller/delivery_test.go @@ -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") + } +} diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index a3a66f0..f1b9109 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -51,6 +51,9 @@ var handActVerbs = []handActVerb{ {Verb: "push"}, {Verb: "plans stop"}, {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"}, // Silencing the same condition twice says the condition, or what it watches, wants mending. {Verb: "conditions silence"}, diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 85e0873..223068d 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -95,6 +95,9 @@ func run() error { return collectionCommand(ctx, args[1:]) case "plans": 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": return pinCommand(ctx, args[1:], true) case "unpin": diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index 4e6b0fb..3100110 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -61,12 +61,43 @@ type mergeCheckInput struct { repository, tree string // changed are the paths the change touches, for the width of its rebuild. 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 string // say is where progress goes; the verdict is returned. 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. type mergeVerdict struct { 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") 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") + 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") if _, err := parseAround(set, args); err != nil { return err @@ -144,12 +177,16 @@ func mergeGateCommand(ctx context.Context, args []string) error { if err != nil { return err } + others, err := readGroup(*group) + if err != nil { + return err + } out := io.Writer(os.Stdout) if *asJSON { out = os.Stderr } v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed), - admin: *admin, say: out}) + group: others, admin: *admin, say: out}) if err != nil { 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 // at an older commit, a main the mesh has not built yet). var reach *mergeReach - if in.repository != "" && len(in.changed) > 0 { - r, err := reachOfChange(in.facts, in.repository, in.changed, in.tree) - if err != nil { - v.Notes = append(v.Notes, "what a merge of the change would rebuild could not be worked out, so every "+ - "definition in its tree is judged: "+err.Error()) - } else { - reach = &r + // The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one + // future state of the mesh, judged as the group would leave it. + heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) + for i, h := range heads { + var r *mergeReach + if h.Repository != "" && len(h.Changed) > 0 { + 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 - change, failures, err = shelfWithChange(base, sources, in.facts, in.repository, in.tree, v.Modules, - scopeOfReach(reach, in.repository)) + change, failures, err = shelfWithChange(change, sources, in.facts, h.Repository, h.Tree, v.Modules, + scopeOfReach(r, h.Repository)) if err != nil { return v, err } 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", len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339)) diff --git a/cmd/mesh-controller/merge_gate_test.go b/cmd/mesh-controller/merge_gate_test.go index 95fc9b4..cb42e03 100644 --- a/cmd/mesh-controller/merge_gate_test.go +++ b/cmd/mesh-controller/merge_gate_test.go @@ -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) } } + +// **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 +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 4ad2590..fdc34ca 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -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 // (novox/hq to-be 45 §9). 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 { return err } diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 0ad9255..0fe1d45 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -538,6 +538,14 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan, if p.Release != nil { 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) { 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)) @@ -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) } 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 { 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 { 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. if err := why.require("plans " + positionals[0]); err != nil { return err @@ -1274,6 +1286,21 @@ func plansCommand(ctx context.Context, args []string) error { if *whatIf != "" { 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. if len(positionals) == 2 && positionals[0] == "retry" { said, err := retryPlan(ctx, open, positionals[1]) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 38ed024..b65dff4 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -289,7 +289,7 @@ func (a *verbArguments) commandLine() ([]string, error) { } return argv, nil } - for _, act := range []string{"stop", "close", "retry"} { + for _, act := range []string{"stop", "close", "retry", "go"} { if id := str(act); id != "" { argv := []string{"plans", act, id} if act == "retry" { @@ -313,6 +313,58 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "-n", n) } 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). case "queue": 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. 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 // 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 { case argv[0] == "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] case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset": return "broker consumer-reset" diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index 5919a00..1490127 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -422,6 +422,9 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error { 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) { fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n", 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 "+ "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 { return notNow(err) } diff --git a/cmd/mesh-controller/watchdogs.go b/cmd/mesh-controller/watchdogs.go index 05e3b19..b305a1c 100644 --- a/cmd/mesh-controller/watchdogs.go +++ b/cmd/mesh-controller/watchdogs.go @@ -442,6 +442,11 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time) ( pause := buildSeatPause(ctx, inv, plans) var out []planFacts 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) 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])), diff --git a/internal/broker/agreement_catalogue_test.go b/internal/broker/agreement_catalogue_test.go index 53fdbec..a8e1358 100644 --- a/internal/broker/agreement_catalogue_test.go +++ b/internal/broker/agreement_catalogue_test.go @@ -162,7 +162,7 @@ func TestTheCataloguesDerivedSubjectsMeet(t *testing.T) { for _, want := range c.Consumes { emitter, event, named := strings.Cut(want, ".") if named { - if s, isASeat := byName[emitter]; isASeat { + if s, isASeat := byName[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { principal.Watches = append(principal.Watches, Seat{Name: s.Name, Emits: []string{event}}) continue diff --git a/internal/broker/states_agreement_test.go b/internal/broker/states_agreement_test.go index 0b3bd4f..179203e 100644 --- a/internal/broker/states_agreement_test.go +++ b/internal/broker/states_agreement_test.go @@ -32,6 +32,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) { states = append(states, link.KeyRolledBack) // And a pull request's merge check, judged (novox/hq to-be 45 §9). 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 { if !slices.Contains(broker.ControllerStates, event) { t.Errorf("the mesh states %q and its account may not publish it", event) diff --git a/internal/broker/streams.go b/internal/broker/streams.go index 62f54ad..4d0a91b 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -218,7 +218,9 @@ var ControllerStates = []string{"applied", "refused", "built-before", "rolled-back", // And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as // the pull request's status, and anybody else may read. - "checked"} + "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 // reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 224f6ea..46c75b6 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.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"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/builder/check.go b/internal/builder/check.go index 5857363..28fceba 100644 --- a/internal/builder/check.go +++ b/internal/builder/check.go @@ -81,6 +81,19 @@ type CheckSpec struct { // Toolchains is every toolchain the mesh holds, by language, for a script that declares another. Toolchain 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. @@ -141,6 +154,9 @@ const ( EnvVerdict = "MESH_CHECK_VERDICT" EnvBeside = "MESH_CHECK_BESIDE" EnvModules = "MESH_CHECK_MODULES" + // EnvGroup is a delivery group's other heads, as JSON, for the gate (novox/hq ADR 0239); empty for a + // pull request checked alone. + EnvGroup = "MESH_CHECK_GROUP" ) // CheckTimeout bounds one check; a check that runs past it is an error, not a pass. @@ -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)) } + // A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this. + groupFile := "" + if len(spec.Group) > 0 { + var heads []map[string]any + for i, g := range spec.Group { + dir := fmt.Sprintf("group-%d", i) + if g.Repo != "" && safeName.MatchString(g.Repo) { + dir = "group-" + g.Repo + } + if err := clone(g.Repository, g.Ref, dir); err != nil { + return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err) + } + say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref)) + heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo, + "tree": filepath.Join(root, dir), "changed": g.Paths}) + } + raw, err := json.Marshal(heads) + if err != nil { + return CheckVerdict{}, err + } + groupFile = filepath.Join(root, "group.json") + if err := os.WriteFile(groupFile, raw, 0o644); err != nil { + return CheckVerdict{}, err + } + } + // The facts, and the versions they say the mesh runs. if registry == "" { return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from") @@ -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, EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root, EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","), + EnvGroup + "=" + groupFile, "GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")} v := CheckVerdict{} @@ -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. switch { + case len(spec.Group) > 0: + // A group's composed check judges the heads together; each member's own tests are its pull request's. + v.Repo = &Layer{Verdict: "pass", Summary: "a group's composed check: each member's own " + CheckScript + + " runs on its pull request"} case !hasScript: v.Repo = &Layer{Verdict: "warning", Summary: noScript} case timedOut(): @@ -447,7 +494,8 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil // 2. Every machine composed with the change. if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c", `"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+ - `--repository "$MESH_CHECK_REPOSITORY" --tree . --changed "$MESH_CHECK_CHANGED" --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 { return "error", "the check was ended during the merge gate" } diff --git a/internal/builder/check_test.go b/internal/builder/check_test.go index 768c4f4..f35e22f 100644 --- a/internal/builder/check_test.go +++ b/internal/builder/check_test.go @@ -232,6 +232,7 @@ var aJudge = map[string]string{ "cmd/mesh-controller/main.go": `package main import ( + "encoding/json" "fmt" "os" "strings" @@ -252,6 +253,24 @@ func main() { fmt.Println(m + ": ok") } 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)"}` + "`" + `) } } @@ -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) } + // 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. 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") diff --git a/internal/catalogue/delivery_seat.go b/internal/catalogue/delivery_seat.go new file mode 100644 index 0000000..c728653 --- /dev/null +++ b/internal/catalogue/delivery_seat.go @@ -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 `.` 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:]) +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index b36ee39..ff30ca7 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -93,7 +93,9 @@ var defaultSeats = append([]Seat{ // A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8). "rolled-back", // 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}, // 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. @@ -121,6 +123,10 @@ var defaultSeats = append([]Seat{ // image registry. {Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, {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 // 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. diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 506fd5a..fba6951 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,7 +46,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { 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 // 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 @@ -56,8 +57,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // 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 // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 36 { - t.Errorf("the mesh defines %d seats rather than 36; the set is closed, so a change here is "+ + if len(Seats()) != 37 { + 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()) } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 727c4e5..9af432d 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -102,19 +102,55 @@ var ControllerVerbs = []Verb{ {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.", Input: schema(map[string]string{ - "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", - "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)", + "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", + "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)", + "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", "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", "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", "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)", - "cause": "with stop or close: the cause in a word, or a condition's kind (optional)", + "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, close or go: the cause in a word, or a condition's kind (optional)", }, 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 — " + "or, with files, the files it would be given.", Input: schema(map[string]string{ diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index af7b5fc..2b38bc9 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -114,7 +114,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio for _, c := range m.Consumes { emitter, event, named := strings.Cut(c, ".") 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}}) continue } diff --git a/internal/inventory/migrations/0075-a-walk-waits-for-its-delivery.sql b/internal/inventory/migrations/0075-a-walk-waits-for-its-delivery.sql new file mode 100644 index 0000000..1cf5e8d --- /dev/null +++ b/internal/inventory/migrations/0075-a-walk-waits-for-its-delivery.sql @@ -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; diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index 0e0e758..34e74f9 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -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 // gate, walked through the machines one at a time. 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. 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 { return err } - var release []byte + var release, delivery []byte if p.Release != nil { if release, err = json.Marshal(p.Release); err != nil { 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 // plan moves, in the same transaction as the move, so no save can move a tier unmeasured or // measure one twice. @@ -195,16 +227,16 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error { var revision int64 err = tx.QueryRow(ctx, `insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, - branch, tier_entered, revision, epoch, release) - values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14) + branch, tier_entered, revision, epoch, release, delivery) + 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, 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, - release = excluded.release + release = excluded.release, delivery = excluded.delivery where release_plan.revision = $12 returning revision`, 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) { // 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 @@ -223,6 +255,9 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error { } else { p.Epoch = 0 } + if PlanSaved != nil { + PlanSaved(*p) + } 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) { rows, err := i.store.Pool().Query(ctx, `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) if err != nil { return nil, err @@ -268,10 +303,11 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) { var out []Plan for rows.Next() { var p Plan - var tiers, modules, release []byte + var tiers, modules, release, delivery []byte var epoch int64 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 } if len(release) > 0 { @@ -279,6 +315,11 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) { return nil, err } } + if len(delivery) > 0 { + if err := json.Unmarshal(delivery, &p.Delivery); err != nil { + return nil, err + } + } p.Epoch = uint64(epoch) if err := json.Unmarshal(tiers, &p.Tiers); err != nil { return nil, err diff --git a/internal/link/build.go b/internal/link/build.go index ca58beb..17cd079 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -123,6 +123,21 @@ type CheckRequest struct { // 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. 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. diff --git a/internal/link/events.go b/internal/link/events.go index d70f62a..d680f18 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -76,6 +76,11 @@ const ( // KeyChecked: a pull request's merge check was judged (novox/hq to-be 45 §9) — the verdict, its // summary and its report, for the forge's holder to set as the pull request's status. KeyChecked = "checked" + // 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. @@ -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 // and send, posted with the verdict. 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 diff --git a/module.json b/module.json index 27c0402..115c786 100644 --- a/module.json +++ b/module.json @@ -35,6 +35,12 @@ "seats", "builds", "plans", + "delivery-plan", + "delivery-order", + "delivery-check", + "deliver", + "delivery-stop", + "delivery-walks", "plan", "assign", "unassign",