feat: add runnable control-plane API role

This commit is contained in:
Josh Creek
2026-09-01 09:24:05 +01:00
parent 18888ed520
commit fe9f6f3cb5
4 changed files with 100 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/cosmic-clash/cosmic-clash/server/api"
"github.com/cosmic-clash/cosmic-clash/server/migrations"
"github.com/cosmic-clash/cosmic-clash/server/store"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
role := flag.String("role", "api", "control-plane role; currently api")
dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string")
migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations")
flag.Parse()
if *role != "api" {
fatalf("unsupported role %q (only api is implemented)", *role)
}
if *dsn == "" {
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
}
db, err := sql.Open("pgx", *dsn)
if err != nil {
fatalf("open PostgreSQL: %v", err)
}
defer db.Close()
startupCtx, startupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer startupCancel()
if err := db.PingContext(startupCtx); err != nil {
fatalf("ping PostgreSQL: %v", err)
}
if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil {
fatalf("apply migrations: %v", err)
}
server := &http.Server{Addr: *listen, Handler: newAPIHandler(db), ReadHeaderTimeout: 5 * time.Second}
serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serveErr:
if err != nil && err != http.ErrServerClosed {
fatalf("serve API: %v", err)
}
case <-ctx.Done():
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
fatalf("shutdown API: %v", err)
}
}
}
func newAPIHandler(db *sql.DB) http.Handler {
return (&api.Service{
SessionBackend: store.PostgresSessions{DB: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
Assignment: api.AssignmentProviderFromStore(db),
Now: func() time.Time { return time.Now().UTC() },
}).Handler()
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "control-plane: "+format+"\n", args...)
os.Exit(1)
}
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAPIHandlerExposesHealthWithoutDatabase(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
newAPIHandler(nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health status = %d", rec.Code)
}
}