diff --git a/cmd/mesh-controller/batches.go b/cmd/mesh-controller/batches.go index 165bdb42..5004b55b 100644 --- a/cmd/mesh-controller/batches.go +++ b/cmd/mesh-controller/batches.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "regexp" "slices" "sort" "strconv" @@ -71,9 +70,9 @@ const batchLateAfter = time.Minute // made before the controller began keeping merges (owedLate). const mergeGrace = 10 * time.Minute -// edgeGroupOrder is a delivery group's order made an edge of a walk's tiers (ADR 0276 decision 5): the -// modules of the member after depend on the modules of the member before, and are gated on them. -const edgeGroupOrder = "group-order" +// edgeChangeOrder is the order the rules of a plan draw among its modules (novox/hq ADR 0419 clauses 3 and 4, delivery_edges.go) +// made an edge of its tiers: the module after depends on the module before, and is gated on it. +const edgeChangeOrder = "change-order" // mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its // default, and said. @@ -1039,9 +1038,13 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [ } } sort.Strings(also) - ordered := groupOrderEdges(members, entries, read, edges) + ordered, err := changeOrderEdges(ctx, members, moved, edges) + if err != nil { + return err + } var withheld []string plan := planOfMoves(moved, append(append([]inventory.Edge{}, edges...), ordered...)) + placedBy(&plan, ordered) // **Withheld, a stopped merge's modules** (novox/hq ADR 0299): a module is built at its branch, which holds the // merge a stop left out of the walk these merges were walked again from — built here only as a dependent, it // would deliver that merge. One a merge of this batch moves is built: that merge carries the stopped change, @@ -1094,7 +1097,14 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [ } var tiers []string for i, t := range plan.Tiers { - tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", "))) + var names []string + for _, n := range t { + if m := plan.Modules[n]; m != nil && m.Placed != "" { + n += " " + m.Placed + } + names = append(names, n) + } + tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(names, ", "))) } var answers []string for _, n := range named { @@ -1122,6 +1132,23 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [ return nil } +// placedBy writes on each module of a plan the edges of its order that put it after another module of the plan, with the +// rule that drew each (novox/hq ADR 0419 clause 5). +func placedBy(plan *inventory.Plan, ordered []inventory.Edge) { + for _, e := range ordered { + m, in := plan.Modules[e.From] + if _, before := plan.Modules[e.To]; !in || !before { + continue + } + said := "after " + e.To + " (" + e.Why + ")" + if m.Placed == "" { + m.Placed = said + } else if !strings.Contains(m.Placed, said) { + m.Placed += "; " + said + } + } +} + // unchangedSaid names the modules a walk says matched what runs, for its note. func unchangedSaid(unchanged map[string]string) string { names := make([]string, 0, len(unchanged)) @@ -1310,73 +1337,30 @@ type batchMember struct { moved []string } -// afterLines are the `after: ` lines of a pull request's description, as mesh-delivery reads them. -var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`) - -// groupOrderEdges is the order of every delivery group among a batch's merges as edges of its walk's tiers -// (ADR 0276 decision 5, ADR 0249 unchanged): merges of two repositories or more from one head branch name are -// a group; each pair its order rules give puts the modules of the member after in a later tier than the -// member before's. A pair in a contradiction orders nothing: its group was refused before it merged. -func groupOrderEdges(members []batchMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository, - edges []inventory.Edge) []inventory.Edge { - byHead := map[string][]batchMember{} +// changeOrderEdges is the order of a batch's moved modules as edges of its plan's tiers (novox/hq ADR 0419): drawn +// per module from the module graph and from what the merges say of the manifests they touch (delivery_edges.go), +// whatever repository the merges are in and whether or not they form a delivery group. No `after:` line is read. +// A cycle is said, never lost: the plan puts the modules left in its last tier, and the composed check fails the +// group before it merges. +func changeOrderEdges(ctx context.Context, members []batchMember, moved []string, edges []inventory.Edge) ([]inventory.Edge, error) { + var sources []changeSource for _, m := range members { - if m.merge.Head != "" { - byHead[m.merge.Head] = append(byHead[m.merge.Head], m) - } + sources = append(sources, changeSource{Owner: m.merge.Owner, Repo: m.merge.Repo, New: m.merge.Commit, + Paths: m.merge.Paths, Truncated: m.merge.PathsTruncated, Removed: m.merge.Removed}) } - var out []inventory.Edge - heads := make([]string, 0, len(byHead)) - for h := range byHead { - heads = append(heads, h) + changes, err := changesOf(ctx, sources) + if err != nil { + return nil, fmt.Errorf("what the merges say of their manifests cannot be read, so the order of their modules "+ + "cannot be drawn: %w", err) } - sort.Strings(heads) - for _, h := range heads { - group := byHead[h] - repos := map[string]bool{} - for _, m := range group { - repos[strings.ToLower(m.merge.Owner+"/"+m.merge.Repo)] = true - } - if len(repos) < 2 { - continue - } - var om []orderMember - moved := map[string][]string{} - for _, m := range group { - repository := m.merge.Owner + "/" + m.merge.Repo - id := repository + "@" + m.merge.Commit - var after []string - for _, l := range afterLines.FindAllStringSubmatch(m.merge.Body, -1) { - after = append(after, strings.TrimSuffix(l[1], ".git")) - } - om = append(om, orderMember{ID: id, Repository: repository, Base: m.merge.Base, Head: m.merge.Commit, - Number: m.merge.Number, Paths: m.merge.Paths, PathsTruncated: m.merge.PathsTruncated, - Removed: m.merge.Removed, ModuleDirs: m.merge.ModuleDirs, ModuleDirsSaid: m.merge.ModuleDirsSaid, - CloneURL: m.merge.CloneURL, After: after}) - moved[id] = m.moved - } - order := orderOf(om, reachOfMembers(om, entries, read, edges), edges) - contradicted := map[string]bool{} - for _, c := range order.Contradictions { - for _, id := range c.Members { - contradicted[id] = true - } - } - for _, p := range order.Pairs { - if contradicted[p.Before] && contradicted[p.After] { - continue - } - for _, before := range moved[p.Before] { - for _, after := range moved[p.After] { - if before != after { - out = append(out, inventory.Edge{From: after, To: before, Kind: edgeGroupOrder}) - } - } - } - fmt.Printf(" group %s: %s before %s (%s) inside the walk\n", h, p.Before, p.After, p.Why) - } + order := orderEdges(moved, changes, edges) + for _, e := range order.Edges { + fmt.Printf(" order: %s\n", edgeSaid(e)) } - return out + if said := order.CycleSaid(); said != "" { + fmt.Printf(" %s\n", said) + } + return order.Edges, nil } // searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered diff --git a/cmd/mesh-controller/batches_test.go b/cmd/mesh-controller/batches_test.go index 4c7a4252..0c02f436 100644 --- a/cmd/mesh-controller/batches_test.go +++ b/cmd/mesh-controller/batches_test.go @@ -19,7 +19,15 @@ import ( // The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a // window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge // was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings. -func init() { mergeWindowDefault = 0 } +func init() { + mergeWindowDefault = 0 + // No forge in a test: no manifest is found at any commit, so a merge draws no edge of the manifests' change + // (delivery_edges.go). A test that wants some replaces these (forgeHolding). + manifestAt = func(context.Context, string, string, string, string) (catalogue.Manifest, bool, error) { + return catalogue.Manifest{}, false, nil + } + parentOf = func(context.Context, string, string, string) (string, error) { return "", nil } +} // windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its // settings file says them, and diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index 86dafe7b..33459102 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -579,8 +579,6 @@ type orderMember struct { 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"` // BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit // a verdict no build judges names when the head is checked again through mesh-delivery. BaseSHA string `json:"base_sha,omitempty"` @@ -611,14 +609,6 @@ type orderPair struct { Over []string `json:"over,omitempty"` } -// orderContradiction is two members that rules of one rank order both ways: no precedence resolves it, so -// the group is refused, the rules named, and Said says how a person declares the order. -type orderContradiction struct { - Members []string `json:"members"` - Rules []orderPair `json:"rules"` - Said string `json:"said"` -} - // orderReach is what a member moves, as the planner says. type orderReach struct { Moved []string `json:"moved,omitempty"` @@ -627,114 +617,76 @@ type orderReach struct { 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 — -// with each contradiction no precedence resolves. +// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one. The +// order is read from the module graph and the manifests' change alone (novox/hq ADR 0419): a cycle is the only +// refusal, and CycleSaid names every module and edge of it. Edges are the module edges the pairs come from. type groupOrder struct { - Order []string `json:"order"` - Pairs []orderPair `json:"pairs,omitempty"` - Cycle []string `json:"cycle,omitempty"` - Contradictions []orderContradiction `json:"contradictions,omitempty"` - Reach map[string]orderReach `json:"reach,omitempty"` + Order []string `json:"order"` + Pairs []orderPair `json:"pairs,omitempty"` + Cycle []string `json:"cycle,omitempty"` + CycleSaid string `json:"cycle_said,omitempty"` + Edges []inventory.Edge `json:"edges,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" -) +// orderBuiltBy is the reason a member goes before another that moves a module built by, standing on, packaging or +// declaring one of its modules: the module graph's own edge. +const orderBuiltBy = "built by" -// orderRank is a rule's precedence when two rules order one pair both ways (novox/hq ADR 0249), the lower -// winning: what a person declared; then what the graph and the change say (built by, version skew); then -// the rollout default, which infers a direction from the modules' names alone. -func orderRank(why string) int { - switch why { - case orderDeclared: - return 0 - case orderBuiltBy, orderVersionSkew: - return 1 - case orderEngineFirst: - return 2 - } - return 3 -} - -// orderOf is a group's order (novox/hq ADR 0239 decision 4, ADR 0249), 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). -// -// When rules order one pair both ways, the one of higher precedence wins (orderRank) and the pair says -// what it won over. Rules of one rank both ways are a contradiction: both are kept as pairs, the members -// are the cycle, and the contradiction says how to declare the order. Otherwise, by repository then id, so -// every reading gives one order. A cycle through three members or more is named by its members, and none -// of them is ordered. -func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge) groupOrder { +// orderOf is a group's order (novox/hq ADR 0419), pure: a member goes before another when a module it moves goes +// before a module the other moves by an edge of the module graph (built by), or by a rule of delivery_edges.go +// (verb before caller, version skew, engine before controller), which changes names the manifests' added verbs. +// No `after:` line is read. Ties break by repository then id, so every reading gives one order. A cycle through +// two members or more is named by its members, and none of them is ordered. +func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge, changes map[string]moduleChange) groupOrder { out := groupOrder{Reach: reach} - byID := map[string]orderMember{} + movedBy := map[string][]string{} + var all []string for _, m := range members { - byID[m.ID] = m + for _, n := range reach[m.ID].Moved { + movedBy[n] = append(movedBy[n], m.ID) + all = appendNew(all, n) + } } - var claims []orderPair add := func(before, after, why string) { if before == after { return } - for _, p := range claims { + for _, p := range out.Pairs { if p.Before == before && p.After == after && p.Why == why { return } } - claims = append(claims, orderPair{Before: before, After: after, Why: why}) + 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 + for _, e := range edges { + if !orders(e) { + continue } - _, repo, _ := strings.Cut(repository, "/") - return strings.EqualFold(said, repo) + for _, before := range movedBy[e.To] { + for _, after := range movedBy[e.From] { + add(before, after, orderBuiltBy) + } + } } - 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) + order := orderEdges(all, changes, edges) + out.Edges = order.Edges + out.CycleSaid = order.CycleSaid() + for _, e := range order.Edges { + for _, before := range movedBy[e.To] { + for _, after := range movedBy[e.From] { + add(before, after, e.Why) } } } - out.Pairs, out.Contradictions = resolveClaims(claims, byID) 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 + if out.Pairs[i].After != out.Pairs[j].After { + return out.Pairs[i].After < out.Pairs[j].After + } + return out.Pairs[i].Why < out.Pairs[j].Why }) // Kahn's walk, the next always the first by repository and id among those with nothing before them. waiting := map[string]int{} @@ -779,89 +731,6 @@ func orderOf(members []orderMember, reach map[string]orderReach, edges []invento return out } -// orderRules is the rules in the order a pair names them when more than one says the same. -var orderRules = []string{orderDeclared, orderBuiltBy, orderVersionSkew, orderEngineFirst} - -// resolveClaims is the pairs the rules' claims come to (novox/hq ADR 0249): for each two members, the claims -// of the highest precedence decide, and the claims they overrule are said on the pair. Claims of one rank -// both ways are a contradiction: both pairs are kept, so the walk finds the cycle, and the contradiction -// says how a person declares the order. -func resolveClaims(claims []orderPair, byID map[string]orderMember) ([]orderPair, []orderContradiction) { - byTwo := map[[2]string][]orderPair{} - var keys [][2]string - for _, c := range claims { - k := [2]string{min(c.Before, c.After), max(c.Before, c.After)} - if _, ok := byTwo[k]; !ok { - keys = append(keys, k) - } - byTwo[k] = append(byTwo[k], c) - } - slices.SortFunc(keys, func(a, b [2]string) int { - if c := strings.Compare(a[0], b[0]); c != 0 { - return c - } - return strings.Compare(a[1], b[1]) - }) - ruleIndex := func(why string) int { return slices.Index(orderRules, why) } - var pairs []orderPair - var contradictions []orderContradiction - for _, k := range keys { - cs := byTwo[k] - slices.SortStableFunc(cs, func(a, b orderPair) int { return ruleIndex(a.Why) - ruleIndex(b.Why) }) - top := orderRank(cs[0].Why) - var won []orderPair - ways := map[string]bool{} - for _, c := range cs { - if orderRank(c.Why) == top { - won = append(won, c) - ways[c.Before] = true - } - } - if len(ways) == 1 { - p := orderPair{Before: won[0].Before, After: won[0].After, Why: won[0].Why} - for _, c := range cs { - if c.Before != p.Before && !slices.Contains(p.Over, c.Why) { - p.Over = append(p.Over, c.Why) - } - } - pairs = append(pairs, p) - continue - } - // One rank both ways: no precedence resolves it. - var first, second *orderPair - var said []string - for i := range won { - c := won[i] - if c.Before == k[0] && first == nil { - first = &won[i] - } - if c.Before == k[1] && second == nil { - second = &won[i] - } - said = append(said, fmt.Sprintf("%s puts %s first", c.Why, c.Before)) - } - pairs = append(pairs, orderPair{Before: first.Before, After: first.After, Why: first.Why}, - orderPair{Before: second.Before, After: second.After, Why: second.Why}) - a, b := byID[k[0]], byID[k[1]] - how := fmt.Sprintf("neither outranks the other: declare the order with a line `after: %s` in %s's "+ - "description, or `after: %s` in %s's", a.Repository, pullName(b), b.Repository, pullName(a)) - if top == orderRank(orderDeclared) { - how = "each pull request declares it goes after the other: remove one of the `after:` lines" - } - contradictions = append(contradictions, orderContradiction{Members: []string{k[0], k[1]}, Rules: won, - Said: fmt.Sprintf("%s and %s: %s; %s", k[0], k[1], strings.Join(said, ", "), how)}) - } - return pairs, contradictions -} - -// pullName is a member as a person finds it: its pull request, or its id when no number was said. -func pullName(m orderMember) string { - if m.Number > 0 { - return fmt.Sprintf("%s#%d", m.Repository, m.Number) - } - return m.ID -} - // 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 { @@ -959,7 +828,11 @@ func deliveryCommand(ctx context.Context, args []string) error { if err != nil { return err } - return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges)) + changes, err := changesOf(ctx, changeSourcesOf(members)) + if err != nil { + return fmt.Errorf("what the members say of their manifests cannot be read: %w", err) + } + return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges, changes)) case "check": if *group == "" && len(members) == 1 { // One head, checked again as the forge's announcement would have it (a recheck): the controller's diff --git a/cmd/mesh-controller/delivery_changes.go b/cmd/mesh-controller/delivery_changes.go new file mode 100644 index 00000000..9c7d52ff --- /dev/null +++ b/cmd/mesh-controller/delivery_changes.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "path" + "slices" + "strings" + "sync" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/link" +) + +// What a change says of the manifests it touches, read from the forge (novox/hq ADR 0419 clause 3, the lead's settlement +// of issue 977 question 1): the controller asks the forge for each changed module.json at the commit and at what it +// replaced, once per commit. The composed check's result is not used: the plan is cut after the merge. + +// changeSource is one change to a repository whose manifests are read: the commit after it and, when known, what it +// replaced (a pull request's base commit or branch). An empty Old is the first parent of New: the merge's base. +type changeSource struct { + Owner, Repo string + New, Old string + Paths []string + Truncated bool + Removed []string +} + +// manifestAt reads a repository's module.json at a ref from the forge; the bool says it exists there. A variable so a +// test needs no forge. +var manifestAt = func(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) { + var found struct { + Result struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } `json:"result"` + } + missing, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/contents/"+escapedPath(file)+"?ref="+url.QueryEscape(ref), &found) + if err != nil { + return catalogue.Manifest{}, false, err + } + if missing { + return catalogue.Manifest{}, false, nil + } + body := []byte(found.Result.Content) + if found.Result.Encoding == "base64" { + if body, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(found.Result.Content, "\n", "")); err != nil { + return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s cannot be read: %w", owner, repo, file, ref, err) + } + } + var m catalogue.Manifest + if err := json.Unmarshal(body, &m); err != nil { + return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s is not a manifest: %w", owner, repo, file, ref, err) + } + return m, true, nil +} + +// parentOf is the first parent of a commit, from the forge. A variable so a test needs no forge. +var parentOf = func(ctx context.Context, owner, repo, commit string) (string, error) { + var found struct { + Result struct { + Parents []struct { + SHA string `json:"sha"` + } `json:"parents"` + } `json:"result"` + } + if _, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/git/commits/"+url.PathEscape(commit), &found); err != nil { + return "", err + } + if len(found.Result.Parents) == 0 { + return "", nil + } + return found.Result.Parents[0].SHA, nil +} + +func escapedPath(p string) string { + parts := strings.Split(p, "/") + for i := range parts { + parts[i] = url.PathEscape(parts[i]) + } + return strings.Join(parts, "/") +} + +// askForgeApi makes one read of the forge's API through its module's tool on the bus. It answers true when the forge +// says there is no such thing (404), and an error for anything else it cannot answer. +func askForgeApi(ctx context.Context, apiPath string, into any) (bool, error) { + js, err := aBus() + if err != nil { + return false, err + } + defer js.Close() + raw, _ := json.Marshal(map[string]any{"path": apiPath}) + answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second) + if err != nil { + return false, err + } + if answer.Error != "" { + if strings.Contains(answer.Error, " 404 ") { + return true, nil + } + return false, fmt.Errorf("the forge did not answer %s: %s", apiPath, answer.Error) + } + if err := json.Unmarshal(answer.Result, into); err != nil { + return false, fmt.Errorf("the forge's answer to %s cannot be read: %w", apiPath, err) + } + return false, nil +} + +// manifestsRead keeps what the forge said of a manifest at a commit, which never changes: one ask per commit. +var ( + manifestsRead = map[string]manifestRead{} + manifestsReadMu sync.Mutex +) + +type manifestRead struct { + manifest catalogue.Manifest + exists bool +} + +func manifestCached(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) { + key := strings.Join([]string{owner, repo, file, ref}, "\x00") + if len(ref) == 40 { + manifestsReadMu.Lock() + got, ok := manifestsRead[key] + manifestsReadMu.Unlock() + if ok { + return got.manifest, got.exists, nil + } + } + m, exists, err := manifestAt(ctx, owner, repo, file, ref) + if err == nil && len(ref) == 40 { + manifestsReadMu.Lock() + if len(manifestsRead) > 512 { + clear(manifestsRead) + } + manifestsRead[key] = manifestRead{m, exists} + manifestsReadMu.Unlock() + } + return m, exists, err +} + +// verbsServed are the seat verbs a manifest serves, as ".": those of the seats it defines, those of the +// seats it claims, and its own tools as ".". +func verbsServed(m catalogue.Manifest) []string { + var out []string + for _, s := range m.DefinesSeats { + for _, v := range s.Serves { + out = appendNew(out, s.Name+"."+v.Name) + } + } + for _, c := range m.Claims { + for _, v := range c.ServesFor(m) { + out = appendNew(out, c.Name+"."+v) + } + } + for _, t := range m.Tools { + out = appendNew(out, m.Module+"."+t) + } + return out +} + +// verbsInvoked are the verbs a manifest calls, as "." or ".". +func verbsInvoked(m catalogue.Manifest) []string { + var out []string + for _, i := range m.Invokes { + if i = strings.TrimPrefix(i, "seat:"); i != "*" && i != "" { + out = appendNew(out, i) + } + } + return out +} + +func added(now, was []string) []string { + var out []string + for _, v := range now { + if !slices.Contains(was, v) { + out = append(out, v) + } + } + return out +} + +// changesOf reads, for each manifest the changes touch, what they add to the verbs its module serves and invokes, by +// module. A change whose paths are unsaid or truncated names no manifest: nothing is read for it. An error is the +// forge's, never read as "no change": the plan is not cut on a guess. +func changesOf(ctx context.Context, sources []changeSource) (map[string]moduleChange, error) { + out := map[string]moduleChange{} + for _, s := range sources { + if s.Truncated { + continue + } + for _, p := range s.Paths { + if path.Base(p) != "module.json" || slices.Contains(s.Removed, p) { + continue + } + now, exists, err := manifestCached(ctx, s.Owner, s.Repo, p, s.New) + if err != nil { + return nil, err + } + if !exists || now.Module == "" { + continue + } + old := s.Old + if old == "" { + if old, err = parentOf(ctx, s.Owner, s.Repo, s.New); err != nil { + return nil, err + } + } + var was catalogue.Manifest + if old != "" { + if was, _, err = manifestCached(ctx, s.Owner, s.Repo, p, old); err != nil { + return nil, err + } + } + c := out[now.Module] + c.Manifest = true + for _, v := range added(verbsServed(now), verbsServed(was)) { + c.Serves = appendNew(c.Serves, v) + } + for _, v := range added(verbsInvoked(now), verbsInvoked(was)) { + c.Invokes = appendNew(c.Invokes, v) + } + out[now.Module] = c + } + } + return out, nil +} + +// changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into. +func changeSourcesOf(members []orderMember) []changeSource { + var out []changeSource + for _, m := range members { + p := m.pull() + old := p.BaseSHA + if old == "" { + old = p.Base + } + out = append(out, changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old, Paths: p.Paths, + Truncated: p.PathsTruncated, Removed: p.Removed}) + } + return out +} diff --git a/cmd/mesh-controller/delivery_edges.go b/cmd/mesh-controller/delivery_edges.go new file mode 100644 index 00000000..8126201f --- /dev/null +++ b/cmd/mesh-controller/delivery_edges.go @@ -0,0 +1,170 @@ +package main + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// The order of a plan's modules is read from the module graph and from what the change says of the manifests +// (novox/hq ADR 0419), per module and never per repository: no `after:` line is read, and no member of a group +// is ordered against another. Pure, so a test needs no store. + +// The rules that draw an edge of a plan's order, in the words the plan shows. +const ( + ruleVerbBeforeCaller = "verb before caller" + ruleVersionSkew = "version skew" + ruleEngineFirst = "engine before controller" +) + +// The two modules the rollout rules name. +const ( + theController = "mesh-controller" + theEngine = "mesh-host" +) + +// moduleChange is what a change says of one module's manifest: the seat verbs (".") it adds to what the +// module serves, the seat verbs it adds to what the module invokes, and whether the manifest changes at all. +type moduleChange struct { + Serves []string + Invokes []string + Manifest bool +} + +// planOrder is the edges the rules draw among a plan's modules, and the cycle they make when they make one. +type planOrder struct { + Edges []inventory.Edge + // Cycle is every edge of a cycle among the order's edges and the graph's, the module graph's included. + Cycle []inventory.Edge +} + +// CycleSaid names every module and edge of the cycle, for a check to fail with (ADR 0419 clause 6). +func (o planOrder) CycleSaid() string { + if len(o.Cycle) == 0 { + return "" + } + var parts []string + for _, e := range o.Cycle { + parts = append(parts, edgeSaid(e)) + } + return "the modules of the plan are ordered in a cycle: " + strings.Join(parts, "; ") +} + +// edgeSaid is an edge as a person reads it: the module after, the module before, and why. +func edgeSaid(e inventory.Edge) string { + why := e.Why + if why == "" { + why = e.Kind + } + return fmt.Sprintf("%s after %s (%s)", e.From, e.To, why) +} + +// orders is whether an edge of the module graph orders a plan's tiers (tiersOf). +func orders(e inventory.Edge) bool { + return e.Kind != inventory.EdgePackages && e.Kind != inventory.EdgeBuiltBy && e.From != e.To +} + +// orderEdges draws the edges of a plan's order among its moved modules (ADR 0419 clause 3, 4): a module whose change +// adds a verb to a seat goes before one whose change adds a call of that verb; the controller, when the plan moves +// it, goes before every module whose manifest the plan changes, the node-engine excepted; and the node-engine goes +// before the controller by default, when the plan moves both and no edge orders them either way, transitively. An +// edge is "From after To". graph is the module graph, whose edges order too and are never drawn again. +func orderEdges(moved []string, changes map[string]moduleChange, graph []inventory.Edge) planOrder { + var out planOrder + sorted := append([]string(nil), moved...) + sort.Strings(sorted) + for _, server := range sorted { + for _, caller := range sorted { + if server == caller { + continue + } + for _, verb := range changes[server].Serves { + if slices.Contains(changes[caller].Invokes, verb) { + out.Edges = append(out.Edges, inventory.Edge{From: caller, To: server, Kind: edgeChangeOrder, + Why: ruleVerbBeforeCaller + ": " + verb}) + } + } + } + } + if slices.Contains(sorted, theController) { + for _, m := range sorted { + if m != theController && m != theEngine && changes[m].Manifest { + out.Edges = append(out.Edges, inventory.Edge{From: m, To: theController, Kind: edgeChangeOrder, + Why: ruleVersionSkew}) + } + } + } + if slices.Contains(sorted, theController) && slices.Contains(sorted, theEngine) { + all := orderingEdges(graph, out.Edges) + switch { + case pathBetween(all, theController, theEngine) != nil: + // An edge already puts the controller after the engine: the default says the same. + case pathBetween(all, theEngine, theController) != nil: + // An edge puts the engine after the controller: it overrules the default, and says so. + path := pathBetween(all, theEngine, theController) + for i := range out.Edges { + if out.Edges[i] == path[0] { + out.Edges[i].Why += " (overrules " + ruleEngineFirst + ")" + break + } + } + default: + out.Edges = append(out.Edges, inventory.Edge{From: theController, To: theEngine, Kind: edgeChangeOrder, + Why: ruleEngineFirst}) + } + } + all := orderingEdges(graph, out.Edges) + for _, e := range out.Edges { + if back := pathBetween(all, e.To, e.From); back != nil { + out.Cycle = append(out.Cycle, e) + out.Cycle = append(out.Cycle, back...) + break + } + } + return out +} + +// orderingEdges is the graph's edges that order and the edges drawn, together. +func orderingEdges(graph, drawn []inventory.Edge) []inventory.Edge { + var out []inventory.Edge + for _, e := range graph { + if orders(e) { + out = append(out, e) + } + } + return append(out, drawn...) +} + +// pathBetween is the edges that lead from a module to another along "depends on" (From to To), the shortest, or nil +// when the first does not depend on the second. +func pathBetween(edges []inventory.Edge, from, to string) []inventory.Edge { + if from == to { + return nil + } + via := map[string]inventory.Edge{} + seen := map[string]bool{from: true} + queue := []string{from} + for len(queue) > 0 { + at := queue[0] + queue = queue[1:] + for _, e := range edges { + if e.From != at || seen[e.To] { + continue + } + seen[e.To] = true + via[e.To] = e + if e.To == to { + var path []inventory.Edge + for m := to; m != from; m = via[m].From { + path = append([]inventory.Edge{via[m]}, path...) + } + return path + } + queue = append(queue, e.To) + } + } + return nil +} diff --git a/cmd/mesh-controller/delivery_edges_test.go b/cmd/mesh-controller/delivery_edges_test.go new file mode 100644 index 00000000..519b4fb7 --- /dev/null +++ b/cmd/mesh-controller/delivery_edges_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// The order of a plan's modules is read from the module graph and the manifests' change alone (novox/hq ADR 0419): +// no `after:` line, no member of a group. Pure, like orderOf was. + +func hasEdge(edges []inventory.Edge, from, to string) (inventory.Edge, bool) { + for _, e := range edges { + if e.From == from && e.To == to { + return e, true + } + } + return inventory.Edge{}, false +} + +// A module whose change adds a verb to a seat goes before the module whose change adds a call of it: the caller +// depends on the server, the plan puts them in two tiers, and the edge says why. +func TestAVerbIsServedBeforeItsCallerIsSent(t *testing.T) { + changes := map[string]moduleChange{ + "server": {Serves: []string{"mesh-delivery.groups"}, Manifest: true}, + "caller": {Invokes: []string{"mesh-delivery.groups"}, Manifest: true}, + } + got := orderEdges([]string{"caller", "server"}, changes, nil) + e, ok := hasEdge(got.Edges, "caller", "server") + if !ok || !strings.Contains(e.Why, "verb before caller") || !strings.Contains(e.Why, "mesh-delivery.groups") { + t.Fatalf("the caller does not follow the server by the verb: %+v", got.Edges) + } + if len(got.Cycle) != 0 { + t.Fatalf("a cycle: %+v", got.Cycle) + } + if tiers := tiersOf([]string{"caller", "server"}, got.Edges); len(tiers) != 2 || tiers[0][0] != "server" { + t.Fatalf("tiers %v, want the server first", tiers) + } +} + +// Edges of one rule both ways are a cycle: every module and edge is named. +func TestEdgesBothWaysAreACycleNamingEveryModuleAndEdge(t *testing.T) { + changes := map[string]moduleChange{ + "x": {Serves: []string{"s.a"}, Invokes: []string{"s.b"}}, + "y": {Serves: []string{"s.b"}, Invokes: []string{"s.a"}}, + } + got := orderEdges([]string{"x", "y"}, changes, nil) + if len(got.Cycle) < 2 { + t.Fatalf("no cycle found: %+v", got) + } + said := got.CycleSaid() + for _, want := range []string{"x", "y", "s.a", "s.b"} { + if !strings.Contains(said, want) { + t.Fatalf("the cycle is said as %q, without %s", said, want) + } + } +} + +// The node-engine goes before the controller by default, and says so; an edge of the manifests the other way +// overrules it, and the edge that did is said. +func TestTheEngineGoesBeforeTheControllerUnlessAnEdgeSaysOtherwise(t *testing.T) { + got := orderEdges([]string{"mesh-controller", "mesh-host"}, nil, nil) + e, ok := hasEdge(got.Edges, "mesh-controller", "mesh-host") + if !ok || e.Why != "engine before controller" { + t.Fatalf("the engine does not go first: %+v", got.Edges) + } + changes := map[string]moduleChange{ + "mesh-host": {Serves: []string{"s.v"}}, + "mesh-controller": {Invokes: []string{"s.v"}}, + } + got = orderEdges([]string{"mesh-controller", "mesh-host"}, changes, nil) + if _, ok := hasEdge(got.Edges, "mesh-controller", "mesh-host"); !ok || len(got.Edges) != 1 || + !strings.Contains(got.Edges[0].Why, "verb before caller") { + t.Fatalf("the verb edge does not place the controller after the engine: %+v", got.Edges) + } + // The other way: the engine calls what the controller brings. + changes = map[string]moduleChange{ + "mesh-controller": {Serves: []string{"s.v"}}, + "mesh-host": {Invokes: []string{"s.v"}}, + } + got = orderEdges([]string{"mesh-controller", "mesh-host"}, changes, nil) + e, ok = hasEdge(got.Edges, "mesh-host", "mesh-controller") + if !ok || len(got.Edges) != 1 || !strings.Contains(e.Why, "overrules engine before controller") { + t.Fatalf("the verb edge does not say it overruled the default: %+v", got.Edges) + } +} + +// The controller goes before every module whose manifest the plan changes (version skew), the node-engine excepted. +func TestTheControllerGoesBeforeEveryChangedManifest(t *testing.T) { + changes := map[string]moduleChange{"app": {Manifest: true}, "other": {}} + got := orderEdges([]string{"mesh-controller", "app", "other"}, changes, nil) + if e, ok := hasEdge(got.Edges, "app", "mesh-controller"); !ok || e.Why != "version skew" { + t.Fatalf("the changed manifest does not follow the controller: %+v", got.Edges) + } + if _, ok := hasEdge(got.Edges, "other", "mesh-controller"); ok { + t.Fatalf("a module with an unchanged manifest was ordered: %+v", got.Edges) + } + if got := orderEdges([]string{"app", "other"}, changes, nil); len(got.Edges) != 0 { + t.Fatalf("version skew ordered a plan that does not move the controller: %+v", got.Edges) + } +} + +// The order is per module, not per repository: of two modules of one repository only the one with the edge moves. +func TestTheOrderIsPerModuleNotPerRepository(t *testing.T) { + changes := map[string]moduleChange{ + "a": {Serves: []string{"s.v"}}, + "c": {Invokes: []string{"s.v"}}, + } + got := orderEdges([]string{"a", "b", "c"}, changes, nil) + tiers := tiersOf([]string{"a", "b", "c"}, got.Edges) + if len(tiers) != 2 || strings.Join(tiers[0], ",") != "a,b" || strings.Join(tiers[1], ",") != "c" { + t.Fatalf("tiers %v, want [a b] then [c]", tiers) + } +} diff --git a/cmd/mesh-controller/delivery_test.go b/cmd/mesh-controller/delivery_test.go index e352028e..271cd648 100644 --- a/cmd/mesh-controller/delivery_test.go +++ b/cmd/mesh-controller/delivery_test.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "reflect" - "slices" "strings" "testing" "time" @@ -17,34 +16,36 @@ import ( "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) { +// novox/hq ADR 0419: a group's order, from the module graph and the manifests' change alone, the same on every +// reading — what a module stands on before it, the controller before a manifest that needs it, the node-engine +// before the controller, a verb's server before its caller — and no `after:` line read. +func TestAGroupIsOrderedByTheGraphAndTheManifestsChange(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: "base", Repository: "novox/base"}, + {ID: "app", Repository: "novox/app"}, {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"}}, + "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"}}, + "base": {Moved: []string{"base"}}, + "app": {Moved: []string{"app"}}, + "lab": {Moved: []string{"lab"}}, } - edges := []inventory.Edge{{From: "app", To: "build-agent", Kind: inventory.EdgeBuiltBy}, + edges := []inventory.Edge{{From: "app", To: "base", Kind: inventory.EdgeStandsOn}, {From: "gitea", To: "postgres", Kind: inventory.EdgeDeclared}} - got := orderOf(members, reach, edges) + changes := map[string]moduleChange{"gitea": {Manifest: true}, "mesh-controller": {Manifest: true}, + "mesh-host": {Manifest: true}, "lab": {Serves: []string{"seat.v"}}, "app": {Invokes: []string{"seat.v"}}} + got := orderOf(members, reach, edges, changes) 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"} + // By repository among those with nothing before them: app waits for base and lab; gitea's catalogue for the controller. + want := []string{"base", "lab", "app", "host", "ctl", "cat"} if !reflect.DeepEqual(got.Order, want) { t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs) } @@ -52,95 +53,44 @@ func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) { 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} { + for pair, reason := range map[string]string{"host>ctl": ruleEngineFirst, "ctl>cat": ruleVersionSkew, + "ctl>host": "", "base>app": orderBuiltBy, "lab>app": ruleVerbBeforeCaller + ": seat.v"} { 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) { + if again := orderOf(shuffled, reach, edges, changes); !reflect.DeepEqual(again.Order, want) { t.Fatalf("another reading ordered %v", again.Order) } - - // A declared order against an inferred one wins (novox/hq ADR 0249), and the pair says what it won over. - members[1].After = []string{"mesh-catalog"} - got = orderOf(members, reach, edges) - if len(got.Cycle) > 0 || len(got.Contradictions) > 0 { - t.Fatalf("a declared order did not win: cycle %v, %+v", got.Cycle, got.Contradictions) - } - if want := []string{"agent", "lab", "app", "cat", "host", "ctl"}; !reflect.DeepEqual(got.Order, want) { - t.Fatalf("ordered %v, wanted %v", got.Order, want) - } - if !slices.ContainsFunc(got.Pairs, func(p orderPair) bool { - return p.Before == "cat" && p.After == "ctl" && p.Why == orderDeclared && reflect.DeepEqual(p.Over, []string{orderVersionSkew}) - }) || slices.ContainsFunc(got.Pairs, func(p orderPair) bool { return p.Before == "ctl" && p.After == "cat" }) { - t.Fatalf("the declared pair does not say it won over version skew: %+v", got.Pairs) - } - - // Declared both ways is a contradiction no precedence resolves: a cycle, and how to end it. - members[0].After = []string{"novox/mesh-controller"} - got = orderOf(members, reach, edges) - if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) || len(got.Contradictions) != 1 { - t.Fatalf("the cycle is %v, ordered %v, contradictions %+v", got.Cycle, got.Order, got.Contradictions) - } - if c := got.Contradictions[0]; !reflect.DeepEqual(c.Members, []string{"cat", "ctl"}) || - !strings.Contains(c.Said, "remove one of the `after:` lines") { - t.Fatalf("the contradiction is said %+v", c) - } } -// novox/hq ADR 0249: when two rules order one pair both ways, the rule of higher precedence wins and the -// pair says what it overruled; rules of one rank both ways are refused, naming both and how to declare. -func TestAnOrderRuleYieldsToAStrongerOne(t *testing.T) { +// A cycle is the one refusal, and it names every module and edge. A description's `after:` line changes nothing. +func TestAGroupInACycleIsNamedAndNothingElseIsRefused(t *testing.T) { members := []orderMember{ - {ID: "ctl", Repository: "novox/mesh-controller", Number: 7}, - {ID: "host", Repository: "novox/mesh-host", Number: 9}, + {ID: "x", Repository: "novox/x", Number: 7}, + {ID: "y", Repository: "novox/y", Number: 9}, } - reach := map[string]orderReach{ - "ctl": {Moved: []string{"mesh-controller", "build-agent"}}, - "host": {Moved: []string{"mesh-host"}}, + reach := map[string]orderReach{"x": {Moved: []string{"mx"}}, "y": {Moved: []string{"my"}}} + changes := map[string]moduleChange{ + "mx": {Serves: []string{"s.a"}, Invokes: []string{"s.b"}}, + "my": {Serves: []string{"s.b"}, Invokes: []string{"s.a"}}, } - builtBy := []inventory.Edge{{From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}} - - // Built by outranks engine before controller. - got := orderOf(members, reach, builtBy) - if !reflect.DeepEqual(got.Order, []string{"ctl", "host"}) || len(got.Cycle) > 0 { - t.Fatalf("ordered %v, cycle %v", got.Order, got.Cycle) + got := orderOf(members, reach, nil, changes) + if !reflect.DeepEqual(got.Cycle, []string{"x", "y"}) || len(got.Order) > 0 { + t.Fatalf("a cycle was ordered: %v, cycle %v", got.Order, got.Cycle) } - if want := []orderPair{{Before: "ctl", After: "host", Why: orderBuiltBy, Over: []string{orderEngineFirst}}}; !reflect.DeepEqual(got.Pairs, want) { - t.Fatalf("pairs %+v, wanted %+v", got.Pairs, want) - } - - // A declared line outranks both. - members[0].After = []string{"mesh-host"} - got = orderOf(members, reach, builtBy) - if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || len(got.Cycle) > 0 || - !reflect.DeepEqual(got.Pairs[0].Over, []string{orderBuiltBy}) || got.Pairs[0].Why != orderDeclared { - t.Fatalf("a declared order did not win: %v %+v", got.Order, got.Pairs) - } - - // Without the build agent, engine before controller stands alone. - members[0].After = nil - got = orderOf(members, map[string]orderReach{"ctl": {Moved: []string{"mesh-controller"}}, "host": reach["host"]}, builtBy) - if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || got.Pairs[0].Why != orderEngineFirst || len(got.Pairs[0].Over) > 0 { - t.Fatalf("engine before controller alone: %v %+v", got.Order, got.Pairs) - } - - // Built by both ways is one rank against itself: refused, both rules named, and how to declare. - both := append(builtBy, inventory.Edge{From: "build-agent", To: "mesh-host", Kind: inventory.EdgeStandsOn}) - got = orderOf(members, reach, both) - if !reflect.DeepEqual(got.Cycle, []string{"ctl", "host"}) || len(got.Order) > 0 || len(got.Contradictions) != 1 { - t.Fatalf("a contradiction was ordered: %v, cycle %v, %+v", got.Order, got.Cycle, got.Contradictions) - } - said := got.Contradictions[0].Said - for _, w := range []string{"built by puts ctl first", "built by puts host first", - "`after: novox/mesh-controller` in novox/mesh-host#9's description", "`after: novox/mesh-host` in novox/mesh-controller#7's"} { - if !strings.Contains(said, w) { - t.Errorf("the contradiction does not say %q: %s", w, said) + for _, w := range []string{"mx", "my", "s.a", "s.b"} { + if !strings.Contains(got.CycleSaid, w) { + t.Errorf("the cycle does not say %q: %s", w, got.CycleSaid) } } + // Without the edges the same members are ordered by repository. + got = orderOf(members, reach, nil, nil) + if !reflect.DeepEqual(got.Order, []string{"x", "y"}) || len(got.Cycle) > 0 || len(got.Pairs) > 0 { + t.Fatalf("members with no edge: %v %v %+v", got.Order, got.Cycle, got.Pairs) + } } // novox/hq ADR 0239 decision 8: a walk waits for the delivery's owner only while the seat has a holder on diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index e10eb7cf..71373a8a 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -114,7 +114,7 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string { // packages edge, read from a record made before novox/hq ADR 0267, widens nothing either: // a shared file moves each module whose build source holds it, directly. if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages || - e.Kind == edgeGroupOrder { + e.Kind == edgeChangeOrder { continue } if in[e.To] && !in[e.From] { @@ -204,7 +204,7 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool) // A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the // member before is running on its machines before the member after is asked. // A built-by edge waits for nothing since novox/hq ADR 0360 §3. - ordered := e.Kind == edgeGroupOrder + ordered := e.Kind == edgeChangeOrder if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) { seen[e.To] = true out = append(out, e.To) diff --git a/cmd/mesh-controller/replay309_test.go b/cmd/mesh-controller/replay309_test.go index f80b948e..c7e46c5d 100644 --- a/cmd/mesh-controller/replay309_test.go +++ b/cmd/mesh-controller/replay309_test.go @@ -7,8 +7,7 @@ import ( "github.com/novox/mesh-controller/internal/inventory" ) -// novox/hq issue 309, replayed with only what orderOf had before its fix, so it can be laid over the older -// commit. The group `feat/a-machine-joins-through-the-tunnel` (2026-10-08): the controller's pull request +// novox/hq issue 309, replayed. The group `feat/a-machine-joins-through-the-tunnel` (2026-10-08): the controller's pull request // moved the controller and the build agent, the node-engine's moved the node-engine, the lab's moved // nothing. The build agent builds the node-engine (built by: the controller's member first), and the // node-engine goes before the controller (engine before controller: the node-engine's member first). Both @@ -34,17 +33,14 @@ func TestReplay309(t *testing.T) { {From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}, {From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy}, } - got := orderOf(members, reach, edges) + got := orderOf(members, reach, edges, map[string]moduleChange{"mesh-controller": {Manifest: true}}) if len(got.Cycle) > 0 { t.Fatalf("the group is refused as a cycle %v; pairs %+v", got.Cycle, got.Pairs) } - want := []string{"novox/mesh-controller@8bbfdb53db2b", "novox/mesh-host@86cbebd10f8a", "novox/mesh-lab@e80a4b1642bf"} + // Being built by the build seat's holder orders nothing (ADR 0360 section 3, ADR 0419): the node-engine goes + // first by the rollout default, and no pair puts the controller's member before it. + want := []string{"novox/mesh-host@86cbebd10f8a", "novox/mesh-controller@8bbfdb53db2b", "novox/mesh-lab@e80a4b1642bf"} if !reflect.DeepEqual(got.Order, want) { t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs) } - for _, p := range got.Pairs { - if p.Before == want[1] && p.After == want[0] { - t.Fatalf("a pair that lost still stands: %+v", p) - } - } } diff --git a/cmd/mesh-controller/same_program.go b/cmd/mesh-controller/same_program.go index 20b7598d..6c79c26b 100644 --- a/cmd/mesh-controller/same_program.go +++ b/cmd/mesh-controller/same_program.go @@ -157,7 +157,7 @@ func withDependentsOf(same map[string]string, plan *inventory.Plan, moved []stri var bases []string for _, e := range edges { ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || - e.Kind == inventory.EdgePackages || e.Kind == edgeGroupOrder + e.Kind == inventory.EdgePackages || e.Kind == edgeChangeOrder if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) { bases = append(bases, e.To) } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 48b62765..a9c1b774 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -149,12 +149,12 @@ var ControllerVerbs = []Verb{ "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 rules each pair won over by precedence (ADR 0249); the cycle, and each contradiction no precedence " + - "resolves with how to declare the order, when the pairs contradict each other.", + {Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " + + "they are delivered, every pair and why — built by, verb before caller, version skew, engine before " + + "controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " + + "cycle, with every module and edge of it, is the one refusal.", 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"})}, + "module_dirs, module_dirs_said, removed}]"}, []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 " + diff --git a/internal/inventory/dependencies.go b/internal/inventory/dependencies.go index d3042d8e..678575bb 100644 --- a/internal/inventory/dependencies.go +++ b/internal/inventory/dependencies.go @@ -38,6 +38,9 @@ type Edge struct { From string `json:"from"` To string `json:"to"` Kind string `json:"kind"` + // Why is the rule and the fact that drew an edge of a plan's order, in the words the plan shows (novox/hq ADR + // 0419 clause 5): "verb before caller: .". Empty on the catalogue's own edges, which Kind names. + Why string `json:"why,omitempty"` } // Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with diff --git a/internal/inventory/dependencies_test.go b/internal/inventory/dependencies_test.go index 689e10ff..566e99f5 100644 --- a/internal/inventory/dependencies_test.go +++ b/internal/inventory/dependencies_test.go @@ -42,12 +42,12 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) { return false } for _, want := range []Edge{ - {"shop", "mesh-tools", EdgeStandsOn}, - {"builder", "mesh-tools", EdgeStandsOn}, - {"shop-plugin", "shop", EdgeDeclared}, - {"shop", "builder", EdgeBuiltBy}, - {"mesh-controller", "builder", EdgeBuiltBy}, - {"mesh-tools", "builder", EdgeBuiltBy}, + {From: "shop", To: "mesh-tools", Kind: EdgeStandsOn}, + {From: "builder", To: "mesh-tools", Kind: EdgeStandsOn}, + {From: "shop-plugin", To: "shop", Kind: EdgeDeclared}, + {From: "shop", To: "builder", Kind: EdgeBuiltBy}, + {From: "mesh-controller", To: "builder", Kind: EdgeBuiltBy}, + {From: "mesh-tools", To: "builder", Kind: EdgeBuiltBy}, } { if !has(want.From, want.To, want.Kind) { t.Errorf("missing %+v in %+v", want, got) diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index dd6b2d1b..4f983b37 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -292,6 +292,9 @@ type PlanModule struct { FirstAt *time.Time `json:"first_at,omitempty"` Commit string `json:"commit,omitempty"` Why string `json:"why,omitempty"` + // Placed is the edge that put the module after another, and the rule that drew it (novox/hq ADR 0419 clause 5): + // "after (verb before caller: .)". Empty for a module placed first by nothing. + Placed string `json:"placed,omitempty"` // Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from ", naming the pull // request's head, the trunk commit and the digests, or "built: ". Said in `plan-moved` and kept here. Made string `json:"made,omitempty"` diff --git a/internal/link/events.go b/internal/link/events.go index 8ef08c94..883ef587 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -224,9 +224,8 @@ type SourceMoved struct { // stands: a directory is a module only when the merge changed its manifest. ModuleDirsSaid bool `json:"module_dirs_said,omitempty"` - // Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery - // group's `after:` lines, read when its members are merged into one batch and their order becomes the - // walk's, and the words `plans` names a walk by. + // Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): the + // pull request's number and words, which `plans` names a walk by (no `after:` line is read, ADR 0419). Number int `json:"number,omitempty"` Title string `json:"title,omitempty"` Body string `json:"body,omitempty"`