382 lines
11 KiB
Go
382 lines
11 KiB
Go
// Package restore provides functionality for restoring ZFS snapshots from a backup server.
|
|
// It supports restoring to ZFS datasets, saving to files, and mounting restored datasets.
|
|
package restore
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mistifyio/go-zfs"
|
|
"github.com/pierrec/lz4/v4"
|
|
)
|
|
|
|
// SnapshotMetadata represents snapshot information from the server.
|
|
type SnapshotMetadata struct {
|
|
ClientID string `json:"client_id"`
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
DatasetName string `json:"dataset_name"`
|
|
StorageKey string `json:"storage_key"`
|
|
StorageType string `json:"storage_type"`
|
|
Compressed bool `json:"compressed"`
|
|
Incremental bool `json:"incremental"`
|
|
BaseSnapshot string `json:"base_snapshot,omitempty"`
|
|
}
|
|
|
|
// Client handles restore operations from the backup server.
|
|
type Client struct {
|
|
ClientID string
|
|
APIKey string
|
|
ServerURL string
|
|
}
|
|
|
|
// New creates a new restore client instance.
|
|
func New(clientID, apiKey, serverURL string) *Client {
|
|
return &Client{
|
|
ClientID: clientID,
|
|
APIKey: apiKey,
|
|
ServerURL: serverURL,
|
|
}
|
|
}
|
|
|
|
// ListSnapshots retrieves all available snapshots from the server.
|
|
func (c *Client) ListSnapshots() ([]*SnapshotMetadata, error) {
|
|
url := fmt.Sprintf("%s/status?client_id=%s&api_key=%s",
|
|
c.ServerURL, c.ClientID, c.APIKey)
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get snapshots: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var status struct {
|
|
Success bool `json:"success"`
|
|
Snapshots []*SnapshotMetadata `json:"snapshots"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if !status.Success {
|
|
return nil, fmt.Errorf("failed to list snapshots")
|
|
}
|
|
|
|
return status.Snapshots, nil
|
|
}
|
|
|
|
// DisplaySnapshots prints a formatted list of available snapshots.
|
|
// Snapshots are sorted by timestamp (newest first).
|
|
func (c *Client) DisplaySnapshots(snapshots []*SnapshotMetadata) {
|
|
if len(snapshots) == 0 {
|
|
fmt.Println("No snapshots available")
|
|
return
|
|
}
|
|
|
|
// Sort by timestamp (newest first)
|
|
sort.Slice(snapshots, func(i, j int) bool {
|
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
|
})
|
|
|
|
fmt.Printf("\n=== Available Snapshots ===\n\n")
|
|
fmt.Printf("%-4s %-25s %-20s %-12s %-10s %s\n",
|
|
"#", "Timestamp", "Dataset", "Size", "Storage", "ID")
|
|
fmt.Println(strings.Repeat("-", 100))
|
|
|
|
for i, snap := range snapshots {
|
|
age := time.Since(snap.Timestamp)
|
|
sizeGB := float64(snap.SizeBytes) / (1024 * 1024 * 1024)
|
|
compressed := ""
|
|
if snap.Compressed {
|
|
compressed = " (lz4)"
|
|
}
|
|
|
|
fmt.Printf("%-4d %-25s %-20s %7.2f GB %-10s %s%s\n",
|
|
i+1,
|
|
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
truncate(snap.DatasetName, 20),
|
|
sizeGB,
|
|
snap.StorageType,
|
|
truncate(snap.SnapshotID, 30),
|
|
compressed)
|
|
|
|
if age < 24*time.Hour {
|
|
fmt.Printf(" (created %s ago)\n", formatDuration(age))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// RestoreSnapshot downloads and restores a snapshot to a local ZFS dataset.
|
|
// If force is true, existing datasets will be overwritten.
|
|
func (c *Client) RestoreSnapshot(snapshot *SnapshotMetadata, targetDataset string, force bool, allSnapshots []*SnapshotMetadata) error {
|
|
fmt.Printf("\n=== Restoring Snapshot ===\n")
|
|
fmt.Printf("Source: %s\n", snapshot.SnapshotID)
|
|
fmt.Printf("Target: %s\n", targetDataset)
|
|
fmt.Printf("Size: %.2f GB\n", float64(snapshot.SizeBytes)/(1024*1024*1024))
|
|
fmt.Printf("Storage: %s\n", snapshot.StorageType)
|
|
fmt.Printf("Compressed: %v\n", snapshot.Compressed)
|
|
fmt.Printf("Incremental: %v\n\n", snapshot.Incremental)
|
|
|
|
// For incremental snapshots, we need to restore base first
|
|
if snapshot.Incremental && snapshot.BaseSnapshot != "" {
|
|
fmt.Printf("\n⚠ This is an INCREMENTAL backup.\n")
|
|
fmt.Printf(" Base snapshot needed: %s\n\n", snapshot.BaseSnapshot)
|
|
|
|
// Find the base snapshot in the list
|
|
var baseSnap *SnapshotMetadata
|
|
for _, s := range allSnapshots {
|
|
if s.SnapshotID == snapshot.BaseSnapshot {
|
|
baseSnap = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if baseSnap == nil {
|
|
return fmt.Errorf("base snapshot %s not found on server. Cannot restore incremental without base", snapshot.BaseSnapshot)
|
|
}
|
|
|
|
fmt.Printf("Found base snapshot:\n")
|
|
fmt.Printf(" - %s from %s (%.2f GB)\n\n",
|
|
baseSnap.SnapshotID,
|
|
baseSnap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
float64(baseSnap.SizeBytes)/(1024*1024*1024))
|
|
|
|
fmt.Printf("To restore this incremental, I need to:\n")
|
|
fmt.Printf(" 1. Restore base snapshot: %s\n", baseSnap.SnapshotID)
|
|
fmt.Printf(" 2. Apply incremental: %s\n\n", snapshot.SnapshotID)
|
|
|
|
// Ask for confirmation
|
|
fmt.Printf("Continue? [y/N]: ")
|
|
var confirm string
|
|
fmt.Scanln(&confirm)
|
|
if confirm != "y" && confirm != "Y" {
|
|
fmt.Println("Cancelled.")
|
|
return nil
|
|
}
|
|
|
|
// First restore the base snapshot
|
|
fmt.Printf("\n→ Restoring base snapshot...\n")
|
|
if err := c.restoreOneSnapshot(baseSnap, targetDataset, true); err != nil {
|
|
return fmt.Errorf("failed to restore base snapshot: %v", err)
|
|
}
|
|
|
|
// Then apply the incremental
|
|
fmt.Printf("\n→ Applying incremental snapshot...\n")
|
|
if err := c.restoreOneSnapshot(snapshot, targetDataset, false); err != nil {
|
|
return fmt.Errorf("failed to apply incremental: %v", err)
|
|
}
|
|
|
|
fmt.Printf("\n✓ Incremental restore completed!\n")
|
|
return nil
|
|
}
|
|
|
|
return c.restoreOneSnapshot(snapshot, targetDataset, force)
|
|
}
|
|
|
|
// restoreOneSnapshot downloads and restores a single snapshot
|
|
func (c *Client) restoreOneSnapshot(snapshot *SnapshotMetadata, targetDataset string, force bool) error {
|
|
// First, let's try to download - only destroy if download succeeds
|
|
var originalExists bool
|
|
if force {
|
|
if _, err := zfs.GetDataset(targetDataset); err == nil {
|
|
originalExists = true
|
|
fmt.Printf("→ Target dataset exists, will overwrite\n")
|
|
} else {
|
|
originalExists = false
|
|
fmt.Printf("→ Target dataset does not exist, will create new\n")
|
|
}
|
|
} else {
|
|
// Check if target dataset exists
|
|
if !force {
|
|
if _, err := zfs.GetDataset(targetDataset); err == nil {
|
|
return fmt.Errorf("target dataset %s already exists. Use --force to overwrite", targetDataset)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request download from server
|
|
downloadURL := fmt.Sprintf("%s/download?client_id=%s&api_key=%s&snapshot_id=%s",
|
|
c.ServerURL, c.ClientID, c.APIKey, snapshot.SnapshotID)
|
|
|
|
fmt.Printf("→ Downloading from server...\n")
|
|
|
|
resp, err := http.Get(downloadURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("download failed: %s", body)
|
|
}
|
|
|
|
// Download succeeded - now safe to destroy if needed
|
|
if force && originalExists {
|
|
fmt.Printf("→ Destroying existing dataset %s...\n", targetDataset)
|
|
cmd := exec.Command("zfs", "destroy", "-r", targetDataset)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
fmt.Printf(" Destroy output: %s\n", string(output))
|
|
return fmt.Errorf("failed to destroy existing dataset: %v", err)
|
|
}
|
|
fmt.Printf(" Destroyed successfully\n")
|
|
}
|
|
|
|
// Create decompression reader if needed
|
|
var reader io.Reader = resp.Body
|
|
if snapshot.Compressed {
|
|
fmt.Printf(" Decompressing with LZ4...\n")
|
|
lz4Reader := lz4.NewReader(resp.Body)
|
|
reader = lz4Reader
|
|
}
|
|
|
|
// Receive into ZFS
|
|
fmt.Printf("→ Receiving into ZFS dataset...\n")
|
|
|
|
var cmd *exec.Cmd
|
|
if force {
|
|
cmd = exec.Command("zfs", "recv", "-F", targetDataset)
|
|
} else {
|
|
cmd = exec.Command("zfs", "recv", targetDataset)
|
|
}
|
|
|
|
cmd.Stdin = reader
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("zfs recv failed: %v", err)
|
|
}
|
|
|
|
fmt.Printf("\n✓ Snapshot restored successfully!\n")
|
|
fmt.Printf(" Dataset: %s\n", targetDataset)
|
|
|
|
// Verify the dataset exists after restore
|
|
if _, err := zfs.GetDataset(targetDataset); err == nil {
|
|
fmt.Printf(" Verified: dataset exists\n")
|
|
} else {
|
|
fmt.Printf(" Warning: could not verify dataset exists: %v\n", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RestoreToFile downloads a snapshot and saves it to a file.
|
|
// This is useful for archiving or manual inspection.
|
|
func (c *Client) RestoreToFile(snapshot *SnapshotMetadata, outputFile string) error {
|
|
fmt.Printf("\n=== Saving Snapshot to File ===\n")
|
|
fmt.Printf("Source: %s\n", snapshot.SnapshotID)
|
|
fmt.Printf("Output: %s\n", outputFile)
|
|
fmt.Printf("Size: %.2f GB\n\n", float64(snapshot.SizeBytes)/(1024*1024*1024))
|
|
|
|
// Request download
|
|
downloadURL := fmt.Sprintf("%s/download?client_id=%s&api_key=%s&snapshot_id=%s",
|
|
c.ServerURL, c.ClientID, c.APIKey, snapshot.SnapshotID)
|
|
|
|
fmt.Printf("→ Downloading...\n")
|
|
|
|
resp, err := http.Get(downloadURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("download failed: %s", body)
|
|
}
|
|
|
|
// Create output file
|
|
outFile, err := os.Create(outputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %v", err)
|
|
}
|
|
defer outFile.Close()
|
|
|
|
// Copy with progress
|
|
written, err := io.Copy(outFile, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save: %v", err)
|
|
}
|
|
|
|
fmt.Printf("\n✓ Snapshot saved successfully!\n")
|
|
fmt.Printf(" Size: %.2f MB\n", float64(written)/(1024*1024))
|
|
fmt.Printf(" File: %s\n", outputFile)
|
|
|
|
return nil
|
|
}
|
|
|
|
// MountDataset mounts a restored dataset to a specified mountpoint for file recovery.
|
|
func (c *Client) MountDataset(dataset, mountpoint string) error {
|
|
fmt.Printf("\n=== Mounting Dataset ===\n")
|
|
fmt.Printf("Dataset: %s\n", dataset)
|
|
fmt.Printf("Mountpoint: %s\n\n", mountpoint)
|
|
|
|
ds, err := zfs.GetDataset(dataset)
|
|
if err != nil {
|
|
return fmt.Errorf("dataset not found: %v", err)
|
|
}
|
|
|
|
// Check current mountpoint
|
|
currentMP, _ := ds.GetProperty("mountpoint")
|
|
fmt.Printf("Current mountpoint: %s\n", currentMP)
|
|
|
|
// Create mountpoint directory if it doesn't exist
|
|
if err := os.MkdirAll(mountpoint, 0755); err != nil {
|
|
return fmt.Errorf("failed to create mountpoint: %v", err)
|
|
}
|
|
|
|
// Set mountpoint property
|
|
if err := ds.SetProperty("mountpoint", mountpoint); err != nil {
|
|
return fmt.Errorf("failed to set mountpoint: %v", err)
|
|
}
|
|
|
|
// Mount the dataset if not already mounted
|
|
cmd := exec.Command("zfs", "mount", dataset)
|
|
if err := cmd.Run(); err != nil {
|
|
// Might already be mounted, that's OK
|
|
fmt.Printf(" (dataset may already be mounted)\n")
|
|
}
|
|
|
|
fmt.Printf("\n✓ Mounted successfully!\n")
|
|
fmt.Printf(" Access files at: %s\n", mountpoint)
|
|
fmt.Printf(" When done, run: umount %s\n", mountpoint)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// truncate shortens a string to maxLen characters with ellipsis.
|
|
func truncate(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
|
|
// formatDuration converts a duration to a human-readable string.
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.0f seconds", d.Seconds())
|
|
} else if d < time.Hour {
|
|
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
|
} else if d < 24*time.Hour {
|
|
return fmt.Sprintf("%.1f hours", d.Hours())
|
|
} else {
|
|
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
|
}
|
|
}
|