Files
mesh-controller/internal/inventory/collection_test.go
T
jochen 01c5ab2aab Hold every kept archive by a manifest so the store's collector keeps it (hq issue 253)
The store's garbage-collect marks only from manifests, and archives were
published as bare blobs, so the first real collection would delete every
archive the mesh keeps. PublishArchive now puts a deterministic OCI holder
manifest (empty config, one layer) beside each archive; the sweep holds every
kept archive before it lets anything go, which backfills existing bare blobs,
and lets go of an archive holder-first. A forgotten module no longer keeps its
five recent builds (ADR 0189). `collection [--json]` reports kept archives
held/unheld and what may be let go, so the dry run can be lifted on evidence.
2026-10-05 18:13:23 +02:00

289 lines
9.6 KiB
Go

package inventory
import (
"context"
"fmt"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// What the store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
//
// The store has collected nothing since it was raised, and the registry's own answer — collect
// what no tag names — would delete images machines are running, because the mesh pushes under one
// moving tag and pins by digest. So the rule is the mesh's, read from its own records, and these
// are the three reasons an artifact stays and the one reason it goes.
// ref is an artifact reference as the mesh records one.
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()
reference := ref(module, "app", n)
b := aBuild(id, module, "")
b.Made = []Artifact{{Name: "app", Kind: "image", Reference: reference}}
if err := inv.RecordBuild(context.Background(), b); err != nil {
t.Fatal(err)
}
return reference
}
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.
var made []string
for i := 1; i <= 8; i++ {
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
want := made[:3] // the three oldest
if len(go_) != len(want) {
t.Fatalf("offered %v to collect; want the %d oldest of %d", go_, len(want), len(made))
}
for i := range want {
if go_[i] != want[i] {
t.Fatalf("offered %v; want %v — and in that order, so a failed sweep is safe to run again",
go_, want)
}
}
}
func TestADefinitionNamingAnArtifactKeepsItHoweverOldItIs(t *testing.T) {
// The floor: no age limit. A module recorded at an older commit still names what the mesh
// would hand a machine now, and that is what must not be collected out from under it.
inv := fresh(t)
ctx := context.Background()
var made []string
for i := 1; i <= 8; i++ {
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
}
oldest := made[0]
// A definition the mesh holds, whose container runs that oldest image.
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
"id": "app", "type": "container", "name": "web", "image": oldest,
}}}
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)
}
for _, reference := range go_ {
if reference == oldest {
t.Fatalf("the mesh offered to collect %s, which a definition it holds names", oldest)
}
}
if len(go_) != 2 {
t.Fatalf("offered %v; want the two oldest that nothing names", go_)
}
}
func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
// Without this the sweep reissues a delete for every artifact it has ever collected, every
// 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)
}
first, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(first) != 2 {
t.Fatalf("offered %v, want two", first)
}
if err := inv.MarkCollected(ctx, first); err != nil {
t.Fatal(err)
}
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("offered %v again after collecting it", again)
}
}
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.
for i := 1; i <= 6; i++ {
built(t, inv, fmt.Sprintf("w%02d", i), "web", i)
}
if err := inv.RecordBuild(ctx, aBuild("w99", "web", "the recipe would not build")); err != nil {
t.Fatal(err)
}
// And a second module with three builds keeps all three: five each, not five between them.
for i := 1; i <= 3; i++ {
built(t, inv, fmt.Sprintf("d%02d", i), "db", 100+i)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(go_) != 1 || go_[0] != ref("web", "app", 1) {
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
}
}
// An artifact recorded with the store's old address is offered for collection, in the vocabulary
// the rest of the mesh speaks (novox/hq issue 226).
//
// Before references were kept without an address the mesh recorded
// `<host>:<port>/<path>@sha256:…` (04-ISSUES/102). Those are the oldest artifacts, which makes
// them exactly the ones an oldest-first sweep reaches first — and the first live run met one,
// read "I will not address this" as "the store refuses everything", and collected none of 1681.
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", "")
old.Made = []Artifact{{Name: "build", Kind: "image",
Reference: "127.0.0.1:5100/tools/build@sha256:" + fmt.Sprintf("%064x", 1)}}
if err := inv.RecordBuild(ctx, old); err != nil {
t.Fatal(err)
}
for i := 2; i <= 6; i++ {
built(t, inv, fmt.Sprintf("a%02d", i), "tools", i)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
want := ref("tools", "build", 1)
if len(go_) != 1 || go_[0] != want {
t.Fatalf("offered %v; want %q — the address is a route to the artifact, not part of its "+
"name, and the sweep speaks the name", go_, want)
}
// And marking it collected uses that same name, so the next sweep does not offer it again
// under a spelling it has not seen.
if err := inv.MarkCollected(ctx, go_); err != nil {
t.Fatal(err)
}
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
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)
}
}
}