208 lines
6.2 KiB
Go
208 lines
6.2 KiB
Go
// Package webroot maintains the $WEBROOT/~project symlinks.
|
|
//
|
|
// The server serves its own content, so these links are not on any request
|
|
// path. They exist for everything else: an nginx that would rather serve the
|
|
// files itself, a backup job, an operator running ls. That makes every
|
|
// operation here best-effort — a failure is logged and reconciled later, never
|
|
// a reason to fail a deployment that the database already committed.
|
|
package webroot
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/iceBear67/simplepages/internal/config"
|
|
)
|
|
|
|
// prefix is what marks an entry as ours. Project names cannot contain a slash
|
|
// or start with a dot — config.ProjectNamePattern sees to that — so "~" + name
|
|
// is always a single entry directly inside the webroot and can never escape it.
|
|
const prefix = "~"
|
|
|
|
// tmpMarker separates a half-built link from a live one. Point never renames a
|
|
// name containing it into place, and Reconcile sweeps any that a crash left.
|
|
const tmpMarker = ".tmp."
|
|
|
|
// Webroot is a directory of symlinks pointing into the deployments directory.
|
|
type Webroot struct {
|
|
dir string
|
|
deployDir string
|
|
}
|
|
|
|
// Open prepares the webroot directory. deployDir bounds what Reconcile is
|
|
// willing to delete: an entry that does not point inside it belongs to the
|
|
// operator, not to us.
|
|
func Open(dir, deployDir string) (*Webroot, error) {
|
|
if dir == "" {
|
|
return nil, errors.New("webroot: no directory configured")
|
|
}
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
deployAbs, err := filepath.Abs(deployDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Webroot{dir: abs, deployDir: deployAbs}, nil
|
|
}
|
|
|
|
// Dir is the directory being maintained.
|
|
func (w *Webroot) Dir() string { return w.dir }
|
|
|
|
// Point makes ~project refer to target.
|
|
//
|
|
// The link is created under a temporary name and renamed into place. rename(2)
|
|
// within one directory replaces an existing symlink atomically, so an external
|
|
// reader never observes the link missing, dangling or half-written — which is
|
|
// the same guarantee, at a coarser grain, that the in-process pointer swap
|
|
// gives HTTP clients.
|
|
func (w *Webroot) Point(project, target string) error {
|
|
link, err := w.linkPath(project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cur, err := os.Readlink(link); err == nil && cur == target {
|
|
return nil
|
|
}
|
|
|
|
tmp, err := w.tempLink(project, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, link); err != nil {
|
|
os.Remove(tmp)
|
|
return fmt.Errorf("webroot: point %s: %w", project, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Unpoint removes ~project, if it is one of ours.
|
|
func (w *Webroot) Unpoint(project string) error {
|
|
link, err := w.linkPath(project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !w.ours(link) {
|
|
return nil
|
|
}
|
|
if err := os.Remove(link); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("webroot: unpoint %s: %w", project, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reconcile makes the directory match want, a project name to target directory
|
|
// map, and reports the first thing that went wrong after attempting all of it.
|
|
//
|
|
// It only ever deletes an entry that is itself a symlink pointing inside the
|
|
// deployments directory. An operator's unrelated file, directory or link that
|
|
// happens to share the webroot is left strictly alone: this program owns the
|
|
// names it created, not the directory.
|
|
func (w *Webroot) Reconcile(want map[string]string) error {
|
|
entries, err := os.ReadDir(w.dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var errs []error
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) || e.Type()&fs.ModeSymlink == 0 {
|
|
continue
|
|
}
|
|
full := filepath.Join(w.dir, name)
|
|
if !w.pointsIntoDeployments(full) {
|
|
continue
|
|
}
|
|
project := name[len(prefix):]
|
|
if strings.Contains(project, tmpMarker) {
|
|
// A Point that died between symlink and rename.
|
|
if err := os.Remove(full); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
continue
|
|
}
|
|
if _, keep := want[project]; keep {
|
|
continue
|
|
}
|
|
if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
for project, target := range want {
|
|
if err := w.Point(project, target); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// linkPath validates the project name and returns the path of its link.
|
|
func (w *Webroot) linkPath(project string) (string, error) {
|
|
if !config.ProjectNamePattern.MatchString(project) {
|
|
return "", fmt.Errorf("webroot: refusing to touch %q: not a project name", project)
|
|
}
|
|
return filepath.Join(w.dir, prefix+project), nil
|
|
}
|
|
|
|
// tempLink creates a symlink under a name that Reconcile will recognise as
|
|
// abandoned if this process dies before the rename.
|
|
func (w *Webroot) tempLink(project, target string) (string, error) {
|
|
base, err := w.linkPath(project)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var buf [6]byte
|
|
for range 8 {
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return "", err
|
|
}
|
|
tmp := base + tmpMarker + hex.EncodeToString(buf[:])
|
|
err := os.Symlink(target, tmp)
|
|
if err == nil {
|
|
return tmp, nil
|
|
}
|
|
if !errors.Is(err, fs.ErrExist) {
|
|
return "", fmt.Errorf("webroot: link %s: %w", project, err)
|
|
}
|
|
}
|
|
return "", fmt.Errorf("webroot: could not create a temporary link for %s", project)
|
|
}
|
|
|
|
// ours reports whether path is a symlink this package would have created.
|
|
func (w *Webroot) ours(path string) bool {
|
|
fi, err := os.Lstat(path)
|
|
if err != nil || fi.Mode()&fs.ModeSymlink == 0 {
|
|
return false
|
|
}
|
|
return w.pointsIntoDeployments(path)
|
|
}
|
|
|
|
// pointsIntoDeployments reports whether a symlink's target is inside the
|
|
// deployments directory. The target is compared lexically after cleaning: it is
|
|
// the string this program wrote, and resolving it would answer a different
|
|
// question about a link that may well dangle.
|
|
func (w *Webroot) pointsIntoDeployments(path string) bool {
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(w.dir, target)
|
|
}
|
|
target = filepath.Clean(target)
|
|
if target == w.deployDir {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(target, w.deployDir+string(filepath.Separator))
|
|
}
|