server client restore

This commit is contained in:
2026-02-13 14:59:43 +01:00
commit 20e90ec240
17 changed files with 3185 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
// 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 (
"bufio"
"os"
"strings"
)
// Config holds restore client configuration.
type Config struct {
// ClientID is the unique identifier for this client
ClientID string
// APIKey is the authentication key for the server
APIKey string
// ServerURL is the base URL of the backup server
ServerURL string
}
// LoadConfig loads restore client configuration from environment variables and .env file.
// Environment variables take precedence over .env file values.
func LoadConfig() *Config {
// Load .env file if exists
loadEnvFile(".env")
return &Config{
ClientID: getEnv("CLIENT_ID", "client1"),
APIKey: getEnv("API_KEY", "secret123"),
ServerURL: getEnv("SERVER_URL", "http://localhost:8080"),
}
}
// loadEnvFile loads key=value pairs from a .env file.
// Lines starting with # are treated as comments.
// Values can be quoted or unquoted.
func loadEnvFile(filename string) {
file, err := os.Open(filename)
if err != nil {
return // .env file is optional
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse key=value
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') {
value = value[1 : len(value)-1]
}
// Only set if not already defined in environment
if os.Getenv(key) == "" {
os.Setenv(key, value)
}
}
}
// getEnv retrieves an environment variable with a default fallback value.
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

284
internal/restore/restore.go Normal file
View File

@@ -0,0 +1,284 @@
// 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 (
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/mistifyio/go-zfs"
)
// 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 = " (gz)"
}
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...\n")
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %v", err)
}
defer gzReader.Close()
reader = gzReader
}
// 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)
}
}