Files
2026-08-15 07:13:00 +00:00

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()
}