mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
8f0b4930ff
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/scheduler"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("SchedulerService", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
mockSched *mockScheduler
|
|
mockTimers *mockTimerRegistry
|
|
testService *testableSchedulerService
|
|
origAfterFn func(time.Duration, func()) *time.Timer
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "scheduler-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy the test-scheduler plugin
|
|
srcPath := filepath.Join(testdataDir, "test-scheduler"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-scheduler"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Create mock scheduler and timer registry
|
|
mockSched = newMockScheduler()
|
|
mockTimers = newMockTimerRegistry()
|
|
|
|
// Replace timeAfterFunc with mock
|
|
origAfterFn = timeAfterFunc
|
|
timeAfterFunc = mockTimers.AfterFunc
|
|
|
|
// Setup mock DataStore with pre-enabled plugin
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-scheduler",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
}})
|
|
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
|
|
|
// Create and start manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
metrics: noopMetricsRecorder{},
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get scheduler service from plugin's closers and wrap it for testing
|
|
service := findSchedulerService(manager, "test-scheduler")
|
|
Expect(service).ToNot(BeNil())
|
|
testService = &testableSchedulerService{schedulerServiceImpl: service}
|
|
testService.scheduler = mockSched
|
|
|
|
DeferCleanup(func() {
|
|
timeAfterFunc = origAfterFn
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
BeforeEach(func() {
|
|
mockSched.Reset()
|
|
mockTimers.Reset()
|
|
testService.ClearSchedules()
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("should detect scheduler capability", func() {
|
|
names := manager.PluginNames(string(CapabilityScheduler))
|
|
Expect(names).To(ContainElement("test-scheduler"))
|
|
})
|
|
|
|
It("should register scheduler service for plugin", func() {
|
|
service := findSchedulerService(manager, "test-scheduler")
|
|
Expect(service).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("ScheduleOneTime", func() {
|
|
It("should schedule a one-time task", func() {
|
|
scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "test-payload", "test-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).To(Equal("test-id"))
|
|
|
|
// Verify schedule was registered
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1))
|
|
})
|
|
|
|
It("should invoke plugin callback and auto-cleanup after firing", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "cleanup-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Trigger fires the callback which calls the plugin's nd_scheduler_callback
|
|
// One-time schedules clean up after the callback completes
|
|
mockTimers.TriggerAll()
|
|
|
|
// One-time schedules should self-cleanup
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should reject duplicate schedule ID", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "dup-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "dup-id")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("already exists"))
|
|
})
|
|
|
|
It("should auto-generate schedule ID when empty", func() {
|
|
scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("ScheduleRecurring", func() {
|
|
It("should schedule recurring tasks", func() {
|
|
scheduleID, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "recurring-data", "recurring-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(scheduleID).To(Equal("recurring-id"))
|
|
|
|
// Verify schedule was registered
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
entry := testService.GetSchedule("recurring-id")
|
|
Expect(entry).ToNot(BeNil())
|
|
Expect(entry.isRecurring).To(BeTrue())
|
|
})
|
|
|
|
It("should invoke plugin callback multiple times without self-canceling", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "persist-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Trigger multiple times - recurring schedules should persist
|
|
mockSched.TriggerAll()
|
|
mockSched.TriggerAll()
|
|
|
|
// Recurring schedules should persist
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Calling Host Functions", func() {
|
|
It("should allow plugin to schedule a one-time task from callback", func() {
|
|
// Schedule with magic payload that triggers plugin to call SchedulerScheduleOneTime
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-followup", "trigger-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Trigger - plugin callback will schedule a follow-up task
|
|
mockSched.TriggerAll()
|
|
|
|
// Verify the plugin created a new schedule via host function
|
|
Expect(testService.GetScheduleCount()).To(Equal(2)) // original + followup
|
|
|
|
// Verify the follow-up schedule was created with correct ID and properties
|
|
followup := testService.GetSchedule("followup-id")
|
|
Expect(followup).ToNot(BeNil())
|
|
Expect(followup.payload).To(Equal("followup-created"))
|
|
Expect(followup.isRecurring).To(BeFalse())
|
|
Expect(followup.timer).ToNot(BeNil()) // One-time tasks use timers
|
|
})
|
|
|
|
It("should allow plugin to schedule a recurring task from callback", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-recurring", "trigger-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
mockSched.TriggerAll()
|
|
|
|
// Verify the plugin created a recurring schedule
|
|
entry := testService.GetSchedule("recurring-from-plugin")
|
|
Expect(entry).ToNot(BeNil())
|
|
Expect(entry.isRecurring).To(BeTrue())
|
|
Expect(entry.payload).To(Equal("recurring-created"))
|
|
})
|
|
})
|
|
|
|
Describe("CancelSchedule", func() {
|
|
It("should cancel a recurring task", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should cancel a one-time task", func() {
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "cancel-onetime-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-onetime-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should remove callback from scheduler for recurring tasks", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(1))
|
|
|
|
err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should return error for non-existent schedule", func() {
|
|
err := testService.CancelSchedule(GinkgoT().Context(), "non-existent")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not found"))
|
|
})
|
|
})
|
|
|
|
Describe("Scheduler Service Isolation", func() {
|
|
It("should share the same scheduler service across multiple plugin instances", func() {
|
|
// This test verifies that when we call plugin.instance() multiple times
|
|
// (creating multiple instances from the same compiled plugin), they all
|
|
// share the same scheduler service. This is the expected behavior since
|
|
// the scheduler service is registered once per plugin at compile time.
|
|
|
|
// Get the plugin
|
|
manager.mu.RLock()
|
|
plugin, ok := manager.plugins["test-scheduler"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
|
|
// Schedule a task using the service directly
|
|
_, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "shared-data", "shared-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(1))
|
|
|
|
// Create a plugin instance
|
|
instance, err := plugin.instance(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer instance.Close(GinkgoT().Context())
|
|
|
|
// The scheduler service is shared, so the schedule ID should clash
|
|
// if another instance tries to use the same ID
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "other-data", "shared-id")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("already exists"))
|
|
|
|
// But different IDs should work fine
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "instance2-data", "otherx-id")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Unload", func() {
|
|
It("should cancel all schedules when plugin is unloaded", func() {
|
|
_, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 10s", "data1", "unload-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
_, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "unload-2")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(testService.GetScheduleCount()).To(Equal(2))
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(1)) // Only recurring task uses scheduler
|
|
Expect(mockTimers.GetTimerCount()).To(Equal(1)) // Only one-time task uses timer
|
|
|
|
err = manager.unloadPlugin("test-scheduler")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(findSchedulerService(manager, "test-scheduler")).To(BeNil())
|
|
Expect(mockSched.GetCallbackCount()).To(Equal(0)) // Recurring task removed
|
|
})
|
|
})
|
|
})
|
|
|
|
// testableSchedulerService wraps schedulerServiceImpl with test helpers.
|
|
type testableSchedulerService struct {
|
|
*schedulerServiceImpl
|
|
}
|
|
|
|
func (t *testableSchedulerService) GetScheduleCount() int {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return len(t.schedules)
|
|
}
|
|
|
|
func (t *testableSchedulerService) GetSchedule(id string) *scheduleEntry {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return t.schedules[id]
|
|
}
|
|
|
|
func (t *testableSchedulerService) ClearSchedules() {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.schedules = make(map[string]*scheduleEntry)
|
|
}
|
|
|
|
// mockScheduler implements scheduler.Scheduler for testing without timing dependencies.
|
|
type mockScheduler struct {
|
|
mu sync.Mutex
|
|
callbacks map[int]func()
|
|
nextID int
|
|
}
|
|
|
|
func newMockScheduler() *mockScheduler {
|
|
return &mockScheduler{
|
|
callbacks: make(map[int]func()),
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
func (s *mockScheduler) Run(_ context.Context) {}
|
|
|
|
func (s *mockScheduler) Add(_ string, cmd func()) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
id := s.nextID
|
|
s.nextID++
|
|
s.callbacks[id] = cmd
|
|
return id, nil
|
|
}
|
|
|
|
func (s *mockScheduler) Remove(id int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.callbacks, id)
|
|
}
|
|
|
|
func (s *mockScheduler) TriggerAll() {
|
|
s.mu.Lock()
|
|
callbacks := make([]func(), 0, len(s.callbacks))
|
|
for _, cb := range s.callbacks {
|
|
callbacks = append(callbacks, cb)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, cb := range callbacks {
|
|
cb()
|
|
}
|
|
}
|
|
|
|
func (s *mockScheduler) GetCallbackCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.callbacks)
|
|
}
|
|
|
|
func (s *mockScheduler) Reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.callbacks = make(map[int]func())
|
|
s.nextID = 1
|
|
}
|
|
|
|
var _ scheduler.Scheduler = (*mockScheduler)(nil)
|
|
|
|
// mockTimerRegistry tracks mock timers created during tests.
|
|
type mockTimerRegistry struct {
|
|
mu sync.Mutex
|
|
callbacks []func()
|
|
timers []*time.Timer
|
|
}
|
|
|
|
func newMockTimerRegistry() *mockTimerRegistry {
|
|
return &mockTimerRegistry{
|
|
callbacks: make([]func(), 0),
|
|
timers: make([]*time.Timer, 0),
|
|
}
|
|
}
|
|
|
|
// AfterFunc creates a timer that we control for testing.
|
|
func (r *mockTimerRegistry) AfterFunc(_ time.Duration, f func()) *time.Timer {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Store callback for TriggerAll
|
|
r.callbacks = append(r.callbacks, f)
|
|
|
|
// Create a real timer that won't fire (very long duration, immediately stopped)
|
|
t := time.NewTimer(time.Hour * 24 * 365)
|
|
t.Stop()
|
|
r.timers = append(r.timers, t)
|
|
|
|
return t
|
|
}
|
|
|
|
// TriggerAll fires all pending timer callbacks.
|
|
func (r *mockTimerRegistry) TriggerAll() {
|
|
r.mu.Lock()
|
|
callbacks := make([]func(), len(r.callbacks))
|
|
copy(callbacks, r.callbacks)
|
|
r.mu.Unlock()
|
|
|
|
for _, cb := range callbacks {
|
|
cb()
|
|
}
|
|
}
|
|
|
|
func (r *mockTimerRegistry) GetTimerCount() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.callbacks)
|
|
}
|
|
|
|
func (r *mockTimerRegistry) Reset() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.callbacks = make([]func(), 0)
|
|
r.timers = make([]*time.Timer, 0)
|
|
}
|
|
|
|
// findSchedulerService finds the scheduler service from a plugin's closers.
|
|
func findSchedulerService(m *Manager, pluginName string) *schedulerServiceImpl {
|
|
m.mu.RLock()
|
|
instance, ok := m.plugins[pluginName]
|
|
m.mu.RUnlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
for _, closer := range instance.closers {
|
|
if svc, ok := closer.(*schedulerServiceImpl); ok {
|
|
return svc
|
|
}
|
|
}
|
|
return nil
|
|
}
|