diff --git a/config/setup/git.go b/config/setup/git.go index f25fbc22..2e6c76ca 100644 --- a/config/setup/git.go +++ b/config/setup/git.go @@ -2,7 +2,6 @@ package setup import ( "fmt" - "log" "net/url" "path/filepath" "runtime" @@ -22,22 +21,8 @@ func Git(c *Controller) (middleware.Middleware, error) { } c.Startup = append(c.Startup, func() error { - // Startup functions are blocking; start - // service routine in background - go func() { - for { - time.Sleep(repo.Interval) - - err := repo.Pull() - if err != nil { - if git.Logger == nil { - log.Println(err) - } else { - git.Logger.Println(err) - } - } - } - }() + // Start service routine in background + git.Start(repo) // Do a pull right away to return error return repo.Pull() diff --git a/config/setup/git_test.go b/config/setup/git_test.go index 3a441bbd..cec0fe9b 100644 --- a/config/setup/git_test.go +++ b/config/setup/git_test.go @@ -1,6 +1,9 @@ package setup import ( + "io/ioutil" + "log" + "strings" "testing" "time" @@ -13,18 +16,83 @@ func init() { git.SetOS(gittest.FakeOS) } +func check(t *testing.T, err error) { + if err != nil { + t.Errorf("Expected no errors, but got: %v", err) + } +} + func TestGit(t *testing.T) { c := newTestController(`git git@github.com:mholt/caddy.git`) mid, err := Git(c) - if err != nil { - t.Errorf("Expected no errors, but got: %v", err) - } + check(t, err) if mid != nil { t.Fatal("Git middleware is a background service and expected to be nil.") } } +func TestIntervals(t *testing.T) { + tests := []string{ + `git git@github.com:user/repo { interval 10 }`, + `git git@github.com:user/repo { interval 5 }`, + `git git@github.com:user/repo { interval 2 }`, + `git git@github.com:user/repo { interval 1 }`, + `git git@github.com:user/repo { interval 6 }`, + } + + for i, test := range tests { + git.Logger = nil + + c1 := newTestController(test) + repo, err := gitParse(c1) + check(t, err) + + c2 := newTestController(test) + _, err = Git(c2) + check(t, err) + + // start startup services + err = c2.Startup[0]() + check(t, err) + + // wait for first background pull + time.Sleep(time.Millisecond * 100) + + // switch logger to test file + logFile := gittest.Open("file") + git.Logger = log.New(logFile, "", 0) + + // sleep for the interval + time.Sleep(repo.Interval) + + // get log output + out, err := ioutil.ReadAll(logFile) + check(t, err) + + // if greater than minimum interval + if repo.Interval >= time.Second*5 { + expected := `https://github.com/user/repo.git pulled. +No new changes.` + + // ensure pull is done by tracing the output + if expected != strings.TrimSpace(string(out)) { + t.Errorf("Test %v: Expected %v found %v", i, expected, string(out)) + } + } else { + // ensure pull is ignored by confirming no output + if string(out) != "" { + t.Errorf("Test %v: Expected no output but found %v", i, string(out)) + } + } + + // stop background thread monitor + git.Monitor.StopAndWait(repo.URL, 1) + + } + +} + func TestGitParse(t *testing.T) { tests := []struct { input string diff --git a/middleware/git/git.go b/middleware/git/git.go index d81421c9..9cfa6565 100644 --- a/middleware/git/git.go +++ b/middleware/git/git.go @@ -33,6 +33,9 @@ var initMutex = sync.Mutex{} // Logger is used to log errors; if nil, the default log.Logger is used. var Logger *log.Logger +// Monitor listens for halt signal to stop repositories from auto pulling. +var Monitor = &monitor{} + // logger is an helper function to retrieve the available logger func logger() *log.Logger { if Logger == nil { diff --git a/middleware/git/git_test.go b/middleware/git/git_test.go index 2d76e0bf..23cd4969 100644 --- a/middleware/git/git_test.go +++ b/middleware/git/git_test.go @@ -139,6 +139,39 @@ Command echo Hello successful. } } + // timeout checks + timeoutTests := []struct { + repo *Repo + shouldPull bool + }{ + {&Repo{Interval: time.Millisecond * 4900}, false}, + {&Repo{Interval: time.Millisecond * 1}, false}, + {&Repo{Interval: time.Second * 5}, true}, + {&Repo{Interval: time.Second * 10}, true}, + } + + for i, r := range timeoutTests { + r.repo = createRepo(r.repo) + + err := r.repo.Prepare() + check(t, err) + err = r.repo.Pull() + check(t, err) + + before := r.repo.lastPull + + time.Sleep(r.repo.Interval) + + err = r.repo.Pull() + after := r.repo.lastPull + check(t, err) + + expected := after.After(before) + if expected != r.shouldPull { + t.Errorf("Pull with Error %v: Expected %v found %v", i, expected, r.shouldPull) + } + } + } func createRepo(r *Repo) *Repo { diff --git a/middleware/git/service.go b/middleware/git/service.go new file mode 100644 index 00000000..dacd1472 --- /dev/null +++ b/middleware/git/service.go @@ -0,0 +1,110 @@ +package git + +import ( + "sync" + "time" +) + +// RepoService is the repository service that runs in background and +// periodic pull from the repository. +type RepoService struct { + repo *Repo + running bool // whether service is running. + halt chan struct{} // channel to notify service to halt and stop pulling. + exit chan struct{} // channel to notify on exit. +} + +// Start starts a new RepoService in background and adds it to monitor. +func Start(repo *Repo) { + service := &RepoService{ + repo, + true, + make(chan struct{}), + make(chan struct{}), + } + + // start service + go func(s *RepoService) { + for { + // if service is halted + if !s.running { + // notify exit channel + service.exit <- struct{}{} + break + } + time.Sleep(repo.Interval) + + err := repo.Pull() + if err != nil { + logger().Println(err) + } + } + }(service) + + // add to monitor to enable halting + Monitor.add(service) +} + +// monitor monitors running services (RepoService) +// and can halt them. +type monitor struct { + services []*RepoService + sync.Mutex +} + +// add adds a new service to the monitor. +func (m *monitor) add(service *RepoService) { + m.Lock() + defer m.Unlock() + + m.services = append(m.services, service) + + // start a goroutine to listen for halt signal + service.running = true + go func(r *RepoService) { + <-r.halt + r.running = false + }(service) +} + +// Stop stops at most `limit` currently running services that is pulling from git repo at +// repoURL. It returns list of exit channels for the services. A wait for message on the +// channels guarantees exit. If limit is less than zero, it is ignored. +// TODO find better ways to identify repos +func (m *monitor) Stop(repoURL string, limit int) []chan struct{} { + m.Lock() + defer m.Unlock() + + var chans []chan struct{} + + // locate services + for i, j := 0, 0; i < len(m.services) && ((limit >= 0 && j < limit) || limit < 0); i++ { + s := m.services[i] + if s.repo.URL == repoURL { + // send halt signal + s.halt <- struct{}{} + chans = append(chans, s.exit) + j++ + m.services[i] = nil + } + } + + // remove them from services list + services := m.services[:0] + for _, s := range m.services { + if s != nil { + services = append(services, s) + } + } + m.services = services + return chans +} + +// StopAndWait is similar to stop but it waits for the services to terminate before +// returning. +func (m *monitor) StopAndWait(repoUrl string, limit int) { + chans := m.Stop(repoUrl, limit) + for _, c := range chans { + <-c + } +} diff --git a/middleware/git/service_test.go b/middleware/git/service_test.go new file mode 100644 index 00000000..114a5893 --- /dev/null +++ b/middleware/git/service_test.go @@ -0,0 +1,60 @@ +package git + +import ( + "fmt" + "testing" + "time" + + "github.com/mholt/caddy/middleware/git/gittest" +) + +func init() { + SetOS(gittest.FakeOS) +} + +func Test(t *testing.T) { + repo := &Repo{URL: "git@github.com", Interval: time.Second} + + Start(repo) + if len(Monitor.services) != 1 { + t.Errorf("Expected 1 service, found %v", len(Monitor.services)) + } + + Monitor.StopAndWait(repo.URL, 1) + if len(Monitor.services) != 0 { + t.Errorf("Expected 1 service, found %v", len(Monitor.services)) + } + + repos := make([]*Repo, 5) + for i := 0; i < 5; i++ { + repos[i] = &Repo{URL: fmt.Sprintf("test%v", i), Interval: time.Second * 2} + Start(repos[i]) + if len(Monitor.services) != i+1 { + t.Errorf("Expected %v service(s), found %v", i+1, len(Monitor.services)) + } + } + + time.Sleep(time.Second * 5) + Monitor.StopAndWait(repos[0].URL, 1) + if len(Monitor.services) != 4 { + t.Errorf("Expected %v service(s), found %v", 4, len(Monitor.services)) + } + + repo = &Repo{URL: "git@github.com", Interval: time.Second} + Start(repo) + if len(Monitor.services) != 5 { + t.Errorf("Expected %v service(s), found %v", 5, len(Monitor.services)) + } + + repo = &Repo{URL: "git@github.com", Interval: time.Second * 2} + Start(repo) + if len(Monitor.services) != 6 { + t.Errorf("Expected %v service(s), found %v", 6, len(Monitor.services)) + } + + time.Sleep(time.Second * 5) + Monitor.StopAndWait(repo.URL, -1) + if len(Monitor.services) != 4 { + t.Errorf("Expected %v service(s), found %v", 4, len(Monitor.services)) + } +}