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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user