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