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
This commit is contained in:
536
pkg/backup/backup.go
Normal file
536
pkg/backup/backup.go
Normal file
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user