Neale Pickett
·
2023-09-29
state.go
1package main
2
3import (
4 "bufio"
5 "encoding/csv"
6 "errors"
7 "fmt"
8 "log"
9 "math/rand"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/dirtbags/moth/v4/pkg/award"
18 "github.com/spf13/afero"
19)
20
21// DistinguishableChars are visually unambiguous glyphs.
22// People with mediocre handwriting could write these down unambiguously,
23// and they can be entered without holding down shift.
24const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
25
26// RFC3339Space is a time layout which replaces 'T' with a space.
27// This is also a valid RFC3339 format.
28const RFC3339Space = "2006-01-02 15:04:05Z07:00"
29
30// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
31var ErrAlreadyRegistered = errors.New("team ID has already been registered")
32
33// State defines the current state of a MOTH instance.
34// We use the filesystem for synchronization between threads.
35// The only thing State methods need to know is the path to the state directory.
36type State struct {
37 afero.Fs
38
39 // Enabled tracks whether the current State system is processing updates
40 enabled bool
41
42 enabledWhy string
43 refreshNow chan bool
44 eventStream chan []string
45 eventWriter *csv.Writer
46 eventWriterFile afero.File
47
48 // Caches, so we're not hammering NFS with metadata operations
49 teamNamesLastChange time.Time
50 teamNames map[string]string
51 pointsLog award.List
52 lock sync.RWMutex
53}
54
55// NewState returns a new State struct backed by the given Fs
56func NewState(fs afero.Fs) *State {
57 s := &State{
58 Fs: fs,
59 enabled: true,
60 refreshNow: make(chan bool, 5),
61 eventStream: make(chan []string, 80),
62
63 teamNames: make(map[string]string),
64 }
65 if err := s.reopenEventLog(); err != nil {
66 log.Fatal(err)
67 }
68 return s
69}
70
71// updateEnabled checks a few things to see if this state directory is "enabled".
72func (s *State) updateEnabled() {
73 nextEnabled := true
74 why := "state/hours.txt has no timestamps before now"
75
76 if untilFile, err := s.Open("hours.txt"); err == nil {
77 defer untilFile.Close()
78
79 scanner := bufio.NewScanner(untilFile)
80 for scanner.Scan() {
81 line := scanner.Text()
82 if len(line) < 1 {
83 continue
84 }
85
86 thisEnabled := true
87 switch line[0] {
88 case '+':
89 thisEnabled = true
90 line = line[1:]
91 case '-':
92 thisEnabled = false
93 line = line[1:]
94 case '#':
95 continue
96 default:
97 log.Println("state/hours.txt has bad line:", line)
98 }
99 line, _, _ = strings.Cut(line, "#") // Remove inline comments
100 line = strings.TrimSpace(line)
101 until := time.Time{}
102 if len(line) == 0 {
103 // Let it stay as zero time, so it's always before now
104 } else if until, err = time.Parse(time.RFC3339, line); err == nil {
105 // Great, it was RFC 3339
106 } else if until, err = time.Parse(RFC3339Space, line); err == nil {
107 // Great, it was RFC 3339 with a space instead of a 'T'
108 } else {
109 log.Println("state/hours.txt has bad timestamp:", line)
110 continue
111 }
112 if until.Before(time.Now()) {
113 nextEnabled = thisEnabled
114 why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
115 }
116 }
117 }
118
119 if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
120 s.enabled = nextEnabled
121 s.enabledWhy = why
122 log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
123 if s.enabled {
124 s.LogEvent("enabled", "", "", 0, s.enabledWhy)
125 } else {
126 s.LogEvent("disabled", "", "", 0, s.enabledWhy)
127 }
128 }
129}
130
131// TeamName returns team name given a team ID.
132func (s *State) TeamName(teamID string) (string, error) {
133 s.lock.RLock()
134 name, ok := s.teamNames[teamID]
135 s.lock.RUnlock()
136 if !ok {
137 return "", fmt.Errorf("unregistered team ID: %s", teamID)
138 }
139 return name, nil
140}
141
142// SetTeamName writes out team name.
143// This can only be done once per team.
144func (s *State) SetTeamName(teamID, teamName string) error {
145 s.lock.RLock()
146 _, ok := s.teamNames[teamID]
147 s.lock.RUnlock()
148 if ok {
149 return ErrAlreadyRegistered
150 }
151
152 idsFile, err := s.Open("teamids.txt")
153 if err != nil {
154 return fmt.Errorf("team IDs file does not exist")
155 }
156 defer idsFile.Close()
157 found := false
158 scanner := bufio.NewScanner(idsFile)
159 for scanner.Scan() {
160 if scanner.Text() == teamID {
161 found = true
162 break
163 }
164 }
165 if !found {
166 return fmt.Errorf("team ID not found in list of valid team IDs")
167 }
168
169 teamFilename := filepath.Join("teams", teamID)
170 teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
171 if os.IsExist(err) {
172 return ErrAlreadyRegistered
173 } else if err != nil {
174 return err
175 }
176 defer teamFile.Close()
177 log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
178 fmt.Fprintln(teamFile, teamName)
179 teamFile.Close()
180
181 s.refreshNow <- true
182
183 return nil
184}
185
186// PointsLog retrieves the current points log.
187func (s *State) PointsLog() award.List {
188 s.lock.RLock()
189 ret := make(award.List, len(s.pointsLog))
190 copy(ret, s.pointsLog)
191 s.lock.RUnlock()
192 return ret
193}
194
195// Enabled returns true if the server is in "enabled" state
196func (s *State) Enabled() bool {
197 return s.enabled
198}
199
200// AwardPoints gives points to teamID in category.
201// This doesn't attempt to ensure the teamID has been registered.
202// It first checks to make sure these are not duplicate points.
203// This is not a perfect check, you can trigger a race condition here.
204// It's just a courtesy to the user.
205// The update task makes sure we never have duplicate points in the log.
206func (s *State) AwardPoints(teamID, category string, points int) error {
207 return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points)
208}
209
210func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error {
211 a := award.T{
212 When: when,
213 TeamID: teamID,
214 Category: category,
215 Points: points,
216 }
217
218 for _, e := range s.PointsLog() {
219 if a.Equal(e) {
220 return fmt.Errorf("points already awarded to this team in this category")
221 }
222 }
223
224 //fn := fmt.Sprintf("%s-%s-%d", a.TeamID, a.Category, a.Points)
225 fn := a.Filename()
226 tmpfn := filepath.Join("points.tmp", fn)
227 newfn := filepath.Join("points.new", fn)
228
229 if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
230 return err
231 }
232
233 if err := s.Rename(tmpfn, newfn); err != nil {
234 return err
235 }
236
237 // State should be updated immediately
238 s.refreshNow <- true
239
240 return nil
241}
242
243// collectPoints gathers up files in points.new/ and appends their contents to points.log,
244// removing each points.new/ file as it goes.
245func (s *State) collectPoints() {
246 files, err := afero.ReadDir(s, "points.new")
247 if err != nil {
248 log.Print(err)
249 return
250 }
251 for _, f := range files {
252 filename := filepath.Join("points.new", f.Name())
253 awardstr, err := afero.ReadFile(s, filename)
254 if err != nil {
255 log.Print("Opening new points: ", err)
256 continue
257 }
258 awd, err := award.Parse(string(awardstr))
259 if err != nil {
260 log.Print("Can't parse award file ", filename, ": ", err)
261 continue
262 }
263
264 duplicate := false
265 s.lock.RLock()
266 for _, e := range s.pointsLog {
267 if awd.Equal(e) {
268 duplicate = true
269 break
270 }
271 }
272 s.lock.RUnlock()
273
274 if duplicate {
275 log.Print("Skipping duplicate points: ", awd.String())
276 } else {
277 log.Print("Award: ", awd.String())
278
279 logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
280 if err != nil {
281 log.Print("Can't append to points log: ", err)
282 return
283 }
284 fmt.Fprintln(logf, awd.String())
285 logf.Close()
286
287 // Stick this on the cache too
288 s.lock.Lock()
289 s.pointsLog = append(s.pointsLog, awd)
290 s.lock.Unlock()
291 }
292
293 if err := s.Remove(filename); err != nil {
294 log.Print("Unable to remove new points file: ", err)
295 }
296 }
297}
298
299func (s *State) maybeInitialize() {
300 // Are we supposed to re-initialize?
301 if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
302 return
303 }
304
305 now := time.Now().UTC().Format(time.RFC3339)
306 log.Print("initialized file missing, re-initializing")
307
308 // Remove any extant control and state files
309 s.Remove("enabled")
310 s.Remove("hours.txt")
311 s.Remove("points.log")
312 s.Remove("events.csv")
313 s.Remove("mothd.log")
314 s.RemoveAll("points.tmp")
315 s.RemoveAll("points.new")
316 s.RemoveAll("teams")
317
318 // Open log file
319 if err := s.reopenEventLog(); err != nil {
320 log.Fatal(err)
321 }
322 s.LogEvent("init", "", "", 0)
323
324 // Make sure various subdirectories exist
325 s.Mkdir("points.tmp", 0755)
326 s.Mkdir("points.new", 0755)
327 s.Mkdir("teams", 0755)
328
329 // Preseed available team ids if file doesn't exist
330 if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
331 id := make([]byte, 8)
332 for i := 0; i < 100; i++ {
333 for i := range id {
334 char := rand.Intn(len(DistinguishableChars))
335 id[i] = DistinguishableChars[char]
336 }
337 fmt.Fprintln(f, string(id))
338 }
339 f.Close()
340 }
341
342 // Create some files
343 if f, err := s.Create("initialized"); err == nil {
344 fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
345 fmt.Fprintln(f)
346 fmt.Fprintln(f, "This instance was initialized at", now)
347 f.Close()
348 }
349
350 if f, err := s.Create("hours.txt"); err == nil {
351 fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
352 fmt.Fprintln(f, "#")
353 fmt.Fprintln(f, "# Enable: + [timestamp]")
354 fmt.Fprintln(f, "# Disable: - [timestamp]")
355 fmt.Fprintln(f, "#")
356 fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
357 fmt.Fprintln(f, "# Default is enabled.")
358 fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
359 fmt.Fprintln(f, "# Rules apply from the top down.")
360 fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
361 fmt.Fprintln(f)
362 fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
363 fmt.Fprintln(f, "+", now)
364 fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
365 f.Close()
366 }
367
368 if f, err := s.Create("points.log"); err == nil {
369 f.Close()
370 }
371}
372
373// LogEvent writes to the event log
374func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
375 s.eventStream <- append(
376 []string{
377 strconv.FormatInt(time.Now().Unix(), 10),
378 event,
379 teamID,
380 cat,
381 strconv.Itoa(points),
382 },
383 extra...,
384 )
385}
386
387func (s *State) reopenEventLog() error {
388 if s.eventWriter != nil {
389 s.eventWriter.Flush()
390 }
391 if s.eventWriterFile != nil {
392 if err := s.eventWriterFile.Close(); err != nil {
393 // We're going to soldier on if Close returns error
394 log.Print(err)
395 }
396 }
397 eventWriterFile, err := s.OpenFile("events.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
398 if err != nil {
399 return err
400 }
401 s.eventWriterFile = eventWriterFile
402 s.eventWriter = csv.NewWriter(s.eventWriterFile)
403 return nil
404}
405
406func (s *State) updateCaches() {
407 s.lock.Lock()
408 defer s.lock.Unlock()
409
410 if f, err := s.Open("points.log"); err != nil {
411 log.Println(err)
412 } else {
413 defer f.Close()
414
415 pointsLog := make(award.List, 0, 200)
416 scanner := bufio.NewScanner(f)
417 for scanner.Scan() {
418 line := scanner.Text()
419 cur, err := award.Parse(line)
420 if err != nil {
421 log.Printf("Skipping malformed award line %s: %s", line, err)
422 continue
423 }
424 pointsLog = append(pointsLog, cur)
425 }
426 s.pointsLog = pointsLog
427 }
428
429 // Only do this if the teams directory has a newer mtime; directories with
430 // hundreds of team names can cause NFS I/O storms
431 {
432 _, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough
433 if fi, err := s.Fs.Stat("teams"); err != nil {
434 log.Printf("Getting modification time of teams directory: %v", err)
435 } else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
436 s.teamNamesLastChange = fi.ModTime()
437
438 // The compiler recognizes this as an optimization case
439 for k := range s.teamNames {
440 delete(s.teamNames, k)
441 }
442
443 teamsFs := afero.NewBasePathFs(s.Fs, "teams")
444 if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
445 log.Printf("Reading team ids: %v", err)
446 } else {
447 for _, dirent := range dirents {
448 teamID := dirent.Name()
449 if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
450 log.Printf("Reading team %s: %v", teamID, err)
451 } else {
452 teamName := strings.TrimSpace(string(teamNameBytes))
453 s.teamNames[teamID] = teamName
454 }
455 }
456 }
457 }
458 }
459}
460
461func (s *State) refresh() {
462 s.maybeInitialize()
463 s.updateEnabled()
464 if s.enabled {
465 s.collectPoints()
466 }
467 s.updateCaches()
468}
469
470// Maintain performs housekeeping on a State struct.
471func (s *State) Maintain(updateInterval time.Duration) {
472 ticker := time.NewTicker(updateInterval)
473 s.refresh()
474 for {
475 select {
476 case msg := <-s.eventStream:
477 s.eventWriter.Write(msg)
478 s.eventWriter.Flush()
479 s.eventWriterFile.Sync()
480 case <-ticker.C:
481 s.refresh()
482 case <-s.refreshNow:
483 s.refresh()
484 }
485 }
486}
487
488// DevelState is a StateProvider for use by development servers
489type DevelState struct {
490 StateProvider
491}
492
493// NewDevelState returns a new state object that can be used by the development server.
494//
495// The main thing this provides is the ability to register a team with any team ID.
496// If a team ID is provided that wasn't recognized by the underlying StateProvider,
497// it is associated with a team named "<devel:$ID>".
498//
499// This makes it possible to use the server without having to register a team.
500func NewDevelState(sp StateProvider) *DevelState {
501 return &DevelState{sp}
502}
503
504// TeamName returns a valid team name for any teamID
505//
506// If one's registered, it will use it.
507// Otherwise, it returns "<devel:$ID>"
508func (ds *DevelState) TeamName(teamID string) (string, error) {
509 if name, err := ds.StateProvider.TeamName(teamID); err == nil {
510 return name, nil
511 }
512 if teamID == "" {
513 return "", fmt.Errorf("empty team ID")
514 }
515 return fmt.Sprintf("«devel:%s»", teamID), nil
516}
517
518// SetTeamName associates a team name with any teamID
519//
520// If the underlying StateProvider returns any sort of error,
521// this returns ErrAlreadyRegistered,
522// so the user can join a pre-existing team for whatever ID the provide.
523func (ds *DevelState) SetTeamName(teamID, teamName string) error {
524 if err := ds.StateProvider.SetTeamName(teamID, teamName); err != nil {
525 return ErrAlreadyRegistered
526 }
527 return nil
528}