229 lines
7.5 KiB
Go
229 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|