init
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// fixture is a CAS holding some content plus the manifest that names it.
|
||||
type fixture struct {
|
||||
cas *cas.Store
|
||||
dir string // deployments root
|
||||
files []store.FileRow
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, contents map[string]string) *fixture {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir})
|
||||
if err != nil {
|
||||
t.Fatalf("cas.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cs.Close() })
|
||||
|
||||
f := &fixture{cas: cs, dir: deployDir}
|
||||
for path, content := range contents {
|
||||
d := cas.Sum([]byte(content))
|
||||
if _, err := cs.Put(t.Context(), d, int64(len(content)), 1<<20, strings.NewReader(content)); err != nil {
|
||||
t.Fatalf("put %s: %v", path, err)
|
||||
}
|
||||
f.files = append(f.files, store.FileRow{Path: path, Digest: d, Size: int64(len(content))})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// walk reads back an assembled tree as path -> content.
|
||||
func walk(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.WalkDir(dir, func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = string(b)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", dir, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAssembleBuildsTheTree(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"index.html": "<h1>hi</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"assets/css/app.css": "body{}",
|
||||
"a/b/c/d/deep.txt": "deep",
|
||||
"copy.html": "<h1>hi</h1>", // shares a blob with index.html
|
||||
}
|
||||
f := newFixture(t, want)
|
||||
dest := DeploymentDir(f.dir, 7, "dpl_0123456789abcdef")
|
||||
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
got := walk(t, dest)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("assembled %d files, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for p, content := range want {
|
||||
if got[p] != content {
|
||||
t.Errorf("%s = %q, want %q", p, got[p], content)
|
||||
}
|
||||
}
|
||||
// The staging directory is gone: it became the tree by rename.
|
||||
if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("staging directory survived: %v", err)
|
||||
}
|
||||
// The project id is a path segment, so the tree is where the registry will
|
||||
// later expect to find it.
|
||||
if !strings.HasSuffix(filepath.Dir(dest), string(filepath.Separator)+"7") {
|
||||
t.Errorf("deployment dir %q is not under its project id", dest)
|
||||
}
|
||||
|
||||
// Where the filesystem allows it, an assembled file is the blob rather than a
|
||||
// copy of it. This is what keeps a hundred deployments of one site costing
|
||||
// one site's worth of disk, so it is worth asserting rather than assuming.
|
||||
if f.cas.LinkMode() != cas.LinkHard {
|
||||
t.Skipf("link mode is %s on this filesystem; skipping the hardlink assertion", f.cas.LinkMode())
|
||||
}
|
||||
for _, e := range f.files {
|
||||
blob, err := os.Stat(f.cas.Path(e.Digest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed, err := os.Stat(filepath.Join(dest, e.Path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(blob, placed) {
|
||||
t.Errorf("%s is a copy of its blob, not a link to it", e.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize can be retried, so assembling onto a finished tree must be a no-op
|
||||
// rather than a rebuild — a rebuild would briefly unlink files that an
|
||||
// external consumer of $WEBROOT is reading.
|
||||
func TestAssembleIsIdempotent(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "one"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.Stat(filepath.Join(dest, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("second Assemble: %v", err)
|
||||
}
|
||||
after, err := os.Stat(filepath.Join(dest, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
t.Error("the second Assemble replaced a file that was already in place")
|
||||
}
|
||||
}
|
||||
|
||||
// A crash leaves a staging tree behind. The next attempt must clear it rather
|
||||
// than build on top of files it did not put there.
|
||||
func TestAssembleDiscardsALeftoverStagingTree(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
staging := dest + stagingSuffix
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same path as a manifest entry, so a build that did not clear this would
|
||||
// fail on the exclusive create rather than silently serve the wrong bytes.
|
||||
if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, "orphan.txt"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
got := walk(t, dest)
|
||||
if len(got) != 1 || got["index.html"] != "real" {
|
||||
t.Errorf("tree = %v, want just the real index.html", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure must leave nothing behind: no partial tree at the destination, and
|
||||
// no staging directory quietly consuming disk until recovery notices it.
|
||||
func TestAssembleLeavesNothingBehindOnFailure(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
missing := store.FileRow{Path: "gone.txt", Digest: cas.Sum([]byte("never stored")), Size: 12}
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
|
||||
err := Assemble(t.Context(), f.cas, append(f.files, missing), dest)
|
||||
if !errors.Is(err, cas.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want cas.ErrNotFound", err)
|
||||
}
|
||||
for _, p := range []string{dest, dest + stagingSuffix} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("%s still exists: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assembly is the one place that turns stored strings into filesystem paths, so
|
||||
// it re-checks them even though the API validated the manifest on the way in.
|
||||
// A row that got past the API — or into the table by some other route — must not
|
||||
// be able to place a file outside the tree being built.
|
||||
func TestAssembleRejectsAnEscapingPath(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "a/../../b", "a//b", `a\..\b`, "a/./b", ""} {
|
||||
row := store.FileRow{Path: bad, Digest: f.files[0].Digest, Size: f.files[0].Size}
|
||||
if err := Assemble(t.Context(), f.cas, []store.FileRow{row}, dest); err == nil {
|
||||
t.Errorf("Assemble accepted %q", bad)
|
||||
}
|
||||
// Nothing is left behind for the next attempt to inherit, and in
|
||||
// particular no directory was created on the way to the rejection.
|
||||
for _, p := range []string{dest, dest + stagingSuffix} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("%q left %s behind: %v", bad, p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleHonoursCancellation(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
if err := Assemble(ctx, f.cas, f.files, dest); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("staging directory survived cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user