Files
sandbox/internal/devicemapper/devicemapper.go
pptx704 1846168736 Fix device-mapper "Device or resource busy" error on sandbox resume
Pause was logging RemoveSnapshot failures as warnings and continuing,
which left stale dm devices behind. Resume then failed trying to create
a device with the same name.

- Make RemoveSnapshot failure a hard error in Pause (clean up remaining
  resources and return error instead of silently proceeding)
- Add defensive stale device cleanup in RestoreSnapshot before creating
  the new dm device
2026-03-14 03:57:14 +06:00

332 lines
10 KiB
Go

// Package devicemapper provides device-mapper snapshot operations for
// copy-on-write rootfs management. Each sandbox gets a dm-snapshot backed
// by a shared read-only loop device (the base template image) and a
// per-sandbox sparse CoW file that stores only modified blocks.
package devicemapper
import (
"fmt"
"log/slog"
"os"
"os/exec"
"strconv"
"strings"
"sync"
)
const (
// ChunkSize is the dm-snapshot chunk size in 512-byte sectors.
// 8 sectors = 4KB, matching the standard page/block size.
ChunkSize = 8
)
// loopEntry tracks a loop device and its reference count.
type loopEntry struct {
device string // e.g., /dev/loop0
refcount int
}
// LoopRegistry manages loop devices for base template images.
// Each unique image path gets one read-only loop device, shared
// across all sandboxes using that template. Reference counting
// ensures the loop device is released when no sandboxes use it.
type LoopRegistry struct {
mu sync.Mutex
entries map[string]*loopEntry // imagePath → loopEntry
}
// NewLoopRegistry creates a new loop device registry.
func NewLoopRegistry() *LoopRegistry {
return &LoopRegistry{
entries: make(map[string]*loopEntry),
}
}
// Acquire returns a read-only loop device for the given image path.
// If one already exists, its refcount is incremented. Otherwise a new
// loop device is created via losetup.
func (r *LoopRegistry) Acquire(imagePath string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.entries[imagePath]; ok {
e.refcount++
slog.Debug("loop device reused", "image", imagePath, "device", e.device, "refcount", e.refcount)
return e.device, nil
}
dev, err := losetupCreate(imagePath)
if err != nil {
return "", fmt.Errorf("losetup %s: %w", imagePath, err)
}
r.entries[imagePath] = &loopEntry{device: dev, refcount: 1}
slog.Info("loop device created", "image", imagePath, "device", dev)
return dev, nil
}
// Release decrements the refcount for the given image path.
// When the refcount reaches zero, the loop device is detached.
func (r *LoopRegistry) Release(imagePath string) {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.entries[imagePath]
if !ok {
return
}
e.refcount--
if e.refcount <= 0 {
if err := losetupDetach(e.device); err != nil {
slog.Warn("losetup detach failed", "device", e.device, "error", err)
}
delete(r.entries, imagePath)
slog.Info("loop device released", "image", imagePath, "device", e.device)
}
}
// ReleaseAll detaches all loop devices. Used during shutdown.
func (r *LoopRegistry) ReleaseAll() {
r.mu.Lock()
defer r.mu.Unlock()
for path, e := range r.entries {
if err := losetupDetach(e.device); err != nil {
slog.Warn("losetup detach failed", "device", e.device, "error", err)
}
delete(r.entries, path)
}
}
// SnapshotDevice holds the state for a single dm-snapshot device.
type SnapshotDevice struct {
Name string // dm device name, e.g., "wrenn-sb-a1b2c3d4"
DevicePath string // /dev/mapper/<Name>
CowPath string // path to the sparse CoW file
CowLoopDev string // loop device for the CoW file
}
// CreateSnapshot sets up a new dm-snapshot device.
//
// It creates a sparse CoW file, attaches it as a loop device, and creates
// a device-mapper snapshot target combining the read-only origin with the
// writable CoW layer.
//
// The origin loop device must already exist (from LoopRegistry.Acquire).
func CreateSnapshot(name, originLoopDev, cowPath string, originSizeBytes int64) (*SnapshotDevice, error) {
// Create sparse CoW file sized to match the origin.
if err := createSparseFile(cowPath, originSizeBytes); err != nil {
return nil, fmt.Errorf("create cow file: %w", err)
}
cowLoopDev, err := losetupCreateRW(cowPath)
if err != nil {
os.Remove(cowPath)
return nil, fmt.Errorf("losetup cow: %w", err)
}
sectors := originSizeBytes / 512
if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil {
if detachErr := losetupDetach(cowLoopDev); detachErr != nil {
slog.Warn("cow losetup detach failed during cleanup", "device", cowLoopDev, "error", detachErr)
}
os.Remove(cowPath)
return nil, fmt.Errorf("dmsetup create: %w", err)
}
devPath := "/dev/mapper/" + name
slog.Info("dm-snapshot created",
"name", name,
"device", devPath,
"origin", originLoopDev,
"cow", cowPath,
)
return &SnapshotDevice{
Name: name,
DevicePath: devPath,
CowPath: cowPath,
CowLoopDev: cowLoopDev,
}, nil
}
// RestoreSnapshot re-attaches a dm-snapshot from an existing persistent CoW file.
// The CoW file must have been created with the persistent (P) flag and still
// contain valid dm-snapshot metadata.
func RestoreSnapshot(name, originLoopDev, cowPath string, originSizeBytes int64) (*SnapshotDevice, error) {
// Defensively remove a stale device with the same name. This can happen
// if a previous pause failed to clean up the dm device (e.g. "device busy").
if dmDeviceExists(name) {
slog.Warn("removing stale dm device before restore", "name", name)
if err := dmsetupRemove(name); err != nil {
return nil, fmt.Errorf("remove stale device %s: %w", name, err)
}
}
cowLoopDev, err := losetupCreateRW(cowPath)
if err != nil {
return nil, fmt.Errorf("losetup cow: %w", err)
}
sectors := originSizeBytes / 512
if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil {
if detachErr := losetupDetach(cowLoopDev); detachErr != nil {
slog.Warn("cow losetup detach failed during cleanup", "device", cowLoopDev, "error", detachErr)
}
return nil, fmt.Errorf("dmsetup create: %w", err)
}
devPath := "/dev/mapper/" + name
slog.Info("dm-snapshot restored",
"name", name,
"device", devPath,
"origin", originLoopDev,
"cow", cowPath,
)
return &SnapshotDevice{
Name: name,
DevicePath: devPath,
CowPath: cowPath,
CowLoopDev: cowLoopDev,
}, nil
}
// RemoveSnapshot tears down a dm-snapshot device and its CoW loop device.
// The CoW file is NOT deleted — the caller decides whether to keep or remove it.
func RemoveSnapshot(dev *SnapshotDevice) error {
if err := dmsetupRemove(dev.Name); err != nil {
return fmt.Errorf("dmsetup remove %s: %w", dev.Name, err)
}
if err := losetupDetach(dev.CowLoopDev); err != nil {
slog.Warn("cow losetup detach failed", "device", dev.CowLoopDev, "error", err)
}
slog.Info("dm-snapshot removed", "name", dev.Name)
return nil
}
// FlattenSnapshot reads the full contents of a dm-snapshot device and writes
// it to a new file. This merges the base image + CoW changes into a standalone
// rootfs image suitable for use as a new template.
func FlattenSnapshot(dmDevPath, outputPath string) error {
cmd := exec.Command("dd",
"if="+dmDevPath,
"of="+outputPath,
"bs=4M",
"status=none",
)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(outputPath)
return fmt.Errorf("dd flatten: %s: %w", string(out), err)
}
return nil
}
// OriginSizeBytes returns the size in bytes of a loop device's backing file.
func OriginSizeBytes(loopDev string) (int64, error) {
// blockdev --getsize64 returns size in bytes.
out, err := exec.Command("blockdev", "--getsize64", loopDev).CombinedOutput()
if err != nil {
return 0, fmt.Errorf("blockdev --getsize64 %s: %s: %w", loopDev, strings.TrimSpace(string(out)), err)
}
s := strings.TrimSpace(string(out))
return strconv.ParseInt(s, 10, 64)
}
// CleanupStaleDevices removes any device-mapper devices matching the
// "wrenn-" prefix that may have been left behind by a previous agent
// instance that crashed or was killed. Should be called at agent startup.
func CleanupStaleDevices() {
out, err := exec.Command("dmsetup", "ls", "--target", "snapshot").CombinedOutput()
if err != nil {
slog.Debug("dmsetup ls failed (may be normal if no devices exist)", "error", err)
return
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" || line == "No devices found" {
continue
}
// dmsetup ls output format: "name\t(major:minor)"
name, _, _ := strings.Cut(line, "\t")
if !strings.HasPrefix(name, "wrenn-") {
continue
}
slog.Warn("removing stale dm-snapshot device", "name", name)
if err := dmsetupRemove(name); err != nil {
slog.Warn("failed to remove stale device", "name", name, "error", err)
}
}
}
// --- low-level helpers ---
// losetupCreate attaches a file as a read-only loop device.
func losetupCreate(imagePath string) (string, error) {
out, err := exec.Command("losetup", "--read-only", "--find", "--show", imagePath).Output()
if err != nil {
return "", fmt.Errorf("losetup --read-only: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// losetupCreateRW attaches a file as a read-write loop device.
func losetupCreateRW(path string) (string, error) {
out, err := exec.Command("losetup", "--find", "--show", path).Output()
if err != nil {
return "", fmt.Errorf("losetup: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// losetupDetach detaches a loop device.
func losetupDetach(dev string) error {
return exec.Command("losetup", "-d", dev).Run()
}
// dmsetupCreate creates a dm-snapshot device with persistent metadata.
func dmsetupCreate(name, originDev, cowDev string, sectors int64) error {
// Table format: <start> <size> snapshot <origin> <cow> P <chunk_size>
// P = persistent — CoW metadata survives dmsetup remove.
table := fmt.Sprintf("0 %d snapshot %s %s P %d", sectors, originDev, cowDev, ChunkSize)
cmd := exec.Command("dmsetup", "create", name, "--table", table)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// dmDeviceExists checks whether a device-mapper device with the given name exists.
func dmDeviceExists(name string) bool {
return exec.Command("dmsetup", "info", name).Run() == nil
}
// dmsetupRemove removes a device-mapper device.
func dmsetupRemove(name string) error {
cmd := exec.Command("dmsetup", "remove", name)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// createSparseFile creates a sparse file of the given size.
func createSparseFile(path string, sizeBytes int64) error {
f, err := os.Create(path)
if err != nil {
return err
}
if err := f.Truncate(sizeBytes); err != nil {
f.Close()
os.Remove(path)
return err
}
return f.Close()
}