diff --git a/build/package/Containerfile b/build/package/Containerfile index 238db91..e93c1b4 100644 --- a/build/package/Containerfile +++ b/build/package/Containerfile @@ -7,6 +7,7 @@ COPY example-puzzles /target/puzzles/ COPY LICENSE.md /target/ RUN mkdir -p /target/state WORKDIR /src/ +RUN go get github.com/go-redis/redis/v8 RUN CGO_ENABLED=0 GOOS=linux go install -a -ldflags '-extldflags "-static"' ./... # I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 32a1cc4..03adedf 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -6,6 +6,7 @@ import ( "log" "mime" "os" + "strconv" "time" "github.com/spf13/afero" @@ -52,6 +53,25 @@ func main() { "", "Random seed to use, overrides $SEED", ) + + stateEngine := flag.String( + "state-engine", + "legacy", + "Specifiy a state engine", + ) + + redis_url := flag.String( + "redis-url", + "", + "URL for Redis state instance", + ) + + redis_db := flag.Uint64( + "redis-db", + ^uint64(0), + "Database number for Redis state instance", + ) + flag.Parse() osfs := afero.NewOsFs() @@ -68,7 +88,34 @@ func main() { } var state StateProvider - state = NewState(afero.NewBasePathFs(osfs, *statePath)) + + switch engine := *stateEngine; engine { + case "redis": + redis_url_parsed := *redis_url + if redis_url_parsed == "" { + redis_url_parsed = os.Getenv("REDIS_URL") + if redis_url_parsed == "" { + log.Fatal("Redis mode was selected, but --redis-url or REDIS_URL were not set") + } + } + + redis_db_parsed := *redis_db + if redis_db_parsed == ^uint64(0) { + redis_db_parsed_inner, err := strconv.ParseUint(os.Getenv("REDIS_DB"), 10, 64) + redis_db_parsed = redis_db_parsed_inner + + if err != nil { + log.Fatal("Redis mode was selected, but --redis-db or REDIS_DB were not set") + } + } + + state = NewRedisState(redis_url_parsed, int(redis_db_parsed)) + default: + case "legacy": + state = NewState(afero.NewBasePathFs(osfs, *statePath)) + } + + if config.Devel { state = NewDevelState(state) } diff --git a/cmd/mothd/redis_state.go b/cmd/mothd/redis_state.go new file mode 100644 index 0000000..41fda79 --- /dev/null +++ b/cmd/mothd/redis_state.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "github.com/go-redis/redis/v8" + "github.com/dirtbags/moth/pkg/award" +) + +const ( + REDIS_KEY_MESSAGE = "moth:message" + REDIS_KEY_TEAMS = "moth:teams" + REDIS_KEY_TEAM_IDS = "moth:team_ids" + REDIS_KEY_POINT_LOG = "moth:points" +) + +type RedisState struct { + ctx context.Context + + redis_client *redis.Client + + // Enabled tracks whether the current State system is processing updates + Enabled bool + + eventStream chan []string + + //lock sync.RWMutex +} + +type RedisEventEntry struct { + /* + []string{ + strconv.FormatInt(time.Now().Unix(), 10), + event, + participantID, + teamID, + cat, + strconv.Itoa(points), + }, + extra...,*/ + // Unix epoch time of this event + When int64 + Event string + ParticipantID string + TeamID string + Category string + Points int + Extra []string +} + +// MarshalJSON returns the award event, encoded as a list. +func (e RedisEventEntry) MarshalJSON() ([]byte, error) { + /*ao := []interface{}{ + a.When, + a.TeamID, + a.Category, + a.Points, + }*/ + + return json.Marshal(e) +} + +// UnmarshalJSON decodes the JSON string b. +func (e RedisEventEntry) UnmarshalJSON(b []byte) error { + //r := bytes.NewReader(b) + //dec := json.NewDecoder(r) + //dec.UseNumber() // Don't use floats + json.Unmarshal(b, &e) + + return nil +} + +// NewRedisState returns a new State struct backed by the given Fs +func NewRedisState(redis_addr string, redis_db int) *RedisState { + + rdb := redis.NewClient(&redis.Options{ + Addr: redis_addr, + Password: "", // no password set + DB: redis_db, // use default DB + }) + + s := &RedisState{ + Enabled: true, + eventStream: make(chan []string, 80), + + ctx: context.Background(), + redis_client: rdb, + } + + return s +} + +// Messages retrieves the current messages. +func (s *RedisState) Messages() string { + val, err := s.redis_client.Get(s.ctx, REDIS_KEY_MESSAGE).Result() + + if err != nil { + return "" + } + + return val +} + +// TeamName returns team name given a team ID. +func (s *RedisState) TeamName(teamID string) (string, error) { + team_name, err := s.redis_client.HGet(s.ctx, REDIS_KEY_TEAMS, teamID).Result() + + if err != nil { + return "", fmt.Errorf("unregistered team ID: %s", teamID) + } + + return team_name, nil +} + +// SetTeamName writes out team name. +// This can only be done once per team. +func (s *RedisState) SetTeamName(teamID, teamName string) error { + valid_id, err := s.redis_client.SIsMember(s.ctx, REDIS_KEY_TEAM_IDS, teamID).Result() + + if err != nil { + return fmt.Errorf("Unexpected error while validating team ID: %s", teamID) + } else if (!valid_id) { + return fmt.Errorf("team ID: (%s) not found in list of valid team IDs", teamID) + } + + success, err := s.redis_client.HSetNX(s.ctx, REDIS_KEY_TEAMS, teamID, teamName).Result() + + if err != nil { + return fmt.Errorf("Unexpected error while setting team ID: %s and team Name: %s", teamID, teamName) + } + + if (success) { + return nil + } + + return fmt.Errorf("Team ID: %s is already set", teamID) +} + +// PointsLog retrieves the current points log. +func (s *RedisState) PointsLog() award.List { + redis_args := &redis.ZRangeBy{ + Min: "0", + Max: "-1", + } + scores, err := s.redis_client.ZRangeByScoreWithScores(s.ctx, REDIS_KEY_POINT_LOG, redis_args).Result() + + if err != nil { + return make(award.List, 0) + } + + point_log := make(award.List, len(scores)) + + for _, item := range scores { + point_entry := award.T{} + + point_string := strings.TrimSpace(item.Member.(string)) + + point_entry.When = int64(item.Score) + n, err := fmt.Sscanf(point_string, "%s %s %d", &point_entry.TeamID, &point_entry.Category, &point_entry.Points) + + if err != nil { + // Do nothing + } else if n != 3 { + // Wrong number of fields, do nothing + } else { + point_log = append(point_log, point_entry) + } + } + + return point_log +} + +func (s *RedisState) AwardPoints(teamID, category string, points int) error { + redis_args := redis.ZAddArgs { + LT: true, + } + awardTime := time.Now().Unix() + + point_string := fmt.Sprintf("%s %s %s", teamID, category, points) + + new_member := redis.Z{ + Score: float64(awardTime), + Member: point_string, + } + + redis_args.Members = append(redis_args.Members, new_member) + + _, err := s.redis_client.ZAddArgs(s.ctx, REDIS_KEY_POINT_LOG, redis_args).Result() + + if err != nil { + return err + } + + return nil +} + +// LogEvent writes to the event log +func (s *RedisState) LogEvent(event, participantID, teamID, cat string, points int, extra ...string) { + new_event := RedisEventEntry { + When: time.Now().Unix(), + Event: event, + ParticipantID: participantID, + TeamID: teamID, + Category: cat, + Points: points, + Extra: extra, + } + + message := new_event.MarshalJSON() + /* + s.eventStream <- append( + []string{ + strconv.FormatInt(time.Now().Unix(), 10), + event, + participantID, + teamID, + cat, + strconv.Itoa(points), + }, + extra..., + )*/ + + +} + + +func (s *RedisState) Maintain(updateInterval time.Duration) { + /* + ticker := time.NewTicker(updateInterval) + s.refresh() + for { + select { + case msg := <-s.eventStream: + s.eventWriter.Write(msg) + s.eventWriter.Flush() + s.eventWriterFile.Sync() + case <-ticker.C: + s.refresh() + case <-s.refreshNow: + s.refresh() + } + } + */ +} \ No newline at end of file