159 lines
6.0 KiB
Go
Executable File
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
|
|
})
|
|
}
|