Files
mesh-controller/cmd/mesh-controller/upgrades.go
T

649 lines
26 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"github.com/novox/mesh-controller/internal/gosource"
"regexp"
"slices"
"sort"
"strings"
"sync"
"time"
"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
// oneShot is a process that ends after the call (a recheck through `delivery check`): what it would do beside
// the call it does within it, or the process's exit would cut it off (novox/hq issue 726).
oneShot bool
}
// 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 {
// Nothing is sent: each machine follows the wants the assigner writes for the new fingerprint (ADR 0539 §5).
fmt.Printf("%s moved to %s\n", u.Module, shortCommit(u.Commit))
return nil
}
func shortCommit(commit string) string {
if len(commit) > 8 {
return commit[:8]
}
return commit
}
// 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
}
// SourceMoved is the forge announcing a merge. It is put into the open batch (novox/hq ADR 0276), which
// becomes one walk when its merge window closes: hearMerge, and cutBatchesHeld in batches.go.
func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
// A merge plans nothing here any more: mesh-delivery merges on the Approve, the indexer writes the fingerprints and
// the builder builds them (ADR 0539 §5, §17). The controller keeps the announcement, for the build of a merge that
// moves a module between repositories to be shown the operator's Approve (ADR 0420 §7).
return keepForgeMerge(ctx, m)
}
// 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):
// **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a
// build or plan of it after the merge already read the repository with the merge in it. Per
// module, since a merge that moved only the module built from the repository says nothing
// about the ones packaging it.
// Judged with a margin for the forge's clock running behind the store's: too late a look
// rebuilds once more, too early one would miss the merge.
if lookedAtCommit(read[e.Manifest.Module], m.Commit) {
continue
}
if looked := lookedOf(read[e.Manifest.Module]); !looked.IsZero() &&
isHistory(m.MergedAt, looked.Add(-historyMargin)) {
continue
}
packaging = append(packaging, e)
}
}
return from, packaging, already
}
// lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit.
func lookedAtCommit(read []inventory.ReadRepository, commit string) bool {
for _, r := range read {
if slices.Contains(r.LookedAt, commit) {
return true
}
}
return false
}
// historyMargin is how far a packaging module's last look is taken back before a merge is history for it.
const historyMargin = time.Minute
// lookedOf is when a module packaging another repository was last looked at, as readForPlanning says.
func lookedOf(read []inventory.ReadRepository) time.Time {
var at time.Time
for _, r := range read {
if r.Looked.After(at) {
at = r.Looked
}
}
return at
}
// 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
}
// 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
}
return followsBase(s.Ref, m.Base)
}
// followsBase is whether a recorded ref follows the branch a merge went into. **An empty ref is the
// trunk, main, and nothing else** (novox/hq ADR 0401 clause 3, issue 889): it used to match a merge
// into any branch, so a merge into a feature branch started a delivery. A ref recorded as a branch
// keeps following that branch.
func followsBase(ref, base string) bool {
if ref = strings.TrimSpace(followedBranch(ref)); ref == "" {
ref = "main"
}
return ref == 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 merge changed what a module's build read in another repository: 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.
//
// **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's
// newest trunk build said its build source in that repository, a merge touching none of it is no change
// to the module (issue 338: every merge to the controller's repository moved the route proxy and the
// build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is
// read, as before.
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
for _, r := range read {
if r.Own || !sameRepository(r.Repository, m) || !followsBase(r.Ref, m.Base) {
continue
}
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
return true
}
for _, p := range m.Paths {
if gosource.SourceHolds(r.Paths, p) {
return true
}
}
}
return false
}
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil
// when it said none, and the module's own directory — or, built from the root, its whole repository — is
// its build source, as before (novox/hq ADR 0267).
func ownSource(read []inventory.ReadRepository) []string {
for _, r := range read {
if r.Own && len(r.Paths) > 0 {
return r.Paths
}
}
return nil
}
// readsFile is whether a module built from the merged repository reads one of its changed files: in its
// build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all
// for a module built from the repository's root.
func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool {
if own := ownSource(read); own != nil {
return gosource.SourceHolds(own, p)
}
return readsDirectory(e, p)
}
// readsDirectory is whether a changed file is in a module's directory, or anywhere for a module built from its
// repository's root: the reading of a module with no recorded build source.
func readsDirectory(e inventory.Entry, p string) bool {
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
// readForPlanning is what each module's build read, as a change is mapped onto it — for a merge's reach, a pull request's
// check and the facts alike (novox/hq ADR 0238), so they cannot disagree. No plan overtakes it any more (ADR 0539 §17).
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
return inv.ReadRepositories(ctx)
}
// 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 its build
// source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure,
// an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is
// built from its root, or another program's package beside it, touches nothing. Where none was said, it 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 the merge builds and registers,
// sending it nowhere (askNewModules, novox/hq issue 300).
//
// 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,
read map[string][]inventory.ReadRepository) (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 {
for _, p := range m.Paths {
if readsFile(e, read[e.Manifest.Module], p) {
touched = append(touched, e)
break
}
}
}
for _, p := range m.Paths {
isRead := newDir["."]
for _, e := range candidates {
isRead = isRead || readsFile(e, read[e.Manifest.Module], p)
}
for d := range newDir {
isRead = isRead || inside(p, d)
}
// A file a module packages from this repository is read too, by that module's build.
for _, e := range known {
isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo,
Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}})
}
if !isRead {
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
// Following are the modules that stand on the moved ones along the catalogue's dependencies: what a merge would
// build after them.
Following []string
}
// 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 {
return r.Following
}
// 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+"/")
}
// 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)
}
// moduleManifestFile is the file that says what a module is, in its directory (the build seat's
// ManifestName).
const moduleManifestFile = "module.json"
// reachOfMerge is what a merge of these changed files into this branch would move, without acting: a pull request's
// reach, as the check reckons it.
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, read)
kept, deleted := splitDeleted(touched, m)
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
moved := r.Moved()
if len(moved) > 0 {
in := map[string]bool{}
for _, name := range moved {
in[name] = true
}
for _, name := range reachableFrom(moved, edges) {
if !in[name] {
r.Following = append(r.Following, name)
}
}
}
return r
}
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change.
func reachableFrom(moved []string, edges []inventory.Edge) []string {
in := map[string]bool{}
for _, m := range moved {
in[m] = true
}
for grew := true; grew; {
grew = false
for _, e := range edges {
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
// nothing it builds, and a new controller changes nothing about the holder it orders. A
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either:
// a shared file moves each module whose build source holds it, directly.
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages ||
e.Kind == edgeChangeOrder {
continue
}
if in[e.To] && !in[e.From] {
in[e.From] = true
grew = true
}
}
}
out := make([]string, 0, len(in))
for m := range in {
out = append(out, m)
}
sort.Strings(out)
return out
}
// edgeChangeOrder is the edge kind a change order draws.
const edgeChangeOrder = "change-order"
// 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 {
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))
// Beside each of its compositions, for the assigner's hold (novox/hq ADR 0539 clause 14).
rejudgeModule(ctx, open, module)
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))
// Beside each of its compositions, for the assigner's hold (novox/hq ADR 0539 clause 14).
rejudgeModule(ctx, open, module)
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)
}