Keep a recorded module at the build its machine runs on every send but a person's push, and say what a send recreates (hq issue 294, ADR 0242)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

A plan's gated send for mail carried postgres's and mongodb's new builds
(policy record) to the control node and the anchor on 2026-10-07: a send
composes the machine's whole declaration from the builds the mesh holds,
and the gate only ever looked at modules that roll out. Every send but a
person's push now composes a recorded module from the manifest of the build
the machine was last sent and records that it still carries it; the bus
step moves the bus alone. And each move a gated send carries says how many
of the module's containers it recreates, and whether with a new image or
only their declaration.
This commit is contained in:
jochen
2026-10-07 19:17:08 +02:00
parent a5a132ac15
commit cdf30349a7
9 changed files with 580 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
package catalogue
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)
// What a move does to a module's containers (novox/hq ADR 0242).
//
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
// variable, a health check adopted. A module whose containers are recreated in one send is down while
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
// send says, per module, how many of its containers it recreates and whether any image changed, before
// it is sent and in the plan that sent it.
// Recreation is what moving a module from one build's manifest to another's does to its containers.
type Recreation struct {
// Containers is how many containers the new build declares.
Containers int
// Recreated are the ids of the containers whose declaration differs — changed, added or gone —
// sorted.
Recreated []string
// Images are the ids among them whose image changed (or that are added or gone).
Images []string
}
// Recreates compares two builds of a module, container by container, by resource id.
func Recreates(from, to Manifest) Recreation {
before := containersByID(from)
after := containersByID(to)
var r Recreation
r.Containers = len(after)
for id, now := range after {
was, held := before[id]
switch {
case !held:
r.Recreated = append(r.Recreated, id)
r.Images = append(r.Images, id)
case !sameDeclaration(was, now):
r.Recreated = append(r.Recreated, id)
if !sameDeclaration(was["image"], now["image"]) {
r.Images = append(r.Images, id)
}
}
}
for id := range before {
if _, kept := after[id]; !kept {
r.Recreated = append(r.Recreated, id)
r.Images = append(r.Images, id)
}
}
sort.Strings(r.Recreated)
sort.Strings(r.Images)
return r
}
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
func (r Recreation) Say(module string) string {
if len(r.Recreated) == 0 {
return ""
}
what := "with a new image"
switch {
case r.SpecOnly():
what = "no new image, only their declaration"
case len(r.Images) < len(r.Recreated):
what = fmt.Sprintf("%d with a new image", len(r.Images))
}
whole := ""
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
whole = ", every one at once: its service is interrupted until they are up again"
} else if len(r.Recreated) > 1 {
whole = ", at once: what they serve is interrupted until they are up again"
}
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
what, strings.Join(r.Recreated, ", "), whole)
}
func containersByID(m Manifest) map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
if t, _ := r["type"].(string); t != "container" {
continue
}
id, _ := r["id"].(string)
out[id] = r
}
return out
}
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
// float are one value.
func sameDeclaration(a, b any) bool {
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return reflect.DeepEqual(a, b)
}
var na, nb any
_ = json.Unmarshal(ra, &na)
_ = json.Unmarshal(rb, &nb)
return reflect.DeepEqual(na, nb)
}
+35
View File
@@ -307,3 +307,38 @@ func (i *Inventory) BuildOf(ctx context.Context, module, commit string) (string,
}
return id, err
}
// ManifestAt is the manifest a module was registered with at a commit (or a commit it abbreviates): the
// newest successful build from it, with the artifacts of the build standing for it when its source was
// unchanged (issue 280) — what a machine last sent that build runs. False when no such build, or none
// with a manifest, is kept (novox/hq issue 294: a recorded module is composed at the build its machine
// runs until a person's push moves it).
func (i *Inventory) ManifestAt(ctx context.Context, module, commit string) (catalogue.Manifest, bool, error) {
if commit == "" {
return catalogue.Manifest{}, false, nil
}
var id string
var raw []byte
err := i.store.Pool().QueryRow(ctx,
`select id, manifest from build
where module = $1 and failed = '' and manifest is not null and manifest::text <> 'null'
and (commit_hash = $2 or starts_with(commit_hash, $2))
order by at desc limit 1`, module, commit).Scan(&id, &raw)
if errors.Is(err, pgx.ErrNoRows) {
return catalogue.Manifest{}, false, nil
}
if err != nil {
return catalogue.Manifest{}, false, err
}
if stands, same, err := i.StandingBuild(ctx, module, id); err != nil {
return catalogue.Manifest{}, false, err
} else if stands != "" && stands != id && len(same) > 0 {
raw = same
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
return catalogue.Manifest{}, false, fmt.Errorf("%s's build from %s is not a manifest the mesh can compose: %w",
module, commit, err)
}
return m, true, nil
}
+4
View File
@@ -152,6 +152,10 @@ type CarriedMove struct {
From string `json:"from,omitempty"`
To string `json:"to"`
Build string `json:"build,omitempty"`
// Recreates is what the send does to the module's containers, in the mesh's words — how many it
// recreates, and whether with a new image or only their declaration (novox/hq ADR 0242); empty when
// it recreates none, or when the build the machine ran is not known.
Recreates string `json:"recreates,omitempty"`
}
// PlanRelease is a release plan's walk through the machines (novox/hq ADR 0236): every module build