243 lines
9.7 KiB
Go
243 lines
9.7 KiB
Go
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.MethodGet && strings.Contains(r.URL.Path, "/manifests/"):
|
|
// An image, not an index: it names no platform manifests.
|
|
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
|
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`))
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
// 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 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)
|
|
}
|
|
}
|