package storage import ( "context" "fmt" "strings" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" ) // SnapshotService handles ZFS snapshot operations type SnapshotService struct { db *database.DB logger *logger.Logger } // NewSnapshotService creates a new snapshot service func NewSnapshotService(db *database.DB, log *logger.Logger) *SnapshotService { return &SnapshotService{ db: db, logger: log, } } // Snapshot represents a ZFS snapshot type Snapshot struct { ID string `json:"id"` Name string `json:"name"` // Full snapshot name (e.g., "pool/dataset@snapshot-name") Dataset string `json:"dataset"` // Dataset name (e.g., "pool/dataset") SnapshotName string `json:"snapshot_name"` // Snapshot name only (e.g., "snapshot-name") Created time.Time `json:"created"` Referenced int64 `json:"referenced"` // Size in bytes Used int64 `json:"used"` // Used space in bytes IsLatest bool `json:"is_latest"` // Whether this is the latest snapshot for the dataset } // ListSnapshots lists all snapshots, optionally filtered by dataset func (s *SnapshotService) ListSnapshots(ctx context.Context, datasetFilter string) ([]*Snapshot, error) { // Build zfs list command args := []string{"list", "-t", "snapshot", "-H", "-o", "name,creation,referenced,used"} if datasetFilter != "" { // List snapshots for specific dataset args = append(args, datasetFilter) } else { // List all snapshots args = append(args, "-r") } cmd := zfsCommand(ctx, args...) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to list snapshots: %w", err) } lines := strings.Split(strings.TrimSpace(string(output)), "\n") var snapshots []*Snapshot // Track latest snapshot per dataset latestSnapshots := make(map[string]*Snapshot) for _, line := range lines { if line == "" { continue } fields := strings.Fields(line) if len(fields) < 4 { continue } fullName := fields[0] creationStr := fields[1] referencedStr := fields[2] usedStr := fields[3] // Parse snapshot name (format: pool/dataset@snapshot-name) parts := strings.Split(fullName, "@") if len(parts) != 2 { continue } dataset := parts[0] snapshotName := parts[1] // Parse creation time (Unix timestamp) var creationTime time.Time if timestamp, err := parseZFSUnixTimestamp(creationStr); err == nil { creationTime = timestamp } else { // Fallback to current time if parsing fails creationTime = time.Now() } // Parse sizes referenced := parseSnapshotSize(referencedStr) used := parseSnapshotSize(usedStr) snapshot := &Snapshot{ ID: fullName, Name: fullName, Dataset: dataset, SnapshotName: snapshotName, Created: creationTime, Referenced: referenced, Used: used, IsLatest: false, } snapshots = append(snapshots, snapshot) // Track latest snapshot per dataset if latest, exists := latestSnapshots[dataset]; !exists || snapshot.Created.After(latest.Created) { latestSnapshots[dataset] = snapshot } } // Mark latest snapshots for _, snapshot := range snapshots { if latest, exists := latestSnapshots[snapshot.Dataset]; exists && latest.ID == snapshot.ID { snapshot.IsLatest = true } } return snapshots, nil } // CreateSnapshot creates a new snapshot func (s *SnapshotService) CreateSnapshot(ctx context.Context, dataset, snapshotName string, recursive bool) error { // Validate dataset exists cmd := zfsCommand(ctx, "list", "-H", "-o", "name", dataset) if err := cmd.Run(); err != nil { return fmt.Errorf("dataset %s does not exist: %w", dataset, err) } // Build snapshot name fullSnapshotName := fmt.Sprintf("%s@%s", dataset, snapshotName) // Build command args := []string{"snapshot"} if recursive { args = append(args, "-r") } args = append(args, fullSnapshotName) cmd = zfsCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to create snapshot: %s: %w", string(output), err) } s.logger.Info("Snapshot created", "snapshot", fullSnapshotName, "dataset", dataset, "recursive", recursive) return nil } // DeleteSnapshot deletes a snapshot func (s *SnapshotService) DeleteSnapshot(ctx context.Context, snapshotName string, recursive bool) error { // Build command args := []string{"destroy"} if recursive { args = append(args, "-r") } args = append(args, snapshotName) cmd := zfsCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to delete snapshot: %s: %w", string(output), err) } s.logger.Info("Snapshot deleted", "snapshot", snapshotName, "recursive", recursive) return nil } // RollbackSnapshot rolls back a dataset to a snapshot func (s *SnapshotService) RollbackSnapshot(ctx context.Context, snapshotName string, force bool) error { // Build command args := []string{"rollback"} if force { args = append(args, "-r", "-f") } else { args = append(args, "-r") } args = append(args, snapshotName) cmd := zfsCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to rollback snapshot: %s: %w", string(output), err) } s.logger.Info("Snapshot rollback completed", "snapshot", snapshotName, "force", force) return nil } // CloneSnapshot clones a snapshot to a new dataset func (s *SnapshotService) CloneSnapshot(ctx context.Context, snapshotName, cloneName string) error { // Build command args := []string{"clone", snapshotName, cloneName} cmd := zfsCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to clone snapshot: %s: %w", string(output), err) } s.logger.Info("Snapshot cloned", "snapshot", snapshotName, "clone", cloneName) return nil } // parseZFSUnixTimestamp parses ZFS Unix timestamp string func parseZFSUnixTimestamp(timestampStr string) (time.Time, error) { // ZFS returns Unix timestamp as string timestamp, err := time.Parse("20060102150405", timestampStr) if err != nil { // Try parsing as Unix timestamp integer var unixTime int64 if _, err := fmt.Sscanf(timestampStr, "%d", &unixTime); err == nil { return time.Unix(unixTime, 0), nil } return time.Time{}, err } return timestamp, nil } // parseZFSSize parses ZFS size string (e.g., "1.2M", "4.5G", "850K") // Note: This function is also defined in zfs.go, but we need it here for snapshot parsing func parseSnapshotSize(sizeStr string) int64 { if sizeStr == "-" || sizeStr == "" { return 0 } // Remove any whitespace sizeStr = strings.TrimSpace(sizeStr) // Parse size with unit var size float64 var unit string if _, err := fmt.Sscanf(sizeStr, "%f%s", &size, &unit); err != nil { return 0 } // Convert to bytes unit = strings.ToUpper(unit) switch unit { case "K", "KB": return int64(size * 1024) case "M", "MB": return int64(size * 1024 * 1024) case "G", "GB": return int64(size * 1024 * 1024 * 1024) case "T", "TB": return int64(size * 1024 * 1024 * 1024 * 1024) default: // Assume bytes if no unit return int64(size) } }