Breaking out state mechanisms into a more abstract form that shouldbe

easier to extend
This commit is contained in:
John Donaldson 2019-12-12 20:59:25 +00:00
parent 0b8a855284
commit 4093797899
7 changed files with 382 additions and 262 deletions

View File

@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- URL parameter to points.json to allow returning only the JSON for a single
team by its team id (e.g., points.json?id=abc123).
### Changed
- Abstract state mechanisms so that it is easier to move to different backends
## [3.4.2] - 2019-11-18
### Fixed

292
src/LegacyMOTHState.go Normal file
View File

@ -0,0 +1,292 @@
package main
import (
"bufio"
"fmt"
"io/ioutil"
"log"
"os"
"path"
"strings"
"time"
)
type LegacyMOTHState struct {
StateDir string
update chan bool
maintenanceInterval time.Duration
}
func (state *LegacyMOTHState) Initialize() (bool, error) {
if _, err := os.Stat(state.StateDir); err != nil {
return false, err
}
state.MaybeInitialize()
if state.update == nil {
state.update = make(chan bool, 10)
go state.Maintenance(state.maintenanceInterval)
}
return true, nil
}
func (state *LegacyMOTHState) login(teamName string, token string) (bool, error) {
for a, _ := range state.getTeams() {
if a == token {
f, err := os.OpenFile(state.StatePath("teams", token), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if os.IsExist(err) {
return true, ErrAlreadyRegistered
} else {
return false, ErrRegistrationError
}
}
defer f.Close()
fmt.Fprintln(f, teamName)
return true, nil
}
}
return false, ErrInvalidTeamID
}
func (state *LegacyMOTHState) StatePath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(state.StateDir, tail)
}
func (state *LegacyMOTHState) TeamName(teamId string) (string, error) {
teamNameBytes, err := ioutil.ReadFile(state.StatePath("teams", teamId))
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, err
}
func (state *LegacyMOTHState) isEnabled() bool {
if _, err := os.Stat(state.StatePath("disabled")); err == nil {
return false
}
untilspec, err := ioutil.ReadFile(state.StatePath("until"))
if err == nil {
untilspecs := strings.TrimSpace(string(untilspec))
until, err := time.Parse(time.RFC3339, untilspecs)
if err != nil {
log.Printf("Suspended: Unparseable until date: %s", untilspec)
return false
}
if until.Before(time.Now()) {
log.Print("Suspended: until time reached, suspending maintenance")
return false
}
}
return true
}
// 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 maintenance task makes sure we never have duplicate points in the log.
func (state *LegacyMOTHState) AwardPoints(teamId, category string, points int) error {
a := Award{
When: time.Now(),
TeamId: teamId,
Category: category,
Points: points,
}
_, err := state.TeamName(teamId)
if err != nil {
return fmt.Errorf("No registered team with this hash")
}
for _, e := range state.PointsLog("") {
if a.Same(e) {
return fmt.Errorf("Points already awarded to this team in this category")
}
}
fn := fmt.Sprintf("%s-%s-%d", teamId, category, points)
tmpfn := state.StatePath("points.tmp", fn)
newfn := state.StatePath("points.new", fn)
if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil {
return err
}
if err := os.Rename(tmpfn, newfn); err != nil {
return err
}
state.update <- true
log.Printf("Award %s %s %d", teamId, category, points)
return nil
}
func (state *LegacyMOTHState) PointsLog(teamId string) []*Award {
var ret []*Award
fn := state.StatePath("points.log")
f, err := os.Open(fn)
if err != nil {
log.Printf("Unable to open %s: %s", fn, err)
return ret
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
cur, err := ParseAward(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
}
if len(teamId) > 0 && cur.TeamId != teamId {
continue
}
ret = append(ret, cur)
}
return ret
}
func (state *LegacyMOTHState) getConfig(configName string) (string, error) {
fn := state.StatePath(configName)
data, err := ioutil.ReadFile(fn)
if err != nil {
log.Printf("Unable to open %s: %s", fn, err)
return "", err
}
return string(data), nil
}
func (state *LegacyMOTHState) Maintenance(maintenanceInterval time.Duration) {
for {
if state.isEnabled() {
state.collectPoints()
}
select {
case <-state.update:
// log.Print("Forced update")
case <-time.After(maintenanceInterval):
// log.Print("Housekeeping")
}
}
}
func (state *LegacyMOTHState) getTeams() map[string]struct{} {
filepath := state.StatePath("teamids.txt")
teamids, err := os.Open(filepath)
teams := make(map[string]struct{})
if err != nil {
log.Printf("Error openining %s: %s", filepath, err)
return teams
}
defer teamids.Close()
// List out team IDs
scanner := bufio.NewScanner(teamids)
for scanner.Scan() {
teamId := scanner.Text()
if (teamId == "..") || strings.ContainsAny(teamId, "/") {
log.Printf("Dangerous team ID dropped: %s", teamId)
continue
}
teams[scanner.Text()] = struct{}{}
//newList = append(newList, scanner.Text())
}
return teams
//return newList
}
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes.
func (state *LegacyMOTHState) collectPoints() {
logf, err := os.OpenFile(state.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("Can't append to points log: %s", err)
return
}
defer logf.Close()
files, err := ioutil.ReadDir(state.StatePath("points.new"))
if err != nil {
log.Printf("Error reading packages: %s", err)
}
for _, f := range files {
filename := state.StatePath("points.new", f.Name())
s, err := ioutil.ReadFile(filename)
if err != nil {
log.Printf("Can't read points file %s: %s", filename, err)
continue
}
award, err := ParseAward(string(s))
if err != nil {
log.Printf("Can't parse award file %s: %s", filename, err)
continue
}
duplicate := false
for _, e := range state.PointsLog("") {
if award.Same(e) {
duplicate = true
break
}
}
if duplicate {
log.Printf("Skipping duplicate points: %s", award.String())
} else {
fmt.Fprintf(logf, "%s\n", award.String())
}
logf.Sync()
if err := os.Remove(filename); err != nil {
log.Printf("Unable to remove %s: %s", filename, err)
}
}
}
func (state *LegacyMOTHState) MaybeInitialize() {
// Only do this if it hasn't already been done
if _, err := os.Stat(state.StatePath("initialized")); err == nil {
return
}
log.Printf("initialized file missing, re-initializing")
// Remove any extant control and state files
os.Remove(state.StatePath("until"))
os.Remove(state.StatePath("disabled"))
os.Remove(state.StatePath("points.log"))
os.RemoveAll(state.StatePath("points.tmp"))
os.RemoveAll(state.StatePath("points.new"))
os.RemoveAll(state.StatePath("teams"))
// Make sure various subdirectories exist
os.Mkdir(state.StatePath("points.tmp"), 0755)
os.Mkdir(state.StatePath("points.new"), 0755)
os.Mkdir(state.StatePath("teams"), 0755)
// Preseed available team ids if file doesn't exist
if f, err := os.OpenFile(state.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
defer f.Close()
for i := 0; i <= 100; i += 1 {
fmt.Fprintln(f, mktoken())
}
}
// Create initialized file that signals whether we're set up
f, err := os.OpenFile(state.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
log.Print(err)
}
defer f.Close()
fmt.Fprintln(f, "Remove this file to reinitialize the contest")
}

View File

@ -63,41 +63,34 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
teamName := req.FormValue("name")
teamId := req.FormValue("id")
if !ctx.ValidTeamId(teamId) {
success, err := ctx.State.login(teamName, teamId)
if (success && err == nil) {
respond(
w, req, JSendSuccess,
"Team registered",
"Your team has been named and you may begin using your team ID!",
)
} else if (err == ErrInvalidTeamID) {
respond(
w, req, JSendFail,
"Invalid Team ID",
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
)
return
} else if (err == ErrAlreadyRegistered) {
respond(
w, req, JSendFail,
"Already registered",
"This team ID has already been registered.",
)
} else if (err != nil) {
log.Print(err)
respond(
w, req, JSendFail,
"Registration failed",
"Unable to register. Perhaps a teammate has already registered?",
)
}
f, err := os.OpenFile(ctx.StatePath("teams", teamId), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if os.IsExist(err) {
respond(
w, req, JSendFail,
"Already registered",
"This team ID has already been registered.",
)
} else {
log.Print(err)
respond(
w, req, JSendFail,
"Registration failed",
"Unable to register. Perhaps a teammate has already registered?",
)
}
return
}
defer f.Close()
fmt.Fprintln(f, teamName)
respond(
w, req, JSendSuccess,
"Team registered",
"Your team has been named and you may begin using your team ID!",
)
}
func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
@ -155,7 +148,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
return
}
if err := ctx.AwardPoints(teamId, category, points); err != nil {
if err := ctx.State.AwardPoints(teamId, category, points); err != nil {
respond(
w, req, JSendError,
"Cannot award points",
@ -172,7 +165,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
teamId := req.FormValue("id")
if _, err := ctx.TeamName(teamId); err != nil {
if _, err := ctx.State.TeamName(teamId); err != nil {
http.Error(w, "Must provide team ID", http.StatusUnauthorized)
return
}
@ -262,7 +255,7 @@ func (ctx *Instance) manifestHandler(w http.ResponseWriter, req *http.Request) {
}
teamId := req.FormValue("id")
if _, err := ctx.TeamName(teamId); err != nil {
if _, err := ctx.State.TeamName(teamId); err != nil {
http.Error(w, "Must provide a valid team ID", http.StatusUnauthorized)
return
}

View File

@ -1,11 +1,8 @@
package main
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"os"
@ -24,6 +21,9 @@ type Instance struct {
MothballDir string
StateDir string
ThemeDir string
State MOTHState
AttemptInterval time.Duration
Runtime RuntimeConfig
@ -43,7 +43,8 @@ func (ctx *Instance) Initialize() error {
if _, err := os.Stat(ctx.MothballDir); err != nil {
return err
}
if _, err := os.Stat(ctx.StateDir); err != nil {
if _, err := ctx.State.Initialize(); err != nil {
return err
}
@ -55,7 +56,6 @@ func (ctx *Instance) Initialize() error {
ctx.mux = http.NewServeMux()
ctx.BindHandlers()
ctx.MaybeInitialize()
return nil
}
@ -72,43 +72,6 @@ func mktoken() string {
return string(a)
}
func (ctx *Instance) MaybeInitialize() {
// Only do this if it hasn't already been done
if _, err := os.Stat(ctx.StatePath("initialized")); err == nil {
return
}
log.Print("initialized file missing, re-initializing")
// Remove any extant control and state files
os.Remove(ctx.StatePath("until"))
os.Remove(ctx.StatePath("disabled"))
os.Remove(ctx.StatePath("points.log"))
os.RemoveAll(ctx.StatePath("points.tmp"))
os.RemoveAll(ctx.StatePath("points.new"))
os.RemoveAll(ctx.StatePath("teams"))
// Make sure various subdirectories exist
os.Mkdir(ctx.StatePath("points.tmp"), 0755)
os.Mkdir(ctx.StatePath("points.new"), 0755)
os.Mkdir(ctx.StatePath("teams"), 0755)
// Preseed available team ids if file doesn't exist
if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
defer f.Close()
for i := 0; i <= 100; i += 1 {
fmt.Fprintln(f, mktoken())
}
}
// Create initialized file that signals whether we're set up
f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
log.Print(err)
}
defer f.Close()
fmt.Fprintln(f, "Remove this file to reinitialize the contest")
}
func pathCleanse(parts []string) string {
clean := make([]string, len(parts))
for i := range parts {
@ -127,11 +90,6 @@ func (ctx Instance) MothballPath(parts ...string) string {
return path.Join(ctx.MothballDir, tail)
}
func (ctx *Instance) StatePath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(ctx.StateDir, tail)
}
func (ctx *Instance) ThemePath(parts ...string) string {
tail := pathCleanse(parts)
return path.Join(ctx.ThemeDir, tail)
@ -151,75 +109,6 @@ func (ctx *Instance) TooFast(teamId string) bool {
return now.Before(next)
}
func (ctx *Instance) PointsLog(teamId string) []*Award {
var ret []*Award
fn := ctx.StatePath("points.log")
f, err := os.Open(fn)
if err != nil {
log.Printf("Unable to open %s: %s", fn, err)
return ret
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
cur, err := ParseAward(line)
if err != nil {
log.Printf("Skipping malformed award line %s: %s", line, err)
continue
}
if len(teamId) > 0 && cur.TeamId != teamId {
continue
}
ret = append(ret, cur)
}
return ret
}
// 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 maintenance task makes sure we never have duplicate points in the log.
func (ctx *Instance) AwardPoints(teamId, category string, points int) error {
a := Award{
When: time.Now(),
TeamId: teamId,
Category: category,
Points: points,
}
_, err := ctx.TeamName(teamId)
if err != nil {
return fmt.Errorf("No registered team with this hash")
}
for _, e := range ctx.PointsLog("") {
if a.Same(e) {
return fmt.Errorf("Points already awarded to this team in this category")
}
}
fn := fmt.Sprintf("%s-%s-%d", teamId, category, points)
tmpfn := ctx.StatePath("points.tmp", fn)
newfn := ctx.StatePath("points.new", fn)
if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil {
return err
}
if err := os.Rename(tmpfn, newfn); err != nil {
return err
}
ctx.update <- true
log.Printf("Award %s %s %d", teamId, category, points)
return nil
}
func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) {
mb, ok := ctx.categories[category]
if !ok {
@ -238,9 +127,3 @@ func (ctx *Instance) ValidTeamId(teamId string) bool {
return ok
}
func (ctx *Instance) TeamName(teamId string) (string, error) {
teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId))
teamName := strings.TrimSpace(string(teamNameBytes))
return teamName, err
}

View File

@ -1,12 +1,10 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
"time"
@ -28,7 +26,7 @@ func (pm *PuzzleMap) MarshalJSON() ([]byte, error) {
func (ctx *Instance) generatePuzzleList() {
maxByCategory := map[string]int{}
for _, a := range ctx.PointsLog("") {
for _, a := range ctx.State.PointsLog("") {
if a.Points > maxByCategory[a.Category] {
maxByCategory[a.Category] = a.Points
}
@ -73,13 +71,13 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte {
Points []*Award `json:"points"`
}
ret.Teams = map[string]string{}
ret.Points = ctx.PointsLog(teamId)
ret.Points = ctx.State.PointsLog(teamId)
teamNumbersById := map[string]int{}
for nr, a := range ret.Points {
teamNumber, ok := teamNumbersById[a.TeamId]
if !ok {
teamName, err := ctx.TeamName(a.TeamId)
teamName, err := ctx.State.TeamName(a.TeamId)
if err != nil {
teamName = "Rodney" // https://en.wikipedia.org/wiki/Rogue_(video_game)#Gameplay
}
@ -95,7 +93,7 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte {
log.Printf("Marshalling points.js: %v", err)
return nil
}
if len(teamId) == 0 {
ctx.jPointsLog = jpl
}
@ -105,7 +103,7 @@ func (ctx *Instance) generatePointsLog(teamId string) []byte {
// maintenance runs
func (ctx *Instance) tidy() {
// Do they want to reset everything?
ctx.MaybeInitialize()
ctx.State.Initialize()
// Check set config
ctx.UpdateConfig()
@ -148,37 +146,19 @@ func (ctx *Instance) tidy() {
// readTeams reads in the list of team IDs,
// so we can quickly validate them.
func (ctx *Instance) readTeams() {
filepath := ctx.StatePath("teamids.txt")
teamids, err := os.Open(filepath)
if err != nil {
log.Printf("Error openining %s: %s", filepath, err)
return
}
defer teamids.Close()
// List out team IDs
newList := map[string]bool{}
scanner := bufio.NewScanner(teamids)
for scanner.Scan() {
teamId := scanner.Text()
if (teamId == "..") || strings.ContainsAny(teamId, "/") {
log.Printf("Dangerous team ID dropped: %s", teamId)
continue
}
newList[scanner.Text()] = true
}
teamList := ctx.State.getTeams()
// For any new team IDs, set their next attempt time to right now
now := time.Now()
added := 0
for k, _ := range newList {
for teamName, _ := range teamList {
ctx.nextAttemptMutex.RLock()
_, ok := ctx.nextAttempt[k]
_, ok := ctx.nextAttempt[teamName]
ctx.nextAttemptMutex.RUnlock()
if !ok {
ctx.nextAttemptMutex.Lock()
ctx.nextAttempt[k] = now
ctx.nextAttempt[teamName] = now
ctx.nextAttemptMutex.Unlock()
added += 1
@ -188,9 +168,9 @@ func (ctx *Instance) readTeams() {
// For any removed team IDs, remove them
removed := 0
ctx.nextAttemptMutex.Lock() // XXX: This could be less of a cludgel
for k, _ := range ctx.nextAttempt {
if _, ok := newList[k]; !ok {
delete(ctx.nextAttempt, k)
for teamName, _ := range ctx.nextAttempt {
if _, ok := teamList[teamName]; !ok {
delete(ctx.nextAttempt, teamName)
}
}
ctx.nextAttemptMutex.Unlock()
@ -200,81 +180,9 @@ func (ctx *Instance) readTeams() {
}
}
// collectPoints gathers up files in points.new/ and appends their contents to points.log,
// removing each points.new/ file as it goes.
func (ctx *Instance) collectPoints() {
logf, err := os.OpenFile(ctx.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("Can't append to points log: %s", err)
return
}
defer logf.Close()
files, err := ioutil.ReadDir(ctx.StatePath("points.new"))
if err != nil {
log.Printf("Error reading packages: %s", err)
}
for _, f := range files {
filename := ctx.StatePath("points.new", f.Name())
s, err := ioutil.ReadFile(filename)
if err != nil {
log.Printf("Can't read points file %s: %s", filename, err)
continue
}
award, err := ParseAward(string(s))
if err != nil {
log.Printf("Can't parse award file %s: %s", filename, err)
continue
}
duplicate := false
for _, e := range ctx.PointsLog("") {
if award.Same(e) {
duplicate = true
break
}
}
if duplicate {
log.Printf("Skipping duplicate points: %s", award.String())
} else {
fmt.Fprintf(logf, "%s\n", award.String())
}
logf.Sync()
if err := os.Remove(filename); err != nil {
log.Printf("Unable to remove %s: %s", filename, err)
}
}
}
func (ctx *Instance) isEnabled() bool {
// Skip if we've been disabled
if _, err := os.Stat(ctx.StatePath("disabled")); err == nil {
log.Print("Suspended: disabled file found")
return false
}
untilspec, err := ioutil.ReadFile(ctx.StatePath("until"))
if err == nil {
untilspecs := strings.TrimSpace(string(untilspec))
until, err := time.Parse(time.RFC3339, untilspecs)
if err != nil {
log.Printf("Suspended: Unparseable until date: %s", untilspec)
return false
}
if until.Before(time.Now()) {
log.Print("Suspended: until time reached, suspending maintenance")
return false
}
}
return true
}
func (ctx *Instance) UpdateConfig() {
// Handle export manifest
if _, err := os.Stat(ctx.StatePath("export_manifest")); err == nil {
if _, err := ctx.State.getConfig("export_manifest"); err == nil {
if !ctx.Runtime.export_manifest {
log.Print("Enabling manifest export")
ctx.Runtime.export_manifest = true
@ -289,10 +197,9 @@ func (ctx *Instance) UpdateConfig() {
// maintenance is the goroutine that runs a periodic maintenance task
func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) {
for {
if ctx.isEnabled() {
if ctx.State.isEnabled() {
ctx.tidy()
ctx.readTeams()
ctx.collectPoints()
ctx.generatePuzzleList()
ctx.generatePointsLog("")
}

View File

@ -17,6 +17,10 @@ func setup() error {
func main() {
ctx := &Instance{}
var state_path string
var state_engine_choice string
var state_engine MOTHState
flag.StringVar(
&ctx.Base,
"base",
@ -30,7 +34,13 @@ func main() {
"Path to read mothballs",
)
flag.StringVar(
&ctx.StateDir,
&state_engine_choice,
"state-engine",
"legacy",
"State engine to use (default: legacy, alt: sqlite)",
)
flag.StringVar(
&state_path,
"state",
"/state",
"Path to write state",
@ -59,10 +69,22 @@ func main() {
)
flag.Parse()
if (state_engine_choice == "legacy") {
lm_engine := &LegacyMOTHState{}
lm_engine.StateDir = state_path
lm_engine.maintenanceInterval = *maintenanceInterval
state_engine = lm_engine
} else {
log.Fatal("Unrecognized state engine '", state_engine_choice, "'")
}
if err := setup(); err != nil {
log.Fatal(err)
}
ctx.State = state_engine
err := ctx.Initialize()
if err != nil {
log.Fatal(err)

21
src/state.go Normal file
View File

@ -0,0 +1,21 @@
package main
import (
"errors"
)
type MOTHState interface {
PointsLog(teamId string) []*Award
AwardPoints(teamID string, category string, points int) error
TeamName(teamId string) (string, error)
isEnabled() bool
getConfig(configName string) (string, error)
getTeams() map[string]struct{}
login(teamId string, token string) (bool, error)
Initialize() (bool, error)
}
var ErrAlreadyRegistered = errors.New("This team ID has already been registered")
var ErrInvalidTeamID = errors.New("Invalid team ID")
var ErrRegistrationError = errors.New("Unable to register. Perhaps a teammate has already registered")