219 lines
7.6 KiB
Go
219 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) }
|
|
|
|
// memRegistry is a registry in memory: blobs and manifests by digest, per repository.
|
|
type memRegistry struct {
|
|
mu sync.Mutex
|
|
blobs map[string][]byte // repo|digest
|
|
manifests map[string][]byte // repo|digest
|
|
types map[string]string
|
|
puts []string
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newMem(t *testing.T) *memRegistry {
|
|
r := &memRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}, types: map[string]string{}}
|
|
r.srv = httptest.NewServer(http.HandlerFunc(r.serve))
|
|
t.Cleanup(r.srv.Close)
|
|
return r
|
|
}
|
|
|
|
func (r *memRegistry) addr() string { return r.srv.Listener.Addr().String() }
|
|
|
|
func (r *memRegistry) serve(w http.ResponseWriter, req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
path := strings.TrimPrefix(req.URL.Path, "/v2/")
|
|
if i := strings.Index(path, "/blobs/uploads/"); i >= 0 {
|
|
if req.Method == http.MethodPost {
|
|
w.Header().Set("Location", "/v2/"+path[:i]+"/blobs/uploads/u1")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(req.Body)
|
|
d := req.URL.Query().Get("digest")
|
|
if digestOf(body) != d {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
r.blobs[path[:i]+"|"+d] = body
|
|
r.puts = append(r.puts, "blob "+d)
|
|
w.WriteHeader(http.StatusCreated)
|
|
return
|
|
}
|
|
for _, kind := range []string{"blobs", "manifests"} {
|
|
if i := strings.Index(path, "/"+kind+"/"); i >= 0 {
|
|
key := path[:i] + "|" + path[i+len(kind)+2:]
|
|
store := r.blobs
|
|
if kind == "manifests" {
|
|
store = r.manifests
|
|
}
|
|
if req.Method == http.MethodPut {
|
|
body, _ := io.ReadAll(req.Body)
|
|
if digestOf(body) != path[i+len(kind)+2:] {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
r.manifests[key] = body
|
|
r.types[key] = req.Header.Get("Content-Type")
|
|
r.puts = append(r.puts, "manifest "+path[i+len(kind)+2:])
|
|
w.WriteHeader(http.StatusCreated)
|
|
return
|
|
}
|
|
body, ok := store[key]
|
|
if !ok {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
w.Header().Set("Docker-Content-Digest", path[i+len(kind)+2:])
|
|
w.Header().Set("Content-Type", r.types[key])
|
|
if req.Method == http.MethodHead {
|
|
return
|
|
}
|
|
w.Write(body)
|
|
return
|
|
}
|
|
}
|
|
http.NotFound(w, req)
|
|
}
|
|
|
|
// upstreamImage puts a two-platform index (two manifests, each with a config and a layer) into r and answers the index digest.
|
|
func upstreamImage(r *memRegistry, repo string) string {
|
|
var children []map[string]string
|
|
for _, tag := range []string{"amd64", "arm64"} {
|
|
cfg, layer := []byte(`{"arch":"`+tag+`"}`), []byte("layer-"+tag)
|
|
r.blobs[repo+"|"+digestOf(cfg)], r.blobs[repo+"|"+digestOf(layer)] = cfg, layer
|
|
mf, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
"config": map[string]string{"digest": digestOf(cfg)}, "layers": []map[string]string{{"digest": digestOf(layer)}}})
|
|
r.manifests[repo+"|"+digestOf(mf)] = mf
|
|
r.types[repo+"|"+digestOf(mf)] = "application/vnd.oci.image.manifest.v1+json"
|
|
children = append(children, map[string]string{"digest": digestOf(mf)})
|
|
}
|
|
idx, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": children})
|
|
d := digestOf(idx)
|
|
r.manifests[repo+"|"+d] = idx
|
|
r.types[repo+"|"+d] = "application/vnd.oci.image.index.v1+json"
|
|
return d
|
|
}
|
|
|
|
func runCopy(t *testing.T, m *Module, states *keptStates, args map[string]any) (CopyResult, delivery.Step) {
|
|
t.Helper()
|
|
if _, err := tool(t, m, CopyTool)(args); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := states.waitEnd(t)
|
|
var res CopyResult
|
|
if st.State == delivery.StepPassed {
|
|
if err := json.Unmarshal(st.Result, &res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return res, st
|
|
}
|
|
|
|
// ADR 0498 §4 / AC: the copy leaves the pinned digest in the store and answers it, with the whole index (every
|
|
// manifest it lists, every blob), and runs no buildah.
|
|
func TestTheCopyLeavesThePinnedDigestInTheStoreAndAnswersIt(t *testing.T) {
|
|
up, store := newMem(t), newMem(t)
|
|
digest := upstreamImage(up, "library/nats")
|
|
f := newFake(t)
|
|
states := newStates()
|
|
m := moduleWith(t, f, states)
|
|
m.HTTP = nil
|
|
reg := store.addr()
|
|
writeFile(t, m.RegistryFile, reg+"\n")
|
|
from := up.addr() + "/library/nats:2.11.17-alpine@" + digest
|
|
res, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from})
|
|
if st.State != delivery.StepPassed {
|
|
t.Fatalf("step = %+v", st)
|
|
}
|
|
if res.Digest != digest || res.Reference != reg+"/nats/server@"+digest || !res.Pushed || res.Artifact != "server" {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
if _, ok := store.manifests["nats/server|"+digest]; !ok {
|
|
t.Fatal("the index is not in the store")
|
|
}
|
|
if len(store.manifests) != 3 || len(store.blobs) != 4 {
|
|
t.Errorf("store holds %d manifests and %d blobs, want 3 and 4", len(store.manifests), len(store.blobs))
|
|
}
|
|
if len(f.lines()) != 0 {
|
|
t.Errorf("the copy ran commands: %v", f.lines())
|
|
}
|
|
// A second copy writes nothing new but the manifests.
|
|
store.puts = nil
|
|
if _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from}); st.State != delivery.StepPassed {
|
|
t.Fatalf("again: %+v", st)
|
|
}
|
|
for _, p := range store.puts {
|
|
if strings.HasPrefix(p, "blob ") {
|
|
t.Errorf("a blob the store holds was sent again: %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0498 §4 / AC: an upstream without a digest fails `not pinned:`, and nothing is read from the upstream.
|
|
func TestAnUpstreamWithoutADigestFailsNotPinned(t *testing.T) {
|
|
up, store := newMem(t), newMem(t)
|
|
upstreamImage(up, "library/nats")
|
|
f := newFake(t)
|
|
states := newStates()
|
|
m := moduleWith(t, f, states)
|
|
writeFile(t, m.RegistryFile, store.addr()+"\n")
|
|
_, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats:2.11.17-alpine"})
|
|
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "not pinned: server") {
|
|
t.Fatalf("step = %+v", st)
|
|
}
|
|
if len(store.puts) != 0 {
|
|
t.Errorf("the store was written: %v", store.puts)
|
|
}
|
|
}
|
|
|
|
// A manifest the upstream serves under another digest than the pin is not copied; the failure is `not stored:`.
|
|
func TestAnUpstreamServingAnotherManifestThanPinnedIsNotStored(t *testing.T) {
|
|
up, store := newMem(t), newMem(t)
|
|
d := upstreamImage(up, "library/nats")
|
|
up.manifests["library/nats|"+d] = []byte(`{"tampered":true}`)
|
|
f := newFake(t)
|
|
states := newStates()
|
|
m := moduleWith(t, f, states)
|
|
writeFile(t, m.RegistryFile, store.addr()+"\n")
|
|
_, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats@" + d})
|
|
if st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, "not stored: server:") {
|
|
t.Fatalf("step = %+v", st)
|
|
}
|
|
if len(store.manifests) != 0 {
|
|
t.Errorf("the store holds %v", store.manifests)
|
|
}
|
|
}
|
|
|
|
func TestAShortNameIsQualifiedAsTheHubsAndAHostIsKept(t *testing.T) {
|
|
d := "sha256:" + strings.Repeat("a", 64)
|
|
for from, want := range map[string]upstreamRef{
|
|
"alpine@" + d: {"https://registry-1.docker.io", "library/alpine", d},
|
|
"nats:2.11.17-alpine@" + d: {"https://registry-1.docker.io", "library/nats", d},
|
|
"grafana/grafana:11@" + d: {"https://registry-1.docker.io", "grafana/grafana", d},
|
|
"ghcr.io/org/app:1@" + d: {"https://ghcr.io", "org/app", d},
|
|
"localhost:5000/x/y@" + d: {"http://localhost:5000", "x/y", d},
|
|
} {
|
|
got, err := parseUpstream(from)
|
|
if err != nil || got != want {
|
|
t.Errorf("%s = %+v, %v; want %+v", from, got, err, want)
|
|
}
|
|
}
|
|
}
|