// 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) } }