Draw a plan's order per module from the graph and the manifests' change, and read no after: line (issue 977, part B, ADR 0419)
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -71,9 +70,9 @@ const batchLateAfter = time.Minute
|
|||||||
// made before the controller began keeping merges (owedLate).
|
// made before the controller began keeping merges (owedLate).
|
||||||
const mergeGrace = 10 * time.Minute
|
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
|
// edgeChangeOrder is the order the rules of a plan draw among its modules (novox/hq ADR 0419 clauses 3 and 4, delivery_edges.go)
|
||||||
// modules of the member after depend on the modules of the member before, and are gated on them.
|
// made an edge of its tiers: the module after depends on the module before, and is gated on it.
|
||||||
const edgeGroupOrder = "group-order"
|
const edgeChangeOrder = "change-order"
|
||||||
|
|
||||||
// mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its
|
// mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its
|
||||||
// default, and said.
|
// default, and said.
|
||||||
@@ -1039,9 +1038,13 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(also)
|
sort.Strings(also)
|
||||||
ordered := groupOrderEdges(members, entries, read, edges)
|
ordered, err := changeOrderEdges(ctx, members, moved, edges)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var withheld []string
|
var withheld []string
|
||||||
plan := planOfMoves(moved, append(append([]inventory.Edge{}, edges...), ordered...))
|
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
|
// **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
|
// 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,
|
// 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
|
var tiers []string
|
||||||
for i, t := range plan.Tiers {
|
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
|
var answers []string
|
||||||
for _, n := range named {
|
for _, n := range named {
|
||||||
@@ -1122,6 +1132,23 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
|||||||
return nil
|
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.
|
// unchangedSaid names the modules a walk says matched what runs, for its note.
|
||||||
func unchangedSaid(unchanged map[string]string) string {
|
func unchangedSaid(unchanged map[string]string) string {
|
||||||
names := make([]string, 0, len(unchanged))
|
names := make([]string, 0, len(unchanged))
|
||||||
@@ -1310,73 +1337,30 @@ type batchMember struct {
|
|||||||
moved []string
|
moved []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// afterLines are the `after: <repository>` lines of a pull request's description, as mesh-delivery reads them.
|
// changeOrderEdges is the order of a batch's moved modules as edges of its plan's tiers (novox/hq ADR 0419): drawn
|
||||||
var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`)
|
// 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.
|
||||||
// groupOrderEdges is the order of every delivery group among a batch's merges as edges of its walk's tiers
|
// A cycle is said, never lost: the plan puts the modules left in its last tier, and the composed check fails the
|
||||||
// (ADR 0276 decision 5, ADR 0249 unchanged): merges of two repositories or more from one head branch name are
|
// group before it merges.
|
||||||
// a group; each pair its order rules give puts the modules of the member after in a later tier than the
|
func changeOrderEdges(ctx context.Context, members []batchMember, moved []string, edges []inventory.Edge) ([]inventory.Edge, error) {
|
||||||
// member before's. A pair in a contradiction orders nothing: its group was refused before it merged.
|
var sources []changeSource
|
||||||
func groupOrderEdges(members []batchMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
|
||||||
edges []inventory.Edge) []inventory.Edge {
|
|
||||||
byHead := map[string][]batchMember{}
|
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
if m.merge.Head != "" {
|
sources = append(sources, changeSource{Owner: m.merge.Owner, Repo: m.merge.Repo, New: m.merge.Commit,
|
||||||
byHead[m.merge.Head] = append(byHead[m.merge.Head], m)
|
Paths: m.merge.Paths, Truncated: m.merge.PathsTruncated, Removed: m.merge.Removed})
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
var out []inventory.Edge
|
order := orderEdges(moved, changes, edges)
|
||||||
heads := make([]string, 0, len(byHead))
|
for _, e := range order.Edges {
|
||||||
for h := range byHead {
|
fmt.Printf(" order: %s\n", edgeSaid(e))
|
||||||
heads = append(heads, h)
|
|
||||||
}
|
}
|
||||||
sort.Strings(heads)
|
if said := order.CycleSaid(); said != "" {
|
||||||
for _, h := range heads {
|
fmt.Printf(" %s\n", said)
|
||||||
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 {
|
return order.Edges, nil
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered
|
// searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// settings file says them, and
|
||||||
|
|||||||
+43
-170
@@ -579,8 +579,6 @@ type orderMember struct {
|
|||||||
ModuleDirs []string `json:"module_dirs,omitempty"`
|
ModuleDirs []string `json:"module_dirs,omitempty"`
|
||||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||||
CloneURL string `json:"clone_url,omitempty"`
|
CloneURL string `json:"clone_url,omitempty"`
|
||||||
// After are the repositories its pull request says it goes after (`after: <repository>`).
|
|
||||||
After []string `json:"after,omitempty"`
|
|
||||||
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
|
// 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.
|
// a verdict no build judges names when the head is checked again through mesh-delivery.
|
||||||
BaseSHA string `json:"base_sha,omitempty"`
|
BaseSHA string `json:"base_sha,omitempty"`
|
||||||
@@ -611,14 +609,6 @@ type orderPair struct {
|
|||||||
Over []string `json:"over,omitempty"`
|
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.
|
// orderReach is what a member moves, as the planner says.
|
||||||
type orderReach struct {
|
type orderReach struct {
|
||||||
Moved []string `json:"moved,omitempty"`
|
Moved []string `json:"moved,omitempty"`
|
||||||
@@ -627,114 +617,76 @@ type orderReach struct {
|
|||||||
Manifests []string `json:"manifests,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 —
|
// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one. The
|
||||||
// with each contradiction no precedence resolves.
|
// 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 {
|
type groupOrder struct {
|
||||||
Order []string `json:"order"`
|
Order []string `json:"order"`
|
||||||
Pairs []orderPair `json:"pairs,omitempty"`
|
Pairs []orderPair `json:"pairs,omitempty"`
|
||||||
Cycle []string `json:"cycle,omitempty"`
|
Cycle []string `json:"cycle,omitempty"`
|
||||||
Contradictions []orderContradiction `json:"contradictions,omitempty"`
|
CycleSaid string `json:"cycle_said,omitempty"`
|
||||||
|
Edges []inventory.Edge `json:"edges,omitempty"`
|
||||||
Reach map[string]orderReach `json:"reach,omitempty"`
|
Reach map[string]orderReach `json:"reach,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reasons a pair is ordered, in the words the delivery plan shows.
|
// orderBuiltBy is the reason a member goes before another that moves a module built by, standing on, packaging or
|
||||||
const (
|
// declaring one of its modules: the module graph's own edge.
|
||||||
orderDeclared = "declared"
|
const orderBuiltBy = "built by"
|
||||||
orderBuiltBy = "built by"
|
|
||||||
orderVersionSkew = "version skew"
|
|
||||||
orderEngineFirst = "engine before controller"
|
|
||||||
)
|
|
||||||
|
|
||||||
// orderRank is a rule's precedence when two rules order one pair both ways (novox/hq ADR 0249), the lower
|
// orderOf is a group's order (novox/hq ADR 0419), pure: a member goes before another when a module it moves goes
|
||||||
// winning: what a person declared; then what the graph and the change say (built by, version skew); then
|
// before a module the other moves by an edge of the module graph (built by), or by a rule of delivery_edges.go
|
||||||
// the rollout default, which infers a direction from the modules' names alone.
|
// (verb before caller, version skew, engine before controller), which changes names the manifests' added verbs.
|
||||||
func orderRank(why string) int {
|
// No `after:` line is read. Ties break by repository then id, so every reading gives one order. A cycle through
|
||||||
switch why {
|
// two members or more is named by its members, and none of them is ordered.
|
||||||
case orderDeclared:
|
func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge, changes map[string]moduleChange) groupOrder {
|
||||||
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 {
|
|
||||||
out := groupOrder{Reach: reach}
|
out := groupOrder{Reach: reach}
|
||||||
byID := map[string]orderMember{}
|
movedBy := map[string][]string{}
|
||||||
|
var all []string
|
||||||
for _, m := range members {
|
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) {
|
add := func(before, after, why string) {
|
||||||
if before == after {
|
if before == after {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, p := range claims {
|
for _, p := range out.Pairs {
|
||||||
if p.Before == before && p.After == after && p.Why == why {
|
if p.Before == before && p.After == after && p.Why == why {
|
||||||
return
|
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
|
|
||||||
}
|
|
||||||
_, 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 {
|
for _, e := range edges {
|
||||||
if slices.Contains(ra.Moved, e.To) && slices.Contains(rb.Moved, e.From) && e.From != e.To {
|
if !orders(e) {
|
||||||
add(a.ID, b.ID, orderBuiltBy)
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
for _, before := range movedBy[e.To] {
|
||||||
|
for _, after := range movedBy[e.From] {
|
||||||
|
add(before, after, orderBuiltBy)
|
||||||
}
|
}
|
||||||
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") {
|
order := orderEdges(all, changes, edges)
|
||||||
add(a.ID, b.ID, orderEngineFirst)
|
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 {
|
sort.Slice(out.Pairs, func(i, j int) bool {
|
||||||
if out.Pairs[i].Before != out.Pairs[j].Before {
|
if out.Pairs[i].Before != out.Pairs[j].Before {
|
||||||
return out.Pairs[i].Before < out.Pairs[j].Before
|
return out.Pairs[i].Before < out.Pairs[j].Before
|
||||||
}
|
}
|
||||||
|
if out.Pairs[i].After != out.Pairs[j].After {
|
||||||
return 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.
|
// Kahn's walk, the next always the first by repository and id among those with nothing before them.
|
||||||
waiting := map[string]int{}
|
waiting := map[string]int{}
|
||||||
@@ -779,89 +731,6 @@ func orderOf(members []orderMember, reach map[string]orderReach, edges []invento
|
|||||||
return out
|
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.
|
// reachOfMembers is each member's reach, as the planner says it.
|
||||||
func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||||
edges []inventory.Edge) map[string]orderReach {
|
edges []inventory.Edge) map[string]orderReach {
|
||||||
@@ -959,7 +828,11 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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":
|
case "check":
|
||||||
if *group == "" && len(members) == 1 {
|
if *group == "" && len(members) == 1 {
|
||||||
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
||||||
|
|||||||
@@ -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 "<seat>.<verb>": those of the seats it defines, those of the
|
||||||
|
// seats it claims, and its own tools as "<module>.<tool>".
|
||||||
|
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 "<seat>.<verb>" or "<module>.<tool>".
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 ("<seat>.<verb>") 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,34 +16,36 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/testbus"
|
"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
|
// novox/hq ADR 0419: a group's order, from the module graph and the manifests' change alone, the same on every
|
||||||
// reading — the build agent before what it builds, the controller before a manifest that needs it, the
|
// reading — what a module stands on before it, the controller before a manifest that needs it, the node-engine
|
||||||
// node-engine before the controller, a declared `after:` — and a contradiction named, never ordered.
|
// before the controller, a verb's server before its caller — and no `after:` line read.
|
||||||
func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
|
func TestAGroupIsOrderedByTheGraphAndTheManifestsChange(t *testing.T) {
|
||||||
members := []orderMember{
|
members := []orderMember{
|
||||||
{ID: "cat", Repository: "novox/mesh-catalog"},
|
{ID: "cat", Repository: "novox/mesh-catalog"},
|
||||||
{ID: "ctl", Repository: "novox/mesh-controller"},
|
{ID: "ctl", Repository: "novox/mesh-controller"},
|
||||||
{ID: "host", Repository: "novox/mesh-host"},
|
{ID: "host", Repository: "novox/mesh-host"},
|
||||||
{ID: "agent", Repository: "novox/build-agent"},
|
{ID: "base", Repository: "novox/base"},
|
||||||
{ID: "app", Repository: "novox/app", After: []string{"lab"}},
|
{ID: "app", Repository: "novox/app"},
|
||||||
{ID: "lab", Repository: "novox/lab"},
|
{ID: "lab", Repository: "novox/lab"},
|
||||||
}
|
}
|
||||||
reach := map[string]orderReach{
|
reach := map[string]orderReach{
|
||||||
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
|
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
|
||||||
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
|
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
|
||||||
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
|
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
|
||||||
"agent": {Moved: []string{"build-agent"}},
|
"base": {Moved: []string{"base"}},
|
||||||
"app": {Moved: []string{"app"}},
|
"app": {Moved: []string{"app"}},
|
||||||
"lab": {Moved: []string{"lab"}},
|
"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}}
|
{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 {
|
if len(got.Cycle) > 0 {
|
||||||
t.Fatalf("a cycle where there is none: %v", got.Cycle)
|
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.
|
// By repository among those with nothing before them: app waits for base and lab; gitea's catalogue for the controller.
|
||||||
want := []string{"agent", "lab", "app", "host", "ctl", "cat"}
|
want := []string{"base", "lab", "app", "host", "ctl", "cat"}
|
||||||
if !reflect.DeepEqual(got.Order, want) {
|
if !reflect.DeepEqual(got.Order, want) {
|
||||||
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
|
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
|
||||||
}
|
}
|
||||||
@@ -52,94 +53,43 @@ func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
|
|||||||
for _, p := range got.Pairs {
|
for _, p := range got.Pairs {
|
||||||
why[p.Before+">"+p.After] = p.Why
|
why[p.Before+">"+p.After] = p.Why
|
||||||
}
|
}
|
||||||
for pair, reason := range map[string]string{"host>ctl": orderEngineFirst, "ctl>cat": orderVersionSkew,
|
for pair, reason := range map[string]string{"host>ctl": ruleEngineFirst, "ctl>cat": ruleVersionSkew,
|
||||||
"ctl>host": "", "agent>app": orderBuiltBy, "lab>app": orderDeclared} {
|
"ctl>host": "", "base>app": orderBuiltBy, "lab>app": ruleVerbBeforeCaller + ": seat.v"} {
|
||||||
if why[pair] != reason {
|
if why[pair] != reason {
|
||||||
t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason)
|
t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The same members in another order read the same.
|
// The same members in another order read the same.
|
||||||
shuffled := []orderMember{members[5], members[3], members[0], members[4], members[2], members[1]}
|
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)
|
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
|
// A cycle is the one refusal, and it names every module and edge. A description's `after:` line changes nothing.
|
||||||
// pair says what it overruled; rules of one rank both ways are refused, naming both and how to declare.
|
func TestAGroupInACycleIsNamedAndNothingElseIsRefused(t *testing.T) {
|
||||||
func TestAnOrderRuleYieldsToAStrongerOne(t *testing.T) {
|
|
||||||
members := []orderMember{
|
members := []orderMember{
|
||||||
{ID: "ctl", Repository: "novox/mesh-controller", Number: 7},
|
{ID: "x", Repository: "novox/x", Number: 7},
|
||||||
{ID: "host", Repository: "novox/mesh-host", Number: 9},
|
{ID: "y", Repository: "novox/y", Number: 9},
|
||||||
}
|
}
|
||||||
reach := map[string]orderReach{
|
reach := map[string]orderReach{"x": {Moved: []string{"mx"}}, "y": {Moved: []string{"my"}}}
|
||||||
"ctl": {Moved: []string{"mesh-controller", "build-agent"}},
|
changes := map[string]moduleChange{
|
||||||
"host": {Moved: []string{"mesh-host"}},
|
"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}}
|
got := orderOf(members, reach, nil, changes)
|
||||||
|
if !reflect.DeepEqual(got.Cycle, []string{"x", "y"}) || len(got.Order) > 0 {
|
||||||
// Built by outranks engine before controller.
|
t.Fatalf("a cycle was ordered: %v, cycle %v", got.Order, got.Cycle)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if want := []orderPair{{Before: "ctl", After: "host", Why: orderBuiltBy, Over: []string{orderEngineFirst}}}; !reflect.DeepEqual(got.Pairs, want) {
|
for _, w := range []string{"mx", "my", "s.a", "s.b"} {
|
||||||
t.Fatalf("pairs %+v, wanted %+v", got.Pairs, want)
|
if !strings.Contains(got.CycleSaid, w) {
|
||||||
|
t.Errorf("the cycle does not say %q: %s", w, got.CycleSaid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
// 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.
|
// 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 ||
|
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages ||
|
||||||
e.Kind == edgeGroupOrder {
|
e.Kind == edgeChangeOrder {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if in[e.To] && !in[e.From] {
|
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
|
// 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.
|
// 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.
|
// 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) {
|
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
|
||||||
seen[e.To] = true
|
seen[e.To] = true
|
||||||
out = append(out, e.To)
|
out = append(out, e.To)
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"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
|
// novox/hq issue 309, replayed. The group `feat/a-machine-joins-through-the-tunnel` (2026-10-08): the controller's pull request
|
||||||
// commit. 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
|
// 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
|
// 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
|
// 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: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
||||||
{From: "route-proxy", 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 {
|
if len(got.Cycle) > 0 {
|
||||||
t.Fatalf("the group is refused as a cycle %v; pairs %+v", got.Cycle, got.Pairs)
|
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) {
|
if !reflect.DeepEqual(got.Order, want) {
|
||||||
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func withDependentsOf(same map[string]string, plan *inventory.Plan, moved []stri
|
|||||||
var bases []string
|
var bases []string
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf ||
|
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) {
|
if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) {
|
||||||
bases = append(bases, e.To)
|
bases = append(bases, e.To)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,12 +149,12 @@ var ControllerVerbs = []Verb{
|
|||||||
"paths": "the files it changes, comma-separated, from the repository's root",
|
"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",
|
"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"})},
|
"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 " +
|
{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 — declared, built by, version skew, engine before controller — and " +
|
"they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
|
||||||
"the rules each pair won over by precedence (ADR 0249); the cycle, and each contradiction no precedence " +
|
"controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
|
||||||
"resolves with how to declare the order, when the pairs contradict each other.",
|
"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, " +
|
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 " +
|
{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; " +
|
"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 " +
|
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type Edge struct {
|
|||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
Kind string `json:"kind"`
|
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: <seat>.<verb>". 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
|
// Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, want := range []Edge{
|
for _, want := range []Edge{
|
||||||
{"shop", "mesh-tools", EdgeStandsOn},
|
{From: "shop", To: "mesh-tools", Kind: EdgeStandsOn},
|
||||||
{"builder", "mesh-tools", EdgeStandsOn},
|
{From: "builder", To: "mesh-tools", Kind: EdgeStandsOn},
|
||||||
{"shop-plugin", "shop", EdgeDeclared},
|
{From: "shop-plugin", To: "shop", Kind: EdgeDeclared},
|
||||||
{"shop", "builder", EdgeBuiltBy},
|
{From: "shop", To: "builder", Kind: EdgeBuiltBy},
|
||||||
{"mesh-controller", "builder", EdgeBuiltBy},
|
{From: "mesh-controller", To: "builder", Kind: EdgeBuiltBy},
|
||||||
{"mesh-tools", "builder", EdgeBuiltBy},
|
{From: "mesh-tools", To: "builder", Kind: EdgeBuiltBy},
|
||||||
} {
|
} {
|
||||||
if !has(want.From, want.To, want.Kind) {
|
if !has(want.From, want.To, want.Kind) {
|
||||||
t.Errorf("missing %+v in %+v", want, got)
|
t.Errorf("missing %+v in %+v", want, got)
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ type PlanModule struct {
|
|||||||
FirstAt *time.Time `json:"first_at,omitempty"`
|
FirstAt *time.Time `json:"first_at,omitempty"`
|
||||||
Commit string `json:"commit,omitempty"`
|
Commit string `json:"commit,omitempty"`
|
||||||
Why string `json:"why,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 <module> (verb before caller: <seat>.<verb>)". 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 <check build>", naming the pull
|
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
|
||||||
// request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here.
|
// request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here.
|
||||||
Made string `json:"made,omitempty"`
|
Made string `json:"made,omitempty"`
|
||||||
|
|||||||
@@ -224,9 +224,8 @@ type SourceMoved struct {
|
|||||||
// stands: a directory is a module only when the merge changed its manifest.
|
// stands: a directory is a module only when the merge changed its manifest.
|
||||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
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
|
// Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): the
|
||||||
// group's `after:` lines, read when its members are merged into one batch and their order becomes the
|
// pull request's number and words, which `plans` names a walk by (no `after:` line is read, ADR 0419).
|
||||||
// walk's, and the words `plans` names a walk by.
|
|
||||||
Number int `json:"number,omitempty"`
|
Number int `json:"number,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user