package update import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "time" ) // A snapshot is a byte-for-byte copy of the deployed artifacts plus a manifest // of their sha256 sums, taken before an install. // // It is copies, not hardlinks and not a git stash, for one reason: the restore // path must work when everything else is broken. A hardlink into the install dir // would be clobbered by the very install it exists to undo, and a git-based // undo needs a toolchain, a clean tree, and a rebuild — three things a failed // update is likely to have taken away. Copying two dozen megabytes of Go // binaries costs a second and needs nothing but the filesystem. // // The sums are what make a restore verifiable rather than hopeful: Restore // re-hashes every file it writes, so "the old bytes are back" is checked, not // assumed. // FileRec — one file in a snapshot. type FileRec struct { Name string `json:"name"` // relative name inside the install dir SHA256 string `json:"sha256"` // of the snapshotted bytes Mode os.FileMode `json:"mode"` Size int64 `json:"size"` } // Snapshot — the manifest. Written last, so a directory without a readable // manifest.json is an aborted snapshot and is never offered as a rollback target. type Snapshot struct { ID string `json:"id"` // sortable timestamp, also the directory name CreatedAt time.Time `json:"created_at"` Commit string `json:"commit,omitempty"` // git HEAD of the tree that produced it, when known Note string `json:"note,omitempty"` Files []FileRec `json:"files"` dir string // absolute path, filled in by List/Load } // Dir — where this snapshot's file copies live. func (s Snapshot) Dir() string { return s.dir } const manifestName = "manifest.json" // Store is a directory of snapshots. type Store struct { Dir string now func() time.Time } func (st *Store) clock() time.Time { if st.now != nil { return st.now() } return time.Now() } // Save copies names (relative to srcDir) into a new snapshot and writes the // manifest. A name that does not exist is skipped rather than fatal: the first // ever run happens on a box where some artifact may legitimately be missing, and // refusing to snapshot then would mean refusing to update. func (st *Store) Save(srcDir string, names []string, commit, note string) (Snapshot, error) { ts := st.clock().UTC() snap := Snapshot{ ID: ts.Format("20060102-150405"), CreatedAt: ts, Commit: commit, Note: note, } snap.dir = filepath.Join(st.Dir, snap.ID) if err := os.MkdirAll(snap.dir, 0o700); err != nil { return Snapshot{}, fmt.Errorf("update: snapshot dir: %w", err) } for _, name := range names { src := filepath.Join(srcDir, name) fi, err := os.Stat(src) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err) } if fi.IsDir() { return Snapshot{}, fmt.Errorf("update: snapshot %s: is a directory (only files are deployable artifacts)", name) } dst := filepath.Join(snap.dir, name) if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { return Snapshot{}, err } sum, err := copyFile(src, dst, fi.Mode().Perm()) if err != nil { return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err) } snap.Files = append(snap.Files, FileRec{Name: name, SHA256: sum, Mode: fi.Mode().Perm(), Size: fi.Size()}) } if len(snap.Files) == 0 { os.RemoveAll(snap.dir) return Snapshot{}, fmt.Errorf("update: snapshot of %s is empty — none of the listed artifacts exist", srcDir) } // Manifest last: its presence is what makes the snapshot usable. blob, err := json.MarshalIndent(snap, "", " ") if err != nil { return Snapshot{}, err } if err := os.WriteFile(filepath.Join(snap.dir, manifestName), blob, 0o600); err != nil { return Snapshot{}, fmt.Errorf("update: snapshot manifest: %w", err) } return snap, nil } // List returns the complete snapshots, newest first. func (st *Store) List() ([]Snapshot, error) { ents, err := os.ReadDir(st.Dir) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var out []Snapshot for _, e := range ents { if !e.IsDir() { continue } s, err := st.Load(e.Name()) if err != nil { continue // aborted or hand-mangled: not a rollback target } out = append(out, s) } sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) return out, nil } // Load reads one snapshot's manifest. func (st *Store) Load(id string) (Snapshot, error) { dir := filepath.Join(st.Dir, id) blob, err := os.ReadFile(filepath.Join(dir, manifestName)) if err != nil { return Snapshot{}, err } var s Snapshot if err := json.Unmarshal(blob, &s); err != nil { return Snapshot{}, fmt.Errorf("update: manifest %s: %w", id, err) } s.dir = dir return s, nil } // Restore copies a snapshot's files back over dstDir and verifies every write // against the manifest sum. Only the named files are touched; anything else in // dstDir is left alone. // // This is the function the whole package exists to be able to run. It uses the // filesystem and nothing else — no toolchain, no build, no cooperation from the // code being replaced. func (s Snapshot) Restore(dstDir string) error { if s.dir == "" { return errors.New("update: snapshot has no directory (load it through the store)") } for _, f := range s.Files { src := filepath.Join(s.dir, f.Name) sum, err := hashFile(src) if err != nil { return fmt.Errorf("update: restore %s: %w", f.Name, err) } if sum != f.SHA256 { return fmt.Errorf("update: restore %s: snapshot is corrupt (sha256 %s, manifest says %s)", f.Name, sum, f.SHA256) } dst := filepath.Join(dstDir, f.Name) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } got, err := copyFile(src, dst, f.Mode) if err != nil { return fmt.Errorf("update: restore %s: %w", f.Name, err) } if got != f.SHA256 { return fmt.Errorf("update: restore %s: wrote the wrong bytes (sha256 %s)", f.Name, got) } } return nil } // Prune keeps the newest keep snapshots and removes the rest. The newest is // never pruned regardless of keep — it is the rollback target. func (st *Store) Prune(keep int) error { if keep < 1 { keep = 1 } snaps, err := st.List() if err != nil { return err } for _, s := range snaps[min(keep, len(snaps)):] { if err := os.RemoveAll(s.dir); err != nil { return err } } return nil } // copyFile writes src to dst atomically (temp + rename, so a reader never sees a // half file and an interrupted copy leaves the old one intact) and returns the // sha256 of what was written. func copyFile(src, dst string, mode os.FileMode) (string, error) { in, err := os.Open(src) if err != nil { return "", err } defer in.Close() if mode == 0 { mode = 0o644 } tmp, err := os.CreateTemp(filepath.Dir(dst), ".update-*") if err != nil { return "", err } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op once the rename succeeds h := sha256.New() if _, err := io.Copy(io.MultiWriter(tmp, h), in); err != nil { tmp.Close() return "", err } // fsync before the rename: a binary that is renamed into place but whose // bytes are still in the page cache is exactly the file a power cut turns // into an unbootable daemon. if err := tmp.Sync(); err != nil { tmp.Close() return "", err } if err := tmp.Chmod(mode); err != nil { tmp.Close() return "", err } if err := tmp.Close(); err != nil { return "", err } if err := os.Rename(tmpName, dst); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func hashFile(p string) (string, error) { f, err := os.Open(p) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil }