feat: initial commit
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Command scoreboard-apid serves the root-me.org scoreboard over HTTP/JSON and
|
||||
// gRPC. It scrapes the public Rankings fragment through a native Anubis
|
||||
// proof-of-work solver, caching the result in memory and refreshing on an
|
||||
// interval.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/anubis"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/config"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/scraper"
|
||||
"git.mkz.me/mycroft/root-me-api/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfg, err := config.Load(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := setupLogger(cfg.LogLevel)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Anubis-solving HTTP client.
|
||||
tr, err := anubis.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr.UserAgent = cfg.UserAgent
|
||||
tr.Logger = logger
|
||||
client := tr.Client()
|
||||
client.Timeout = cfg.RequestTimeout
|
||||
|
||||
// Scraper + store + refresher.
|
||||
sc := scraper.New(client, logger)
|
||||
store := scoreboard.NewStore()
|
||||
fetch := func(ctx context.Context) ([]scoreboard.Entry, error) {
|
||||
return sc.Fetch(ctx, cfg.Pages)
|
||||
}
|
||||
refresher := scoreboard.NewRefresher(store, fetch, cfg.RefreshInterval, logger)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Warm start: try once before serving so /readyz flips green quickly. A
|
||||
// failure here is non-fatal — the refresher will retry on its interval.
|
||||
warmCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout*time.Duration(cfg.Pages+1))
|
||||
if err := refresher.RefreshOnce(warmCtx); err != nil {
|
||||
logger.Warn("initial scoreboard load failed; serving will start unready", "err", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
go refresher.Run(ctx)
|
||||
|
||||
// HTTP server.
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: server.HTTPHandler(store, refresher, logger),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
// gRPC server.
|
||||
grpcSrv := grpc.NewServer()
|
||||
pb.RegisterScoreboardServiceServer(grpcSrv, server.NewGRPC(store))
|
||||
reflection.Register(grpcSrv)
|
||||
|
||||
grpcLis, err := net.Listen("tcp", cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grpc listen: %w", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
logger.Info("HTTP server listening", "addr", cfg.HTTPAddr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("http: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
logger.Info("gRPC server listening", "addr", cfg.GRPCAddr)
|
||||
if err := grpcSrv.Serve(grpcLis); err != nil {
|
||||
errCh <- fmt.Errorf("grpc: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
logger.Error("server error", "err", err)
|
||||
}
|
||||
|
||||
// Graceful shutdown.
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutCancel()
|
||||
if err := httpSrv.Shutdown(shutCtx); err != nil {
|
||||
logger.Warn("http shutdown", "err", err)
|
||||
}
|
||||
grpcSrv.GracefulStop()
|
||||
logger.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
Reference in New Issue
Block a user