Every merge opened a walk and the next merge of the branch superseded it: two catalogue merges 18 s apart left a walk no delivery held, and the operator started it by hand 58 minutes later. A merge now joins the open batch, kept in the store (migration 0089), which is cut into one walk when no merge came for merge-window (90 s) or at merge-window-at-most (10 min): one commit per repository, the latest of its branch, with every file the batch's merges changed. One walk at a time; a started walk is never superseded, a waiting one is folded into the next. The walk names every merge it answers on the wire (delivery.merges, taken_over_by, batch). A failed walk walks its earlier merges alone, newest first, until one is delivered. A delivery group's order becomes tier edges inside the walk. plans shows the batch assembling; S18 and S19 bound its waits; S16 names the merges a waiting walk answers.
1135 lines
45 KiB
Go
1135 lines
45 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/builder"
|
|
"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. 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 {
|
|
return f.hearMerge(ctx, m, time.Now().UTC())
|
|
}
|
|
|
|
// movesOfMerge is what one merge moves, acted on: every module recorded as built from that repository and
|
|
// branch is marked as moved to the merge commit; a module the merge deleted is forgotten or said; a new module
|
|
// is asked for and sent nowhere. The names returned are what the walk builds, bases first along the graph
|
|
// (novox/hq 04-ISSUES/131). Nothing is built or pushed here: the walk asks its tiers. Called when a batch is
|
|
// cut, once per repository, with the latest merge of the repository's branch and every file the batch's
|
|
// merges of it changed.
|
|
func movesOfMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, entries []inventory.Entry,
|
|
read map[string][]inventory.ReadRepository) ([]string, error) {
|
|
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, 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, 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, added, _ := touchedBy(from, entries, m, read)
|
|
// **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 nil, notNow(err)
|
|
}
|
|
}
|
|
// **A new module is built and registered, and sent nowhere** (novox/hq issue 300): the delivery plan
|
|
// says so, and assigning it is a person's act, which needs it registered first.
|
|
built := askNewModules(ctx, inv, m, from, added)
|
|
moved := append(append([]inventory.Entry{}, touched...), packaging...)
|
|
if len(moved) == 0 {
|
|
if len(built) > 0 {
|
|
fmt.Printf("%s/%s merged into %s (%.8s); it changed no module the mesh holds, and adds %s: "+
|
|
"built, registered when the build lands, and sent nowhere\n", m.Owner, m.Repo, m.Base, m.Commit,
|
|
strings.Join(built, ", "))
|
|
return nil, nil
|
|
}
|
|
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, nil
|
|
}
|
|
// Why each is in it (issue 363): the files of its build source the merge changed, or why it is read whole.
|
|
for _, e := range moved {
|
|
fmt.Printf(" %s: %s\n", e.Manifest.Module, whyMoved(e, read[e.Manifest.Module], m))
|
|
}
|
|
for _, e := range packaging {
|
|
fmt.Printf(" %s reads %s/%s through its build context: its own source record is left where it is\n",
|
|
e.Manifest.Module, m.Owner, m.Repo)
|
|
}
|
|
var names []string
|
|
for _, e := range moved {
|
|
names = append(names, e.Manifest.Module)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// askNewModules asks the build seat for every module a merge adds to the repository — a directory holding a
|
|
// module.json that no module of the mesh is known from (touchedBy's `added`) — at the branch merged into,
|
|
// not waited for: the build's take-in registers it, as a hand `build` does, and sends it nowhere, since no
|
|
// machine is assigned it (novox/hq issue 300). Before this a merge said "it changed nothing any module
|
|
// the mesh holds is built from", the module was never registered, and `assign` refused it as unknown.
|
|
//
|
|
// The repository is spelled, and found on the seat, as the modules already built from it are; `from` is
|
|
// those of them this merge moves, so a merge read as history, or of a branch nothing follows, builds no
|
|
// new module either. Outside the plan: the plan walks modules the catalogue holds, and a new one has no
|
|
// machine to send to and nothing standing on it. What could not be asked is said, and left to `build`.
|
|
// Returns the directories asked for.
|
|
func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, from []inventory.Entry,
|
|
added []string) []string {
|
|
if len(added) == 0 || len(from) == 0 {
|
|
return nil
|
|
}
|
|
source := buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat}
|
|
var asked []string
|
|
for _, dir := range added {
|
|
path := dir
|
|
if path == "." {
|
|
path = ""
|
|
}
|
|
id, err := askABuild(ctx, source, path, m.Base)
|
|
// Kept, asked or not, so `assign` can tell a build in flight, or a merge that could not ask for one,
|
|
// from a module nobody ever heard of (novox/hq issue 325).
|
|
request := inventory.BuildRequest{ID: id, Repository: source.Repository, Seat: source.Seat, Path: path,
|
|
Ref: m.Base, Commit: m.Commit, For: "merge"}
|
|
if err != nil {
|
|
request.ID = fmt.Sprintf("not-asked-%s-%s", short(m.Commit), strings.ReplaceAll(dir, "/", "-"))
|
|
request.NotAsked = err.Error()
|
|
recordBuildRequest(ctx, inv, request)
|
|
fmt.Printf(" %s is a new module in %s/%s and could not be built: %v — a hand `build` of it "+
|
|
"asks again\n", dir, m.Owner, m.Repo, err)
|
|
continue
|
|
}
|
|
recordBuildRequest(ctx, inv, request)
|
|
fmt.Printf(" %s is a new module in %s/%s: build %s asked at %s; registered when it lands, assigned "+
|
|
"nowhere\n", dir, m.Owner, m.Repo, id, m.Base)
|
|
asked = append(asked, dir)
|
|
}
|
|
return asked
|
|
}
|
|
|
|
// 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
|
|
|
|
// whyMoved is why a merge moves a module, as a plan says it (novox/hq ADR 0267, issue 363): the changed
|
|
// files in its build source, or why it is read whole. For a module built from the merged repository or one
|
|
// whose build context is that repository; a dependent is in a plan for what it stands on.
|
|
func whyMoved(e inventory.Entry, read []inventory.ReadRepository, m link.SourceMoved) string {
|
|
if len(m.Paths) == 0 || m.PathsTruncated {
|
|
return "read whole: the merge's changed files were not all said"
|
|
}
|
|
whole := "no build source recorded"
|
|
for _, r := range read {
|
|
if r.Own && r.Whole != "" {
|
|
whole = r.Whole
|
|
}
|
|
}
|
|
held := func(paths []string) []string {
|
|
var in []string
|
|
for _, p := range m.Paths {
|
|
if builder.SourceHolds(paths, p) {
|
|
in = append(in, p)
|
|
}
|
|
}
|
|
return in
|
|
}
|
|
changed := func(where string, in []string) string {
|
|
return fmt.Sprintf("its build source%s changed: %d changed file(s) in it, e.g. %s", where, len(in), in[0])
|
|
}
|
|
if sameRepository(e.Source.Repository, m) {
|
|
if own := ownSource(read); own != nil {
|
|
if in := held(own); len(in) > 0 {
|
|
return changed("", in)
|
|
}
|
|
}
|
|
dir := strings.Trim(e.Source.Path, "/")
|
|
if dir == "" {
|
|
return "read whole: " + whole + ", so every file of its repository is its build source"
|
|
}
|
|
for _, p := range m.Paths {
|
|
if inside(p, dir) {
|
|
return "read whole: " + whole + ", so its directory is its build source; e.g. " + p
|
|
}
|
|
}
|
|
return "read whole: " + whole
|
|
}
|
|
for _, r := range read {
|
|
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) {
|
|
continue
|
|
}
|
|
if len(r.Paths) > 0 {
|
|
if in := held(r.Paths); len(in) > 0 {
|
|
return changed(" in "+m.Owner+"/"+m.Repo, in)
|
|
}
|
|
continue
|
|
}
|
|
return "read whole: " + whole + ", so every file of " + m.Owner + "/" + m.Repo + ", which its build context is, is its build source"
|
|
}
|
|
return "read whole: " + whole
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made
|
|
// without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built
|
|
// from the repository as looked at, so the merge then reads as history for it.
|
|
//
|
|
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of
|
|
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed
|
|
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a
|
|
// merge acted on for it reads as history once its plan is made.
|
|
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
|
from, packaging, _ := mergeCandidates(m, entries, read)
|
|
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
|
|
return append(touched, packaging...)
|
|
}
|
|
|
|
// 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 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) || (r.Ref != "" && r.Ref != m.Base) {
|
|
continue
|
|
}
|
|
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
|
|
return true
|
|
}
|
|
for _, p := range m.Paths {
|
|
if builder.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 builder.SourceHolds(own, p)
|
|
}
|
|
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
|
}
|
|
|
|
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
|
|
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
|
|
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
|
|
// that added an import to it, and a later one changing only what that import names, would otherwise move
|
|
// nothing. Each is read whole, as before, until a build of it works again.
|
|
//
|
|
// Each is answered with the plan that overtook it, for saying why it is read whole.
|
|
func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]string {
|
|
stale := map[string]string{}
|
|
for name, rs := range read {
|
|
var since time.Time
|
|
for _, r := range rs {
|
|
if r.Built.After(since) {
|
|
since = r.Built
|
|
}
|
|
}
|
|
for _, p := range plans {
|
|
s, in := p.Modules[name]
|
|
if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) {
|
|
continue
|
|
}
|
|
if p.Open() || p.Created.After(since) {
|
|
stale[name] = p.ID
|
|
}
|
|
}
|
|
}
|
|
return stale
|
|
}
|
|
|
|
// lookedAt is when a merge was last acted on for a module that packages another repository's source: its
|
|
// newest build, or the newest plan that built it or is still building it, whichever is later. A build asked
|
|
// after a merge clones that repository with the merge in it, so an older merge is history for it; a plan
|
|
// that closed without building it looked at nothing.
|
|
func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time {
|
|
var at time.Time
|
|
for _, r := range read {
|
|
if r.Looked.After(at) {
|
|
at = r.Looked
|
|
}
|
|
}
|
|
for _, p := range plans {
|
|
s, in := p.Modules[name]
|
|
if in && (p.Open() || (s != nil && s.State == "built")) && p.Created.After(at) {
|
|
at = p.Created
|
|
}
|
|
}
|
|
return at
|
|
}
|
|
|
|
// readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge
|
|
// acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning
|
|
// and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for
|
|
// the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt).
|
|
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
|
|
read, err := inv.ReadRepositories(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Every plan since the oldest build whose source is recorded: one made after a module's build can have
|
|
// overtaken it, however long ago, so no window of recent plans would do.
|
|
var oldest time.Time
|
|
for _, rs := range read {
|
|
for _, r := range rs {
|
|
if !r.Built.IsZero() && (oldest.IsZero() || r.Built.Before(oldest)) {
|
|
oldest = r.Built
|
|
}
|
|
}
|
|
}
|
|
plans, err := inv.PlansSince(ctx, oldest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return planningView(read, plans), nil
|
|
}
|
|
|
|
// planningView is readForPlanning over what was read, so a test can hand it records.
|
|
func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository {
|
|
stale := staleIn(read, plans)
|
|
out := make(map[string][]inventory.ReadRepository, len(read))
|
|
for name, rs := range read {
|
|
looked := lookedAt(name, rs, plans)
|
|
var commits []string
|
|
for _, p := range plans {
|
|
if st, in := p.Modules[name]; in && p.Commit != "" && (p.Open() || (st != nil && st.State == "built")) {
|
|
// Every commit the walk carries (novox/hq ADR 0276): a batch's walk answers one per repository.
|
|
for _, c := range p.Carried() {
|
|
commits = append(commits, c.Commit)
|
|
}
|
|
}
|
|
}
|
|
var kept []inventory.ReadRepository
|
|
overtaken, isStale := stale[name]
|
|
for _, r := range rs {
|
|
if isStale {
|
|
if r.Own {
|
|
continue
|
|
}
|
|
r.Paths = nil
|
|
}
|
|
r.Looked, r.LookedAt = looked, commits
|
|
kept = append(kept, r)
|
|
}
|
|
if isStale {
|
|
kept = append(kept, inventory.ReadRepository{Own: true, Whole: "plan " + overtaken + " has not built it yet",
|
|
Looked: looked, LookedAt: commits})
|
|
}
|
|
out[name] = kept
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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,
|
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
|
touched, _, _ := touchedBy(candidates, known, m, read)
|
|
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 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
|
|
// 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, read)
|
|
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")
|
|
}
|