Files
volt/pkg/backup/backup.go
Karl Clinger 0ebe75b2ca Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 02:08:15 -05:00

537 lines
17 KiB
Go

/*
Backup Manager — CAS-based backup and restore for Volt workloads.
Provides named, metadata-rich backups built on top of the CAS store.
A backup is a CAS BlobManifest + a metadata sidecar (JSON) that records
the workload name, mode, timestamp, tags, size, and blob count.
Features:
- Create backup from a workload's rootfs → CAS + CDN
- List backups (all or per-workload)
- Restore backup → reassemble rootfs via TinyVol
- Delete backup (metadata only — blobs cleaned up by CAS GC)
- Schedule automated backups via systemd timers
Backups are incremental by nature — CAS dedup means only changed files
produce new blobs. A 2 GB rootfs with 50 MB of changes stores 50 MB new data.
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package backup
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/armoredgate/volt/pkg/storage"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
// DefaultBackupDir is where backup metadata is stored.
DefaultBackupDir = "/var/lib/volt/backups"
// BackupTypeManual is a user-initiated backup.
BackupTypeManual = "manual"
// BackupTypeScheduled is an automatically scheduled backup.
BackupTypeScheduled = "scheduled"
// BackupTypeSnapshot is a point-in-time snapshot.
BackupTypeSnapshot = "snapshot"
// BackupTypePreDeploy is created automatically before deployments.
BackupTypePreDeploy = "pre-deploy"
)
// ── Backup Metadata ──────────────────────────────────────────────────────────
// BackupMeta holds the metadata sidecar for a backup. This is stored alongside
// the CAS manifest reference and provides human-friendly identification.
type BackupMeta struct {
// ID is a unique identifier for this backup (timestamp-based).
ID string `json:"id"`
// WorkloadName is the workload that was backed up.
WorkloadName string `json:"workload_name"`
// WorkloadMode is the execution mode at backup time (container, hybrid-native, etc.).
WorkloadMode string `json:"workload_mode,omitempty"`
// Type indicates how the backup was created (manual, scheduled, snapshot, pre-deploy).
Type string `json:"type"`
// ManifestRef is the CAS manifest filename in the refs directory.
ManifestRef string `json:"manifest_ref"`
// Tags are user-defined labels for the backup.
Tags []string `json:"tags,omitempty"`
// CreatedAt is when the backup was created.
CreatedAt time.Time `json:"created_at"`
// BlobCount is the number of files/blobs in the backup.
BlobCount int `json:"blob_count"`
// TotalSize is the total logical size of all backed-up files.
TotalSize int64 `json:"total_size"`
// NewBlobs is the number of blobs that were newly stored (not deduplicated).
NewBlobs int `json:"new_blobs"`
// DedupBlobs is the number of blobs that were already in CAS.
DedupBlobs int `json:"dedup_blobs"`
// Duration is how long the backup took.
Duration time.Duration `json:"duration"`
// PushedToCDN indicates whether blobs were pushed to the CDN.
PushedToCDN bool `json:"pushed_to_cdn"`
// SourcePath is the rootfs path that was backed up.
SourcePath string `json:"source_path,omitempty"`
// Notes is an optional user-provided description.
Notes string `json:"notes,omitempty"`
}
// ── Backup Manager ───────────────────────────────────────────────────────────
// Manager handles backup operations, coordinating between the CAS store,
// backup metadata directory, and optional CDN client.
type Manager struct {
cas *storage.CASStore
backupDir string
}
// NewManager creates a backup manager with the given CAS store.
func NewManager(cas *storage.CASStore) *Manager {
return &Manager{
cas: cas,
backupDir: DefaultBackupDir,
}
}
// NewManagerWithDir creates a backup manager with a custom backup directory.
func NewManagerWithDir(cas *storage.CASStore, backupDir string) *Manager {
if backupDir == "" {
backupDir = DefaultBackupDir
}
return &Manager{
cas: cas,
backupDir: backupDir,
}
}
// Init creates the backup metadata directory. Idempotent.
func (m *Manager) Init() error {
return os.MkdirAll(m.backupDir, 0755)
}
// ── Create ───────────────────────────────────────────────────────────────────
// CreateOptions configures a backup creation.
type CreateOptions struct {
WorkloadName string
WorkloadMode string
SourcePath string // rootfs path to back up
Type string // manual, scheduled, snapshot, pre-deploy
Tags []string
Notes string
PushToCDN bool // whether to push blobs to CDN after backup
}
// Create performs a full backup of the given source path into CAS and records
// metadata. Returns the backup metadata with timing and dedup statistics.
func (m *Manager) Create(opts CreateOptions) (*BackupMeta, error) {
if err := m.Init(); err != nil {
return nil, fmt.Errorf("backup init: %w", err)
}
if opts.SourcePath == "" {
return nil, fmt.Errorf("backup create: source path is required")
}
if opts.WorkloadName == "" {
return nil, fmt.Errorf("backup create: workload name is required")
}
if opts.Type == "" {
opts.Type = BackupTypeManual
}
// Verify source exists.
info, err := os.Stat(opts.SourcePath)
if err != nil {
return nil, fmt.Errorf("backup create: source %s: %w", opts.SourcePath, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("backup create: source %s is not a directory", opts.SourcePath)
}
// Generate backup ID.
backupID := generateBackupID(opts.WorkloadName, opts.Type)
// Build CAS manifest from the source directory.
manifestName := fmt.Sprintf("backup-%s-%s", opts.WorkloadName, backupID)
result, err := m.cas.BuildFromDir(opts.SourcePath, manifestName)
if err != nil {
return nil, fmt.Errorf("backup create: CAS build: %w", err)
}
// Compute total size of all blobs in the backup.
var totalSize int64
// Load the manifest we just created to iterate blobs.
manifestBasename := filepath.Base(result.ManifestPath)
bm, err := m.cas.LoadManifest(manifestBasename)
if err == nil {
for _, digest := range bm.Objects {
blobPath := m.cas.GetPath(digest)
if fi, err := os.Stat(blobPath); err == nil {
totalSize += fi.Size()
}
}
}
// Create metadata.
meta := &BackupMeta{
ID: backupID,
WorkloadName: opts.WorkloadName,
WorkloadMode: opts.WorkloadMode,
Type: opts.Type,
ManifestRef: manifestBasename,
Tags: opts.Tags,
CreatedAt: time.Now().UTC(),
BlobCount: result.TotalFiles,
TotalSize: totalSize,
NewBlobs: result.Stored,
DedupBlobs: result.Deduplicated,
Duration: result.Duration,
SourcePath: opts.SourcePath,
Notes: opts.Notes,
}
// Save metadata.
if err := m.saveMeta(meta); err != nil {
return nil, fmt.Errorf("backup create: save metadata: %w", err)
}
return meta, nil
}
// ── List ─────────────────────────────────────────────────────────────────────
// ListOptions configures backup listing.
type ListOptions struct {
WorkloadName string // filter by workload (empty = all)
Type string // filter by type (empty = all)
Limit int // max results (0 = unlimited)
}
// List returns backup metadata, optionally filtered by workload name and type.
// Results are sorted by creation time, newest first.
func (m *Manager) List(opts ListOptions) ([]*BackupMeta, error) {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("backup list: read dir: %w", err)
}
var backups []*BackupMeta
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
meta, err := m.loadMeta(entry.Name())
if err != nil {
continue // skip corrupt entries
}
// Apply filters.
if opts.WorkloadName != "" && meta.WorkloadName != opts.WorkloadName {
continue
}
if opts.Type != "" && meta.Type != opts.Type {
continue
}
backups = append(backups, meta)
}
// Sort by creation time, newest first.
sort.Slice(backups, func(i, j int) bool {
return backups[i].CreatedAt.After(backups[j].CreatedAt)
})
// Apply limit.
if opts.Limit > 0 && len(backups) > opts.Limit {
backups = backups[:opts.Limit]
}
return backups, nil
}
// ── Get ──────────────────────────────────────────────────────────────────────
// Get retrieves a single backup by ID.
func (m *Manager) Get(backupID string) (*BackupMeta, error) {
filename := backupID + ".json"
return m.loadMeta(filename)
}
// ── Restore ──────────────────────────────────────────────────────────────────
// RestoreOptions configures a backup restoration.
type RestoreOptions struct {
BackupID string
TargetDir string // where to restore (defaults to original source path)
Force bool // overwrite existing target directory
}
// RestoreResult holds the outcome of a restore operation.
type RestoreResult struct {
TargetDir string
FilesLinked int
TotalSize int64
Duration time.Duration
}
// Restore reassembles a workload's rootfs from a backup's CAS manifest.
// Uses TinyVol hard-link assembly for instant, space-efficient restoration.
func (m *Manager) Restore(opts RestoreOptions) (*RestoreResult, error) {
start := time.Now()
// Load backup metadata.
meta, err := m.Get(opts.BackupID)
if err != nil {
return nil, fmt.Errorf("backup restore: %w", err)
}
// Determine target directory.
targetDir := opts.TargetDir
if targetDir == "" {
targetDir = meta.SourcePath
}
if targetDir == "" {
return nil, fmt.Errorf("backup restore: no target directory specified and no source path in backup metadata")
}
// Check if target exists.
if _, err := os.Stat(targetDir); err == nil {
if !opts.Force {
return nil, fmt.Errorf("backup restore: target %s already exists (use --force to overwrite)", targetDir)
}
// Remove existing target.
if err := os.RemoveAll(targetDir); err != nil {
return nil, fmt.Errorf("backup restore: remove existing target: %w", err)
}
}
// Create target directory.
if err := os.MkdirAll(targetDir, 0755); err != nil {
return nil, fmt.Errorf("backup restore: create target dir: %w", err)
}
// Load the CAS manifest.
bm, err := m.cas.LoadManifest(meta.ManifestRef)
if err != nil {
return nil, fmt.Errorf("backup restore: load manifest %s: %w", meta.ManifestRef, err)
}
// Assemble using TinyVol.
tv := storage.NewTinyVol(m.cas, "")
assemblyResult, err := tv.Assemble(bm, targetDir)
if err != nil {
return nil, fmt.Errorf("backup restore: TinyVol assembly: %w", err)
}
return &RestoreResult{
TargetDir: targetDir,
FilesLinked: assemblyResult.FilesLinked,
TotalSize: assemblyResult.TotalBytes,
Duration: time.Since(start),
}, nil
}
// ── Delete ───────────────────────────────────────────────────────────────────
// Delete removes a backup's metadata. The CAS blobs are not removed — they
// will be cleaned up by `volt cas gc` if no other manifests reference them.
func (m *Manager) Delete(backupID string) error {
filename := backupID + ".json"
metaPath := filepath.Join(m.backupDir, filename)
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
return fmt.Errorf("backup delete: backup %s not found", backupID)
}
if err := os.Remove(metaPath); err != nil {
return fmt.Errorf("backup delete: %w", err)
}
return nil
}
// ── Schedule ─────────────────────────────────────────────────────────────────
// ScheduleConfig holds the configuration for automated backups.
type ScheduleConfig struct {
WorkloadName string `json:"workload_name"`
Interval time.Duration `json:"interval"`
MaxKeep int `json:"max_keep"` // max backups to retain (0 = unlimited)
PushToCDN bool `json:"push_to_cdn"`
Tags []string `json:"tags,omitempty"`
}
// Schedule creates a systemd timer unit for automated backups.
// The timer calls `volt backup create` at the specified interval.
func (m *Manager) Schedule(cfg ScheduleConfig) error {
if cfg.WorkloadName == "" {
return fmt.Errorf("backup schedule: workload name is required")
}
if cfg.Interval <= 0 {
return fmt.Errorf("backup schedule: interval must be positive")
}
unitName := fmt.Sprintf("volt-backup-%s", cfg.WorkloadName)
// Create the service unit (one-shot, runs the backup command).
serviceContent := fmt.Sprintf(`[Unit]
Description=Volt Automated Backup for %s
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/volt backup create %s --type scheduled
`, cfg.WorkloadName, cfg.WorkloadName)
if cfg.MaxKeep > 0 {
serviceContent += fmt.Sprintf("ExecStartPost=/usr/local/bin/volt backup prune %s --keep %d\n",
cfg.WorkloadName, cfg.MaxKeep)
}
// Create the timer unit.
intervalStr := formatSystemdInterval(cfg.Interval)
timerContent := fmt.Sprintf(`[Unit]
Description=Volt Backup Timer for %s
[Timer]
OnActiveSec=0
OnUnitActiveSec=%s
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
`, cfg.WorkloadName, intervalStr)
// Write units.
unitDir := "/etc/systemd/system"
servicePath := filepath.Join(unitDir, unitName+".service")
timerPath := filepath.Join(unitDir, unitName+".timer")
if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil {
return fmt.Errorf("backup schedule: write service unit: %w", err)
}
if err := os.WriteFile(timerPath, []byte(timerContent), 0644); err != nil {
return fmt.Errorf("backup schedule: write timer unit: %w", err)
}
// Save schedule config for reference.
configPath := filepath.Join(m.backupDir, fmt.Sprintf("schedule-%s.json", cfg.WorkloadName))
configData, _ := json.MarshalIndent(cfg, "", " ")
if err := os.WriteFile(configPath, configData, 0644); err != nil {
return fmt.Errorf("backup schedule: save config: %w", err)
}
return nil
}
// ── Metadata Persistence ─────────────────────────────────────────────────────
func (m *Manager) saveMeta(meta *BackupMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal backup meta: %w", err)
}
filename := meta.ID + ".json"
metaPath := filepath.Join(m.backupDir, filename)
return os.WriteFile(metaPath, data, 0644)
}
func (m *Manager) loadMeta(filename string) (*BackupMeta, error) {
metaPath := filepath.Join(m.backupDir, filename)
data, err := os.ReadFile(metaPath)
if err != nil {
return nil, fmt.Errorf("load backup meta %s: %w", filename, err)
}
var meta BackupMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, fmt.Errorf("unmarshal backup meta %s: %w", filename, err)
}
return &meta, nil
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// generateBackupID creates a unique, sortable backup ID.
// Format: YYYYMMDD-HHMMSS-<type> (e.g., "20260619-143052-manual")
func generateBackupID(workloadName, backupType string) string {
now := time.Now().UTC()
return fmt.Sprintf("%s-%s-%s",
workloadName,
now.Format("20060102-150405"),
backupType)
}
// formatSystemdInterval converts a time.Duration to a systemd OnUnitActiveSec value.
func formatSystemdInterval(d time.Duration) string {
hours := int(d.Hours())
if hours >= 24 && hours%24 == 0 {
return fmt.Sprintf("%dd", hours/24)
}
if hours > 0 {
return fmt.Sprintf("%dh", hours)
}
minutes := int(d.Minutes())
if minutes > 0 {
return fmt.Sprintf("%dmin", minutes)
}
return fmt.Sprintf("%ds", int(d.Seconds()))
}
// FormatSize formats bytes into a human-readable string.
func FormatSize(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// FormatDuration formats a duration for human display.
func FormatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}