package storage import ( "context" "database/sql" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" "github.com/lib/pq" ) // zfsCommand executes a ZFS command with sudo func zfsCommand(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "sudo", append([]string{"zfs"}, args...)...) } // zpoolCommand executes a ZPOOL command with sudo func zpoolCommand(ctx context.Context, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "sudo", append([]string{"zpool"}, args...)...) } // ZFSService handles ZFS pool management type ZFSService struct { db *database.DB logger *logger.Logger } // NewZFSService creates a new ZFS service func NewZFSService(db *database.DB, log *logger.Logger) *ZFSService { return &ZFSService{ db: db, logger: log, } } // ZFSPool represents a ZFS pool type ZFSPool struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` RaidLevel string `json:"raid_level"` // stripe, mirror, raidz, raidz2, raidz3 Disks []string `json:"disks"` // device paths SpareDisks []string `json:"spare_disks"` // spare disk paths SizeBytes int64 `json:"size_bytes"` UsedBytes int64 `json:"used_bytes"` Compression string `json:"compression"` // off, lz4, zstd, gzip Deduplication bool `json:"deduplication"` AutoExpand bool `json:"auto_expand"` ScrubInterval int `json:"scrub_interval"` // days IsActive bool `json:"is_active"` HealthStatus string `json:"health_status"` // online, degraded, faulted, offline CompressRatio float64 `json:"compress_ratio"` // compression ratio (e.g., 1.45x) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CreatedBy string `json:"created_by"` } // CreatePool creates a new ZFS pool func (s *ZFSService) CreatePool(ctx context.Context, name string, raidLevel string, disks []string, compression string, deduplication bool, autoExpand bool, createdBy string) (*ZFSPool, error) { // Validate inputs if name == "" { return nil, fmt.Errorf("pool name is required") } if len(disks) == 0 { return nil, fmt.Errorf("at least one disk is required") } // Validate RAID level validRaidLevels := map[string]int{ "stripe": 1, "mirror": 2, "raidz": 3, "raidz2": 4, "raidz3": 5, } minDisks, ok := validRaidLevels[raidLevel] if !ok { return nil, fmt.Errorf("invalid RAID level: %s", raidLevel) } if len(disks) < minDisks { return nil, fmt.Errorf("RAID level %s requires at least %d disks, got %d", raidLevel, minDisks, len(disks)) } // Check if pool already exists var existingID string err := s.db.QueryRowContext(ctx, "SELECT id FROM zfs_pools WHERE name = $1", name, ).Scan(&existingID) if err == nil { return nil, fmt.Errorf("pool with name %s already exists", name) } else if err != sql.ErrNoRows { // Check if table exists - if not, this is a migration issue if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "relation") { return nil, fmt.Errorf("zfs_pools table does not exist - please run database migrations") } return nil, fmt.Errorf("failed to check existing pool: %w", err) } // Check if disks are available (not used) for _, diskPath := range disks { var isUsed bool err := s.db.QueryRowContext(ctx, "SELECT is_used FROM physical_disks WHERE device_path = $1", diskPath, ).Scan(&isUsed) if err == sql.ErrNoRows { // Disk not in database, that's okay - we'll still try to use it s.logger.Warn("Disk not found in database, will attempt to use anyway", "disk", diskPath) } else if err != nil { return nil, fmt.Errorf("failed to check disk %s: %w", diskPath, err) } else if isUsed { return nil, fmt.Errorf("disk %s is already in use", diskPath) } } // Build zpool create command var args []string args = append(args, "create", "-f") // -f to force creation // Set default mountpoint to /opt/calypso/data/pool/ mountPoint := fmt.Sprintf("/opt/calypso/data/pool/%s", name) args = append(args, "-m", mountPoint) // Note: compression is a filesystem property, not a pool property // We'll set it after pool creation using zfs set // Add deduplication property (this IS a pool property) if deduplication { args = append(args, "-o", "dedup=on") } // Add autoexpand property (this IS a pool property) if autoExpand { args = append(args, "-o", "autoexpand=on") } // Add pool name args = append(args, name) // Add RAID level and disks switch raidLevel { case "stripe": // Simple stripe: just list all disks args = append(args, disks...) case "mirror": // Mirror: group disks in pairs if len(disks)%2 != 0 { return nil, fmt.Errorf("mirror requires even number of disks") } for i := 0; i < len(disks); i += 2 { args = append(args, "mirror", disks[i], disks[i+1]) } case "raidz": args = append(args, "raidz") args = append(args, disks...) case "raidz2": args = append(args, "raidz2") args = append(args, disks...) case "raidz3": args = append(args, "raidz3") args = append(args, disks...) } // Create mountpoint directory if it doesn't exist if err := os.MkdirAll(mountPoint, 0755); err != nil { return nil, fmt.Errorf("failed to create mountpoint directory %s: %w", mountPoint, err) } s.logger.Info("Created mountpoint directory", "path", mountPoint) // Execute zpool create (with sudo for permissions) s.logger.Info("Creating ZFS pool", "name", name, "raid_level", raidLevel, "disks", disks, "mountpoint", mountPoint, "args", args) cmd := zpoolCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { errorMsg := string(output) s.logger.Error("Failed to create ZFS pool", "name", name, "error", err, "output", errorMsg) return nil, fmt.Errorf("failed to create ZFS pool: %s", errorMsg) } s.logger.Info("ZFS pool created successfully", "name", name, "output", string(output)) // Set filesystem properties (compression, etc.) after pool creation // ZFS creates a root filesystem with the same name as the pool if compression != "" && compression != "off" { cmd = zfsCommand(ctx, "set", fmt.Sprintf("compression=%s", compression), name) output, err = cmd.CombinedOutput() if err != nil { s.logger.Warn("Failed to set compression property", "pool", name, "compression", compression, "error", string(output)) // Don't fail pool creation if compression setting fails, just log warning } else { s.logger.Info("Compression property set", "pool", name, "compression", compression) } } // Get pool information poolInfo, err := s.getPoolInfo(ctx, name) if err != nil { // Try to destroy the pool if we can't get info s.logger.Warn("Failed to get pool info, attempting to destroy pool", "name", name, "error", err) zpoolCommand(ctx, "destroy", "-f", name).Run() return nil, fmt.Errorf("failed to get pool info after creation: %w", err) } // Mark disks as used for _, diskPath := range disks { _, err = s.db.ExecContext(ctx, "UPDATE physical_disks SET is_used = true, updated_at = NOW() WHERE device_path = $1", diskPath, ) if err != nil { s.logger.Warn("Failed to mark disk as used", "disk", diskPath, "error", err) } } // Insert into database query := ` INSERT INTO zfs_pools ( name, raid_level, disks, size_bytes, used_bytes, compression, deduplication, auto_expand, scrub_interval, is_active, health_status, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at ` var pool ZFSPool err = s.db.QueryRowContext(ctx, query, name, raidLevel, pq.Array(disks), poolInfo.SizeBytes, poolInfo.UsedBytes, compression, deduplication, autoExpand, 30, // default scrub interval 30 days true, "online", createdBy, ).Scan(&pool.ID, &pool.CreatedAt, &pool.UpdatedAt) if err != nil { // Cleanup: destroy pool if database insert fails s.logger.Error("Failed to save pool to database, destroying pool", "name", name, "error", err) zpoolCommand(ctx, "destroy", "-f", name).Run() return nil, fmt.Errorf("failed to save pool to database: %w", err) } pool.Name = name pool.RaidLevel = raidLevel pool.Disks = disks pool.SizeBytes = poolInfo.SizeBytes pool.UsedBytes = poolInfo.UsedBytes pool.Compression = compression pool.Deduplication = deduplication pool.AutoExpand = autoExpand pool.ScrubInterval = 30 pool.IsActive = true pool.HealthStatus = "online" pool.CreatedBy = createdBy s.logger.Info("ZFS pool created", "name", name, "raid_level", raidLevel, "disks", len(disks)) return &pool, nil } // getPoolInfo retrieves information about a ZFS pool func (s *ZFSService) getPoolInfo(ctx context.Context, poolName string) (*ZFSPool, error) { // Get pool size and used space cmd := zpoolCommand(ctx, "list", "-H", "-o", "name,size,allocated", poolName) output, err := cmd.CombinedOutput() if err != nil { errorMsg := string(output) s.logger.Error("Failed to get pool info", "pool", poolName, "error", err, "output", errorMsg) return nil, fmt.Errorf("failed to get pool info: %s", errorMsg) } outputStr := strings.TrimSpace(string(output)) if outputStr == "" { return nil, fmt.Errorf("pool %s not found or empty output", poolName) } fields := strings.Fields(outputStr) if len(fields) < 3 { s.logger.Error("Unexpected zpool list output", "pool", poolName, "output", outputStr, "fields", len(fields)) return nil, fmt.Errorf("unexpected zpool list output: %s (expected 3+ fields, got %d)", outputStr, len(fields)) } // Parse size (format: 100G, 1T, etc.) sizeBytes, err := parseZFSSize(fields[1]) if err != nil { return nil, fmt.Errorf("failed to parse pool size: %w", err) } usedBytes, err := parseZFSSize(fields[2]) if err != nil { return nil, fmt.Errorf("failed to parse used size: %w", err) } return &ZFSPool{ Name: poolName, SizeBytes: sizeBytes, UsedBytes: usedBytes, }, nil } // parseZFSSize parses ZFS size strings like "100G", "1T", "500M" func parseZFSSize(sizeStr string) (int64, error) { sizeStr = strings.TrimSpace(sizeStr) if sizeStr == "" { return 0, nil } var multiplier int64 = 1 lastChar := sizeStr[len(sizeStr)-1] if lastChar >= '0' && lastChar <= '9' { // No suffix, assume bytes var size int64 _, err := fmt.Sscanf(sizeStr, "%d", &size) return size, err } switch strings.ToUpper(string(lastChar)) { case "K": multiplier = 1024 case "M": multiplier = 1024 * 1024 case "G": multiplier = 1024 * 1024 * 1024 case "T": multiplier = 1024 * 1024 * 1024 * 1024 case "P": multiplier = 1024 * 1024 * 1024 * 1024 * 1024 default: return 0, fmt.Errorf("unknown size suffix: %c", lastChar) } var size int64 _, err := fmt.Sscanf(sizeStr[:len(sizeStr)-1], "%d", &size) if err != nil { return 0, err } return size * multiplier, nil } // getSpareDisks retrieves spare disks from zpool status func (s *ZFSService) getSpareDisks(ctx context.Context, poolName string) ([]string, error) { cmd := zpoolCommand(ctx, "status", poolName) output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("failed to get pool status: %w", err) } outputStr := string(output) var spareDisks []string // Parse spare disks from zpool status output // Format: spares\n sde AVAIL lines := strings.Split(outputStr, "\n") inSparesSection := false for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "spares") { inSparesSection = true continue } if inSparesSection { if line == "" || strings.HasPrefix(line, "errors:") || strings.HasPrefix(line, "config:") { break } // Extract disk name (e.g., "sde AVAIL" -> "sde") fields := strings.Fields(line) if len(fields) > 0 { diskName := fields[0] // Convert to full device path if !strings.HasPrefix(diskName, "/dev/") { diskName = "/dev/" + diskName } spareDisks = append(spareDisks, diskName) } } } return spareDisks, nil } // getCompressRatio gets the compression ratio from ZFS func (s *ZFSService) getCompressRatio(ctx context.Context, poolName string) (float64, error) { cmd := zfsCommand(ctx, "get", "-H", "-o", "value", "compressratio", poolName) output, err := cmd.Output() if err != nil { return 1.0, err } ratioStr := strings.TrimSpace(string(output)) // Remove 'x' suffix if present (e.g., "1.45x" -> "1.45") ratioStr = strings.TrimSuffix(ratioStr, "x") ratio, err := strconv.ParseFloat(ratioStr, 64) if err != nil { return 1.0, err } return ratio, nil } // ListPools lists all ZFS pools func (s *ZFSService) ListPools(ctx context.Context) ([]*ZFSPool, error) { query := ` SELECT id, name, description, raid_level, disks, size_bytes, used_bytes, compression, deduplication, auto_expand, scrub_interval, is_active, health_status, created_at, updated_at, created_by FROM zfs_pools ORDER BY created_at DESC ` rows, err := s.db.QueryContext(ctx, query) if err != nil { // Check if table exists errStr := err.Error() if strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "relation") { return nil, fmt.Errorf("zfs_pools table does not exist - please run database migrations") } return nil, fmt.Errorf("failed to query pools: %w", err) } defer rows.Close() var pools []*ZFSPool for rows.Next() { var pool ZFSPool var description sql.NullString var createdBy sql.NullString err := rows.Scan( &pool.ID, &pool.Name, &description, &pool.RaidLevel, pq.Array(&pool.Disks), &pool.SizeBytes, &pool.UsedBytes, &pool.Compression, &pool.Deduplication, &pool.AutoExpand, &pool.ScrubInterval, &pool.IsActive, &pool.HealthStatus, &pool.CreatedAt, &pool.UpdatedAt, &createdBy, ) if err != nil { s.logger.Error("Failed to scan pool row", "error", err, "error_type", fmt.Sprintf("%T", err)) continue // Skip this pool instead of failing entire query } if createdBy.Valid { pool.CreatedBy = createdBy.String } if description.Valid { pool.Description = description.String } // Get spare disks from zpool status spareDisks, err := s.getSpareDisks(ctx, pool.Name) if err != nil { s.logger.Warn("Failed to get spare disks", "pool", pool.Name, "error", err) pool.SpareDisks = []string{} } else { pool.SpareDisks = spareDisks } // Get compressratio from ZFS system compressRatio, err := s.getCompressRatio(ctx, pool.Name) if err != nil { s.logger.Warn("Failed to get compressratio", "pool", pool.Name, "error", err) pool.CompressRatio = 1.0 // Default to 1.0 if can't get ratio } else { pool.CompressRatio = compressRatio } pools = append(pools, &pool) s.logger.Debug("Added pool to list", "pool_id", pool.ID, "name", pool.Name, "compressratio", pool.CompressRatio) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating pool rows: %w", err) } s.logger.Debug("Listed ZFS pools", "count", len(pools)) return pools, nil } // GetPool retrieves a ZFS pool by ID func (s *ZFSService) GetPool(ctx context.Context, poolID string) (*ZFSPool, error) { query := ` SELECT id, name, description, raid_level, disks, size_bytes, used_bytes, compression, deduplication, auto_expand, scrub_interval, is_active, health_status, created_at, updated_at, created_by FROM zfs_pools WHERE id = $1 ` var pool ZFSPool var description sql.NullString err := s.db.QueryRowContext(ctx, query, poolID).Scan( &pool.ID, &pool.Name, &description, &pool.RaidLevel, pq.Array(&pool.Disks), &pool.SizeBytes, &pool.UsedBytes, &pool.Compression, &pool.Deduplication, &pool.AutoExpand, &pool.ScrubInterval, &pool.IsActive, &pool.HealthStatus, &pool.CreatedAt, &pool.UpdatedAt, &pool.CreatedBy, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("pool not found") } if err != nil { return nil, fmt.Errorf("failed to get pool: %w", err) } if description.Valid { pool.Description = description.String } // Get spare disks from zpool status spareDisks, err := s.getSpareDisks(ctx, pool.Name) if err != nil { s.logger.Warn("Failed to get spare disks", "pool", pool.Name, "error", err) pool.SpareDisks = []string{} } else { pool.SpareDisks = spareDisks } return &pool, nil } // DeletePool destroys a ZFS pool func (s *ZFSService) DeletePool(ctx context.Context, poolID string) error { pool, err := s.GetPool(ctx, poolID) if err != nil { return err } // Destroy ZFS pool with -f flag to force destroy (works for both empty and non-empty pools) // The -f flag is needed to destroy pools even if they have datasets or are in use s.logger.Info("Destroying ZFS pool", "pool", pool.Name) cmd := zpoolCommand(ctx, "destroy", "-f", pool.Name) output, err := cmd.CombinedOutput() if err != nil { errorMsg := string(output) // Check if pool doesn't exist (might have been destroyed already) if strings.Contains(errorMsg, "no such pool") || strings.Contains(errorMsg, "cannot open") { s.logger.Warn("Pool does not exist in ZFS, continuing with database cleanup", "pool", pool.Name) // Continue with database cleanup even if pool doesn't exist } else { return fmt.Errorf("failed to destroy ZFS pool: %s: %w", errorMsg, err) } } else { s.logger.Info("ZFS pool destroyed successfully", "pool", pool.Name) } // Remove mount point directory (default: /opt/calypso/data/pool/) mountPoint := fmt.Sprintf("/opt/calypso/data/pool/%s", pool.Name) if err := os.RemoveAll(mountPoint); err != nil { s.logger.Warn("Failed to remove mount point directory", "mountpoint", mountPoint, "error", err) // Don't fail pool deletion if mount point removal fails } else { s.logger.Info("Removed mount point directory", "mountpoint", mountPoint) } // Mark disks as unused for _, diskPath := range pool.Disks { _, err = s.db.ExecContext(ctx, "UPDATE physical_disks SET is_used = false, updated_at = NOW() WHERE device_path = $1", diskPath, ) if err != nil { s.logger.Warn("Failed to mark disk as unused", "disk", diskPath, "error", err) } } // Delete from database _, err = s.db.ExecContext(ctx, "DELETE FROM zfs_pools WHERE id = $1", poolID) if err != nil { return fmt.Errorf("failed to delete pool from database: %w", err) } s.logger.Info("ZFS pool deleted", "name", pool.Name) return nil } // AddSpareDisk adds one or more spare disks to a ZFS pool func (s *ZFSService) AddSpareDisk(ctx context.Context, poolID string, diskPaths []string) error { if len(diskPaths) == 0 { return fmt.Errorf("at least one disk must be specified") } // Get pool information pool, err := s.GetPool(ctx, poolID) if err != nil { return err } // Verify pool exists in ZFS and check if disks are already spare cmd := zpoolCommand(ctx, "status", pool.Name) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("pool %s does not exist in ZFS: %w", pool.Name, err) } outputStr := string(output) // Check if any disk is already a spare in this pool for _, diskPath := range diskPaths { // Extract just the device name (e.g., /dev/sde -> sde) diskName := strings.TrimPrefix(diskPath, "/dev/") if strings.Contains(outputStr, "spares") && strings.Contains(outputStr, diskName) { s.logger.Warn("Disk is already a spare in this pool", "disk", diskPath, "pool", pool.Name) // Don't return error, just skip - zpool add will handle duplicate gracefully } } // Verify pool exists in ZFS (already checked above with zpool status) // Build zpool add command with spare option args := []string{"add", pool.Name, "spare"} args = append(args, diskPaths...) // Execute zpool add s.logger.Info("Adding spare disks to ZFS pool", "pool", pool.Name, "disks", diskPaths) cmd = zpoolCommand(ctx, args...) output, err = cmd.CombinedOutput() if err != nil { errorMsg := string(output) s.logger.Error("Failed to add spare disks to ZFS pool", "pool", pool.Name, "disks", diskPaths, "error", err, "output", errorMsg) return fmt.Errorf("failed to add spare disks: %s", errorMsg) } s.logger.Info("Spare disks added successfully", "pool", pool.Name, "disks", diskPaths) // Mark disks as used for _, diskPath := range diskPaths { _, err = s.db.ExecContext(ctx, "UPDATE physical_disks SET is_used = true, updated_at = NOW() WHERE device_path = $1", diskPath, ) if err != nil { s.logger.Warn("Failed to mark disk as used", "disk", diskPath, "error", err) } } // Update pool's updated_at timestamp _, err = s.db.ExecContext(ctx, "UPDATE zfs_pools SET updated_at = NOW() WHERE id = $1", poolID, ) if err != nil { s.logger.Warn("Failed to update pool timestamp", "pool_id", poolID, "error", err) } return nil } // ZFSDataset represents a ZFS dataset type ZFSDataset struct { ID string `json:"id"` Name string `json:"name"` Pool string `json:"pool"` Type string `json:"type"` // filesystem, volume, snapshot MountPoint string `json:"mount_point"` UsedBytes int64 `json:"used_bytes"` AvailableBytes int64 `json:"available_bytes"` ReferencedBytes int64 `json:"referenced_bytes"` Compression string `json:"compression"` Deduplication string `json:"deduplication"` Quota int64 `json:"quota"` // -1 for unlimited Reservation int64 `json:"reservation"` CreatedAt time.Time `json:"created_at"` } // ListDatasets lists all datasets in a ZFS pool from database func (s *ZFSService) ListDatasets(ctx context.Context, poolName string) ([]*ZFSDataset, error) { // Get datasets from database query := ` SELECT id, name, pool_name, type, mount_point, used_bytes, available_bytes, referenced_bytes, compression, deduplication, quota, reservation, created_at FROM zfs_datasets WHERE pool_name = $1 ORDER BY name ` rows, err := s.db.QueryContext(ctx, query, poolName) if err != nil { // If table doesn't exist, return empty list (migration not run yet) if strings.Contains(err.Error(), "does not exist") { s.logger.Warn("zfs_datasets table does not exist, returning empty list", "pool", poolName) return []*ZFSDataset{}, nil } return nil, fmt.Errorf("failed to list datasets from database: %w", err) } defer rows.Close() var datasets []*ZFSDataset for rows.Next() { var ds ZFSDataset var mountPoint sql.NullString err := rows.Scan( &ds.ID, &ds.Name, &ds.Pool, &ds.Type, &mountPoint, &ds.UsedBytes, &ds.AvailableBytes, &ds.ReferencedBytes, &ds.Compression, &ds.Deduplication, &ds.Quota, &ds.Reservation, &ds.CreatedAt, ) if err != nil { s.logger.Error("Failed to scan dataset row", "error", err) continue } // Handle nullable mount_point if mountPoint.Valid { ds.MountPoint = mountPoint.String } else { ds.MountPoint = "none" } datasets = append(datasets, &ds) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating dataset rows: %w", err) } return datasets, nil } // CreateDatasetRequest represents a request to create a ZFS dataset type CreateDatasetRequest struct { Name string `json:"name"` // Dataset name (e.g., "pool/dataset" or just "dataset") Type string `json:"type"` // "filesystem" or "volume" Compression string `json:"compression"` // off, lz4, zstd, gzip, etc. Quota int64 `json:"quota"` // -1 for unlimited Reservation int64 `json:"reservation"` // 0 for none MountPoint string `json:"mount_point"` // Optional mount point } // CreateDataset creates a new ZFS dataset func (s *ZFSService) CreateDataset(ctx context.Context, poolName string, req CreateDatasetRequest) (*ZFSDataset, error) { // Construct full dataset name fullName := poolName + "/" + req.Name // Get pool mount point to validate dataset mount point is within pool directory poolMountPoint := fmt.Sprintf("/opt/calypso/data/pool/%s", poolName) var mountPath string // For filesystem datasets, validate and set mount point if req.Type == "filesystem" { if req.MountPoint != "" { // User provided mount point - validate it's within pool directory mountPath = filepath.Clean(req.MountPoint) // Check if mount point is within pool mount point directory poolMountAbs, err := filepath.Abs(poolMountPoint) if err != nil { return nil, fmt.Errorf("failed to resolve pool mount point: %w", err) } mountPathAbs, err := filepath.Abs(mountPath) if err != nil { return nil, fmt.Errorf("failed to resolve mount point: %w", err) } // Check if mount path is within pool mount point directory relPath, err := filepath.Rel(poolMountAbs, mountPathAbs) if err != nil || strings.HasPrefix(relPath, "..") { return nil, fmt.Errorf("mount point must be within pool directory: %s (pool mount: %s)", mountPath, poolMountPoint) } } else { // No mount point provided - use default: /opt/calypso/data/pool/// mountPath = filepath.Join(poolMountPoint, req.Name) } // Check if directory already exists if info, err := os.Stat(mountPath); err == nil { if !info.IsDir() { return nil, fmt.Errorf("mount point path exists but is not a directory: %s", mountPath) } // Directory exists, check if it's empty dir, err := os.Open(mountPath) if err == nil { entries, err := dir.Readdirnames(1) dir.Close() if err == nil && len(entries) > 0 { s.logger.Warn("Mount directory is not empty", "path", mountPath) // Continue anyway, ZFS will mount over it } } } else if os.IsNotExist(err) { // Create directory with proper permissions (0755) s.logger.Info("Creating mount directory", "path", mountPath) if err := os.MkdirAll(mountPath, 0755); err != nil { return nil, fmt.Errorf("failed to create mount directory %s: %w", mountPath, err) } s.logger.Info("Mount directory created successfully", "path", mountPath) } else { return nil, fmt.Errorf("failed to check mount directory %s: %w", mountPath, err) } } // Build zfs create command args := []string{"create"} // Add type if volume if req.Type == "volume" { // For volumes, we need size (use quota as size) if req.Quota <= 0 { return nil, fmt.Errorf("volume size (quota) must be specified and greater than 0") } args = append(args, "-V", fmt.Sprintf("%d", req.Quota), fullName) } else { // For filesystems args = append(args, fullName) } // Set compression if req.Compression != "" && req.Compression != "off" { args = append(args, "-o", fmt.Sprintf("compression=%s", req.Compression)) } // Set mount point for filesystems (always set, either user-provided or default) if req.Type == "filesystem" { args = append(args, "-o", fmt.Sprintf("mountpoint=%s", mountPath)) } // Execute zfs create s.logger.Info("Creating ZFS dataset", "name", fullName, "type", req.Type) cmd := zfsCommand(ctx, args...) output, err := cmd.CombinedOutput() if err != nil { errorMsg := string(output) s.logger.Error("Failed to create dataset", "name", fullName, "error", err, "output", errorMsg) return nil, fmt.Errorf("failed to create dataset: %s", errorMsg) } // Set quota if specified (for filesystems) if req.Type == "filesystem" && req.Quota > 0 { quotaCmd := zfsCommand(ctx, "set", fmt.Sprintf("quota=%d", req.Quota), fullName) if quotaOutput, err := quotaCmd.CombinedOutput(); err != nil { s.logger.Warn("Failed to set quota", "dataset", fullName, "error", err, "output", string(quotaOutput)) } } // Set reservation if specified if req.Reservation > 0 { resvCmd := zfsCommand(ctx, "set", fmt.Sprintf("reservation=%d", req.Reservation), fullName) if resvOutput, err := resvCmd.CombinedOutput(); err != nil { s.logger.Warn("Failed to set reservation", "dataset", fullName, "error", err, "output", string(resvOutput)) } } // Get pool ID from pool name var poolID string err = s.db.QueryRowContext(ctx, "SELECT id FROM zfs_pools WHERE name = $1", poolName).Scan(&poolID) if err != nil { s.logger.Error("Failed to get pool ID", "pool", poolName, "error", err) // Try to destroy the dataset if we can't save to database zfsCommand(ctx, "destroy", "-r", fullName).Run() return nil, fmt.Errorf("failed to get pool ID: %w", err) } // Get dataset info from ZFS to save to database cmd = zfsCommand(ctx, "list", "-H", "-o", "name,used,avail,refer,compress,dedup,quota,reservation,mountpoint", fullName) output, err = cmd.CombinedOutput() if err != nil { s.logger.Error("Failed to get dataset info", "name", fullName, "error", err) // Try to destroy the dataset if we can't get info zfsCommand(ctx, "destroy", "-r", fullName).Run() return nil, fmt.Errorf("failed to get dataset info: %w", err) } // Parse dataset info lines := strings.TrimSpace(string(output)) if lines == "" { zfsCommand(ctx, "destroy", "-r", fullName).Run() return nil, fmt.Errorf("dataset not found after creation") } fields := strings.Fields(lines) if len(fields) < 9 { zfsCommand(ctx, "destroy", "-r", fullName).Run() return nil, fmt.Errorf("invalid dataset info format") } usedBytes, _ := parseZFSSize(fields[1]) availableBytes, _ := parseZFSSize(fields[2]) referencedBytes, _ := parseZFSSize(fields[3]) compression := fields[4] deduplication := fields[5] quotaStr := fields[6] reservationStr := fields[7] mountPoint := fields[8] // Determine dataset type datasetType := req.Type typeCmd := zfsCommand(ctx, "get", "-H", "-o", "value", "type", fullName) if typeOutput, err := typeCmd.Output(); err == nil { volType := strings.TrimSpace(string(typeOutput)) if volType == "volume" { datasetType = "volume" } else if strings.Contains(volType, "snapshot") { datasetType = "snapshot" } } // Parse quota quota := int64(-1) if datasetType == "volume" { // For volumes, get volsize volsizeCmd := zfsCommand(ctx, "get", "-H", "-o", "value", "volsize", fullName) if volsizeOutput, err := volsizeCmd.Output(); err == nil { volsizeStr := strings.TrimSpace(string(volsizeOutput)) if volsizeStr != "-" && volsizeStr != "none" { if vs, err := parseZFSSize(volsizeStr); err == nil { quota = vs } } } } else if quotaStr != "-" && quotaStr != "none" { if q, err := parseZFSSize(quotaStr); err == nil { quota = q } } // Parse reservation reservation := int64(0) if reservationStr != "-" && reservationStr != "none" { if r, err := parseZFSSize(reservationStr); err == nil { reservation = r } } // Normalize mount point for volumes if datasetType == "volume" && mountPoint == "-" { mountPoint = "none" } // Get creation time createdAt := time.Now() creationCmd := zfsCommand(ctx, "get", "-H", "-o", "value", "creation", fullName) if creationOutput, err := creationCmd.Output(); err == nil { creationStr := strings.TrimSpace(string(creationOutput)) if t, err := time.Parse("Mon Jan 2 15:04:05 2006", creationStr); err == nil { createdAt = t } else if t, err := time.Parse(time.RFC3339, creationStr); err == nil { createdAt = t } } // Save to database (works for both filesystem and volume datasets) // Volume datasets are stored in the same zfs_datasets table with type='volume' insertQuery := ` INSERT INTO zfs_datasets ( name, pool_id, pool_name, type, mount_point, used_bytes, available_bytes, referenced_bytes, compression, deduplication, quota, reservation, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) RETURNING id ` var datasetID string err = s.db.QueryRowContext(ctx, insertQuery, fullName, poolID, poolName, datasetType, mountPoint, usedBytes, availableBytes, referencedBytes, compression, deduplication, quota, reservation, createdAt, ).Scan(&datasetID) if err != nil { s.logger.Error("Failed to save dataset to database", "name", fullName, "error", err) // Try to destroy the dataset if we can't save to database zfsCommand(ctx, "destroy", "-r", fullName).Run() return nil, fmt.Errorf("failed to save dataset to database: %w", err) } // Return dataset info dataset := &ZFSDataset{ Name: fullName, Pool: poolName, Type: datasetType, MountPoint: mountPoint, UsedBytes: usedBytes, AvailableBytes: availableBytes, ReferencedBytes: referencedBytes, Compression: compression, Deduplication: deduplication, Quota: quota, Reservation: reservation, CreatedAt: createdAt, } s.logger.Info("ZFS dataset created and saved to database", "name", fullName, "id", datasetID) return dataset, nil } // DeleteDataset deletes a ZFS dataset func (s *ZFSService) DeleteDataset(ctx context.Context, datasetName string) error { // Check if dataset exists and get its mount point before deletion var mountPoint string cmd := zfsCommand(ctx, "list", "-H", "-o", "name,mountpoint", datasetName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("dataset %s does not exist: %w", datasetName, err) } lines := strings.TrimSpace(string(output)) if lines == "" { return fmt.Errorf("dataset %s not found", datasetName) } // Parse output to get mount point fields := strings.Fields(lines) if len(fields) >= 2 { mountPoint = fields[1] } // Get dataset type to determine if we should clean up mount directory var datasetType string typeCmd := zfsCommand(ctx, "get", "-H", "-o", "value", "type", datasetName) typeOutput, err := typeCmd.Output() if err == nil { datasetType = strings.TrimSpace(string(typeOutput)) } // Delete from database first (before ZFS deletion, so we have the record) // This ensures we can clean up even if ZFS deletion partially fails // Works for both filesystem and volume datasets deleteQuery := "DELETE FROM zfs_datasets WHERE name = $1" result, err := s.db.ExecContext(ctx, deleteQuery, datasetName) if err != nil { s.logger.Warn("Failed to delete dataset from database (may not exist)", "name", datasetName, "error", err) // Continue with ZFS deletion anyway } else { rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { s.logger.Info("Dataset removed from database", "name", datasetName) } } // Delete the dataset from ZFS (use -r for recursive to delete children) s.logger.Info("Deleting ZFS dataset", "name", datasetName, "mountpoint", mountPoint) cmd = zfsCommand(ctx, "destroy", "-r", datasetName) output, err = cmd.CombinedOutput() if err != nil { errorMsg := string(output) s.logger.Error("Failed to delete dataset", "name", datasetName, "error", err, "output", errorMsg) return fmt.Errorf("failed to delete dataset: %s", errorMsg) } // Clean up mount directory if it exists and is a filesystem dataset // Only remove if mount point is not "-" (volumes) and not "none" or "legacy" if datasetType == "filesystem" && mountPoint != "" && mountPoint != "-" && mountPoint != "none" && mountPoint != "legacy" { mountPath := filepath.Clean(mountPoint) // Check if directory exists if info, err := os.Stat(mountPath); err == nil && info.IsDir() { // Check if directory is empty dir, err := os.Open(mountPath) if err == nil { entries, err := dir.Readdirnames(1) dir.Close() // Only remove if directory is empty if err == nil && len(entries) == 0 { s.logger.Info("Removing empty mount directory", "path", mountPath) if err := os.Remove(mountPath); err != nil { s.logger.Warn("Failed to remove mount directory", "path", mountPath, "error", err) // Don't fail the deletion if we can't remove the directory } else { s.logger.Info("Mount directory removed successfully", "path", mountPath) } } else { s.logger.Info("Mount directory is not empty, keeping it", "path", mountPath) } } } } s.logger.Info("ZFS dataset deleted successfully", "name", datasetName) return nil }