While the mesh-delivery seat has a holder on record, a merge that moves no core module opens its walk and asks nothing until mesh-delivery or a person says go; nothing of it is registered before its turn, so no other send carries it. The controller keeps the planner, the gate, sending and the walk, and gains the verbs the owner asks with: delivery-plan, -order, -check (a group composed as one future state), deliver, delivery-stop, delivery-walks; every walk kept is said as plan-moved.
942 lines
37 KiB
Go
942 lines
37 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// following acts on what the catalogue announces.
|
|
//
|
|
// **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when
|
|
// one arrives, so changing it takes effect on the next upgrade rather than on the next restart of
|
|
// the control plane.
|
|
type following struct{ open *stores }
|
|
|
|
// Upgraded sends the machines running a module the version the catalogue now considers current —
|
|
// or records that they are behind, which is the default and needs no record.
|
|
//
|
|
// **Recording is not a second code path.** A machine that is not running what the mesh would send
|
|
// it is already something the mesh notices and reports; that is what `status` and `push --behind`
|
|
// are built on. So "record it" is the absence of an action, and the only thing this has to decide
|
|
// is whether to act.
|
|
func (f following) Upgraded(ctx context.Context, u link.Upgraded) error {
|
|
inv := f.open.inventory
|
|
|
|
// The store read first, and an outage there said as one, so the announcement is held and asked
|
|
// again (novox/hq issue 083). Only here: a push that fails further down is not asked again.
|
|
decision, err := inv.UpgradeOf(ctx, u.Module)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
on, err := inv.Running(ctx, u.Module)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
if len(on) == 0 {
|
|
fmt.Printf("%s moved to %s; no machine runs it\n", u.Module, shortCommit(u.Commit))
|
|
return nil
|
|
}
|
|
if !decision.RollOut {
|
|
// Named rather than counted, and said even though nothing happens: an upgrade that was
|
|
// deliberately not rolled out and an upgrade that was never noticed look identical in a
|
|
// log that only speaks when it acts.
|
|
fmt.Printf("%s moved to %s; %s %s behind it, and this mesh records upgrades rather than "+
|
|
"rolling them out — `push --behind` when you want them\n",
|
|
u.Module, shortCommit(u.Commit), readableList(on), isAre(len(on)))
|
|
return nil
|
|
}
|
|
|
|
// **A plan that holds the module rolls it out, and this does not** (novox/hq issue 249, ADR
|
|
// 0218). A merge's plan builds the module and sends it one machine first, the rest once that one
|
|
// has applied it; this announcement arrives as the build registers, and sending here too — one
|
|
// machine after another without waiting for any to apply — put the new bundle on every machine in
|
|
// the same minute, whatever the plan was waiting for. A move no plan answers (a build asked by
|
|
// hand) is still this handler's.
|
|
if plans, err := inv.OpenPlans(ctx); err != nil {
|
|
return notNow(err)
|
|
} else if id := rolledOutByAPlan(plans, u.Module); id != "" {
|
|
// Said with its remedy: a plan that ends without sending it — failed, or closed by hand — leaves
|
|
// these machines behind, which `status` lists and `push --behind` sends (novox/hq issue 249).
|
|
fmt.Printf("%s moved to %s; %s rolls it out to %s — if that plan ends without sending it, "+
|
|
"`status` lists them as behind and `push --behind` sends it\n",
|
|
u.Module, shortCommit(u.Commit), id, readableList(on))
|
|
return nil
|
|
}
|
|
// **No plan holds it: a release plan sends it** (novox/hq ADR 0236). A build asked outside a plan — a
|
|
// `rebuild`, a `replay` registered — was sent here one machine after another without any judging; it
|
|
// now waits for a gate like any other build, and the release plan walks it through the machines, one
|
|
// at a time, each judged.
|
|
fmt.Printf("%s moved to %s outside a plan; a release plan sends it to %s, one machine at a time, each judged "+
|
|
"(`upgrade backlog` lists what waits)\n", u.Module, shortCommit(u.Commit), readableList(on))
|
|
return nil
|
|
}
|
|
|
|
// askAgainOnGrants marks a send that stopped at its grants as one to ask again (novox/hq issue 249):
|
|
// an announcement handled by a send whose memberships could not be issued is held and redelivered,
|
|
// rather than taken as handled with the machines left on the old version.
|
|
func askAgainOnGrants(err error) error {
|
|
if err != nil && errors.Is(err, errGrants) && !errors.Is(err, link.ErrTryAgain) {
|
|
return fmt.Errorf("%w: %w", link.ErrTryAgain, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// rolledOutByAPlan is the open plan that will send a module's machines its new build — one holding
|
|
// the module that has not finished sending it — or empty when none will (novox/hq issue 249).
|
|
func rolledOutByAPlan(plans []inventory.Plan, module string) string {
|
|
for _, p := range plans {
|
|
if s, holds := p.Modules[module]; p.Open() && holds && (s == nil || (s.SentAt == nil && s.State != "failed")) {
|
|
return p.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// readableList names machines the way a sentence does, because this is read by a person deciding
|
|
// whether an upgrade went where they expected.
|
|
func readableList(names []string) string {
|
|
switch len(names) {
|
|
case 0:
|
|
return "nothing"
|
|
case 1:
|
|
return names[0]
|
|
case 2:
|
|
return names[0] + " and " + names[1]
|
|
}
|
|
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
|
}
|
|
|
|
func shortCommit(commit string) string {
|
|
if len(commit) > 8 {
|
|
return commit[:8]
|
|
}
|
|
return commit
|
|
}
|
|
|
|
func isAre(n int) string {
|
|
if n == 1 {
|
|
return "is"
|
|
}
|
|
return "are"
|
|
}
|
|
|
|
// upgradeCommand says what should happen when a module's current version moves (ADR 0162 §3, ADR
|
|
// 0235): every module's policy and where it comes from; one module's; or a person's choice for one.
|
|
func upgradeCommand(ctx context.Context, args []string) error {
|
|
// What waits for a gate, and releasing it by a person's word (ADR 0236).
|
|
if len(args) > 0 && (args[0] == "backlog" || args[0] == "release-backlog") {
|
|
return backlogCommand(ctx, args[0], args[1:])
|
|
}
|
|
set := flag.NewFlagSet("upgrade", flag.ContinueOnError)
|
|
together := set.Bool("together", false,
|
|
"send every machine running it at once, instead of one machine first")
|
|
why := set.String("why", "", "why this choice — required for record; kept and said with the policy")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
if len(positionals) == 0 {
|
|
all, err := inv.Upgrades(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names := make([]string, 0, len(all))
|
|
for n := range all {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
u := all[n]
|
|
line := fmt.Sprintf("%-28s %-9s %s", n, u.Policy(), u.From)
|
|
if u.Why != "" {
|
|
line += ": " + u.Why
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
return nil
|
|
}
|
|
module := positionals[0]
|
|
if len(positionals) == 1 {
|
|
decision, err := inv.UpgradeOf(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(sayUpgrade(module, decision))
|
|
return nil
|
|
}
|
|
|
|
decision := inventory.Upgrade{Why: strings.TrimSpace(*why), By: link.Caller()}
|
|
switch positionals[1] {
|
|
case "roll-out", "roll":
|
|
decision.RollOut, decision.Together = true, *together
|
|
case "record":
|
|
if *together {
|
|
// Refused rather than ignored: --together only means anything for a roll-out, and
|
|
// accepting it here would store a preference that never applies and looks like it does.
|
|
return errors.New("`--together` says how to roll out, so it cannot be given with " +
|
|
"`record`, which is the choice not to")
|
|
}
|
|
if decision.Why == "" {
|
|
return fmt.Errorf("holding %s back from every merge is a choice a person reads later: --why <text> "+
|
|
"(novox/hq ADR 0236). Nothing was changed", module)
|
|
}
|
|
case "default":
|
|
if err := inv.ClearUpgradeOf(ctx, module); err != nil {
|
|
return err
|
|
}
|
|
decision, err := inv.UpgradeOf(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(sayUpgrade(module, decision))
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("upgrade <module> roll-out|record|default — not %q", positionals[1])
|
|
}
|
|
if err := inv.SetUpgradeOf(ctx, module, decision); err != nil {
|
|
return err
|
|
}
|
|
decision, err = inv.UpgradeOf(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(sayUpgrade(module, decision))
|
|
return nil
|
|
}
|
|
|
|
func sayUpgrade(module string, u inventory.Upgrade) string {
|
|
from := " (" + u.From
|
|
if u.By != "" {
|
|
from += ", " + u.By
|
|
}
|
|
if u.Why != "" {
|
|
from += ": " + u.Why
|
|
}
|
|
from += ")"
|
|
if !u.RollOut {
|
|
return fmt.Sprintf("when %s moves, the mesh records it and the machines running it are "+
|
|
"reported as behind until a person pushes them%s", module, from)
|
|
}
|
|
if u.Together {
|
|
return fmt.Sprintf("when %s moves, every machine running it is sent the new version "+
|
|
"together%s", module, from)
|
|
}
|
|
return fmt.Sprintf("when %s moves, one machine running it is sent the new version first and judged at "+
|
|
"the gate; the rest follow once it passes, and a build that fails is put back there%s", module, from)
|
|
}
|
|
|
|
// Announceable is every build this mesh recorded, in the shape the builder announces one.
|
|
//
|
|
// **The catalogue asks for this when it starts, and the answer is the graph's foundation**
|
|
// (novox/hq 04-ISSUES/050). A durable queue keeps what arrived after it existed, so a running
|
|
// catalogue misses nothing — but the modules built before it first ran were announced to a queue
|
|
// that did not exist, and on a fresh mesh those are always the same three: the shared base, the
|
|
// store the catalogue runs on, and the catalogue itself.
|
|
//
|
|
// **Announced as fetchable, recorded as what it is** (novox/hq 04-ISSUES/102). A build is
|
|
// recorded by digest and path; the catalogue hears the builder's own announcements, which name
|
|
// the store's address, so a replay composes the address back in — the store's address as the
|
|
// network reaches it NOW, which is the whole point of not having recorded the old one. With no
|
|
// store on the network yet, the recorded form goes as it is.
|
|
func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) {
|
|
builds, err := f.open.inventory.Announceable(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
address, err := whereTheStoreIs(ctx, f.open.inventory, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]link.Announcement, 0, len(builds))
|
|
for _, b := range builds {
|
|
a := link.Announcement{
|
|
Module: b.Module, Commit: b.Commit, Repository: b.Repository,
|
|
Path: b.Path, Ref: b.Ref, Against: b.Against,
|
|
}
|
|
if len(b.Manifest) > 0 {
|
|
a.Manifest = b.Manifest
|
|
if address != "" {
|
|
a.Manifest = routedManifest(b.Manifest, b.Made, address)
|
|
}
|
|
}
|
|
for _, made := range routedArtifacts(b.Made, address) {
|
|
a.Made = append(a.Made, link.MadeArtifact{
|
|
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
|
})
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// notNow marks a store that could not be read right now, so the announcement is held rather than
|
|
// lost; anything else is returned as it was.
|
|
func notNow(err error) error {
|
|
if inventory.Unreachable(err) {
|
|
return fmt.Errorf("%w: %w", link.ErrTryAgain, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SourceMoved is the forge announcing a merge: every module recorded as built from that
|
|
// repository and branch is marked as moved to the merge commit, and built — bases first, so a
|
|
// module that stands on another's artifact is built after it and not against the old one
|
|
// (novox/hq 04-ISSUES/131). Nothing is pushed here: what a finished build does to the machines
|
|
// running the module is the upgrade's decision, taken when the catalogue announces it.
|
|
func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|
// One merge acted on at a time, whoever hands it over: the bus, or the catch-up that reads back
|
|
// what the bus did not hand over (novox/hq issue 266). Each judges against what the other wrote.
|
|
actingOnMerges.Lock()
|
|
defer actingOnMerges.Unlock()
|
|
|
|
inv := f.open.inventory
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
read, err := inv.ReadRepositories(ctx)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
|
|
from, packaging, already := mergeCandidates(m, entries, read)
|
|
if len(from) == 0 && len(packaging) == 0 {
|
|
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
|
// as the second sends somebody looking for a broken trigger when the mesh is up to date.
|
|
if already > 0 {
|
|
fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+
|
|
"from it\n", m.Owner, m.Repo, m.Base, m.Commit, already)
|
|
return nil
|
|
}
|
|
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n",
|
|
m.Owner, m.Repo, m.Base, m.Commit)
|
|
return nil
|
|
}
|
|
// The same judgement for the packaging kind, against the newest look at that repository by
|
|
// anything built from it: they keep no record of it themselves, and a replayed old merge should
|
|
// not rebuild them either.
|
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
|
packaging = nil
|
|
}
|
|
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
|
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
|
for _, e := range entries {
|
|
if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) {
|
|
fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n",
|
|
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
|
}
|
|
}
|
|
touched := whatTheMergeTouched(from, entries, m)
|
|
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
|
|
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
|
|
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
|
|
touched, deleted := splitDeleted(touched, m)
|
|
for _, e := range deleted {
|
|
retireDeleted(ctx, inv, e, m)
|
|
}
|
|
for _, e := range touched {
|
|
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
|
return notNow(err)
|
|
}
|
|
}
|
|
moved := append(append([]inventory.Entry{}, touched...), packaging...)
|
|
if len(moved) == 0 {
|
|
fmt.Printf("%s/%s merged into %s (%.8s); it changed nothing any module the mesh holds is "+
|
|
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
|
return nil
|
|
}
|
|
// A merge produces a plan the mesh keeps (novox/hq ADR 0162): what moved and everything that
|
|
// depends on it, along the catalogue's one dependency relation, sorted into tiers. The plan is
|
|
// written before any build is asked; the first tier is asked; this returns. Outcomes advance it.
|
|
edges, err := inv.Dependencies(ctx)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
var movedNames []string
|
|
for _, e := range moved {
|
|
movedNames = append(movedNames, e.Manifest.Module)
|
|
}
|
|
// Written and its first tier asked as one act on the plans (novox/hq issue 213): a timer on
|
|
// another controller reading it between the two would ask the tier again.
|
|
release, err := inv.HoldPlans(ctx, true)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
defer release()
|
|
plan := planOfMerge(m, movedNames, edges)
|
|
// **A newer plan supersedes the older open plans of this repository and branch** (novox/hq issue
|
|
// 254, ADR 0218): what they had not built is planned here again, and they are closed, so one plan
|
|
// works a repository's modules at a time and a stuck one ends at the next merge.
|
|
working, err := inv.OpenPlans(ctx)
|
|
if err != nil {
|
|
return notNow(err)
|
|
}
|
|
rollsOut := func(module string) bool {
|
|
u, err := inv.UpgradeOf(ctx, module)
|
|
return err == nil && u.RollOut
|
|
}
|
|
folded, superseded := supersededBy(plan, working, rollsOut)
|
|
if len(folded) > 0 {
|
|
held := map[string]bool{}
|
|
for _, e := range entries {
|
|
held[e.Manifest.Module] = true
|
|
}
|
|
names := map[string]bool{}
|
|
for _, name := range movedNames {
|
|
names[name] = true
|
|
}
|
|
var also []string
|
|
for _, name := range folded {
|
|
// One the catalogue no longer holds would fail the newer plan's ask; it is not this
|
|
// merge's to build.
|
|
if held[name] && !names[name] {
|
|
names[name] = true
|
|
movedNames = append(movedNames, name)
|
|
also = append(also, name)
|
|
}
|
|
}
|
|
if len(also) > 0 {
|
|
again := planOfMerge(m, movedNames, edges)
|
|
again.ID, again.Created = plan.ID, plan.Created
|
|
plan = again
|
|
fmt.Printf(" %s, left unbuilt by an older plan of %s, are planned here again\n",
|
|
strings.Join(also, ", "), plan.Repository)
|
|
}
|
|
}
|
|
// **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a
|
|
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
|
|
plan.Delivery = awaitsFor(entries, movedNames)
|
|
if hasCycle(plan.Tiers, edges) {
|
|
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
|
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
|
}
|
|
if err := inv.SavePlan(ctx, &plan); err != nil {
|
|
return notNow(err)
|
|
}
|
|
// Closed after the newer plan is kept, never before: a controller replaced between the two leaves
|
|
// both open, which the next merge settles, rather than neither.
|
|
for _, old := range superseded {
|
|
if err := inv.SavePlan(ctx, &old); err != nil {
|
|
return notNow(err)
|
|
}
|
|
fmt.Printf(" %s (%s at %s) is %s\n", old.ID, old.Repository, short(old.Commit), old.Note)
|
|
}
|
|
var tiers []string
|
|
for i, t := range plan.Tiers {
|
|
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", ")))
|
|
}
|
|
fmt.Printf("%s/%s merged into %s (%.8s); plan %s, %d module(s) in %d tier(s)\n %s\n",
|
|
m.Owner, m.Repo, m.Base, m.Commit, plan.ID, len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n "))
|
|
if len(packaging) > 0 {
|
|
var also []string
|
|
for _, e := range packaging {
|
|
also = append(also, e.Manifest.Module)
|
|
}
|
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
|
"is left where it is\n", strings.Join(also, ", "))
|
|
}
|
|
if plan.Waiting() {
|
|
plan.Note = waitingNote(plan)
|
|
if err := inv.SavePlan(ctx, &plan); err != nil {
|
|
return notNow(err)
|
|
}
|
|
fmt.Printf(" %s waits for %s's word before its first tier is asked\n", plan.ID, plan.Delivery.Awaits)
|
|
return nil
|
|
}
|
|
if err := askTier(ctx, inv, &plan); err != nil {
|
|
return notNow(err)
|
|
}
|
|
if err := inv.SavePlan(ctx, &plan); err != nil {
|
|
return notNow(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// actingOnMerges keeps one merge acted on at a time (novox/hq issue 266).
|
|
var actingOnMerges sync.Mutex
|
|
|
|
// mergeCandidates is what one merge could move, judged against what the catalogue holds.
|
|
//
|
|
// Two kinds of module are affected by one merge, and they are affected differently.
|
|
//
|
|
// A module **built from** this repository and branch has moved: the mesh records the new commit as
|
|
// what its source now has, and only what the merge actually changed is rebuilt. A module that only
|
|
// **packages source from** it has not moved — its own source is somewhere else, at the commit it
|
|
// already records — so it is rebuilt and its record left alone. Writing this commit as its source
|
|
// would make it permanently behind a repository its manifest does not come from. `already` counts
|
|
// the modules built from this repository that are already built from this very commit.
|
|
func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
|
|
read map[string][]inventory.ReadRepository) (from, packaging []inventory.Entry, already int) {
|
|
for _, e := range entries {
|
|
switch {
|
|
case sourceIs(e.Source, m):
|
|
if e.Source.BuiltFrom == m.Commit {
|
|
already++
|
|
continue
|
|
}
|
|
// **A merge older than the last look at the source is history, not a move.** The forge
|
|
// announces what it finds merged, and an old merge surfacing late would otherwise move
|
|
// the recorded head backwards and rebuild everything built from that repository, once
|
|
// per old merge (2026-09-28).
|
|
if isHistory(m.MergedAt, e.Source.Seen) {
|
|
continue
|
|
}
|
|
from = append(from, e)
|
|
case readsFrom(read[e.Manifest.Module], m):
|
|
packaging = append(packaging, e)
|
|
}
|
|
}
|
|
return from, packaging, already
|
|
}
|
|
|
|
// wouldMove is the modules built from the merged repository that acting on this merge would mark as
|
|
// moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
|
|
// merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
|
|
//
|
|
// **Only the modules built from it, never the ones that merely package source from it.** Acting
|
|
// records nothing about those, so a merge acted on would go on reading as unacted for them, and be
|
|
// acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
|
|
// rebuilds the second as well.
|
|
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
|
from, _, _ := mergeCandidates(m, entries, read)
|
|
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
|
|
return touched
|
|
}
|
|
|
|
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
|
|
// removed — deleted at their source (ADR 0236).
|
|
func splitDeleted(touched []inventory.Entry, m link.SourceMoved) (kept, deleted []inventory.Entry) {
|
|
removed := map[string]bool{}
|
|
for _, p := range m.Removed {
|
|
removed[strings.Trim(p, "/")] = true
|
|
}
|
|
for _, e := range touched {
|
|
dir := strings.Trim(e.Source.Path, "/")
|
|
manifest := moduleManifestFile
|
|
if dir != "" {
|
|
manifest = dir + "/" + manifest
|
|
}
|
|
if len(removed) > 0 && removed[manifest] {
|
|
deleted = append(deleted, e)
|
|
continue
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
return kept, deleted
|
|
}
|
|
|
|
// retireDeleted is what the mesh does with a module deleted at its source: its record says the merge
|
|
// was looked at, so it is not acted on again; it is forgotten where nothing holds it; where a machine
|
|
// runs it or the mesh holds something for it, that is said, and nothing is built.
|
|
func retireDeleted(ctx context.Context, inv *inventory.Inventory, e inventory.Entry, m link.SourceMoved) {
|
|
name := e.Manifest.Module
|
|
if err := inv.SourceMoved(ctx, name, m.Commit); err != nil {
|
|
fmt.Printf(" %s was deleted from %s/%s at %.8s, and that could not be recorded: %v\n", name, m.Owner, m.Repo, m.Commit, err)
|
|
}
|
|
err := inv.ForgetModule(ctx, name)
|
|
switch {
|
|
case err == nil:
|
|
fmt.Printf(" %s was deleted from %s/%s at %.8s: forgotten, nothing built\n", name, m.Owner, m.Repo, m.Commit)
|
|
case errors.Is(err, inventory.ErrStillAssigned) || errors.Is(err, inventory.ErrStillHolds):
|
|
fmt.Printf(" %s was deleted from %s/%s at %.8s and is not built; the mesh still runs or holds it, so it is "+
|
|
"kept until a person unassigns it and `module forget %s`: %v\n", name, m.Owner, m.Repo, m.Commit, name,
|
|
firstLine(err.Error()))
|
|
default:
|
|
fmt.Printf(" %s was deleted from %s/%s at %.8s and is not built; forgetting it failed: %v\n", name, m.Owner,
|
|
m.Repo, m.Commit, err)
|
|
}
|
|
}
|
|
|
|
// sourceIs is whether a recorded source is the repository and branch a merge announced. A source on
|
|
// the git seat is recorded as its path on the forge; one elsewhere as the URL it was cloned from.
|
|
// An empty recorded ref is the repository's default branch, which is what a merge into the base
|
|
// branch of the forge's default means.
|
|
func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
|
if !sameRepository(s.Repository, m) {
|
|
return false
|
|
}
|
|
ref := followedBranch(s.Ref)
|
|
return ref == "" || ref == m.Base
|
|
}
|
|
|
|
// commitRef is a ref that names a commit rather than a branch: what `build --ref <commit>` asks for.
|
|
var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
|
|
|
// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A
|
|
// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a
|
|
// fix — recorded that commit as the module's ref; every merge after it then failed to match the
|
|
// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same
|
|
// old commit again. A commit recorded so is read as the repository's default branch, which is what
|
|
// the module followed before it; a branch is followed as named.
|
|
func followedBranch(ref string) string {
|
|
if commitRef.MatchString(strings.TrimSpace(ref)) {
|
|
return ""
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
|
// may have been recorded in: a path on the git seat, or the URL it was cloned from.
|
|
func sameRepository(repository string, m link.SourceMoved) bool {
|
|
want := strings.ToLower(m.Owner + "/" + m.Repo)
|
|
repo := strings.ToLower(strings.TrimSuffix(repository, ".git"))
|
|
return repo == want || strings.HasSuffix(repo, "/"+want) ||
|
|
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
|
}
|
|
|
|
// readsFrom is whether a module's build read the repository a merge names: the second repository its
|
|
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
|
// module's own source follows.
|
|
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
|
for _, r := range read {
|
|
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lastLookAt is the most recent look at this repository by anything built from it.
|
|
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
|
var newest time.Time
|
|
for _, e := range entries {
|
|
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
|
newest = e.Source.Seen
|
|
}
|
|
}
|
|
return newest
|
|
}
|
|
|
|
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
|
|
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
|
|
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
|
|
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
|
|
touched, _, _ := touchedBy(candidates, known, m)
|
|
return touched
|
|
}
|
|
|
|
// touchedBy maps a merge's changed files onto the mesh's modules — **the one place it is done**, for the
|
|
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
|
|
// ADR 0238), so planning and gating cannot disagree about what a change touches.
|
|
//
|
|
// **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
|
|
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
|
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
|
|
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
|
|
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
|
|
// is that module's; a file in no module's directory — a script at the root, a README, CI configuration —
|
|
// is read by no build and touches nothing: it is answered as `unread`.
|
|
//
|
|
// **It used to be read as shared code**, rebuilding everything built from the repository, on the theory
|
|
// that a root file might be a build input (04-ISSUES/131). No build reads one: the theory cost a rebuild
|
|
// of 103 modules for a merge-check.sh added at the catalogue's root (issue 280), as it had for a module
|
|
// held by no machine (278) and a new module's directory (252), each patched as an exception to a rule that
|
|
// was wrong. Rebuilding too much is not the safe direction when every rebuild is a rollout.
|
|
//
|
|
// `added` is the directories the change holds a module in that no module of this repository is known
|
|
// from — said by the announcer at the head (issue 278), or a module.json among the changed files — `.`
|
|
// for the root: a new module, which a check judges before it merges and a merge does not build.
|
|
//
|
|
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
|
|
// be narrowed.
|
|
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
|
|
knownDirs := map[string]bool{}
|
|
for _, e := range known {
|
|
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
|
knownDirs[strings.Trim(e.Source.Path, "/")] = true
|
|
}
|
|
}
|
|
newDir := map[string]bool{}
|
|
for _, d := range saidModuleDirs(m) {
|
|
if !knownDirs[d] {
|
|
newDir[d] = true
|
|
}
|
|
}
|
|
for _, p := range m.Paths {
|
|
p = strings.Trim(p, "/")
|
|
if p != moduleManifestFile && !strings.HasSuffix(p, "/"+moduleManifestFile) {
|
|
continue
|
|
}
|
|
d := strings.TrimSuffix(strings.TrimSuffix(p, moduleManifestFile), "/")
|
|
if !knownDirs[d] {
|
|
if d == "" {
|
|
d = "."
|
|
}
|
|
newDir[d] = true
|
|
}
|
|
}
|
|
for d := range newDir {
|
|
added = append(added, d)
|
|
}
|
|
sort.Strings(added)
|
|
|
|
if len(m.Paths) == 0 || m.PathsTruncated {
|
|
return candidates, added, nil
|
|
}
|
|
for _, e := range candidates {
|
|
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
|
|
touched = append(touched, e)
|
|
}
|
|
}
|
|
for _, p := range m.Paths {
|
|
read := newDir["."]
|
|
for _, e := range candidates {
|
|
read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
|
}
|
|
for d := range newDir {
|
|
read = read || inside(p, d)
|
|
}
|
|
if !read {
|
|
unread = append(unread, p)
|
|
}
|
|
}
|
|
return touched, added, unread
|
|
}
|
|
|
|
// mergeReach is what a merge of a repository's branch reaches, as the planner reckons it: the planner's
|
|
// one answer, given to the release planner's what-if, the merge gate and a pull request's check (novox/hq
|
|
// ADR 0238). The merge handler acts on the same pieces — mergeCandidates, touchedBy, splitDeleted,
|
|
// planOfMerge — as it goes.
|
|
type mergeReach struct {
|
|
// Touched are the modules built from the repository and branch whose build reads a changed file, and
|
|
// Deleted those of them whose manifest the merge removes.
|
|
Touched, Deleted []inventory.Entry
|
|
// Packaging are the modules whose build packages source from the repository.
|
|
Packaging []inventory.Entry
|
|
// Already counts the modules built from the repository that are built from this very commit.
|
|
Already int
|
|
// Added are the directories the change holds a new module in; Unread the changed files no build reads.
|
|
Added, Unread []string
|
|
// Plan is what moves and everything that follows it along the catalogue's dependencies, tiered —
|
|
// what a merge would build. Empty when nothing moves.
|
|
Plan inventory.Plan
|
|
}
|
|
|
|
// Moved are the modules the merge moves itself, by name: touched, deleted and packaging.
|
|
func (r mergeReach) Moved() []string {
|
|
var out []string
|
|
for _, group := range [][]inventory.Entry{r.Touched, r.Deleted, r.Packaging} {
|
|
for _, e := range group {
|
|
out = append(out, e.Manifest.Module)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return slices.Compact(out)
|
|
}
|
|
|
|
// Dependents are the modules the plan builds after the moved ones because they stand on them.
|
|
func (r mergeReach) Dependents() []string {
|
|
moved := map[string]bool{}
|
|
for _, name := range r.Moved() {
|
|
moved[name] = true
|
|
}
|
|
var out []string
|
|
for name := range r.Plan.Modules {
|
|
if !moved[name] {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// reachOfMerge is what a merge of these changed files into this branch would move and build, without
|
|
// acting: the merge handler's own judgement and the release plan's dependency walk.
|
|
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
|
edges []inventory.Edge) mergeReach {
|
|
from, packaging, already := mergeCandidates(m, entries, read)
|
|
touched, added, unread := touchedBy(from, entries, m)
|
|
kept, deleted := splitDeleted(touched, m)
|
|
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
|
var building []string
|
|
for _, e := range append(append([]inventory.Entry{}, kept...), packaging...) {
|
|
building = append(building, e.Manifest.Module)
|
|
}
|
|
if len(building) > 0 {
|
|
r.Plan = planOfMerge(m, building, edges)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// saidModuleDirs is the directories the announcer says hold a module at the merge commit (novox/hq
|
|
// issue 278): a changed file in one of them is that module's business and nobody else's — the
|
|
// catalogue's reference module, held by no machine, whose code a merge touched beside its manifest,
|
|
// read as shared and rebuilt 103 modules. Nothing when the announcer did not look, and never the
|
|
// repository's root: a module built from the root is handled as one built from it, and a root that
|
|
// counted would make every file a module's.
|
|
func saidModuleDirs(m link.SourceMoved) []string {
|
|
if !m.ModuleDirsSaid {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, d := range m.ModuleDirs {
|
|
if d = strings.Trim(strings.TrimSpace(d), "/"); d != "" && d != "." {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// inside is whether a changed file is in a directory: that directory itself, or under it.
|
|
func inside(path, dir string) bool {
|
|
dir = strings.Trim(dir, "/")
|
|
path = strings.TrimPrefix(path, "/")
|
|
return path == dir || strings.HasPrefix(path, dir+"/")
|
|
}
|
|
|
|
// anyInside is whether any of these changed files is in a directory.
|
|
func anyInside(paths []string, dir string) bool {
|
|
for _, p := range paths {
|
|
if inside(p, dir) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// orderByBases is the entries with every base before what stands on it: a module whose build stood
|
|
// on another's artifact comes after that module. Entries outside the set are not waited for — they
|
|
// are not being rebuilt. Stable for what has no order between it.
|
|
//
|
|
// `against` is what each module's newest build stood on (inventory.BuiltAgainst): the edges are
|
|
// derived from builds, not declared, because a recorded manifest no longer carries `build.on`.
|
|
func orderByBases(entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
|
inSet := map[string]bool{}
|
|
for _, e := range entries {
|
|
inSet[e.Manifest.Module] = true
|
|
}
|
|
var out []inventory.Entry
|
|
placed := map[string]bool{}
|
|
var place func(e inventory.Entry, seen map[string]bool)
|
|
place = func(e inventory.Entry, seen map[string]bool) {
|
|
name := e.Manifest.Module
|
|
if placed[name] || seen[name] {
|
|
return
|
|
}
|
|
seen[name] = true
|
|
for _, base := range entries {
|
|
if base.Manifest.Module != name && inSet[base.Manifest.Module] && standsOnModule(e, base.Manifest.Module, against) {
|
|
place(base, seen)
|
|
}
|
|
}
|
|
placed[name] = true
|
|
out = append(out, e)
|
|
}
|
|
for _, e := range entries {
|
|
place(e, map[string]bool{})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// standsOn is whether anything in the set is built on the named module's artifacts.
|
|
func standsOn(entries []inventory.Entry, module string, against map[string][]string) bool {
|
|
for _, e := range entries {
|
|
if standsOnModule(e, module, against) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// standsOnModule is whether an entry's build stood on the named module: by what its newest build
|
|
// recorded it was handed (`artifact-store://<module>/<artifact>@…`, the module's own artifact), or
|
|
// — for a module registered from a manifest and not yet built — by the base its manifest names.
|
|
func standsOnModule(e inventory.Entry, module string, against map[string][]string) bool {
|
|
if e.Manifest.Module == module {
|
|
return false
|
|
}
|
|
if e.Manifest.Build != nil {
|
|
for _, on := range e.Manifest.Build.On {
|
|
if on.Module == module {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
prefix := catalogue.ArtifactStoreScheme + module + "/"
|
|
for _, ref := range against[e.Manifest.Module] {
|
|
if strings.HasPrefix(ref, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isHistory is whether a merge made at mergedAt predates the last time the source was seen. A merge
|
|
// with no time on it is taken as news: refusing it would silence a forge that says less.
|
|
func isHistory(mergedAt string, seen time.Time) bool {
|
|
if mergedAt == "" || seen.IsZero() {
|
|
return false
|
|
}
|
|
at, err := time.Parse(time.RFC3339, mergedAt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return at.Before(seen)
|
|
}
|
|
|
|
// dependentsOf is every catalogued module that stands on one of the moved modules, directly or
|
|
// through another dependent, and is not itself among them — in the catalogue's order, so the
|
|
// answer is the same each time. A module standing on nothing that moved is left alone: a merge
|
|
// rebuilds what it changed and what is built on top of that, not the catalogue.
|
|
func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
|
bases := map[string]bool{}
|
|
for _, e := range moved {
|
|
bases[e.Manifest.Module] = true
|
|
}
|
|
var out []inventory.Entry
|
|
taken := map[string]bool{}
|
|
for grew := true; grew; {
|
|
grew = false
|
|
for _, e := range entries {
|
|
name := e.Manifest.Module
|
|
if bases[name] || taken[name] {
|
|
continue
|
|
}
|
|
for base := range bases {
|
|
if standsOnModule(e, base, against) {
|
|
taken[name] = true
|
|
out = append(out, e)
|
|
grew = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for _, e := range out {
|
|
bases[e.Manifest.Module] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// moduleManifestFile is the file that says what a module is, in its directory (the build seat's
|
|
// ManifestName).
|
|
const moduleManifestFile = "module.json"
|
|
|
|
// deletedAtSource is whether a build failed because its module's manifest is not at its source any
|
|
// more — the build seat's own words (internal/builder) — which a merge that deleted the module causes
|
|
// when its announcer did not say which files went (ADR 0236). Such a module is not a failure of the plan.
|
|
func deletedAtSource(failed string) bool {
|
|
return strings.Contains(failed, "has no "+moduleManifestFile+" at ") &&
|
|
strings.Contains(failed, "so there is nothing saying what it is")
|
|
}
|