moth/cmd/mothd/state.go

406 lines
9.7 KiB
Go
Raw Normal View History

package main
import (
2019-09-02 19:47:24 -06:00
"bufio"
"fmt"
"log"
2019-09-02 19:47:24 -06:00
"math/rand"
"os"
2019-12-01 18:58:09 -07:00
"path/filepath"
2019-09-02 19:47:24 -06:00
"strings"
"time"
2020-08-14 20:26:04 -06:00
2020-08-17 17:43:57 -06:00
"github.com/dirtbags/moth/pkg/award"
2020-08-14 20:26:04 -06:00
"github.com/spf13/afero"
)
2020-08-17 17:43:57 -06:00
// DistinguishableChars are visually unambiguous glyphs.
// People with mediocre handwriting could write these down unambiguously,
// and they can be entered without holding down shift.
const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
// RFC3339Space is a time layout which replaces 'T' with a space.
// This is also a valid RFC3339 format.
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
2020-08-17 17:43:57 -06:00
// State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory.
type State struct {
afero.Fs
2020-08-18 17:04:23 -06:00
// Enabled tracks whether the current State system is processing updates
2020-02-29 22:37:22 -07:00
Enabled bool
2020-08-18 17:04:23 -06:00
refreshNow chan bool
eventStream chan string
eventWriter afero.File
}
2020-08-17 17:43:57 -06:00
// NewState returns a new State struct backed by the given Fs
2019-12-01 18:58:09 -07:00
func NewState(fs afero.Fs) *State {
2020-08-18 17:04:23 -06:00
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)
}
2020-08-18 17:04:23 -06:00
return s
}
2020-08-18 17:04:23 -06:00
// updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() {
nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing"
2020-08-21 17:02:38 -06:00
if untilFile, err := s.Open("hours.txt"); err == nil {
2020-08-21 17:02:38 -06:00
defer untilFile.Close()
why = "`state/hours.txt` present"
2020-08-21 17:02:38 -06:00
scanner := bufio.NewScanner(untilFile)
for scanner.Scan() {
line := scanner.Text()
if len(line) < 1 {
continue
}
thisEnabled := true
switch line[0] {
case '+':
thisEnabled = true
line = line[1:]
case '-':
thisEnabled = false
line = line[1:]
case '#':
continue
default:
log.Println("Misformatted line in hours.txt file")
2020-08-21 17:02:38 -06:00
}
line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line)
if err != nil {
until, err = time.Parse(RFC3339Space, line)
}
2020-08-21 17:02:38 -06:00
if err != nil {
log.Println("Suspended: Unparseable until date:", line)
continue
}
if until.Before(time.Now()) {
nextEnabled = thisEnabled
}
}
}
2020-08-21 17:02:38 -06:00
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
dirs, _ := afero.ReadDir(s, ".")
for _, dir := range dirs {
log.Println(dir.Name())
}
2020-08-21 17:02:38 -06:00
log.Print(s, err)
nextEnabled = false
why = "`state/enabled` missing"
}
2020-08-21 17:02:38 -06:00
if nextEnabled != s.Enabled {
s.Enabled = nextEnabled
2020-08-21 17:02:38 -06:00
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
}
}
2020-08-17 17:43:57 -06:00
// TeamName returns team name given a team ID.
2020-08-14 20:26:04 -06:00
func (s *State) TeamName(teamID string) (string, error) {
2020-08-18 17:04:23 -06:00
teamFs := afero.NewBasePathFs(s.Fs, "teams")
teamNameBytes, err := afero.ReadFile(teamFs, teamID)
if os.IsNotExist(err) {
2020-08-14 20:26:04 -06:00
return "", fmt.Errorf("Unregistered team ID: %s", teamID)
} else if err != nil {
2020-08-14 20:26:04 -06:00
return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err)
}
2019-09-02 19:47:24 -06:00
2020-08-18 17:04:23 -06:00
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, nil
}
2020-08-17 17:43:57 -06:00
// SetTeamName writes out team name.
// This can only be done once.
2020-08-14 20:26:04 -06:00
func (s *State) SetTeamName(teamID, teamName string) error {
2020-08-21 17:02:38 -06:00
idsFile, err := s.Open("teamids.txt")
2020-08-17 17:43:57 -06:00
if err != nil {
2019-12-01 20:53:13 -07:00
return fmt.Errorf("Team IDs file does not exist")
2020-08-17 17:43:57 -06:00
}
2020-08-21 17:02:38 -06:00
defer idsFile.Close()
2020-08-17 17:43:57 -06:00
found := false
2020-08-21 17:02:38 -06:00
scanner := bufio.NewScanner(idsFile)
2020-08-17 17:43:57 -06:00
for scanner.Scan() {
if scanner.Text() == teamID {
found = true
break
2019-12-01 20:53:13 -07:00
}
2019-12-01 20:47:46 -07:00
}
2020-08-17 17:43:57 -06:00
if !found {
return fmt.Errorf("Team ID not found in list of valid Team IDs")
}
2019-12-01 20:47:46 -07:00
2020-08-21 17:02:38 -06:00
teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_EXCL, 0644)
if os.IsExist(err) {
return fmt.Errorf("Team ID is already registered")
2020-08-21 17:02:38 -06:00
} else if err != nil {
return err
}
2020-08-21 17:02:38 -06:00
defer teamFile.Close()
fmt.Fprintln(teamFile, teamName)
return nil
}
2020-08-17 17:43:57 -06:00
// PointsLog retrieves the current points log.
func (s *State) PointsLog() award.List {
f, err := s.Open("points.log")
if err != nil {
log.Println(err)
return nil
}
defer f.Close()
2019-09-02 19:47:24 -06:00
2020-08-17 17:43:57 -06:00
pointsLog := make(award.List, 0, 200)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
2020-08-21 17:02:38 -06:00
log.Println(line)
2020-08-17 17:43:57 -06:00
cur, err := award.Parse(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
}
pointsLog = append(pointsLog, cur)
}
return pointsLog
}
2020-08-17 17:43:57 -06:00
// Messages retrieves the current messages.
2020-03-01 16:10:55 -07:00
func (s *State) Messages() string {
2020-02-29 22:37:22 -07:00
bMessages, _ := afero.ReadFile(s, "messages.html")
2020-03-01 16:10:55 -07:00
return string(bMessages)
}
2020-08-14 20:26:04 -06:00
// AwardPoints gives points to teamID in category.
// It first checks to make sure these are not duplicate points.
// This is not a perfect check, you can trigger a race condition here.
// It's just a courtesy to the user.
// The update task makes sure we never have duplicate points in the log.
2020-08-14 20:26:04 -06:00
func (s *State) AwardPoints(teamID, category string, points int) error {
2020-08-17 17:43:57 -06:00
a := award.T{
When: time.Now().Unix(),
2020-08-14 20:26:04 -06:00
TeamID: teamID,
Category: category,
Points: points,
}
2020-08-14 20:26:04 -06:00
_, err := s.TeamName(teamID)
if err != nil {
return err
}
for _, e := range s.PointsLog() {
2020-08-17 17:43:57 -06:00
if a.Equal(e) {
return fmt.Errorf("Points already awarded to this team in this category")
}
}
2020-08-14 20:26:04 -06:00
fn := fmt.Sprintf("%s-%s-%d", teamID, category, points)
2019-12-01 18:58:09 -07:00
tmpfn := filepath.Join("points.tmp", fn)
newfn := filepath.Join("points.new", fn)
if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
return err
}
if err := s.Rename(tmpfn, newfn); err != nil {
return err
}
2020-08-19 18:01:21 -06:00
// State should be updated immediately
s.refreshNow <- true
return nil
}
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes.
func (s *State) collectPoints() {
files, err := afero.ReadDir(s, "points.new")
if err != nil {
log.Print(err)
return
}
for _, f := range files {
2019-12-01 18:58:09 -07:00
filename := filepath.Join("points.new", f.Name())
awardstr, err := afero.ReadFile(s, filename)
if err != nil {
log.Print("Opening new points: ", err)
continue
}
2020-08-17 17:43:57 -06:00
awd, err := award.Parse(string(awardstr))
if err != nil {
log.Print("Can't parse award file ", filename, ": ", err)
continue
}
duplicate := false
for _, e := range s.PointsLog() {
2020-08-17 17:43:57 -06:00
if awd.Equal(e) {
duplicate = true
break
}
}
if duplicate {
2020-08-17 17:43:57 -06:00
log.Print("Skipping duplicate points: ", awd.String())
} else {
2020-08-17 17:43:57 -06:00
log.Print("Award: ", awd.String())
logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Print("Can't append to points log: ", err)
return
}
2020-08-17 17:43:57 -06:00
fmt.Fprintln(logf, awd.String())
logf.Close()
}
if err := s.Remove(filename); err != nil {
log.Print("Unable to remove new points file: ", err)
}
}
}
func (s *State) maybeInitialize() {
// Are we supposed to re-initialize?
if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
return
}
2020-02-29 22:37:22 -07:00
now := time.Now().UTC().Format(time.RFC3339)
log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files
s.Remove("enabled")
s.Remove("hours.txt")
s.Remove("points.log")
2020-02-29 22:37:22 -07:00
s.Remove("messages.html")
2020-08-18 17:04:23 -06:00
s.Remove("mothd.log")
s.RemoveAll("points.tmp")
s.RemoveAll("points.new")
s.RemoveAll("teams")
2020-08-18 17:04:23 -06:00
// 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)
s.Mkdir("teams", 0755)
// Preseed available team ids if file doesn't exist
if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
2020-02-29 22:37:22 -07:00
id := make([]byte, 8)
2020-08-17 17:43:57 -06:00
for i := 0; i < 100; i++ {
2020-02-29 22:37:22 -07:00
for i := range id {
char := rand.Intn(len(DistinguishableChars))
id[i] = DistinguishableChars[char]
}
fmt.Fprintln(f, string(id))
}
2020-02-29 22:37:22 -07:00
f.Close()
}
// Create some files
2020-02-29 22:37:22 -07:00
if f, err := s.Create("initialized"); err == nil {
fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
fmt.Fprintln(f)
2020-10-12 10:46:03 -06:00
fmt.Fprintln(f, "This instance was initialized at", now)
2020-02-29 22:37:22 -07:00
f.Close()
}
if f, err := s.Create("enabled"); err == nil {
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
f.Close()
}
if f, err := s.Create("hours.txt"); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
2020-02-29 22:37:22 -07:00
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp")
fmt.Fprintln(f, "# Disable: - timestamp")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# You can have multiple start/stop times.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
fmt.Fprintln(f, "# Times in the future are ignored.")
fmt.Fprintln(f)
fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 3019-10-31T00:00:00Z")
f.Close()
}
if f, err := s.Create("messages.html"); err == nil {
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
f.Close()
}
if f, err := s.Create("points.log"); err == nil {
f.Close()
}
2020-08-18 17:04:23 -06:00
}
// LogEvent writes msg to the event log
func (s *State) LogEvent(msg string) {
s.eventStream <- msg
}
2020-02-29 22:37:22 -07:00
2020-08-18 17:04:23 -06:00
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)
}
}
2020-08-19 15:38:13 -06:00
eventWriter, err := s.OpenFile("event.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
2020-08-18 17:04:23 -06:00
if err != nil {
return err
}
s.eventWriter = eventWriter
return nil
}
func (s *State) refresh() {
2019-12-01 18:58:09 -07:00
s.maybeInitialize()
2020-08-18 17:04:23 -06:00
s.updateEnabled()
2019-12-01 18:58:09 -07:00
if s.Enabled {
s.collectPoints()
}
}
2020-08-18 17:04:23 -06:00
// 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:
2020-08-19 15:38:13 -06:00
fmt.Fprintln(s.eventWriter, time.Now().Unix(), msg)
2020-08-18 17:04:23 -06:00
s.eventWriter.Sync()
case <-ticker.C:
s.refresh()
case <-s.refreshNow:
s.refresh()
}
}
}