leader discovery fix and code cleaning

This commit is contained in:
Pierre
2026-02-01 13:39:32 +01:00
parent d1e74e0b70
commit 9a60f0e231
14 changed files with 2095 additions and 120 deletions
+93 -3
View File
@@ -9,12 +9,16 @@ import (
// Handlers provides HTTP handlers for mount operations
type Handlers struct {
manager *Manager
manager *Manager
scanFunc ScanFunc
}
// NewHandlers creates new mount handlers
func NewHandlers(manager *Manager) *Handlers {
return &Handlers{manager: manager}
func NewHandlers(manager *Manager, scanFunc ScanFunc) *Handlers {
return &Handlers{
manager: manager,
scanFunc: scanFunc,
}
}
// RegisterRoutes registers all mount-related routes
@@ -24,10 +28,12 @@ func (h *Handlers) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/mounts", h.handleCreateMount).Methods("POST")
r.HandleFunc("/api/mounts/rclone/remotes", h.handleListRcloneRemotes).Methods("GET")
r.HandleFunc("/api/mounts/{id}", h.handleGetMount).Methods("GET")
r.HandleFunc("/api/mounts/{id}", h.handleUpdateMount).Methods("PUT", "PATCH")
r.HandleFunc("/api/mounts/{id}", h.handleDeleteMount).Methods("DELETE")
r.HandleFunc("/api/mounts/{id}/mount", h.handleRequestMount).Methods("POST")
r.HandleFunc("/api/mounts/{id}/unmount", h.handleRequestUnmount).Methods("POST")
r.HandleFunc("/api/mounts/{id}/safe-unmount", h.handleSafeUnmount).Methods("POST")
r.HandleFunc("/api/mounts/{id}/scan", h.handleTriggerMountScan).Methods("POST")
}
// handleListMounts handles GET /api/mounts
@@ -194,6 +200,90 @@ func (h *Handlers) handleListRcloneRemotes(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, RcloneRemotesResponse{Remotes: remotes})
}
// handleUpdateMount handles PUT/PATCH /api/mounts/{id}
func (h *Handlers) handleUpdateMount(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
mount, err := h.manager.UpdateMount(id, updates)
if err != nil {
if err.Error() == "mount not found" {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, MountResponse{Mount: mount})
}
// handleTriggerMountScan handles POST /api/mounts/{id}/scan
func (h *Handlers) handleTriggerMountScan(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
// Get mount info
mount, err := h.manager.GetMount(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if mount == nil {
writeError(w, http.StatusNotFound, "mount not found")
return
}
// Check if mount is mounted
if !mount.Mounted {
writeJSON(w, http.StatusBadRequest, MountScanResponse{
Status: "error",
Message: "mount is not currently mounted",
MountID: mount.ID,
MountPath: mount.MountPath,
})
return
}
// Check if scan function is available
if h.scanFunc == nil {
writeJSON(w, http.StatusServiceUnavailable, MountScanResponse{
Status: "error",
Message: "file watcher not available",
MountID: mount.ID,
MountPath: mount.MountPath,
})
return
}
// Trigger scan
fileCount, err := h.scanFunc(mount.MountPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, MountScanResponse{
Status: "error",
Message: err.Error(),
MountID: mount.ID,
MountPath: mount.MountPath,
})
return
}
writeJSON(w, http.StatusOK, MountScanResponse{
Status: "ok",
Message: "scan triggered successfully",
MountID: mount.ID,
MountPath: mount.MountPath,
FileCount: fileCount,
})
}
// Helper functions
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
+150 -13
View File
@@ -23,6 +23,7 @@ type Manager struct {
mu sync.RWMutex
filesPath string
mountsDir string
poller *Poller
}
// NewManager creates a new mount manager
@@ -41,6 +42,11 @@ func NewManager(cfg *config.Config) (*Manager, error) {
return m, nil
}
// SetPoller sets the poller for the manager
func (m *Manager) SetPoller(poller *Poller) {
m.poller = poller
}
// ensureDirs creates required directories
func (m *Manager) ensureDirs() error {
dirs := []string{
@@ -95,14 +101,30 @@ func (m *Manager) writeConfig(mountsFile *MountsFile) error {
return os.WriteFile(m.config.MountsFilePath(), data, 0644)
}
// IsMounted checks if a path is currently mounted
// IsMounted checks if a path is currently mounted by parsing /proc/mounts
// Note: Uses /proc/mounts instead of findmnt for Alpine compatibility
func (m *Manager) IsMounted(mountPath string) bool {
cmd := exec.Command("findmnt", "-n", mountPath)
output, err := cmd.CombinedOutput()
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return false
}
return strings.TrimSpace(string(output)) != ""
// Clean the mount path (remove trailing slash)
cleanPath := strings.TrimSuffix(mountPath, "/")
// Parse each line of /proc/mounts
lines := strings.Split(string(data), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 2 {
// Field 1 is the mount point
mountPoint := strings.TrimSuffix(fields[1], "/")
if mountPoint == cleanPath {
return true
}
}
}
return false
}
// ReadError reads the error file for a mount
@@ -164,6 +186,26 @@ func ObscurePassword(password string) (string, error) {
// ListMounts returns all mounts with their current status
func (m *Manager) ListMounts() ([]MountStatus, error) {
statuses, err := m.listMountsWithoutPollingStatus()
if err != nil {
return nil, err
}
// Add polling status from poller (done separately to avoid deadlock)
if m.poller != nil {
for i := range statuses {
pollingStatus := m.poller.GetPollingStatus(statuses[i].ID)
statuses[i].PollingActive = pollingStatus.Active
statuses[i].LastPolledScan = pollingStatus.LastScan
}
}
return statuses, nil
}
// listMountsWithoutPollingStatus returns mounts without querying the poller
// This is used internally by the poller to avoid deadlocks
func (m *Manager) listMountsWithoutPollingStatus() ([]MountStatus, error) {
mountsFile, err := m.readConfig()
if err != nil {
return nil, err
@@ -197,12 +239,21 @@ func (m *Manager) GetMount(id string) (*MountStatus, error) {
mounted := m.IsMounted(mount.MountPath)
errMsg, _ := m.ReadError(mount.ID)
return &MountStatus{
status := &MountStatus{
MountConfig: mount,
Mounted: mounted,
Error: errMsg,
LastChecked: NowMS(),
}, nil
}
// Add polling status from poller (safe since GetMount isn't called from poller with lock held)
if m.poller != nil {
pollingStatus := m.poller.GetPollingStatus(mount.ID)
status.PollingActive = pollingStatus.Active
status.LastPolledScan = pollingStatus.LastScan
}
return status, nil
}
}
@@ -258,14 +309,26 @@ func (m *Manager) CreateMount(req *CreateMountRequest) (*MountStatus, error) {
enabled = *req.Enabled
}
// Default polling settings
pollingEnabled := false
if req.PollingEnabled != nil {
pollingEnabled = *req.PollingEnabled
}
pollingIntervalMs := DefaultPollingIntervalMs
if req.PollingIntervalMs != nil && *req.PollingIntervalMs >= MinPollingIntervalMs {
pollingIntervalMs = *req.PollingIntervalMs
}
mount := MountConfig{
ID: id,
Name: req.Name,
Type: req.Type,
Enabled: enabled,
DesiredMounted: enabled, // Auto-mount if enabled
MountPath: mountPath,
Options: req.Options,
ID: id,
Name: req.Name,
Type: req.Type,
Enabled: enabled,
DesiredMounted: enabled, // Auto-mount if enabled
MountPath: mountPath,
Options: req.Options,
PollingEnabled: pollingEnabled,
PollingIntervalMs: pollingIntervalMs,
}
// Type-specific fields
@@ -297,6 +360,11 @@ func (m *Manager) CreateMount(req *CreateMountRequest) (*MountStatus, error) {
log.Printf("[Mounts] Created mount config: %s (%s) -> %s", mount.Name, mount.Type, mount.MountPath)
// Notify poller about new mount
if m.poller != nil {
m.poller.NotifyMountChanged()
}
return &MountStatus{
MountConfig: mount,
Mounted: false,
@@ -304,6 +372,63 @@ func (m *Manager) CreateMount(req *CreateMountRequest) (*MountStatus, error) {
}, nil
}
// UpdateMount updates mount configuration (for polling settings)
func (m *Manager) UpdateMount(id string, updates map[string]interface{}) (*MountStatus, error) {
mountsFile, err := m.readConfig()
if err != nil {
return nil, err
}
index := -1
for i, mount := range mountsFile.Mounts {
if mount.ID == id {
index = i
break
}
}
if index == -1 {
return nil, fmt.Errorf("mount not found")
}
// Apply updates
mount := &mountsFile.Mounts[index]
if val, ok := updates["pollingEnabled"]; ok {
if enabled, ok := val.(bool); ok {
mount.PollingEnabled = enabled
}
}
if val, ok := updates["pollingIntervalMs"]; ok {
// Handle both int and float64 (JSON numbers are float64)
switch v := val.(type) {
case int:
if v >= MinPollingIntervalMs {
mount.PollingIntervalMs = v
}
case float64:
if int(v) >= MinPollingIntervalMs {
mount.PollingIntervalMs = int(v)
}
}
}
if err := m.writeConfig(mountsFile); err != nil {
return nil, err
}
log.Printf("[Mounts] Updated mount config: %s", mount.Name)
// Notify poller about config change
if m.poller != nil {
m.poller.NotifyMountChanged()
}
// Return updated status
return m.GetMount(id)
}
// RequestMount sets desiredMounted to true
func (m *Manager) RequestMount(id string) error {
mountsFile, err := m.readConfig()
@@ -318,6 +443,12 @@ func (m *Manager) RequestMount(id string) error {
return err
}
log.Printf("[Mounts] Mount requested: %s", mount.Name)
// Notify poller about state change
if m.poller != nil {
m.poller.NotifyMountChanged()
}
return nil
}
}
@@ -339,6 +470,12 @@ func (m *Manager) RequestUnmount(id string) error {
return err
}
log.Printf("[Mounts] Unmount requested: %s", mount.Name)
// Notify poller about state change
if m.poller != nil {
m.poller.NotifyMountChanged()
}
return nil
}
}
+269
View File
@@ -0,0 +1,269 @@
package mounts
import (
"log"
"sync"
"time"
)
// ScanFunc is a function type for scanning a mount path
type ScanFunc func(path string) (int, error)
// mountPoller manages polling for a single mount
type mountPoller struct {
mountID string
stopChan chan struct{}
stopped bool
mu sync.Mutex
}
// PollingStatus holds the polling state for a mount
type PollingStatus struct {
Active bool `json:"active"`
LastScan int64 `json:"lastScan,omitempty"`
}
// Poller manages per-mount polling goroutines
type Poller struct {
manager *Manager
scanFunc ScanFunc
pollers map[string]*mountPoller // key: mount ID
status map[string]*PollingStatus
running bool
mu sync.RWMutex
}
// NewPoller creates a new mount poller
func NewPoller(manager *Manager, scanFunc ScanFunc) *Poller {
return &Poller{
manager: manager,
scanFunc: scanFunc,
pollers: make(map[string]*mountPoller),
status: make(map[string]*PollingStatus),
}
}
// Start begins the polling service
func (p *Poller) Start() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return nil
}
p.running = true
log.Println("[Poller] Starting mount polling service")
// Initial sync with mount configurations
go p.syncWithMounts()
return nil
}
// Stop stops all polling goroutines
func (p *Poller) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return nil
}
p.running = false
log.Println("[Poller] Stopping mount polling service")
// Stop all pollers
for id, poller := range p.pollers {
p.stopPoller(poller)
delete(p.pollers, id)
}
return nil
}
// SyncWithMounts synchronizes pollers with current mount configurations
func (p *Poller) SyncWithMounts() {
p.syncWithMounts()
}
// syncWithMounts is the internal implementation
func (p *Poller) syncWithMounts() {
p.mu.RLock()
running := p.running
p.mu.RUnlock()
if !running {
return
}
// Get current mounts BEFORE acquiring the write lock to avoid deadlock
// (ListMounts calls GetPollingStatus which needs RLock)
mounts, err := p.manager.listMountsWithoutPollingStatus()
if err != nil {
log.Printf("[Poller] Failed to list mounts: %v", err)
return
}
p.mu.Lock()
defer p.mu.Unlock()
// Re-check running state after acquiring lock
if !p.running {
return
}
// Track which mounts we've seen
seenMounts := make(map[string]bool)
for _, mount := range mounts {
seenMounts[mount.ID] = true
// Check if mount should be polled
shouldPoll := mount.PollingEnabled && mount.Mounted
existingPoller, exists := p.pollers[mount.ID]
if shouldPoll {
// Calculate interval
interval := mount.PollingIntervalMs
if interval < MinPollingIntervalMs {
interval = DefaultPollingIntervalMs
}
if !exists {
// Start new poller
log.Printf("[Poller] Starting poller for mount %s (interval: %dms)", mount.Name, interval)
p.startPollerForMount(mount.ID, mount.MountPath, interval)
}
} else if exists {
// Stop existing poller
log.Printf("[Poller] Stopping poller for mount %s", mount.Name)
p.stopPoller(existingPoller)
delete(p.pollers, mount.ID)
}
}
// Stop pollers for mounts that no longer exist
for id, poller := range p.pollers {
if !seenMounts[id] {
log.Printf("[Poller] Stopping poller for removed mount %s", id)
p.stopPoller(poller)
delete(p.pollers, id)
}
}
}
// startPollerForMount starts a polling goroutine for a mount
func (p *Poller) startPollerForMount(mountID, mountPath string, intervalMs int) {
poller := &mountPoller{
mountID: mountID,
stopChan: make(chan struct{}),
}
p.pollers[mountID] = poller
// Initialize status
if _, exists := p.status[mountID]; !exists {
p.status[mountID] = &PollingStatus{}
}
p.status[mountID].Active = true
go func() {
ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-poller.stopChan:
return
case <-ticker.C:
p.runPollScan(mountID, mountPath)
}
}
}()
}
// stopPoller stops a mount poller
func (p *Poller) stopPoller(poller *mountPoller) {
poller.mu.Lock()
defer poller.mu.Unlock()
if poller.stopped {
return
}
poller.stopped = true
close(poller.stopChan)
// Update status
if status, exists := p.status[poller.mountID]; exists {
status.Active = false
}
}
// runPollScan executes a scan for a mount
func (p *Poller) runPollScan(mountID, mountPath string) {
if p.scanFunc == nil {
return
}
fileCount, err := p.scanFunc(mountPath)
if err != nil {
log.Printf("[Poller] Scan failed for mount %s: %v", mountID, err)
return
}
// Update status
p.mu.Lock()
if status, exists := p.status[mountID]; exists {
status.LastScan = NowMS()
}
p.mu.Unlock()
log.Printf("[Poller] Poll scan complete for mount %s: %d files", mountID, fileCount)
}
// TriggerScan triggers an immediate scan for a mount
func (p *Poller) TriggerScan(mountID, mountPath string) (int, error) {
if p.scanFunc == nil {
return 0, nil
}
fileCount, err := p.scanFunc(mountPath)
if err != nil {
return 0, err
}
// Update status
p.mu.Lock()
if status, exists := p.status[mountID]; exists {
status.LastScan = NowMS()
} else {
p.status[mountID] = &PollingStatus{
LastScan: NowMS(),
}
}
p.mu.Unlock()
return fileCount, nil
}
// GetPollingStatus returns the polling status for a mount
func (p *Poller) GetPollingStatus(mountID string) *PollingStatus {
p.mu.RLock()
defer p.mu.RUnlock()
if status, exists := p.status[mountID]; exists {
return &PollingStatus{
Active: status.Active,
LastScan: status.LastScan,
}
}
return &PollingStatus{}
}
// NotifyMountChanged notifies the poller that a mount's state has changed
func (p *Poller) NotifyMountChanged() {
go p.syncWithMounts()
}
+28 -3
View File
@@ -11,6 +11,12 @@ const (
MountTypeRclone MountType = "rclone"
)
// Polling constants
const (
DefaultPollingIntervalMs = 30000 // 30 seconds
MinPollingIntervalMs = 5000 // 5 seconds
)
// MountConfig represents a mount configuration
type MountConfig struct {
// Common fields
@@ -36,14 +42,20 @@ type MountConfig struct {
// rclone-specific fields
RcloneRemote string `json:"rcloneRemote,omitempty"`
RclonePath string `json:"rclonePath,omitempty"`
// Polling configuration
PollingEnabled bool `json:"pollingEnabled"`
PollingIntervalMs int `json:"pollingIntervalMs,omitempty"` // Default: 30000
}
// MountStatus represents the runtime status of a mount
type MountStatus struct {
MountConfig
Mounted bool `json:"mounted"`
Error string `json:"error,omitempty"`
LastChecked int64 `json:"lastChecked"`
Mounted bool `json:"mounted"`
Error string `json:"error,omitempty"`
LastChecked int64 `json:"lastChecked"`
PollingActive bool `json:"pollingActive,omitempty"`
LastPolledScan int64 `json:"lastPolledScan,omitempty"`
}
// MountsFile represents the mounts.json file structure
@@ -79,6 +91,19 @@ type CreateMountRequest struct {
// rclone
RcloneRemote string `json:"rcloneRemote,omitempty"`
RclonePath string `json:"rclonePath,omitempty"`
// Polling
PollingEnabled *bool `json:"pollingEnabled,omitempty"`
PollingIntervalMs *int `json:"pollingIntervalMs,omitempty"`
}
// MountScanResponse is the response for mount scan operations
type MountScanResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
MountID string `json:"mountId"`
MountPath string `json:"mountPath"`
FileCount int `json:"fileCount,omitempty"`
}
// MountResponse is the response for mount operations