244 lines
6.6 KiB
Go
244 lines
6.6 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"syncbot/internal/config"
|
|
)
|
|
|
|
func testConfig(workDir string, jobs ...config.Job) *config.Config {
|
|
return &config.Config{
|
|
WorkDir: workDir,
|
|
Concurrency: 2,
|
|
ReloadInterval: time.Second,
|
|
Jobs: jobs,
|
|
}
|
|
}
|
|
|
|
// newJob builds a spec pointing at paths that do not exist. The job goroutine
|
|
// will fail its sync quickly and harmlessly, which is all these tests need —
|
|
// they are about supervision, not about git.
|
|
func newJob(name, workDir string, interval time.Duration) config.Job {
|
|
return config.Job{
|
|
Name: name,
|
|
Src: config.Endpoint{URL: filepath.Join(workDir, name+"-src.git")},
|
|
Dst: config.Endpoint{URL: filepath.Join(workDir, name+"-dst.git")},
|
|
Dir: filepath.Join(workDir, "mirrors", name+".git"),
|
|
Interval: interval,
|
|
Timeout: 5 * time.Second,
|
|
MaxBackoff: time.Hour,
|
|
Refs: config.DefaultRefs,
|
|
Prune: true,
|
|
Force: true,
|
|
}
|
|
}
|
|
|
|
func newTestManager(t *testing.T) (*Manager, string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
workDir := t.TempDir()
|
|
m := New(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
t.Cleanup(func() { m.Stop(5 * time.Second) })
|
|
return m, workDir
|
|
}
|
|
|
|
func (m *Manager) jobPointers() map[string]*job {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make(map[string]*job, len(m.jobs))
|
|
for k, v := range m.jobs {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestApplyStartsJobs(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := len(m.jobPointers()); got != 2 {
|
|
t.Fatalf("running jobs = %d, want 2", got)
|
|
}
|
|
if got := len(m.Statuses()); got != 2 {
|
|
t.Errorf("statuses = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
// The whole point of an incremental reload: editing one repo must not disturb
|
|
// the others' timers or in-flight work.
|
|
func TestApplyLeavesUnchangedJobsRunning(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
cfg := testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))
|
|
if err := m.Apply(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := m.jobPointers()
|
|
|
|
// Identical configuration: nothing should be touched.
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after := m.jobPointers()
|
|
for name, j := range before {
|
|
if after[name] != j {
|
|
t.Errorf("job %q was restarted despite an identical config", name)
|
|
}
|
|
}
|
|
|
|
// Change only "a": "b" must survive untouched.
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, 30*time.Minute), newJob("b", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := m.jobPointers()
|
|
if changed["a"] == before["a"] {
|
|
t.Error("job \"a\" should have been restarted after its interval changed")
|
|
}
|
|
if changed["b"] != before["b"] {
|
|
t.Error("job \"b\" should not have been restarted")
|
|
}
|
|
if got := changed["a"].spec.Interval; got != 30*time.Minute {
|
|
t.Errorf("restarted job carries interval %s, want 30m", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyStopsRemovedJobs(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
removed := m.jobPointers()["b"]
|
|
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, still := m.jobPointers()["b"]; still {
|
|
t.Fatal("job \"b\" is still registered after being removed from the config")
|
|
}
|
|
select {
|
|
case <-removed.done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("goroutine for the removed job did not exit")
|
|
}
|
|
for _, s := range m.Statuses() {
|
|
if s.Name == "b" {
|
|
t.Error("status for the removed job should be dropped")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCountersSurviveJobRestart(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m.update("a", func(s *Status) { s.Syncs, s.Pushes = 7, 3 })
|
|
|
|
if err := m.Apply(testConfig(dir, newJob("a", dir, 5*time.Minute))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := m.Statuses()[0]
|
|
if got.Syncs != 7 || got.Pushes != 3 {
|
|
t.Errorf("counters reset on restart: syncs=%d pushes=%d, want 7/3", got.Syncs, got.Pushes)
|
|
}
|
|
if got.Interval != "5m0s" {
|
|
t.Errorf("status interval = %q, want it to follow the new spec", got.Interval)
|
|
}
|
|
}
|
|
|
|
func TestConcurrencyChangeKeepsSemaphoreConsistent(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
cfg := testConfig(dir, newJob("a", dir, time.Hour))
|
|
cfg.Concurrency = 1
|
|
if err := m.Apply(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Hold a slot from the old semaphore, then resize.
|
|
release, err := m.acquire(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg2 := testConfig(dir, newJob("a", dir, time.Hour))
|
|
cfg2.Concurrency = 4
|
|
if err := m.Apply(cfg2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release() // must return the slot to the channel it came from, not the new one
|
|
|
|
// The new semaphore should have its full capacity available.
|
|
var releases []func()
|
|
for i := 0; i < 4; i++ {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
r, err := m.acquire(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatalf("acquire %d/4 blocked after resize: %v", i+1, err)
|
|
}
|
|
releases = append(releases, r)
|
|
}
|
|
for _, r := range releases {
|
|
r()
|
|
}
|
|
}
|
|
|
|
func TestApplyRejectsUnusableWorkDir(t *testing.T) {
|
|
m, dir := newTestManager(t)
|
|
// A regular file cannot host the mirror directories.
|
|
blocked := filepath.Join(dir, "not-a-dir")
|
|
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := m.Apply(testConfig(blocked, newJob("a", dir, time.Hour))); err == nil {
|
|
t.Fatal("want an error for a work_dir that cannot be created")
|
|
}
|
|
}
|
|
|
|
func TestBackoff(t *testing.T) {
|
|
const interval, max = time.Minute, 30 * time.Minute
|
|
cases := []struct {
|
|
failures int
|
|
want time.Duration
|
|
}{
|
|
{0, time.Minute},
|
|
{1, time.Minute},
|
|
{2, 2 * time.Minute},
|
|
{3, 4 * time.Minute},
|
|
{4, 8 * time.Minute},
|
|
{6, 30 * time.Minute}, // capped
|
|
{100, 30 * time.Minute},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := backoff(interval, max, tc.failures); got != tc.want {
|
|
t.Errorf("backoff(failures=%d) = %s, want %s", tc.failures, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaggerIsBounded(t *testing.T) {
|
|
if got := stagger(0); got != 0 {
|
|
t.Errorf("the first repo should start immediately, got %s", got)
|
|
}
|
|
if got := stagger(3); got != 750*time.Millisecond {
|
|
t.Errorf("stagger(3) = %s, want 750ms", got)
|
|
}
|
|
if got := stagger(1000); got != 15*time.Second {
|
|
t.Errorf("stagger should be capped at 15s, got %s", got)
|
|
}
|
|
}
|