// 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) 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\n", snapshot.Compressed) // 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) } // 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) 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 } // MountSnapshot mounts a restored dataset to a specified mountpoint. // This allows browsing the restored files. func (c *Client) MountSnapshot(dataset, mountpoint string) error { ds, err := zfs.GetDataset(dataset) if err != nil { return fmt.Errorf("dataset not found: %v", err) } // Create mountpoint 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 cmd := exec.Command("zfs", "mount", dataset) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to mount: %v", err) } fmt.Printf("✓ Mounted %s at %s\n", dataset, 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) } }