469 lines
15 KiB
Go
469 lines
15 KiB
Go
package deploy
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/cas"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// env is a service on a real database and a real CAS, which is what these tests
|
|
// are about: every interesting rule here is enforced by a trigger, a unique
|
|
// index or the filesystem, and a fake would only assert that the fake agrees
|
|
// with itself.
|
|
type env struct {
|
|
svc *Service
|
|
db *store.DB
|
|
cas *cas.Store
|
|
p *store.Project
|
|
dir string // deployments root
|
|
}
|
|
|
|
func newEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
base := t.TempDir()
|
|
log := slog.New(slog.DiscardHandler)
|
|
|
|
db, err := store.Open(t.Context(), filepath.Join(base, "pages.db"), log)
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
deployDir := filepath.Join(base, "deployments")
|
|
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir, Log: log})
|
|
if err != nil {
|
|
t.Fatalf("cas.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { cs.Close() })
|
|
|
|
p := store.DefaultProject("demo")
|
|
if err := db.CreateProject(t.Context(), p); err != nil {
|
|
t.Fatalf("CreateProject: %v", err)
|
|
}
|
|
return &env{
|
|
svc: &Service{DB: db, CAS: cs, Log: log, Dir: deployDir},
|
|
db: db, cas: cs, p: p, dir: deployDir,
|
|
}
|
|
}
|
|
|
|
func (e *env) create(t *testing.T) *store.Deployment {
|
|
t.Helper()
|
|
dep, err := e.svc.Create(t.Context(), e.p, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
return dep
|
|
}
|
|
|
|
// manifest turns path->content into the rows a client would have sent.
|
|
func manifest(contents map[string]string) []store.FileRow {
|
|
files := make([]store.FileRow, 0, len(contents))
|
|
for p, c := range contents {
|
|
files = append(files, store.FileRow{Path: p, Digest: cas.Sum([]byte(c)), Size: int64(len(c))})
|
|
}
|
|
return files
|
|
}
|
|
|
|
// upload pushes every named blob through the service, as a client would.
|
|
func (e *env) upload(t *testing.T, contents map[string]string, want ...string) {
|
|
t.Helper()
|
|
for _, p := range want {
|
|
c := contents[p]
|
|
if _, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte(c)), strings.NewReader(c)); err != nil {
|
|
t.Fatalf("Upload %s: %v", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// apiCode returns the wire code of err, or "" if it is not a client error.
|
|
func apiCode(err error) api.Code {
|
|
var e *api.Error
|
|
if errors.As(err, &e) {
|
|
return e.Code
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func wantCode(t *testing.T, err error, want api.Code) {
|
|
t.Helper()
|
|
if got := apiCode(err); got != want {
|
|
t.Fatalf("error = %v (code %q), want code %q", err, got, want)
|
|
}
|
|
}
|
|
|
|
func TestFullDeploymentRoundTrip(t *testing.T) {
|
|
e := newEnv(t)
|
|
contents := map[string]string{
|
|
"index.html": "<h1>hello</h1>",
|
|
"assets/app.js": "console.log(1)",
|
|
"copy.html": "<h1>hello</h1>", // same blob as index.html
|
|
}
|
|
dep := e.create(t)
|
|
if dep.State != store.StatePending {
|
|
t.Fatalf("state = %s, want pending", dep.State)
|
|
}
|
|
|
|
files := manifest(contents)
|
|
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep, files)
|
|
if err != nil {
|
|
t.Fatalf("SetManifest: %v", err)
|
|
}
|
|
// Two unique blobs for three files: the shared one is only asked for once.
|
|
if len(missing) != 2 {
|
|
t.Fatalf("missing = %d digests, want 2", len(missing))
|
|
}
|
|
if want := int64(len("<h1>hello</h1>") + len("console.log(1)")); missingBytes != want {
|
|
t.Errorf("missingBytes = %d, want %d", missingBytes, want)
|
|
}
|
|
|
|
// Finalize before the content arrives names what is still outstanding rather
|
|
// than failing opaquely, because that list is what the client retries.
|
|
_, err = e.svc.Finalize(t.Context(), e.p, dep)
|
|
wantCode(t, err, api.CodeBlobsMissing)
|
|
var apiErr *api.Error
|
|
if errors.As(err, &apiErr) {
|
|
list, _ := apiErr.Details["missing"].([]string)
|
|
if len(list) != 2 {
|
|
t.Errorf("details.missing = %v, want 2 digests", apiErr.Details["missing"])
|
|
}
|
|
}
|
|
|
|
e.upload(t, contents, "index.html", "assets/app.js")
|
|
|
|
dep, err = e.svc.Finalize(t.Context(), e.p, dep)
|
|
if err != nil {
|
|
t.Fatalf("Finalize: %v", err)
|
|
}
|
|
if dep.State != store.StateReady {
|
|
t.Fatalf("state = %s, want ready", dep.State)
|
|
}
|
|
if dep.FileCount != 3 || dep.TotalBytes != int64(len(contents["index.html"])*2+len(contents["assets/app.js"])) {
|
|
t.Errorf("file_count = %d, total_bytes = %d", dep.FileCount, dep.TotalBytes)
|
|
}
|
|
if dep.FinalizedAt == nil {
|
|
t.Error("finalized_at was not recorded")
|
|
}
|
|
if dep.Active {
|
|
t.Error("finalize activated the deployment; that is a separate decision")
|
|
}
|
|
|
|
// The tree on disk is the deployment, byte for byte.
|
|
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
|
|
if len(got) != len(contents) {
|
|
t.Fatalf("assembled %v", got)
|
|
}
|
|
for p, c := range contents {
|
|
if got[p] != c {
|
|
t.Errorf("%s = %q, want %q", p, got[p], c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A second deployment of a mostly-unchanged site is the case the whole
|
|
// content-addressed protocol exists for: only what actually changed is asked
|
|
// for, across deployments and across projects.
|
|
func TestASecondDeploymentOnlyAsksForWhatChanged(t *testing.T) {
|
|
e := newEnv(t)
|
|
first := map[string]string{"index.html": "v1", "assets/app.js": "shared"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(first)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.upload(t, first, "index.html", "assets/app.js")
|
|
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second := map[string]string{"index.html": "v2", "assets/app.js": "shared"}
|
|
dep2 := e.create(t)
|
|
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep2, manifest(second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(missing) != 1 || missing[0] != cas.Sum([]byte("v2")) {
|
|
t.Fatalf("missing = %v, want just the changed index.html", digests(missing))
|
|
}
|
|
if missingBytes != 2 {
|
|
t.Errorf("missingBytes = %d, want 2", missingBytes)
|
|
}
|
|
|
|
e.upload(t, second, "index.html")
|
|
if _, err := e.svc.Finalize(t.Context(), e.p, dep2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Both trees exist and disagree, which is what makes a rollback a rollback.
|
|
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID)); got["index.html"] != "v1" {
|
|
t.Errorf("first deployment = %v, want v1 intact", got)
|
|
}
|
|
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep2.PublicID)); got["index.html"] != "v2" {
|
|
t.Errorf("second deployment = %v", got)
|
|
}
|
|
}
|
|
|
|
func digests(ds []cas.Digest) []string {
|
|
out := make([]string, len(ds))
|
|
for i, d := range ds {
|
|
out[i] = d.String()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The upload endpoint is not general-purpose storage. Content nobody declared
|
|
// has no size the server agreed to and no deployment that would ever reference
|
|
// it, so it is refused before a byte is read.
|
|
func TestUploadRejectsContentNoManifestAskedFor(t *testing.T) {
|
|
e := newEnv(t)
|
|
body := &countingReader{r: strings.NewReader("unsolicited")}
|
|
|
|
_, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte("unsolicited")), body)
|
|
wantCode(t, err, api.CodeNotFound)
|
|
if body.n != 0 {
|
|
t.Errorf("read %d bytes of a body it had already decided to refuse", body.n)
|
|
}
|
|
if has, _ := e.cas.Has(cas.Sum([]byte("unsolicited"))); has {
|
|
t.Error("the content was stored anyway")
|
|
}
|
|
}
|
|
|
|
// The claimed digest is only ever a claim. Without this check a client could
|
|
// declare another project's digest and poison every project sharing that blob.
|
|
func TestUploadRejectsContentThatDoesNotHashToItsDigest(t *testing.T) {
|
|
e := newEnv(t)
|
|
contents := map[string]string{"index.html": "honest"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
claimed := cas.Sum([]byte("honest"))
|
|
_, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("forged"))
|
|
wantCode(t, err, api.CodeDigestMismatch)
|
|
if has, _ := e.cas.Has(claimed); has {
|
|
t.Fatal("the forged content was stored under the honest digest")
|
|
}
|
|
|
|
// And the deployment is still deployable once the real bytes arrive.
|
|
if _, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("honest")); err != nil {
|
|
t.Fatalf("honest upload after a forged one: %v", err)
|
|
}
|
|
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
|
t.Fatalf("Finalize: %v", err)
|
|
}
|
|
}
|
|
|
|
// The manifest's size is the ceiling, so a client cannot spend more disk than
|
|
// the manifest it got accepted for. Content-Length is never consulted.
|
|
func TestUploadRejectsMoreBytesThanTheManifestDeclared(t *testing.T) {
|
|
e := newEnv(t)
|
|
dep := e.create(t)
|
|
body := strings.Repeat("x", 4096)
|
|
// Declare a small file, then send a large one under the same digest.
|
|
files := []store.FileRow{{Path: "a.txt", Digest: cas.Sum([]byte(body)), Size: 4}}
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(body))
|
|
if code := apiCode(err); code != api.CodeSizeMismatch && code != api.CodeLimitExceeded {
|
|
t.Fatalf("error = %v (code %q), want a size rejection", err, code)
|
|
}
|
|
if has, _ := e.cas.Has(files[0].Digest); has {
|
|
t.Error("the oversized content was stored")
|
|
}
|
|
}
|
|
|
|
// Re-uploading a blob the server already has is the fast path a retried deploy
|
|
// depends on: no body is read and nothing is rewritten.
|
|
func TestUploadOfAPresentBlobDoesNotReadTheBody(t *testing.T) {
|
|
e := newEnv(t)
|
|
contents := map[string]string{"index.html": "hello"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := cas.Sum([]byte("hello"))
|
|
|
|
size, stored, err := e.svc.Upload(t.Context(), d, strings.NewReader("hello"))
|
|
if err != nil || !stored || size != 5 {
|
|
t.Fatalf("first upload: size=%d stored=%v err=%v", size, stored, err)
|
|
}
|
|
body := &countingReader{r: strings.NewReader("hello")}
|
|
size, stored, err = e.svc.Upload(t.Context(), d, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored {
|
|
t.Error("the second upload claimed to have stored content the server already had")
|
|
}
|
|
if size != 5 {
|
|
t.Errorf("size = %d, want 5", size)
|
|
}
|
|
if body.n != 0 {
|
|
t.Errorf("read %d bytes of a blob it already had", body.n)
|
|
}
|
|
}
|
|
|
|
func TestUploadStoresAnEmptyBlob(t *testing.T) {
|
|
e := newEnv(t)
|
|
dep := e.create(t)
|
|
files := []store.FileRow{{Path: "empty", Digest: cas.Sum(nil), Size: 0}}
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Put insists on a positive ceiling; the service floors it at one byte so a
|
|
// legitimately empty file is still uploadable.
|
|
size, stored, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(""))
|
|
if err != nil || !stored || size != 0 {
|
|
t.Fatalf("size=%d stored=%v err=%v", size, stored, err)
|
|
}
|
|
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
|
t.Fatalf("Finalize: %v", err)
|
|
}
|
|
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
|
|
if c, ok := got["empty"]; !ok || c != "" {
|
|
t.Errorf("tree = %v, want one empty file", got)
|
|
}
|
|
}
|
|
|
|
// Finalizing twice must be a no-op rather than a second assembly: the client
|
|
// that lost its response to a timeout retries, and the tree may already be live.
|
|
func TestFinalizeIsIdempotent(t *testing.T) {
|
|
e := newEnv(t)
|
|
contents := map[string]string{"index.html": "hello"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.upload(t, contents, "index.html")
|
|
|
|
first, err := e.svc.Finalize(t.Context(), e.p, dep)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := e.svc.Finalize(t.Context(), e.p, dep)
|
|
if err != nil {
|
|
t.Fatalf("second Finalize: %v", err)
|
|
}
|
|
if !first.FinalizedAt.Equal(*second.FinalizedAt) {
|
|
t.Errorf("finalized_at moved from %v to %v on a retry", first.FinalizedAt, second.FinalizedAt)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeRejectsADeploymentWithNoManifest(t *testing.T) {
|
|
e := newEnv(t)
|
|
dep := e.create(t) // still pending
|
|
|
|
_, err := e.svc.Finalize(t.Context(), e.p, dep)
|
|
wantCode(t, err, api.CodeConflict)
|
|
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("a tree was built for a deployment that never had a manifest")
|
|
}
|
|
}
|
|
|
|
// A deployment belongs to exactly one project. Finalizing another project's
|
|
// deployment must not work even when the caller knows its id.
|
|
func TestFinalizeIsProjectScoped(t *testing.T) {
|
|
e := newEnv(t)
|
|
other := store.DefaultProject("other")
|
|
if err := e.db.CreateProject(t.Context(), other); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents := map[string]string{"index.html": "hello"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.upload(t, contents, "index.html")
|
|
|
|
_, err := e.svc.Finalize(t.Context(), other, dep)
|
|
wantCode(t, err, api.CodeNotFound)
|
|
}
|
|
|
|
// assemble_mode=none: content is served straight from the CAS, so finalize must
|
|
// still succeed and must not build anything on disk.
|
|
func TestFinalizeWithoutAssemblyBuildsNothing(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.svc.Dir = ""
|
|
contents := map[string]string{"index.html": "hello"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.upload(t, contents, "index.html")
|
|
|
|
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
|
|
if err != nil {
|
|
t.Fatalf("Finalize: %v", err)
|
|
}
|
|
if dep.State != store.StateReady {
|
|
t.Fatalf("state = %s, want ready", dep.State)
|
|
}
|
|
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("a tree was assembled despite assemble_mode=none")
|
|
}
|
|
}
|
|
|
|
// If assembly fails the deployment must end up failed rather than ready — a
|
|
// ready deployment is one that could be served, and this one could not be.
|
|
func TestFinalizeMarksTheDeploymentFailedWhenAssemblyCannotProceed(t *testing.T) {
|
|
e := newEnv(t)
|
|
contents := map[string]string{"index.html": "hello"}
|
|
dep := e.create(t)
|
|
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.upload(t, contents, "index.html")
|
|
|
|
// A plain file where the project's directory has to go: MkdirAll cannot get
|
|
// past it, so assembly fails for a reason that is not the client's fault.
|
|
if err := os.MkdirAll(e.dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Dir(DeploymentDir(e.dir, e.p.ID, dep.PublicID)), []byte("in the way"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := e.svc.Finalize(t.Context(), e.p, dep)
|
|
wantCode(t, err, api.CodeInternal)
|
|
// The reason stays server-side: the client is told nothing about the layout
|
|
// of the server's disk.
|
|
if strings.Contains(err.Error(), e.dir) {
|
|
t.Errorf("the error exposes a server path: %v", err)
|
|
}
|
|
|
|
after, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.State != store.StateFailed {
|
|
t.Fatalf("state = %s, want failed", after.State)
|
|
}
|
|
if after.Error == "" {
|
|
t.Error("no reason was recorded for the failure")
|
|
}
|
|
// And it stays failed: a failed deployment is never resurrected.
|
|
_, err = e.svc.Finalize(t.Context(), e.p, dep)
|
|
wantCode(t, err, api.CodeConflict)
|
|
}
|
|
|
|
// countingReader reports whether a body was read at all, which is how the tests
|
|
// tell "refused up front" apart from "read and then discarded".
|
|
type countingReader struct {
|
|
r io.Reader
|
|
n int
|
|
}
|
|
|
|
func (c *countingReader) Read(p []byte) (int, error) {
|
|
n, err := c.r.Read(p)
|
|
c.n += n
|
|
return n, err
|
|
}
|