27a52d2986
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
117 lines
2.8 KiB
Go
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
|
|
}
|