Merge remote-tracking branch 'origin/main' into merge-tmp

This commit is contained in:
jochen
2026-10-07 20:04:52 +02:00
12 changed files with 660 additions and 2 deletions
+16
View File
@@ -127,6 +127,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
return catalogue.Resolution{}, nil, err
}
// A recorded module is composed at the build this machine runs, on any send but a person's push
// (novox/hq issue 295, ADR 0242).
if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil {
return catalogue.Resolution{}, nil, err
}
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
At: onNetwork[nodeName], PublicDomain: publicDomain,
@@ -433,6 +439,16 @@ func declarationWith(ctx context.Context, open *stores, node string,
names = append(names, m.Module)
}
out.Builds = carriedBuilds(names, composed.LeftOut, current, before)
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0242).
kept, err := recordedKept(ctx, open, node)
if err != nil {
return sendable{}, err
}
for m, was := range kept {
if _, carried := out.Builds[m]; carried {
out.Builds[m] = was
}
}
out.Bindings = boundToData(plan, composed.LeftOut)
}
return out, nil
+7
View File
@@ -1082,6 +1082,13 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er
return nil, err
}
// **A recorded build moves only by a person's push** (novox/hq issue 295, ADR 0242): this send — a
// plan's, a release plan's, a rollback's, a healer's, a rotation's — composes every recorded module at
// the build its machine runs. The bus step is a person's word for the bus alone.
if ctx, err = sendKeeps(ctx, inv); err != nil {
return nil, err
}
// Held from composing to sending (novox/hq ADR 0100); a caller that holds them already —
// converge, which flips the node and then sends it — is not made to wait on itself.
ctx, release, err := holdNodes(ctx, open, names)
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"context"
"fmt"
"sort"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242).
//
// **A send carries the machine's whole declaration** (ADR 0221), composed from the build the mesh holds
// of every module on it. A module whose upgrade policy records — postgres, mongodb, keycloak, the
// network path — has its new build registered at its merge and sent nowhere, "until a person pushes".
// But every other send to its machine composed it too: on 2026-10-07 a catalogue merge adopting the
// images' health checks rebuilt postgres and mongodb with the rest, and the plan's gated send to the
// control node for mail — and to the anchor for the spreadsheet app — carried both, recreating the
// providers every consumer on those machines drops with. No gate judged them (the gate judges only what
// rolls out), and nobody had pushed.
//
// So **every send but a person's push composes a recorded module at the build its machine was last
// sent**: the manifest of that build, from the build records, in place of the one the mesh holds. The
// machine runs what it ran; the send records that it still carries that build; `status` keeps saying
// the machine is behind, and `push <node>` — a person's word — sends the new one. The bus step is a
// person's too, and carries the bus; any other recorded module waiting on the bus's machine stays.
//
// A recorded module the machine was never sent (a new assignment) is composed as the mesh holds it —
// there is nothing running to keep. One whose kept build is no longer in the records refuses the send,
// said: composing the new build would be the very move this exists to stop.
type keepRecordedKey struct{}
// sendKeeps is the context a send that is not a person's push composes under: every recorded module
// kept at the build its machine runs — except, on the bus step, the bus.
func sendKeeps(ctx context.Context, inv *inventory.Inventory) (context.Context, error) {
if !busStepSending(ctx) {
return keepingRecorded(ctx), nil
}
bus, err := pendingBus(ctx, inv)
if err != nil {
return nil, err
}
return keepingRecorded(ctx, bus.module), nil
}
// keepingRecorded is a context whose sends compose every recorded module at the build its machine runs,
// except the modules named (the bus, on the bus step).
func keepingRecorded(ctx context.Context, except ...string) context.Context {
skip := map[string]bool{}
for _, m := range except {
skip[m] = true
}
return context.WithValue(ctx, keepRecordedKey{}, skip)
}
// keptExcept is whether this context keeps recorded modules, and the modules it lets move.
func keptExcept(ctx context.Context) (map[string]bool, bool) {
skip, on := ctx.Value(keepRecordedKey{}).(map[string]bool)
return skip, on
}
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
// ADR 0221).
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
skip, on := keptExcept(ctx)
if !on {
return nil, nil
}
inv := open.inventory
sent, known, err := inv.SentBuilds(ctx, node)
if err != nil || !known {
return nil, err
}
current, err := inv.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
var f *moveFacts
out := map[string]string{}
for m, was := range sent {
now, held := current[m]
if !held || now.RollOut || skip[m] || was == "" || sameCommit(was, now.Commit) {
continue
}
if f == nil {
read, err := readMoveFacts(ctx, inv)
if err != nil {
return nil, err
}
f = &read
}
if f.identical(m, was, now.Commit) {
continue
}
out[m] = was
}
return out, nil
}
// keepRecorded puts, in a shelf about to be resolved for a machine, the build each recorded module there
// runs in place of the one the mesh holds; it answers what it kept, module → commit.
func keepRecorded(ctx context.Context, open *stores, node string, shelf map[string]catalogue.Manifest) (map[string]string, error) {
kept, err := recordedKept(ctx, open, node)
if err != nil || len(kept) == 0 {
return nil, err
}
names := make([]string, 0, len(kept))
for m := range kept {
names = append(names, m)
}
sort.Strings(names)
for _, m := range names {
ran, found, err := open.inventory.ManifestAt(ctx, m, kept[m])
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("%s records rather than rolls out, and %s runs its build %s, which the build "+
"records no longer hold: this send cannot keep it and does not move it — `push %s` sends the new "+
"one on a person's word (novox/hq ADR 0242)", m, node, short(kept[m]), node)
}
shelf[m] = ran
}
return kept, nil
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242).
// aContainerBuild is a build outcome of a module of containers, each named by id with the image and the
// health it is given; a policy when one is said.
func aContainerBuild(t *testing.T, module, commit, policy string, asked time.Time, containers map[string][2]string) link.BuildResult {
t.Helper()
var resources []any
for id, c := range containers {
r := map[string]any{"id": id, "type": "container", "name": module + "-" + id, "image": c[0]}
if c[1] != "" {
r["health"] = map[string]any{"kind": c[1]}
}
resources = append(resources, r)
}
m := map[string]any{"module": module, "version": "1", "resources": resources}
if policy != "" {
m["upgrade"] = map[string]any{"policy": policy, "why": "a provider whose restart drops every consumer"}
}
manifest, _ := json.Marshal(m)
return link.BuildResult{ID: link.NewBuildID(asked), Repository: "novox/mesh-catalog", Path: "modules/" + module,
On: "anchor", Module: module, Commit: commit, Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
// imageOf is the image the composed plan of a machine gives one of a module's containers.
func imageOf(t *testing.T, plan catalogue.Resolution, module, id string) string {
t.Helper()
for _, m := range plan.Modules {
if m.Module != module {
continue
}
for _, r := range m.Resources {
if r["id"] == id {
image, _ := r["image"].(string)
return image
}
}
}
t.Fatalf("%s has no container %s in the plan", module, id)
return ""
}
// Tonight's case, 2026-10-07: a catalogue merge adopting the images' own health checks rebuilt the
// database (policy record) with mail (policy roll). The plan's gated send for mail carried the
// database's new build to the control node and recreated it, unjudged, with nobody's word. A send that
// is not a person's push now composes the database at the build the machine runs and records that it
// still carries it; a person's push composes the new one; the bus step moves the bus alone.
func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
start := time.Now().Add(-time.Hour)
pgImage := "registry.invalid:5000/postgres/server@sha256:" + strings.Repeat("a", 64)
mailImage := "registry.invalid:5000/mailu/smtp@sha256:" + strings.Repeat("c", 64)
for _, b := range []link.BuildResult{
aContainerBuild(t, "postgres", "c1111111", catalogue.PolicyRecord, start,
map[string][2]string{"server": {pgImage, ""}}),
aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second),
map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}),
} {
if _, _, err := takeIn(ctx, inv, b); err != nil {
t.Fatal(err)
}
}
for _, m := range []string{"postgres", "mailu"} {
if _, err := inv.Assign(ctx, "anchor", m); err != nil {
t.Fatal(err)
}
}
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor",
map[string]string{"postgres": "c1111111", "mailu": "c1111111"}); err != nil {
t.Fatal(err)
}
// The merge: both adopt a health check; the images are the same.
for _, b := range []link.BuildResult{
aContainerBuild(t, "postgres", "c2222222", catalogue.PolicyRecord, start.Add(time.Minute),
map[string][2]string{"server": {pgImage, "runtime"}}),
aContainerBuild(t, "mailu", "c2222222", "", start.Add(time.Minute+time.Second),
map[string][2]string{"smtp": {mailImage, "runtime"}, "imap": {mailImage, "runtime"}}),
} {
if _, _, err := takeIn(ctx, inv, b); err != nil {
t.Fatal(err)
}
}
healthOf := func(plan catalogue.Resolution, module, id string) any {
for _, m := range plan.Modules {
for _, r := range m.Resources {
if m.Module == module && r["id"] == id {
return r["health"]
}
}
}
return nil
}
// A plan's, a release plan's, a healer's send: the database is kept as it runs, mail moves.
kept := keepingRecorded(ctx)
plan, settings, err := planFor(kept, open, "anchor")
if err != nil {
t.Fatal(err)
}
if healthOf(plan, "postgres", "server") != nil {
t.Fatal("a send that is not a person's push composed the recorded module's new build")
}
if healthOf(plan, "mailu", "smtp") == nil {
t.Fatal("the module that rolls out was not composed at its new build")
}
if imageOf(t, plan, "postgres", "server") != pgImage {
t.Fatal("the recorded module's container lost its image")
}
gens, err := generators(kept, open)
if err != nil {
t.Fatal(err)
}
declared, err := declarationWith(kept, open, "anchor", plan, settings, gens, Allocating)
if err != nil {
t.Fatal(err)
}
if declared.Builds["postgres"] != "c1111111" || declared.Builds["mailu"] != "c2222222" {
t.Fatalf("the send records it carries %v; want postgres still at c1111111 and mailu at c2222222", declared.Builds)
}
// A person's push: the recorded module's new build.
plan, _, err = planFor(ctx, open, "anchor")
if err != nil {
t.Fatal(err)
}
if healthOf(plan, "postgres", "server") == nil {
t.Fatal("a person's push did not compose the recorded module's new build")
}
// The bus step moves the bus alone: a recorded module it is told it may move moves, the others stay.
plan, _, err = planFor(keepingRecorded(ctx, "postgres"), open, "anchor")
if err != nil {
t.Fatal(err)
}
if healthOf(plan, "postgres", "server") == nil {
t.Fatal("the module a send is let move was kept")
}
// What a send composes under (sendToEach): every recorded module kept; on the bus step, the bus moves.
if under, err := sendKeeps(ctx, inv); err != nil {
t.Fatal(err)
} else if skip, on := keptExcept(under); !on || len(skip) != 0 {
t.Fatalf("an ordinary send keeps %v %v", on, skip)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "nats", Version: "2",
Provides: []catalogue.Offer{{Name: "mesh-bus"}}}, inventory.Source{Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/nats", BuiltFrom: "n2", Head: "n2"}); err != nil {
t.Fatal(err)
}
if under, err := sendKeeps(withBusStep(ctx), inv); err != nil {
t.Fatal(err)
} else if skip, on := keptExcept(under); !on || !skip["nats"] || len(skip) != 1 {
t.Fatalf("the bus step keeps %v %v; want everything recorded but the bus", on, skip)
}
// And a recorded module whose kept build the records no longer hold refuses the send, said.
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor",
map[string]string{"postgres": "c0000000", "mailu": "c2222222"}); err != nil {
t.Fatal(err)
}
if _, _, err := planFor(kept, open, "anchor"); err == nil || !strings.Contains(err.Error(), "push anchor") {
t.Fatalf("a recorded build that cannot be kept did not refuse the send with its remedy: %v", err)
}
// And the gated send for mail says what it recreates: both containers, no new image, at once.
moves := []inventory.CarriedMove{{Module: "mailu", Node: "anchor", From: "c1111111", To: "c2222222"}}
sayRecreations(ctx, open, moves)
if !strings.Contains(moves[0].Recreates, "recreates 2 of mailu's 2") || !strings.Contains(moves[0].Recreates, "no new image") {
t.Fatalf("the send says %q of mail's containers", moves[0].Recreates)
}
if said := recreationsSaid(moves); !strings.HasPrefix(said, "on anchor recreates") {
t.Fatalf("the plan's note says %q", said)
}
}
// The send says what it recreates (ADR 0242): mail's health checks adopted recreate both its
// containers, with no new image, every one at once.
func TestASendSaysWhatItRecreates(t *testing.T) {
image := "registry.invalid:5000/mailu/smtp@sha256:" + strings.Repeat("c", 64)
from := catalogue.Manifest{Module: "mailu", Resources: []map[string]any{
{"id": "smtp", "type": "container", "image": image},
{"id": "imap", "type": "container", "image": image},
{"id": "redis", "type": "container", "image": image},
{"id": "config", "type": "file", "path": "/etc/x"}}}
to := catalogue.Manifest{Module: "mailu", Resources: []map[string]any{
{"id": "smtp", "type": "container", "image": image, "health": map[string]any{"kind": "runtime"}},
{"id": "imap", "type": "container", "image": image, "health": map[string]any{"kind": "runtime"}},
{"id": "redis", "type": "container", "image": image},
{"id": "config", "type": "file", "path": "/etc/y"}}}
r := catalogue.Recreates(from, to)
if !r.SpecOnly() || len(r.Recreated) != 2 || r.Containers != 3 {
t.Fatalf("recreation %+v", r)
}
said := r.Say("mailu")
for _, want := range []string{"recreates 2 of mailu's 3", "no new image", "imap, smtp", "interrupted"} {
if !strings.Contains(said, want) {
t.Fatalf("%q does not say %q", said, want)
}
}
if catalogue.Recreates(from, from).Say("mailu") != "" {
t.Fatal("a move that recreates nothing said something")
}
to.Resources[2]["image"] = strings.Replace(image, "c", "d", 1)
if r := catalogue.Recreates(from, to); r.SpecOnly() || len(r.Images) != 1 {
t.Fatalf("a new image read as a declaration only: %+v", r)
}
}
+39
View File
@@ -301,6 +301,7 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
}
}
sort.Slice(moves, func(i, j int) bool { return moves[i].Module < moves[j].Module })
sayRecreations(ctx, open, moves)
sent, err := sendRollout(withScope(ctx, sendScope{judged: map[string]bool{node: true}}), open, []string{node})
if err != nil {
return nil, nil, err
@@ -552,6 +553,9 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
}
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves}
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
if said := recreationsSaid(moves); said != "" {
p.Note += "; " + said
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
@@ -665,3 +669,38 @@ func backlogCommand(ctx context.Context, sub string, args []string) error {
}
return nil
}
// sayRecreations says, on each move a send carries, what it does to the module's containers (novox/hq
// ADR 0242): the build the machine ran against the one it is sent, container by container. Left unsaid
// for a move whose earlier build is not in the records.
func sayRecreations(ctx context.Context, open *stores, moves []inventory.CarriedMove) {
if len(moves) == 0 {
return
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return
}
for i, mv := range moves {
to, held := shelf[mv.Module]
if !held || mv.From == "" {
continue
}
from, found, err := open.inventory.ManifestAt(ctx, mv.Module, mv.From)
if err != nil || !found {
continue
}
moves[i].Recreates = catalogue.Recreates(from, to).Say(mv.Module)
}
}
// recreationsSaid is every recreation a send's moves say, joined: what a plan's note carries.
func recreationsSaid(moves []inventory.CarriedMove) string {
var said []string
for _, mv := range moves {
if mv.Recreates != "" {
said = append(said, fmt.Sprintf("on %s %s", mv.Node, mv.Recreates))
}
}
return strings.Join(said, "; ")
}
+16
View File
@@ -904,6 +904,11 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
strings.Join(sent, ", "))
fmt.Printf("%s: tier %d built; sent %d module(s) to %s first in one send (%s), the rest once its gate passes\n",
p.ID, p.Tier, len(modules), strings.Join(sent, ", "), strings.Join(modules, ", "))
// What the send recreates, said with it (novox/hq ADR 0242).
if said := recreationsSaid(carried); said != "" {
p.Note += "; " + said
fmt.Printf("%s: %s\n", p.ID, said)
}
return nil
}
@@ -1244,6 +1249,9 @@ func plansCommand(ctx context.Context, args []string) error {
fmt.Printf(" %s\n", gateLine(r.Gate))
for _, c := range r.Gate.Carried {
fmt.Printf(" %-22s %s → %s\n", c.Module, short(c.From), short(c.To))
if c.Recreates != "" {
fmt.Printf(" %-22s %s\n", "", c.Recreates)
}
}
}
return nil
@@ -1279,6 +1287,14 @@ func plansCommand(ctx context.Context, args []string) error {
case s != nil && s.Gate != nil:
fmt.Printf(" %-22s %s\n", "", gateLine(s.Gate))
}
// What the send to its first machine did to its containers (ADR 0242).
if s != nil && s.Gate != nil {
for _, c := range s.Gate.Carried {
if c.Recreates != "" {
fmt.Printf(" %-22s on %s %s\n", "", c.Node, c.Recreates)
}
}
}
}
}
return nil
+11 -1
View File
@@ -9,7 +9,7 @@ package catalogue
// DeliverySeat is the seat mesh-delivery holds.
const DeliverySeat = "mesh-delivery"
// deliveryVerbs are the delivery seat's tools: five that read, and the acts of a person and of healer H2.
// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person and of healer H2.
func deliveryVerbs() []Verb {
return []Verb{
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
@@ -32,6 +32,16 @@ func deliveryVerbs() []Verb {
Input: schema(map[string]string{"repository": "owner/repository",
"paths": "the files it changes, comma-separated, from the repository's root",
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
"of the commit's mesh statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, " +
"description and when it was set; the merge check's full verdict as the controller said it — each " +
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
"protection would let it merge, every required status being success.",
Input: schema(map[string]string{"repository": "owner/repository",
"number": "a pull request's number: its head is read",
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
Optional: true},
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
Input: schema(map[string]string{}, nil)},
+60
View File
@@ -0,0 +1,60 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
seat, ok := SeatNamed(DeliverySeat)
if !ok {
t.Fatal("the delivery seat is not in the set")
}
var checks *Verb
for i := range seat.Serves {
if seat.Serves[i].Name == "checks" {
checks = &seat.Serves[i]
}
}
if checks == nil {
t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves))
}
props, _ := checks.Input["properties"].(map[string]any)
for _, arg := range []string{"repository", "number", "commit"} {
if _, has := props[arg]; !has {
t.Errorf("checks takes no %q", arg)
}
}
if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) {
t.Errorf("checks requires %v, wanted only the repository", req)
}
// Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still
// holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in
// either order, and no check of the catalogue fails on a module nobody touched between the two.
if !checks.Optional {
t.Fatal("checks is a condition of holding before its holder serves it")
}
var before []string
for _, v := range VerbNames(seat.Serves) {
if v != "checks" {
before = append(before, v)
}
}
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}}
if err := CanHold(m, seat); err != nil {
t.Fatalf("a holder without checks yet: %v", err)
}
m.Claims[0].Serves = VerbNames(seat.Serves)
if err := CanHold(m, seat); err != nil {
t.Fatalf("a holder serving every verb: %v", err)
}
// Every other verb is still a condition of holding.
m.Claims[0].Serves = append([]string{"checks"}, before[1:]...)
if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) {
t.Fatalf("a holder without %s: %v", before[0], err)
}
}
+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)
}
+9 -1
View File
@@ -22,6 +22,14 @@ type Verb struct {
Description string `json:"description,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
// Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7,
// additive within a version): a holder that serves it is accepted, and one that does not yet still
// holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that
// does not serve the verb, the controller before it refusing the holder that does — and every check of
// the catalogue between the two would fail on a module nobody touched. Once every holder serves it,
// the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the
// seat set's protocol is the compiled one.
Optional bool `json:"-"`
}
func (v *Verb) UnmarshalJSON(raw []byte) error {
@@ -442,7 +450,7 @@ func unservedVerbs(tools []string, promised []Verb) []string {
}
var missing []string
for _, v := range promised {
if !has[v.Name] {
if !has[v.Name] && !v.Optional {
missing = append(missing, v.Name)
}
}
+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 295: 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