Implements the K8s-native Go API service per the plan architecture: - HTTP server with graceful shutdown and env-var configuration - PostgreSQL schema (bots, matches, match_participants, jobs, rating_history) - Health/ready endpoints checking PostgreSQL and Valkey connectivity - Bot registration with health check, HMAC secret gen, AES-256-GCM encryption - Key rotation and bot status endpoints - Job claim via Valkey BRPOP, result submission with Glicko-2 rating update - Glicko-2 rating system: multi-player pairwise, Illinois volatility algorithm - Background tickers: matchmaker (1m), health checker (15m), stale job reaper (5m) - Worker API key authentication (Bearer/X-API-Key) - Dockerfile, K8s Deployment (2 replicas), ClusterIP Service - 30 unit tests covering Glicko-2, crypto, config, and handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
6.5 KiB
Go
231 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var validBotName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{1,30}[a-zA-Z0-9]$`)
|
|
|
|
type RegisterRequest struct {
|
|
Name string `json:"name"`
|
|
EndpointURL string `json:"endpoint_url"`
|
|
Owner string `json:"owner"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type RegisterResponse struct {
|
|
BotID string `json:"bot_id"`
|
|
SharedSecret string `json:"shared_secret"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
var req RegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Owner = strings.TrimSpace(req.Owner)
|
|
req.EndpointURL = strings.TrimSpace(req.EndpointURL)
|
|
|
|
if !validBotName.MatchString(req.Name) {
|
|
writeError(w, http.StatusBadRequest, "name must be 3-32 alphanumeric/hyphen chars")
|
|
return
|
|
}
|
|
if req.EndpointURL == "" {
|
|
writeError(w, http.StatusBadRequest, "endpoint_url is required")
|
|
return
|
|
}
|
|
if req.Owner == "" {
|
|
writeError(w, http.StatusBadRequest, "owner is required")
|
|
return
|
|
}
|
|
|
|
// Health check the bot endpoint
|
|
if err := s.checkBotHealth(req.EndpointURL); err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("bot health check failed: %v", err))
|
|
return
|
|
}
|
|
|
|
botID, err := generateID("b_", 4) // b_ + 8 hex chars
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate bot ID")
|
|
return
|
|
}
|
|
|
|
secret, err := generateSecret()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate shared secret")
|
|
return
|
|
}
|
|
|
|
// Encrypt secret for storage
|
|
encryptedSecret := secret // default: store plaintext if no key
|
|
if s.cfg.EncryptionKey != "" {
|
|
encryptedSecret, err = encryptSecret(secret, s.cfg.EncryptionKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
return
|
|
}
|
|
}
|
|
|
|
_, err = s.db.ExecContext(r.Context(),
|
|
`INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status, description, last_active)
|
|
VALUES ($1, $2, $3, $4, $5, 'active', $6, NOW())`,
|
|
botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret, req.Description,
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
|
writeError(w, http.StatusConflict, "bot name already taken")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to register bot")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, RegisterResponse{
|
|
BotID: botID,
|
|
SharedSecret: secret,
|
|
Message: "Bot registered. Save the shared_secret — it will not be shown again.",
|
|
})
|
|
}
|
|
|
|
func (s *Server) checkBotHealth(endpointURL string) error {
|
|
url := strings.TrimRight(endpointURL, "/") + "/health"
|
|
client := &http.Client{Timeout: time.Duration(s.cfg.BotTimeoutSecs) * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("connection failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type RotateKeyRequest struct {
|
|
BotID string `json:"bot_id"`
|
|
SharedSecret string `json:"shared_secret"`
|
|
Retire bool `json:"retire,omitempty"`
|
|
}
|
|
|
|
type RotateKeyResponse struct {
|
|
NewSecret string `json:"new_secret,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
|
|
var req RotateKeyRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
// Verify current secret
|
|
var storedSecret string
|
|
err := s.db.QueryRowContext(r.Context(),
|
|
`SELECT shared_secret FROM bots WHERE bot_id = $1`, req.BotID,
|
|
).Scan(&storedSecret)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "bot not found")
|
|
return
|
|
}
|
|
|
|
// Decrypt stored secret for comparison
|
|
plainSecret := storedSecret
|
|
if s.cfg.EncryptionKey != "" {
|
|
plainSecret, err = decryptSecret(storedSecret, s.cfg.EncryptionKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "decryption error")
|
|
return
|
|
}
|
|
}
|
|
|
|
if plainSecret != req.SharedSecret {
|
|
writeError(w, http.StatusUnauthorized, "invalid shared secret")
|
|
return
|
|
}
|
|
|
|
if req.Retire {
|
|
_, err = s.db.ExecContext(r.Context(),
|
|
`UPDATE bots SET status = 'retired' WHERE bot_id = $1`, req.BotID,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to retire bot")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, RotateKeyResponse{Message: "Bot retired."})
|
|
return
|
|
}
|
|
|
|
newSecret, err := generateSecret()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate new secret")
|
|
return
|
|
}
|
|
|
|
encryptedSecret := newSecret
|
|
if s.cfg.EncryptionKey != "" {
|
|
encryptedSecret, err = encryptSecret(newSecret, s.cfg.EncryptionKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
return
|
|
}
|
|
}
|
|
|
|
_, err = s.db.ExecContext(r.Context(),
|
|
`UPDATE bots SET shared_secret = $1 WHERE bot_id = $2`,
|
|
encryptedSecret, req.BotID,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update secret")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, RotateKeyResponse{
|
|
NewSecret: newSecret,
|
|
Message: "Secret rotated. Save the new secret — it will not be shown again.",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBotStatus(w http.ResponseWriter, r *http.Request) {
|
|
botID := r.PathValue("bot_id")
|
|
|
|
var bot struct {
|
|
BotID string `json:"bot_id"`
|
|
Name string `json:"name"`
|
|
Owner string `json:"owner"`
|
|
Status string `json:"status"`
|
|
Rating float64 `json:"rating"`
|
|
RatingMu float64 `json:"rating_mu"`
|
|
RatingPhi float64 `json:"rating_phi"`
|
|
Description *string `json:"description,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastActive *string `json:"last_active,omitempty"`
|
|
}
|
|
|
|
var desc, lastActive *string
|
|
err := s.db.QueryRowContext(r.Context(),
|
|
`SELECT bot_id, name, owner, status, rating_mu, rating_phi, description, created_at, last_active
|
|
FROM bots WHERE bot_id = $1`, botID,
|
|
).Scan(&bot.BotID, &bot.Name, &bot.Owner, &bot.Status, &bot.RatingMu, &bot.RatingPhi,
|
|
&desc, &bot.CreatedAt, &lastActive)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "bot not found")
|
|
return
|
|
}
|
|
|
|
bot.Description = desc
|
|
bot.LastActive = lastActive
|
|
bot.Rating = bot.RatingMu - 2*bot.RatingPhi // conservative display rating
|
|
|
|
writeJSON(w, http.StatusOK, bot)
|
|
}
|