sorcery-go/pkg/state/committer.go

159 lines
6.0 KiB
Go
Executable File

// Atomic manifest committer.
//
// Commit() walks the sandbox "upper" directory and migrates every produced
// file into the live root using the "safe swap" pattern:
//
// 1. Copy (or reflink) the new file to <target>.sorcery_tmp
// 2. os.Rename() — atomic at the kernel level — replaces the old file
//
// On success, the file list is written to the Manifests bucket in the same
// bbolt transaction so the disk and the database always agree. If the
// commit fails part-way, the journal entry is marked StateFailed and the
// .sorcery_tmp leftovers are cleaned up on the next engine start.
package state
import (
"bytes"
"fmt"
"log"
bolt "go.etcd.io/bbolt"
)
// Commit migrates files from the sandbox upper directory into the live
// root atomically. upperDir is the Box.WorkDir; relPath is computed
// against "/" so a sandbox-produced /usr/bin/wget becomes /usr/bin/wget
// on the host.
//
// If dryRun is true, no files are written — only the manifest is recorded.
// Used by `sorcery cast --dry-run`.
func (m *Manager) Commit(spellName, variant, upperDir string, dryRun bool) ([]string, error) {
var installed []string
err := filepath.Walk(upperDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, e := filepath.Rel(upperDir, path)
if e != nil {
return e
}
target := filepath.Join("/", relPath)
if !dryRun {
if err := copyFileAtomic(path, target, info.Mode()); err != nil {
return fmt.Errorf("commit: %s: %w", target, err)
}
}
installed = append(installed, target)
return nil
})
if err != nil {
return nil, err
}
// Persist the manifest + reverse index in a single transaction.
if err := m.SaveManifest(spellName, variant, installed); err != nil {
return nil, err
}
return installed, nil
}
// copyFileAtomic uses reflink (btrfs/xfs FICLONE) when available, falling
// back to a plain io.Copy + atomic rename. The intermediate .sorcery_tmp
// file is cleaned up on error.
func copyFileAtomic(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
tmp := dst + ".sorcery_tmp"
// Try reflink first (instant on btrfs/xfs, zero extra disk).
if err := reflinkOrCopy(src, tmp, mode); err != nil {
return err
}
// Atomic rename — replaces dst atomically even if it already exists.
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename %s -> %s: %w", tmp, dst, err)
}
return nil
}
// reflinkOrCopy tries the Linux FICLONE ioctl first; on any error it
// falls back to a buffered io.Copy.
func reflinkOrCopy(src, dst string, mode os.FileMode) error {
if err := reflink(src, dst); err == nil {
// Reflink preserves mode/owner but we set them explicitly to be safe.
_ = os.Chmod(dst, mode)
return nil
}
// Fallback: plain copy.
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// Dispel removes every file recorded in the spell's manifest and deletes
// the manifest + reverse-index entries. Used to "banish" a spell from the
// live system.
func (m *Manager) Dispel(spellName, variant string) error {
files, err := m.GetManifest(spellName, variant)
if err != nil {
return err
}
// Remove files. Empty parent dirs are pruned best-effort.
for _, f := range files {
if err := os.Remove(f); err != nil {
log.Printf("dispel: warning: remove %s: %v", f, err)
}
// Walk up pruning empty dirs (stop at /).
dir := filepath.Dir(f)
for dir != "/" && dir != "." {
if err := os.Remove(dir); err != nil {
break // not empty — stop
}
dir = filepath.Dir(dir)
}
}
// Drop manifest + reverse-index entries.
// Collect index keys first to avoid modifying the bucket while iterating.
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
if err := b.Delete([]byte(spellName + ":" + variant)); err != nil {
return err
}
idx := tx.Bucket([]byte("Index"))
c := idx.Cursor()
owner := []byte(spellName + ":" + variant)
var toDelete [][]byte
for k, v := c.First(); k != nil; k, v = c.Next() {
if bytes.Equal(v, owner) {
// Copy the key since bbolt reuses the slice on Next().
keyCopy := make([]byte, len(k))
copy(keyCopy, k)
toDelete = append(toDelete, keyCopy)
}
}
for _, k := range toDelete {
if err := idx.Delete(k); err != nil {
log.Printf("dispel: warning: index delete %s: %v", string(k), err)
}
}
return nil
})
}