Files
baby-nas/internal/storage/mergerfs.go
T
darroyo 27a52d2986 feat: add mergerfs mover and snapraid integration
New 'Almacenamiento' page with:
- Auto-detection of rsync, mergerfs, snapraid binaries and mergerfs mount
- Configurable pool settings (source/dest, macOS cleanup, rsync flags)
- Mergerfs mover with dry-run preview and live SSE output streaming
- SnapRAID diff/sync/scrub/check with live SSE output
- Async job system (1 concurrent job) with SSE streaming
- Job history table

Backend:
- internal/storage/ package with capabilities, mergerfs, snapraid, jobs
- storage_config and storage_jobs DB tables (migration 0008)
- GET/PUT /api/storage/config, GET /api/storage/capabilities
- POST/GET /api/storage/jobs, GET /api/storage/jobs/{id}/stream
- Storage operations disabled when NASCTL_EXEC_SYSTEM=false

Closes #new-feature
2026-07-06 23:23:22 -04:00

117 lines
2.8 KiB
Go

package storage
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
)
var forbiddenFlagChars = regexp.MustCompile("[;&|$`\"<>\r\n\x00]")
type MoverConfig struct {
Source string
Dest string
CleanMacOS bool
RemoveSrc bool
Inplace bool
ExtraFlags []string
DryRun bool
}
func BuildPreviewArgs(cfg MoverConfig) ([]string, error) {
if cfg.Source == "" || cfg.Dest == "" {
return nil, fmt.Errorf("source and dest are required")
}
args := []string{
"rsync",
"-an",
"--remove-source-files",
"--out-format=%n",
cfg.Source + "/",
cfg.Dest + "/",
}
return args, nil
}
func BuildMoverArgs(cfg MoverConfig) ([]string, error) {
if cfg.Source == "" || cfg.Dest == "" {
return nil, fmt.Errorf("source and dest are required")
}
args := []string{"rsync", "-aAXv"}
if !cfg.Inplace {
args = append(args, "--inplace")
}
args = append(args, "--remove-source-files")
if !cfg.DryRun {
args = append(args, "--progress", "--info=progress2")
}
args = append(args, "--no-compress")
args = append(args, cfg.ExtraFlags...)
args = append(args, "--info=progress2")
args = append(args, cfg.Source+"/", cfg.Dest+"/")
return args, nil
}
func CleanMacOSJunk(root string) error {
for _, pattern := range []string{".DS_Store", "._*"} {
cmd := exec.Command("find", root, "-name", pattern, "-delete")
if err := cmd.Run(); err != nil {
return fmt.Errorf("clean junk %s: %w", pattern, err)
}
}
return nil
}
func RemoveEmptyDirs(root string) error {
cmd := exec.Command("find", root, "-mindepth", "1", "-type", "d", "-empty", "-delete")
if err := cmd.Run(); err != nil {
return fmt.Errorf("remove empty dirs: %w", err)
}
return nil
}
func ValidateMoverPaths(cfg MoverConfig) error {
if cfg.Source == "" {
return fmt.Errorf("source path is required")
}
if cfg.Dest == "" {
return fmt.Errorf("destination path is required")
}
info, err := os.Stat(cfg.Source)
if err != nil {
return fmt.Errorf("source %s: %w", cfg.Source, err)
}
if !info.IsDir() {
return fmt.Errorf("source %s: not a directory", cfg.Source)
}
info, err = os.Stat(cfg.Dest)
if err != nil {
return fmt.Errorf("destination %s: %w", cfg.Dest, err)
}
if !info.IsDir() {
return fmt.Errorf("destination %s: not a directory", cfg.Dest)
}
return nil
}
func ParseExtraRsyncFlags(raw string) ([]string, error) {
var out []string
lines := strings.Split(raw, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "-") {
return nil, fmt.Errorf("line %d: flag must start with '-' (%q)", i+1, line)
}
if forbiddenFlagChars.MatchString(line) {
return nil, fmt.Errorf("line %d: flag contains forbidden characters (%q)", i+1, line)
}
out = append(out, line)
}
return out, nil
}