Answer what the records keep, collect on a person's word, and name a machine's images (hq ADR 0251)
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 delivery to the same trunk took over its walk

The store's tools and a machine's image pruning decide from the records, so
the controller says them: artifacts (every recorded artifact, kept and why,
eligible, collected on request), collect (the after-build sweep on demand,
a dry run unless confirmed with a why, recorded as a hand act) and images
(what a machine's declaration names, now and as last sent). The sweep is one
implementation with two sets of bounds.
This commit is contained in:
jochen
2026-10-08 12:04:42 +02:00
parent 4d337ff897
commit 557b23d43f
12 changed files with 1116 additions and 127 deletions
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251).
func TestTheRegistryVerbsComposeTheirCommandsAndRefuseWhatTheyDoNotTake(t *testing.T) {
for _, c := range []struct {
verb string
args map[string]any
want string
}{
{"artifacts", map[string]any{}, "artifacts --json"},
{"artifacts", map[string]any{"repository": "web/app", "collected": "true"},
"artifacts --json --repository web/app --collected"},
{"artifacts", map[string]any{"collected": "false"}, "artifacts --json"},
{"collect", map[string]any{}, "collect --json"},
{"collect", map[string]any{"most": "50"}, "collect --json --most 50"},
{"collect", map[string]any{"confirm": "true", "why": "the disk is full"},
"collect --json --confirm --why the disk is full"},
{"collect", map[string]any{"confirm": true, "why": "x", "most": "10"}, "collect --json --most 10 --confirm --why x"},
{"images", map[string]any{"node": "anchor"}, "images anchor --json"},
} {
argv, err := argvFor(c.verb, c.args)
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("%s %v: %q %v, want %q", c.verb, c.args, argv, err, c.want)
}
}
for _, c := range []struct {
verb string
args map[string]any
}{
{"collect", map[string]any{"confirm": "true"}}, // a deletion without a why
{"collect", map[string]any{"why": "tidy"}}, // a why for a dry run, recorded nowhere
{"collect", map[string]any{"confirm": "yes", "why": "x"}}, // a switch is true or false
{"collect", map[string]any{"force": "true"}},
{"artifacts", map[string]any{"node": "anchor"}},
{"images", map[string]any{}},
{"images", map[string]any{"node": "anchor", "all": "true"}},
} {
if argv, err := argvFor(c.verb, c.args); err == nil {
t.Errorf("%s %v was composed as %q", c.verb, c.args, argv)
}
}
if repairingCommand([]string{"collect", "--json", "--confirm", "--why", "x"}) != "collect" {
t.Error("a real collect through the generic verb would go unrecorded")
}
if repairingCommand([]string{"collect", "--json"}) != "" {
t.Error("a dry run was taken for an act by hand")
}
if !personsDecision(link.HandAct{Verb: "collect"}) {
t.Error("a collect a person asked for would count toward a healer the mesh lacks")
}
}
func ref(module, artifact string, n int) string {
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
}
func archiveRef(module, artifact string, n int) string {
return fmt.Sprintf("%s%s/%s/blobs/sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
}
func TestArtifactsCountsEverythingAndListsTheCollectedOnlyWhenAsked(t *testing.T) {
states := []inventory.ArtifactState{
{Reference: ref("web", "app", 1), State: inventory.ArtifactCollected},
{Reference: ref("web", "app", 2), State: inventory.ArtifactEligible},
{Reference: archiveRef("web", "tools", 3), State: inventory.ArtifactKept,
Why: []string{inventory.KeptByRecentBuild, inventory.KeptByDefinition}},
{Reference: ref("db", "server", 4), State: inventory.ArtifactKept, Why: []string{inventory.KeptByDefinition}},
// Not the mesh's own store: never listed, never counted.
{Reference: "registry.invalid/x@sha256:" + strings.Repeat("a", 64), State: inventory.ArtifactKept,
Why: []string{inventory.KeptUnaddressable}},
}
a := artifactsOf(states, "", false)
if a.Counts != (artifactCounts{Kept: 2, Eligible: 1, Collected: 1}) {
t.Errorf("counts %+v", a.Counts)
}
if len(a.References) != 3 {
t.Fatalf("listed %d, want the kept and the eligible: %+v", len(a.References), a.References)
}
archive := a.References[1]
if archive.Kind != "archive" || archive.Repository != "web/tools" || archive.Digest != fmt.Sprintf("sha256:%064x", 3) ||
!slices.Equal(archive.Why, []string{"recent-build", "definition"}) {
t.Errorf("an archive read as %+v", archive)
}
if image := a.References[0]; image.Kind != "image" || image.Repository != "web/app" || image.State != "eligible" {
t.Errorf("an image read as %+v", image)
}
narrowed := artifactsOf(states, "web/app", true)
if narrowed.Counts != a.Counts || len(narrowed.References) != 2 {
t.Errorf("narrowed to one repository: %+v", narrowed)
}
raw, _ := json.Marshal(a.References[0])
if strings.Contains(string(raw), `"why"`) {
t.Errorf("an empty why is carried: %s", raw)
}
}
// fakeStore answers as a registry does for the sweep's questions, and records every request.
type fakeStore struct {
mu sync.Mutex
requests []string
refuse string // a DELETE whose path contains this is refused
}
func (f *fakeStore) serve(t *testing.T) artifacts.Store {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
refuse := f.refuse
f.mu.Unlock()
switch {
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodHead:
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost:
w.Header().Set("Location", "/upload/x?state=1")
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodPut:
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodDelete && refuse != "" && strings.Contains(r.URL.Path, refuse):
w.WriteHeader(http.StatusInternalServerError)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusBadRequest)
}
}))
t.Cleanup(server.Close)
return artifacts.Store{Address: strings.TrimPrefix(server.URL, "http://")}
}
func (f *fakeStore) seen() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.requests)
}
func TestADryRunCollectAsksOnlyAndChangesNothing(t *testing.T) {
f := &fakeStore{}
store := f.serve(t)
kept := []string{archiveRef("web", "tools", 1)}
eligible := []string{ref("web", "app", 2), archiveRef("web", "tools", 3)}
a := runCollect(context.Background(), nil, store, eligible, kept, false,
sweepBounds{most: 10, budget: 5 * time.Second})
if !a.DryRun || a.Eligible != 2 || !slices.Equal(a.WouldLetGo, eligible) || len(a.LetGo) != 0 {
t.Errorf("a dry run answered %+v", a)
}
if a.KeptArchives != 1 || a.Held != 0 || a.Unheld != 1 {
t.Errorf("the kept archive is unheld in the fake store, and was said as %+v", a)
}
for _, r := range f.seen() {
if !strings.HasPrefix(r, "HEAD ") {
t.Errorf("a dry run asked the store %s", r)
}
}
}
func TestCollectSaysWhatItDoesNotListAndWithNoStoreAsksNothing(t *testing.T) {
var many []string
for i := range mostListed + 5 {
many = append(many, ref("web", "app", i))
}
a := runCollect(context.Background(), nil, artifacts.Store{}, many, nil, false, sweepBounds{most: 1, budget: time.Second})
if len(a.WouldLetGo) != mostListed || a.NotListed != 5 || a.Left != len(many) || a.Stopped == "" {
t.Errorf("with no store and %d eligible: %d listed, %d not, left %d, stopped %q",
len(many), len(a.WouldLetGo), a.NotListed, a.Left, a.Stopped)
}
}
func TestARealCollectHoldsEveryKeptArchiveBeforeItLetsAnythingGo(t *testing.T) {
inv := inventory.ForTest(t)
f := &fakeStore{}
store := f.serve(t)
kept := []string{archiveRef("web", "tools", 1)}
eligible := []string{ref("web", "app", 2), ref("web", "app", 3), ref("web", "app", 4)}
madeBy(t, inv, eligible)
a := runCollect(t.Context(), inv, store, eligible, kept, true, sweepBounds{most: 2, budget: 5 * time.Second})
if a.DryRun || a.HoldersWritten != 1 || a.Held != 1 {
t.Errorf("the kept archive was not held first: %+v", a)
}
if !slices.Equal(a.LetGo, eligible[:2]) || a.Left != 1 || a.Stopped == "" {
t.Errorf("bounded at two: let go %v, left %d, stopped %q", a.LetGo, a.Left, a.Stopped)
}
requests := f.seen()
firstPut := slices.IndexFunc(requests, func(r string) bool { return strings.HasPrefix(r, "PUT ") && strings.Contains(r, "/manifests/") })
firstDelete := slices.IndexFunc(requests, func(r string) bool { return strings.HasPrefix(r, "DELETE ") })
if firstPut < 0 || firstDelete < 0 || firstPut > firstDelete {
t.Errorf("the holder was not put before the first delete: %v", requests)
}
// What was let go is recorded collected: the sweep no longer offers it.
left, err := inv.ToCollect(t.Context())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(left, eligible[2:]) {
t.Errorf("after letting go of two, the records offer %v", left)
}
}
// madeBy records one successful build, of a module the mesh does not hold, that made these images:
// eligible, every one.
func madeBy(t *testing.T, inv *inventory.Inventory, references []string) {
t.Helper()
b := inventory.Build{ID: "b1", Repository: "https://forge.invalid/web.git", Module: "web", On: "a-build-machine",
Commit: "c0ffee"}
for i, r := range references {
b.Made = append(b.Made, inventory.Artifact{Name: fmt.Sprintf("app%d", i), Kind: "image", Reference: r})
}
if err := inv.RecordBuild(t.Context(), b); err != nil {
t.Fatal(err)
}
}
func TestARealCollectStopsAtTheStoresFirstRefusal(t *testing.T) {
inv := inventory.ForTest(t)
f := &fakeStore{refuse: fmt.Sprintf("%064x", 3)}
store := f.serve(t)
eligible := []string{ref("web", "app", 2), ref("web", "app", 3), ref("web", "app", 4)}
a := runCollect(t.Context(), inv, store, eligible, nil, true, sweepBounds{most: 10, budget: 5 * time.Second})
if !slices.Equal(a.LetGo, eligible[:1]) || a.Left != 2 || !strings.Contains(a.Stopped, "kept") {
t.Errorf("after a refusal: let go %v, left %d, stopped %q", a.LetGo, a.Left, a.Stopped)
}
}
func TestImagesAreEveryContainerImageOfTheDeclarationAndOfWhatWasSent(t *testing.T) {
body := []byte(`{"declaration":1,"resources":[
{"id":"web.server","type":"container","image":"store.invalid/web/server@sha256:aa"},
{"id":"web.collect","type":"container","image":"store.invalid/web/server@sha256:aa","schedule":"30 3 * * *"},
{"id":"db.server","type":"container","image":"postgres@sha256:bb"},
{"id":"web.bundle-tools","type":"archive","source":"http://store.invalid/v2/web/tools/blobs/sha256:cc"}]}`)
sent := []sentResource{
{ID: "web.server", Type: "container", Image: "store.invalid/web/server@sha256:99"},
{ID: "db.server", Type: "container", Image: "postgres@sha256:bb"},
{ID: "web.state", Type: "directory"},
}
a, err := imagesOf("anchor", body, sent, true)
if err != nil {
t.Fatal(err)
}
if !a.SentKnown || len(a.Images) != 3 {
t.Fatalf("answered %+v", a)
}
byImage := map[string]declaredImage{}
for _, d := range a.Images {
byImage[d.Image] = d
}
if d := byImage["store.invalid/web/server@sha256:aa"]; !slices.Equal(d.Resources, []string{"web.collect", "web.server"}) ||
!slices.Equal(d.In, []string{"declaration"}) {
t.Errorf("a scheduled step's image and its server's are one: %+v", d)
}
if d := byImage["store.invalid/web/server@sha256:99"]; !slices.Equal(d.In, []string{"sent"}) {
t.Errorf("the image last sent is kept beside the one to send: %+v", d)
}
if d := byImage["postgres@sha256:bb"]; !slices.Equal(d.In, []string{"declaration", "sent"}) {
t.Errorf("an image in both: %+v", d)
}
// A summary kept before it carried images cannot say what was sent.
old, err := imagesOf("anchor", body, []sentResource{{ID: "web.server", Type: "container"}}, true)
if err != nil || old.SentKnown {
t.Errorf("an old summary was read as knowing what was sent: %+v %v", old, err)
}
none, err := imagesOf("anchor", body, nil, false)
if err != nil || none.SentKnown || len(none.Images) != 2 {
t.Errorf("with nothing sent yet: %+v %v", none, err)
}
}
func TestTheSentSummaryKeepsAContainersImage(t *testing.T) {
summary, err := summarize([]byte(`{"resources":[{"id":"web.server","type":"container","image":"x@sha256:aa"},
{"id":"web.state","type":"directory","path":"/srv"}]}`))
if err != nil {
t.Fatal(err)
}
if summary[0].Image != "x@sha256:aa" || summary[1].Image != "" {
t.Errorf("summarized as %+v", summary)
}
}