move feature from meta-sort to meta-core

This commit is contained in:
Pierre
2026-01-25 22:01:25 +01:00
parent 768942d28b
commit 7d9d502912
69 changed files with 12831 additions and 13 deletions
+478
View File
@@ -0,0 +1,478 @@
package mounts
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/metazla/meta-core/internal/config"
)
// Manager handles mount configuration and status
type Manager struct {
config *config.Config
mu sync.RWMutex
filesPath string
mountsDir string
}
// NewManager creates a new mount manager
func NewManager(cfg *config.Config) (*Manager, error) {
m := &Manager{
config: cfg,
filesPath: cfg.FilesPath,
mountsDir: cfg.MountsDir,
}
// Ensure directories exist
if err := m.ensureDirs(); err != nil {
return nil, fmt.Errorf("failed to create mount directories: %w", err)
}
return m, nil
}
// ensureDirs creates required directories
func (m *Manager) ensureDirs() error {
dirs := []string{
m.mountsDir,
m.config.MountsErrorDir(),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
return nil
}
// readConfig reads the mounts configuration file
func (m *Manager) readConfig() (*MountsFile, error) {
m.mu.RLock()
defer m.mu.RUnlock()
data, err := os.ReadFile(m.config.MountsFilePath())
if err != nil {
if os.IsNotExist(err) {
return &MountsFile{Version: 1, Mounts: []MountConfig{}}, nil
}
return nil, err
}
var mountsFile MountsFile
if err := json.Unmarshal(data, &mountsFile); err != nil {
return nil, err
}
return &mountsFile, nil
}
// writeConfig writes the mounts configuration file
func (m *Manager) writeConfig(mountsFile *MountsFile) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.ensureDirs(); err != nil {
return err
}
data, err := json.MarshalIndent(mountsFile, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.config.MountsFilePath(), data, 0644)
}
// IsMounted checks if a path is currently mounted
func (m *Manager) IsMounted(mountPath string) bool {
cmd := exec.Command("findmnt", "-n", mountPath)
output, err := cmd.CombinedOutput()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) != ""
}
// ReadError reads the error file for a mount
func (m *Manager) ReadError(id string) (string, error) {
errorFile := filepath.Join(m.config.MountsErrorDir(), id+".error")
data, err := os.ReadFile(errorFile)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) > 1 {
// Skip timestamp line, return rest
return strings.Join(lines[1:], "\n"), nil
}
return "", nil
}
// SanitizeName sanitizes a mount name for use as a directory name
func SanitizeName(name string) string {
// Convert to lowercase
result := strings.ToLower(name)
// Replace non-alphanumeric chars with hyphens
re := regexp.MustCompile(`[^a-z0-9-_]`)
result = re.ReplaceAllString(result, "-")
// Collapse multiple hyphens
re = regexp.MustCompile(`-+`)
result = re.ReplaceAllString(result, "-")
// Remove leading/trailing hyphens
result = strings.Trim(result, "-")
// Limit length
if len(result) > 64 {
result = result[:64]
}
return result
}
// ObscurePassword obscures a password using rclone
func ObscurePassword(password string) (string, error) {
// Escape special characters for shell
escapedPass := strings.ReplaceAll(password, `"`, `\"`)
cmd := exec.Command("rclone", "obscure", escapedPass)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to obscure password: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// ListMounts returns all mounts with their current status
func (m *Manager) ListMounts() ([]MountStatus, error) {
mountsFile, err := m.readConfig()
if err != nil {
return nil, err
}
statuses := make([]MountStatus, len(mountsFile.Mounts))
for i, mount := range mountsFile.Mounts {
mounted := m.IsMounted(mount.MountPath)
errMsg, _ := m.ReadError(mount.ID)
statuses[i] = MountStatus{
MountConfig: mount,
Mounted: mounted,
Error: errMsg,
LastChecked: NowMS(),
}
}
return statuses, nil
}
// GetMount returns a single mount by ID
func (m *Manager) GetMount(id string) (*MountStatus, error) {
mountsFile, err := m.readConfig()
if err != nil {
return nil, err
}
for _, mount := range mountsFile.Mounts {
if mount.ID == id {
mounted := m.IsMounted(mount.MountPath)
errMsg, _ := m.ReadError(mount.ID)
return &MountStatus{
MountConfig: mount,
Mounted: mounted,
Error: errMsg,
LastChecked: NowMS(),
}, nil
}
}
return nil, nil
}
// CreateMount creates a new mount configuration
func (m *Manager) CreateMount(req *CreateMountRequest) (*MountStatus, error) {
if req.Name == "" {
return nil, fmt.Errorf("mount name is required")
}
if req.Type != MountTypeNFS && req.Type != MountTypeSMB && req.Type != MountTypeRclone {
return nil, fmt.Errorf("valid mount type (nfs, smb, rclone) is required")
}
// Validate type-specific fields
switch req.Type {
case MountTypeNFS:
if req.NFSServer == "" || req.NFSPath == "" {
return nil, fmt.Errorf("NFS server and path are required")
}
case MountTypeSMB:
if req.SMBServer == "" || req.SMBShare == "" {
return nil, fmt.Errorf("SMB server and share are required")
}
case MountTypeRclone:
if req.RcloneRemote == "" {
return nil, fmt.Errorf("rclone remote is required")
}
}
// Generate ID and mount path
id := uuid.New().String()
safeName := SanitizeName(req.Name)
mountPath := filepath.Join(m.filesPath, safeName)
// Check if path already exists
mountsFile, err := m.readConfig()
if err != nil {
return nil, err
}
for _, mount := range mountsFile.Mounts {
if mount.MountPath == mountPath {
return nil, fmt.Errorf("mount path %s already configured", mountPath)
}
}
// Default enabled to true if not specified
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
mount := MountConfig{
ID: id,
Name: req.Name,
Type: req.Type,
Enabled: enabled,
DesiredMounted: enabled, // Auto-mount if enabled
MountPath: mountPath,
Options: req.Options,
}
// Type-specific fields
switch req.Type {
case MountTypeNFS:
mount.NFSServer = req.NFSServer
mount.NFSPath = req.NFSPath
case MountTypeSMB:
mount.SMBServer = req.SMBServer
mount.SMBShare = req.SMBShare
mount.SMBUsername = req.SMBUsername
mount.SMBDomain = req.SMBDomain
if req.SMBPassword != "" {
obscured, err := ObscurePassword(req.SMBPassword)
if err != nil {
return nil, fmt.Errorf("failed to secure password: %w", err)
}
mount.SMBPasswordObscured = obscured
}
case MountTypeRclone:
mount.RcloneRemote = req.RcloneRemote
mount.RclonePath = req.RclonePath
}
mountsFile.Mounts = append(mountsFile.Mounts, mount)
if err := m.writeConfig(mountsFile); err != nil {
return nil, err
}
log.Printf("[Mounts] Created mount config: %s (%s) -> %s", mount.Name, mount.Type, mount.MountPath)
return &MountStatus{
MountConfig: mount,
Mounted: false,
LastChecked: NowMS(),
}, nil
}
// RequestMount sets desiredMounted to true
func (m *Manager) RequestMount(id string) error {
mountsFile, err := m.readConfig()
if err != nil {
return err
}
for i, mount := range mountsFile.Mounts {
if mount.ID == id {
mountsFile.Mounts[i].DesiredMounted = true
if err := m.writeConfig(mountsFile); err != nil {
return err
}
log.Printf("[Mounts] Mount requested: %s", mount.Name)
return nil
}
}
return fmt.Errorf("mount not found")
}
// RequestUnmount sets desiredMounted to false
func (m *Manager) RequestUnmount(id string) error {
mountsFile, err := m.readConfig()
if err != nil {
return err
}
for i, mount := range mountsFile.Mounts {
if mount.ID == id {
mountsFile.Mounts[i].DesiredMounted = false
if err := m.writeConfig(mountsFile); err != nil {
return err
}
log.Printf("[Mounts] Unmount requested: %s", mount.Name)
return nil
}
}
return fmt.Errorf("mount not found")
}
// WaitForUnmount waits for a mount to be unmounted
func (m *Manager) WaitForUnmount(mountPath string, timeoutMS int) bool {
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
for time.Now().Before(deadline) {
if !m.IsMounted(mountPath) {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
// DeleteMount removes a mount configuration
func (m *Manager) DeleteMount(id string) error {
mountsFile, err := m.readConfig()
if err != nil {
return err
}
var mount *MountConfig
index := -1
for i, mnt := range mountsFile.Mounts {
if mnt.ID == id {
mount = &mnt
index = i
break
}
}
if index == -1 {
return fmt.Errorf("mount not found")
}
// Request unmount first
mountsFile.Mounts[index].DesiredMounted = false
if err := m.writeConfig(mountsFile); err != nil {
return err
}
// Wait for unmount (15 seconds max)
m.WaitForUnmount(mount.MountPath, 15000)
// Remove from config
mountsFile.Mounts = append(mountsFile.Mounts[:index], mountsFile.Mounts[index+1:]...)
if err := m.writeConfig(mountsFile); err != nil {
return err
}
// Clean up error file
errorFile := filepath.Join(m.config.MountsErrorDir(), id+".error")
os.Remove(errorFile)
// Try to remove mount directory (will fail if not empty, which is fine)
os.Remove(mount.MountPath)
log.Printf("[Mounts] Deleted mount: %s", mount.Name)
return nil
}
// ListRcloneRemotes lists available rclone remotes
func (m *Manager) ListRcloneRemotes() ([]RcloneRemote, error) {
// Call rclone RC API
cmd := exec.Command("curl", "-s", "-X", "POST",
"-H", "Content-Type: application/json",
"-u", "admin:admin",
"http://127.0.0.1:5572/config/listremotes")
output, err := cmd.Output()
if err != nil {
log.Printf("[Mounts] Failed to list rclone remotes: %v", err)
return []RcloneRemote{}, nil
}
var response struct {
Remotes []string `json:"remotes"`
}
if err := json.Unmarshal(output, &response); err != nil {
return []RcloneRemote{}, nil
}
remotes := make([]RcloneRemote, 0, len(response.Remotes))
for _, name := range response.Remotes {
// Strip trailing colon if present
cleanName := strings.TrimSuffix(name, ":")
// Get type for this remote
remoteType := m.getRcloneRemoteType(cleanName)
remotes = append(remotes, RcloneRemote{
Name: cleanName,
Type: remoteType,
})
}
return remotes, nil
}
// getRcloneRemoteType gets the type of an rclone remote
func (m *Manager) getRcloneRemoteType(name string) string {
// Build request body
body := fmt.Sprintf(`{"name":"%s"}`, name)
cmd := exec.Command("curl", "-s", "-X", "POST",
"-H", "Content-Type: application/json",
"-u", "admin:admin",
"-d", body,
"http://127.0.0.1:5572/config/get")
output, err := cmd.Output()
if err != nil {
return "unknown"
}
var response map[string]interface{}
if err := json.Unmarshal(output, &response); err != nil {
return "unknown"
}
if typeStr, ok := response["type"].(string); ok {
return typeStr
}
return "unknown"
}
// Basic auth helper for rclone API
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}