init
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user