Merge main: hold a moving machine back, then deliver the rest grants first

Main sends every machine through deliver (memberships before declarations,
hq ADR 0218); this branch holds back a machine whose running module's data
would move (ADR 0217). Both stand: the held machines are filtered out before
delivery, in the named push and its cascade alike.
This commit is contained in:
jochen
2026-10-05 18:47:52 +02:00
37 changed files with 2821 additions and 205 deletions
+23 -7
View File
@@ -42,26 +42,37 @@ func theSeatDeclarer() catalogue.Manifest {
// A module assigned to a machine becomes a user with the authority its manifest declared — and the
// protocol of a seat declared by a *different* module, which is the whole reason a seat exists.
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
// It declares where its account is delivered: a module with no own secret named broker can never
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
shop := catalogue.Manifest{
Module: "shop", Version: "1",
Emits: []string{"order.placed"}, Tools: []string{"price"},
Uses: []string{"telegram-sender"},
Uses: []string{"telegram-sender"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
}
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop)
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
if _, err := inv.AddNode(ctx, "one"); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, "one", "shop"); err != nil {
t.Fatal(err)
for _, module := range []string{"shop", "quiet"} {
if _, err := inv.Assign(ctx, "one", module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
on := records.Assigned["one"]
if len(on) != 1 || on[0].Module != "shop" {
t.Fatalf("the machine's modules read as %+v", on)
var on []broker.Declared
for _, d := range records.Assigned["one"] {
if d.Module == "shop" {
on = append(on, d)
}
}
if len(on) != 1 || on[0].NoAccount {
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
}
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
@@ -98,6 +109,11 @@ func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
if !found {
t.Fatal("no user was derived for the assigned module")
}
for _, u := range users {
if u.Username() == "one.quiet" {
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
}
}
}
// A machine holding a live token gets an enrolment user; one whose token is spent or expired does
+36 -5
View File
@@ -3,6 +3,7 @@ package inventory
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -33,7 +34,8 @@ const KeptBuilds = 5
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
// what the mesh would hand a machine now. No age limit: this is the floor;
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
// recent successful builds of its module;
// recent successful builds of a module the mesh still holds. A forgotten module keeps
// nothing beyond what a held definition names (novox/hq issue 253);
// - it was already collected, in which case there is nothing left to do.
//
// Returned in a stated order so two runs over the same records ask for the same things in the
@@ -109,12 +111,19 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
return nil, err
}
// The KeptBuilds most recent successful builds of each module, whole.
// The KeptBuilds most recent successful builds of each module the mesh still holds, whole.
//
// **Only a module the mesh still holds can be gone back to** (novox/hq issue 253). "Somewhere
// to return to" is a reason about a module's releases; a module that has been forgotten has
// no releases left to return between, and its build rows stay only as history. Without the
// join every module ever built kept five builds' artifacts for ever — and once the store's
// collector runs for real, what the keep set says is what the disk holds.
recent, err := i.store.Pool().Query(ctx,
`select made from (
select made, row_number() over (partition by module order by at desc, id desc) as back
from build
where failed = '' and module is not null and module <> ''
select b.made, row_number() over (partition by b.module order by b.at desc, b.id desc) as back
from build b
join module m on m.name = b.module
where b.failed = '' and b.module is not null and b.module <> ''
) ranked where back <= $1`, KeptBuilds)
if err != nil {
return nil, err
@@ -169,6 +178,28 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
return keep, nil
}
// KeptArchives is every archive the mesh keeps, in a stated order: the references the sweep must
// hold by a manifest before it lets anything go, and the ones an operator needs to read as all
// held before the store's collector is let loose (novox/hq issue 253, ADR 0189).
//
// Only references into the mesh's own store, and only blobs: an image is its own manifest, and a
// reference that is kept because nothing here can speak for it is not one the store can be asked
// about.
func (i *Inventory) KeptArchives(ctx context.Context) ([]string, error) {
keep, err := i.keptReferences(ctx)
if err != nil {
return nil, err
}
var out []string
for reference := range keep {
if path, ours := catalogue.InArtifactStore(reference); ours && strings.Contains(path, "/blobs/sha256:") {
out = append(out, reference)
}
}
sort.Strings(out)
return out, nil
}
// everyReferenceMade is every artifact reference any successful build recorded.
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
rows, err := i.store.Pool().Query(ctx,
+96
View File
@@ -20,6 +20,19 @@ func ref(module, artifact string, n int) string {
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
}
// holding registers a definition for each module that names no artifact, so the mesh holds the
// module and its recent builds are somewhere it can go back to — and nothing more.
func holding(t *testing.T, inv *Inventory, modules ...string) {
t.Helper()
for _, module := range modules {
m := catalogue.Manifest{Module: module, Version: "1"}
if err := inv.RegisterModule(context.Background(), m,
Source{Repository: "https://forge.invalid/" + module + ".git"}); err != nil {
t.Fatal(err)
}
}
}
// built records one successful build of a module publishing one image.
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
t.Helper()
@@ -35,6 +48,7 @@ func built(t *testing.T, inv *Inventory, id, module string, n int) string {
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web")
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
// release that turns out wrong can be taken back to.
@@ -98,6 +112,7 @@ func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web")
for i := 1; i <= 7; i++ {
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
}
@@ -123,6 +138,7 @@ func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web", "db")
// A failed build published nothing, so it is neither kept nor collected — and it must not
// count against the module's five.
@@ -156,6 +172,7 @@ func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *test
func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "tools")
// The oldest build published the old way; five newer ones fill the module's five.
old := aBuild("a00", "tools", "")
@@ -190,3 +207,82 @@ func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.
t.Fatalf("offered %v again after collecting it", again)
}
}
// A forgotten module keeps nothing beyond what a held definition names (novox/hq issue 253).
//
// "Somewhere to go back to" is a reason about a module's releases, and a module the mesh no
// longer holds has none. Its build rows stay as history; its artifacts go — except one a module
// the mesh still holds names, which is the floor whatever built it.
func TestAForgottenModuleKeepsNothingAHeldDefinitionDoesNotName(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
// Three builds of a module that was never held, or was held and then forgotten: within its
// five, and kept for that reason until now.
var gone []string
for i := 1; i <= 3; i++ {
gone = append(gone, built(t, inv, fmt.Sprintf("o%02d", i), "old", 200+i))
}
// A module the mesh holds, whose definition runs the forgotten module's newest image.
named := gone[2]
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
"id": "app", "type": "container", "name": "web", "image": named,
}}}
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
t.Fatal(err)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(go_) != 2 || go_[0] != gone[0] || go_[1] != gone[1] {
t.Fatalf("offered %v; want %v — a forgotten module's builds are no release to go back to, "+
"and only what a held definition names stays", go_, gone[:2])
}
// And once the module is held again, its five are kept again.
holding(t, inv, "old")
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("offered %v for a module the mesh holds, within its five", again)
}
}
// The archives the mesh keeps are what the sweep holds before it lets anything go, and what an
// operator reads as all held before the store's collector is let loose (novox/hq issue 253).
func TestKeptArchivesAreTheKeptBlobsOnly(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "shell")
archive := func(n int) string {
return fmt.Sprintf("%sshell/config/blobs/sha256:%064x", catalogue.ArtifactStoreScheme, n)
}
for i := 1; i <= 6; i++ {
b := aBuild(fmt.Sprintf("s%02d", i), "shell", "")
b.Made = []Artifact{
{Name: "app", Kind: "image", Reference: ref("shell", "app", i)},
{Name: "config", Kind: "archive", Reference: archive(i)},
}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
}
kept, err := inv.KeptArchives(ctx)
if err != nil {
t.Fatal(err)
}
want := []string{archive(2), archive(3), archive(4), archive(5), archive(6)}
if len(kept) != len(want) {
t.Fatalf("kept archives %v; want the five recent ones and no images", kept)
}
for i := range want {
if kept[i] != want[i] {
t.Fatalf("kept archives %v; want %v", kept, want)
}
}
}
@@ -0,0 +1,22 @@
-- A newer plan supersedes the older open plans of the same repository and branch (novox/hq issue 254,
-- ADR 0218).
--
-- A merge produced a plan without looking at the plans still open, so two merges a few minutes apart
-- were two plans working the same modules, and a plan stuck waiting on something that would never
-- come stayed open for ever beside the newer ones. The newer plan now takes over what the older had
-- not yet built and the older is closed as `superseded` — a state of its own, so `plans` can say
-- which plan replaced it rather than reading as a failure.
--
-- `branch` is the branch the merge went into, so only a plan of the same branch is superseded. Empty
-- for every plan from before this was kept: which branch it answered is not known, and such a plan
-- is superseded by the next plan of its repository, whichever branch — nothing is lost by it, since
-- what it had not built is folded into the plan that supersedes it.
alter table release_plan add column branch text not null default '';
-- And the bus's user list the machine holding the bus was last sent, as a digest (novox/hq issue
-- 249). A module's new grants are refused by the bus until its user list says them, so that machine
-- is sent first whenever the list it would be sent differs from the one it was. Read from its whole
-- declaration, every pending change on it — a recorded upgrade the operator chose not to roll out —
-- went with every send anywhere. A digest and never the list (ADR 0043: the list is composed on each
-- push, never kept). Empty for a machine never sent one, which reads as behind once.
alter table node add column sent_bus_users text not null default '';
+20
View File
@@ -921,6 +921,26 @@ func (i *Inventory) RecordSent(ctx context.Context, node, digest string) error {
return err
}
// RecordSentBusUsers keeps a digest of the bus's user list a machine was just sent, by its name
// (novox/hq issue 249): whether the machine holding the bus must go first is whether this differs
// from the list composed now.
func (i *Inventory) RecordSentBusUsers(ctx context.Context, name, digest string) error {
_, err := i.store.Pool().Exec(ctx,
`update node set sent_bus_users = $2 where name = $1`, name, digest)
return err
}
// SentBusUsers is the digest of the bus's user list a machine was last sent, empty for none.
func (i *Inventory) SentBusUsers(ctx context.Context, name string) (string, error) {
var sent string
err := i.store.Pool().QueryRow(ctx,
`select sent_bus_users from node where name = $1`, name).Scan(&sent)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return sent, err
}
// Outstanding is the digest of the declaration a machine was last sent, by its name, and empty
// for one that has never been sent anything.
//
+31 -18
View File
@@ -15,16 +15,19 @@ import (
// the store so a controller replaced mid-plan resumes it, and so `status` can say what a merge
// still waits for.
type Plan struct {
ID string `json:"id"`
Repository string `json:"repository"`
Commit string `json:"commit"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
State string `json:"state"`
Tier int `json:"tier"`
Tiers [][]string `json:"tiers"`
Modules map[string]*PlanModule `json:"modules"`
Note string `json:"note,omitempty"`
ID string `json:"id"`
Repository string `json:"repository"`
// Branch is the branch the merge went into (novox/hq issue 254): a newer plan supersedes the open
// ones of the same repository and branch. Empty for a plan from before it was kept.
Branch string `json:"branch,omitempty"`
Commit string `json:"commit"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
State string `json:"state"`
Tier int `json:"tier"`
Tiers [][]string `json:"tiers"`
Modules map[string]*PlanModule `json:"modules"`
Note string `json:"note,omitempty"`
}
// PlanModule is one module's state within a plan.
@@ -37,8 +40,15 @@ type PlanModule struct {
// later tier is built by it (ADR 0163's gate): the reports that open the gate are the ones
// after this.
SentAt *time.Time `json:"sent_at,omitempty"`
Commit string `json:"commit,omitempty"`
Why string `json:"why,omitempty"`
// First is the machines the plan sent the new build to first, and FirstAt when (novox/hq issue
// 249, ADR 0218): unless the module's policy rolls it out together, one machine takes it before
// the rest, and the rest are sent once that one reports it applied. Kept so a controller
// replaced while the plan waits on that report resumes the wait rather than sending again. The
// machine holding the bus is among them when its user list had to go first.
First []string `json:"first,omitempty"`
FirstAt *time.Time `json:"first_at,omitempty"`
Commit string `json:"commit,omitempty"`
Why string `json:"why,omitempty"`
}
// The states a plan passes through.
@@ -47,6 +57,9 @@ const (
PlanRolling = "rolling"
PlanDone = "done"
PlanFailed = "failed"
// PlanSuperseded is a plan a newer merge of the same repository and branch took over (novox/hq
// issue 254, ADR 0218): what it had not built is in the newer plan, and its note names it.
PlanSuperseded = "superseded"
)
// Open says whether the plan is still being worked.
@@ -63,11 +76,11 @@ func (i *Inventory) SavePlan(ctx context.Context, p Plan) error {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note)
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9)
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch)
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10)
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note`,
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note)
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch`,
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch)
return err
}
@@ -95,7 +108,7 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
rows, err := i.store.Pool().Query(ctx,
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch
from release_plan `+tail)
if err != nil {
return nil, err
@@ -106,7 +119,7 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
var p Plan
var tiers, modules []byte
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
&p.Tier, &tiers, &modules, &p.Note); err != nil {
&p.Tier, &tiers, &modules, &p.Note, &p.Branch); err != nil {
return nil, err
}
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
+27
View File
@@ -46,3 +46,30 @@ func TestAPlanIsKeptAdvancedAndResumedFromTheStore(t *testing.T) {
t.Fatalf("a done plan is still among the recent ones: %+v", recent)
}
}
// novox/hq issue 254: a plan keeps the branch its merge went into, and a superseded plan is not open.
func TestASupersededPlanIsNotOpen(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
p := Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "abc",
Created: time.Now().UTC(), State: PlanBuilding, Tiers: [][]string{{"gitea"}},
Modules: map[string]*PlanModule{"gitea": {}}}
if err := inv.SavePlan(ctx, p); err != nil {
t.Fatal(err)
}
kept, err := inv.PlanByID(ctx, "plan-1")
if err != nil || kept.Branch != "main" {
t.Fatalf("the branch was not kept: %v %+v", err, kept)
}
kept.State = PlanSuperseded
kept.Note = "superseded at tier 0 by plan-2"
if err := inv.SavePlan(ctx, kept); err != nil {
t.Fatal(err)
}
if open, err := inv.OpenPlans(ctx); err != nil || len(open) != 0 {
t.Fatalf("a superseded plan is still open: %v %+v", err, open)
}
if recent, _ := inv.RecentPlans(ctx, 5); len(recent) != 1 || recent[0].State != PlanSuperseded {
t.Fatalf("a superseded plan is not among the recent ones as superseded: %+v", recent)
}
}
+25
View File
@@ -0,0 +1,25 @@
package inventory
import "testing"
// novox/hq issue 249: the digest of the user list a machine was last sent is kept, by its name, and
// is empty for a machine never sent one.
func TestTheUserListAMachineWasSentIsKept(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
if sent, err := inv.SentBusUsers(ctx, "anchor"); err != nil || sent != "" {
t.Fatalf("a machine never sent a list has %q: %v", sent, err)
}
if err := inv.RecordSentBusUsers(ctx, "anchor", "abc"); err != nil {
t.Fatal(err)
}
if sent, err := inv.SentBusUsers(ctx, "anchor"); err != nil || sent != "abc" {
t.Fatalf("the list sent was not kept: %q %v", sent, err)
}
if sent, err := inv.SentBusUsers(ctx, "nobody"); err != nil || sent != "" {
t.Fatalf("a machine the mesh does not know: %q %v", sent, err)
}
}