This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user