diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index adc7f6d..b77a258 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -9,20 +9,6 @@ import ( "github.com/spf13/afero" ) -func custodian(updateInterval time.Duration, components []Provider) { - update := func() { - for _, c := range components { - c.Update() - } - } - - ticker := time.NewTicker(updateInterval) - update() - for range ticker.C { - update() - } -} - func main() { log.Print("Started") @@ -67,7 +53,9 @@ func main() { mime.AddExtensionType(".json", "application/json") mime.AddExtensionType(".zip", "application/zip") - go custodian(*refreshInterval, []Provider{theme, state, puzzles}) + go theme.Maintain(*refreshInterval) + go state.Maintain(*refreshInterval) + go puzzles.Maintain(*refreshInterval) server := NewMothServer(puzzles, theme, state) httpd := NewHTTPServer(*base, server) diff --git a/cmd/mothd/main_test.go b/cmd/mothd/main_test.go index 250f9cb..5735e86 100644 --- a/cmd/mothd/main_test.go +++ b/cmd/mothd/main_test.go @@ -8,5 +8,5 @@ func TestEverything(t *testing.T) { state := NewTestState() t.Error("No test") - state.Update() + state.refresh() } diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index 829490b..81a629e 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -109,9 +109,9 @@ func (m *Mothballs) CheckAnswer(cat string, points int, answer string) error { return fmt.Errorf("Invalid answer") } -// Update refreshes internal state. +// refresh refreshes internal state. // It looks for changes to the directory listing, and caches any new mothballs. -func (m *Mothballs) Update() { +func (m *Mothballs) refresh() { m.categoryLock.Lock() defer m.categoryLock.Unlock() @@ -169,3 +169,11 @@ func (m *Mothballs) Update() { } } } + +// Maintain performs housekeeping for Mothballs. +func (m *Mothballs) Maintain(updateInterval time.Duration) { + m.refresh() + for range time.NewTicker(updateInterval).C { + m.refresh() + } +} diff --git a/cmd/mothd/mothballs_test.go b/cmd/mothd/mothballs_test.go index f5a6786..ac94e1c 100644 --- a/cmd/mothd/mothballs_test.go +++ b/cmd/mothd/mothballs_test.go @@ -32,7 +32,7 @@ func (m *Mothballs) createMothball(cat string) { func TestMothballs(t *testing.T) { m := NewMothballs(new(afero.MemMapFs)) m.createMothball("test1") - m.Update() + m.refresh() if _, ok := m.categories["test1"]; !ok { t.Error("Didn't create a new category") } @@ -54,7 +54,7 @@ func TestMothballs(t *testing.T) { m.createMothball("test2") m.Fs.Remove("test1.mb") - m.Update() + m.refresh() inv = m.Inventory() if len(inv) != 1 { t.Error("Deleted mothball is still around", inv) diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index df672ad..e363f3e 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -38,13 +38,13 @@ type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category CheckAnswer(cat string, points int, answer string) error - Provider + Maintainer } // ThemeProvider defines what's required to provide a theme. type ThemeProvider interface { Open(path string) (ReadSeekCloser, time.Time, error) - Provider + Maintainer } // StateProvider defines what's required to provide MOTH state. @@ -54,12 +54,15 @@ type StateProvider interface { TeamName(teamID string) (string, error) SetTeamName(teamID, teamName string) error AwardPoints(teamID string, cat string, points int) error - Provider + Maintainer } -// Provider defines providers that can be updated. -type Provider interface { - Update() +// Maintainer is something that can be maintained. +type Maintainer interface { + // Maintain is the maintenance loop. + // It will only be called once, when execution begins. + // It's okay to just exit if there's no maintenance to be done. + Maintain(updateInterval time.Duration) } // MothServer gathers together the providers that make up a MOTH server. @@ -113,8 +116,7 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, // Register associates a team name with a team ID. func (mh *MothRequestHandler) Register(teamName string) error { - // XXX: Should we just return success if the team is already registered? - // XXX: Should this function be renamed to Login? + // BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success if teamName == "" { return fmt.Errorf("Empty team name") } diff --git a/cmd/mothd/state.go b/cmd/mothd/state.go index 5caf843..58215ff 100644 --- a/cmd/mothd/state.go +++ b/cmd/mothd/state.go @@ -24,19 +24,31 @@ const DistinguishableChars = "234678abcdefhikmnpqrtwxyz=" // The only thing State methods need to know is the path to the state directory. type State struct { afero.Fs + + // Enabled tracks whether the current State system is processing updates Enabled bool + + refreshNow chan bool + eventStream chan string + eventWriter afero.File } // NewState returns a new State struct backed by the given Fs func NewState(fs afero.Fs) *State { - return &State{ - Fs: fs, - Enabled: true, + s := &State{ + Fs: fs, + Enabled: true, + refreshNow: make(chan bool, 5), + eventStream: make(chan string, 80), } + if err := s.reopenEventLog(); err != nil { + log.Fatal(err) + } + return s } -// UpdateEnabled checks a few things to see if this state directory is "enabled". -func (s *State) UpdateEnabled() { +// updateEnabled checks a few things to see if this state directory is "enabled". +func (s *State) updateEnabled() { if _, err := s.Stat("enabled"); os.IsNotExist(err) { s.Enabled = false log.Println("Suspended: enabled file missing") @@ -88,17 +100,15 @@ func (s *State) UpdateEnabled() { // TeamName returns team name given a team ID. func (s *State) TeamName(teamID string) (string, error) { - // XXX: directory traversal - teamFile := filepath.Join("teams", teamID) - teamNameBytes, err := afero.ReadFile(s, teamFile) - teamName := strings.TrimSpace(string(teamNameBytes)) - + teamFs := afero.NewBasePathFs(s.Fs, "teams") + teamNameBytes, err := afero.ReadFile(teamFs, teamID) if os.IsNotExist(err) { return "", fmt.Errorf("Unregistered team ID: %s", teamID) } else if err != nil { return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err) } + teamName := strings.TrimSpace(string(teamNameBytes)) return teamName, nil } @@ -194,7 +204,7 @@ func (s *State) AwardPoints(teamID, category string, points int) error { return err } - // XXX: update everything + // BUG(neale): When points are awarded, state should be updated immediately return nil } @@ -261,10 +271,16 @@ func (s *State) maybeInitialize() { s.Remove("hours") s.Remove("points.log") s.Remove("messages.html") + s.Remove("mothd.log") s.RemoveAll("points.tmp") s.RemoveAll("points.new") s.RemoveAll("teams") + // Open log file + if err := s.reopenEventLog(); err != nil { + log.Fatal(err) + } + // Make sure various subdirectories exist s.Mkdir("points.tmp", 0755) s.Mkdir("points.new", 0755) @@ -319,14 +335,55 @@ func (s *State) maybeInitialize() { if f, err := s.Create("points.log"); err == nil { f.Close() } - } -// Update performs housekeeping on a State struct. -func (s *State) Update() { +// LogEvent writes msg to the event log +func (s *State) LogEvent(msg string) { + s.eventStream <- msg +} + +// LogEventf writes a formatted message to the event log +func (s *State) LogEventf(format string, a ...interface{}) { + msg := fmt.Sprintf(format, a...) + s.LogEvent(msg) +} + +func (s *State) reopenEventLog() error { + if s.eventWriter != nil { + if err := s.eventWriter.Close(); err != nil { + // We're going to soldier on if Close returns error + log.Print(err) + } + } + eventWriter, err := s.OpenFile("events.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + s.eventWriter = eventWriter + return nil +} + +func (s *State) refresh() { s.maybeInitialize() - s.UpdateEnabled() + s.updateEnabled() if s.Enabled { s.collectPoints() } } + +// Maintain performs housekeeping on a State struct. +func (s *State) Maintain(updateInterval time.Duration) { + ticker := time.NewTicker(updateInterval) + s.refresh() + for { + select { + case msg := <-s.eventStream: + fmt.Println(s.eventWriter, time.Now().Unix(), msg) + s.eventWriter.Sync() + case <-ticker.C: + s.refresh() + case <-s.refreshNow: + s.refresh() + } + } +} diff --git a/cmd/mothd/state_test.go b/cmd/mothd/state_test.go index 617a942..29327eb 100644 --- a/cmd/mothd/state_test.go +++ b/cmd/mothd/state_test.go @@ -4,13 +4,14 @@ import ( "bytes" "os" "testing" + "time" "github.com/spf13/afero" ) func NewTestState() *State { s := NewState(new(afero.MemMapFs)) - s.Update() + s.refresh() return s } @@ -55,7 +56,7 @@ func TestState(t *testing.T) { category := "poot" points := 3928 s.AwardPoints(teamID, category, points) - s.Update() + s.refresh() pl = s.PointsLog() if len(pl) != 1 { @@ -65,10 +66,34 @@ func TestState(t *testing.T) { } s.Fs.Remove("initialized") - s.Update() + s.refresh() pl = s.PointsLog() if len(pl) != 0 { t.Errorf("After reinitialization, points log has length %d", len(pl)) } } + +func TestStateEvents(t *testing.T) { + s := NewTestState() + s.LogEvent("moo") + s.LogEventf("moo %d", 2) + + if msg := <-s.eventStream; msg != "moo" { + t.Error("Wrong message from event stream", msg) + } + if msg := <-s.eventStream; msg != "moo 2" { + t.Error("Formatted event is wrong:", msg) + } +} + +func TestStateMaintainer(t *testing.T) { + s := NewTestState() + go s.Maintain(2 * time.Second) + + s.LogEvent("Hello!") + eventLog, _ := afero.ReadFile(s.Fs, "event.log") + if len(eventLog) != 12 { + t.Error("Wrong event log length:", len(eventLog)) + } +} diff --git a/cmd/mothd/theme.go b/cmd/mothd/theme.go index 6bd1bf9..a70ca32 100644 --- a/cmd/mothd/theme.go +++ b/cmd/mothd/theme.go @@ -34,7 +34,7 @@ func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) { return f, fi.ModTime(), nil } -// Update performs housekeeping for a Theme. -func (t *Theme) Update() { +// Maintain performs housekeeping for a Theme. +func (t *Theme) Maintain(i time.Duration) { // No periodic tasks for a theme }