148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
// Package deploy owns the deployment lifecycle: creating one, negotiating its
|
|
// manifest, accepting blob uploads, assembling the directory tree, and (from M3)
|
|
// switching a project over to it.
|
|
//
|
|
// It is the only package that writes to $DATA_DIR/deployments. Everything it
|
|
// writes lands in a staging directory first and becomes visible with a single
|
|
// rename, which is the same discipline the CAS uses for blobs and the registry
|
|
// uses for the active pointer.
|
|
package deploy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"github.com/iceBear67/simplepages/internal/cas"
|
|
"github.com/iceBear67/simplepages/internal/pathutil"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
const (
|
|
dirMode = 0o755
|
|
// stagingSuffix marks a tree that is still being built. Recovery deletes
|
|
// every directory carrying it, since by definition nothing references one.
|
|
stagingSuffix = ".staging"
|
|
)
|
|
|
|
// DeploymentDir is where a deployment's assembled tree lives.
|
|
//
|
|
// The project id rather than its name: a project that is renamed keeps its
|
|
// deployments where they are, and no user-chosen string is ever a path segment
|
|
// under $DATA_DIR.
|
|
func DeploymentDir(root string, projectID int64, publicID string) string {
|
|
return filepath.Join(root, strconv.FormatInt(projectID, 10), publicID)
|
|
}
|
|
|
|
// Assemble builds destDir from the CAS.
|
|
//
|
|
// The tree is built under destDir+".staging" and moved into place with one
|
|
// rename, so destDir either does not exist or is the complete deployment —
|
|
// there is no state in which a reader could walk a half-built tree.
|
|
//
|
|
// It is idempotent in the way finalize needs: an existing destDir is a finished
|
|
// tree (rename is atomic, so a partial one cannot survive a crash) and is left
|
|
// alone.
|
|
func Assemble(ctx context.Context, cs *cas.Store, files []store.FileRow, destDir string) error {
|
|
staging := destDir + stagingSuffix
|
|
if fi, err := os.Stat(destDir); err == nil {
|
|
if !fi.IsDir() {
|
|
return fmt.Errorf("deploy: %s exists and is not a directory", destDir)
|
|
}
|
|
return os.RemoveAll(staging)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
// Whatever an earlier attempt left behind is unreferenced by construction.
|
|
if err := os.RemoveAll(staging); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(staging, dirMode); err != nil {
|
|
return err
|
|
}
|
|
// One cleanup for every failure path: a staging tree that outlives its
|
|
// attempt is wasted disk that only recovery would find.
|
|
ok := false
|
|
defer func() {
|
|
if !ok {
|
|
os.RemoveAll(staging)
|
|
}
|
|
}()
|
|
|
|
// The set of directories that had to be created, so each can be fsynced
|
|
// once. Recorded per ancestor because MkdirAll creates parents silently.
|
|
dirs := map[string]bool{".": true}
|
|
for _, f := range files {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
// The manifest was validated when it was accepted. Checking again here
|
|
// costs a few hundred nanoseconds per file and means the one place that
|
|
// turns stored strings into filesystem paths does not depend on a
|
|
// promise made by a different package at a different time.
|
|
if err := pathutil.Validate(f.Path); err != nil {
|
|
return fmt.Errorf("deploy: manifest path %q: %w", f.Path, err)
|
|
}
|
|
if dir := path.Dir(f.Path); !dirs[dir] {
|
|
if err := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(dir)), dirMode); err != nil {
|
|
return err
|
|
}
|
|
for d := dir; !dirs[d]; d = path.Dir(d) {
|
|
dirs[d] = true
|
|
}
|
|
}
|
|
if err := cs.LinkInto(f.Digest, staging, f.Path); err != nil {
|
|
return fmt.Errorf("deploy: %s: %w", f.Path, err)
|
|
}
|
|
}
|
|
|
|
// Blob content is already durable — Put fsynced it, and a hardlink shares
|
|
// that inode — but the directory entries pointing at it are not. Without
|
|
// this, a crash could leave a renamed-into-place tree with missing files,
|
|
// which is exactly the half-updated site the whole design exists to avoid.
|
|
if err := syncDirs(staging, dirs); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destDir), dirMode); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(staging, destDir); err != nil {
|
|
return err
|
|
}
|
|
ok = true
|
|
return syncDir(filepath.Dir(destDir))
|
|
}
|
|
|
|
// syncDirs fsyncs every directory of the staged tree, deepest first, so a
|
|
// parent is only made durable once the entries it names are.
|
|
func syncDirs(staging string, dirs map[string]bool) error {
|
|
rel := make([]string, 0, len(dirs))
|
|
for d := range dirs {
|
|
rel = append(rel, d)
|
|
}
|
|
sort.Sort(sort.Reverse(sort.StringSlice(rel)))
|
|
for _, d := range rel {
|
|
if err := syncDir(filepath.Join(staging, filepath.FromSlash(d))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncDir(dir string) error {
|
|
f, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if err := f.Sync(); err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|