package buildstep import ( "context" "crypto/sha256" "encoding/hex" "io" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) func digestOfBytes(b []byte) string { h := sha256.Sum256(b) return "sha256:" + hex.EncodeToString(h[:]) } // registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest. type registry struct { mu sync.Mutex blobs map[string][]byte calls []string put int // the status a PUT answers, 201 when 0 } func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, req.Method+" "+req.URL.Path) switch { case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"): if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusNotFound) case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"): w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x") w.WriteHeader(http.StatusAccepted) case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"): if r.put != 0 { w.WriteHeader(r.put) _, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`) return } body, _ := io.ReadAll(req.Body) d := req.URL.Query().Get("digest") if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) { w.WriteHeader(http.StatusBadRequest) return } r.blobs[d] = body w.WriteHeader(http.StatusCreated) default: w.WriteHeader(http.StatusMethodNotAllowed) } } // A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1 // and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is // the artifact-store form the builder's key and the controller check. func TestABlobIsPushedOnceByItsDigest(t *testing.T) { reg := ®istry{blobs: map[string][]byte{}} srv := httptest.NewServer(reg) defer srv.Close() addr := strings.TrimPrefix(srv.URL, "http://") body := []byte("the packed bundle") d := digestOfBytes(body) if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { t.Fatal(err) } if string(reg.blobs[d]) != string(body) { t.Fatalf("the store holds %q", reg.blobs[d]) } reg.calls = nil if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { t.Fatal(err) } if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") { t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls) } ref := BlobReference("mail", "code", d) if ref != "artifact-store://mail/code/blobs/"+d { t.Fatalf("BlobReference = %q", ref) } b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64), Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}} if _, err := delivery.EncodeBuilderBuild(b); err != nil { t.Fatalf("the key refuses the reference BlobReference gives: %v", err) } } // A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are // refused before anything is sent. func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) { reg := ®istry{blobs: map[string][]byte{}, put: http.StatusBadRequest} srv := httptest.NewServer(reg) defer srv.Close() addr := strings.TrimPrefix(srv.URL, "http://") body := []byte("x") err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body) if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") { t.Fatalf("a refused upload said %v", err) } reg.calls = nil err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body) if err == nil || len(reg.calls) != 0 { t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls) } } // A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457). func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := l.Addr().String() l.Close() // refusing now reg := ®istry{blobs: map[string][]byte{}} go func() { time.Sleep(300 * time.Millisecond) l2, err := net.Listen("tcp", addr) if err != nil { return } _ = http.Serve(l2, reg) }() beforeWait, beforePause := RegistryWait, RegistryFirstPause RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }() body := []byte("late") if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil { t.Fatalf("a store that came back was not waited for: %v", err) } l3, _ := net.Listen("tcp", "127.0.0.1:0") gone := l3.Addr().String() l3.Close() RegistryWait = 300 * time.Millisecond err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body) if err == nil || !strings.Contains(err.Error(), "refused every connection") { t.Fatalf("a store that stayed down said %v", err) } }