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