Merge remote-tracking branch 'origin/main' into merge-tmp
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "; ")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user