Nereus/apps/api/main.go

165 lines
4.3 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
)
var appVersion = "dev"
type config struct {
port string
database string
chaosRate float64
logLevel slog.Level
traceReady bool
}
func main() {
if err := run(); err != nil {
slog.Error("startup failed", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig()
if err != nil {
return err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.logLevel}))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
shutdownTrace, err := configureTracing(ctx, cfg.traceReady)
if err != nil {
return err
}
defer func() {
if err := shutdownTrace(context.Background()); err != nil {
logger.Error("trace shutdown failed", "error", err)
}
}()
registry := prometheus.NewRegistry()
m := newMetrics(registry)
db, err := newPostgresStore(ctx, cfg.database, m)
if err != nil {
return err
}
defer db.Close()
var migrated atomic.Bool
go migrateUntilReady(ctx, db, &migrated, logger)
application := &api{store: db, metrics: m, logger: logger, version: appVersion, chaosRate: cfg.chaosRate, migrated: &migrated, prometheus: registry}
server := &http.Server{
Addr: ":" + cfg.port,
Handler: application.routes(),
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
errCh := make(chan error, 1)
go func() { errCh <- server.ListenAndServe() }()
logger.Info("server started", "port", cfg.port, "version", appVersion)
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown server: %w", err)
}
return nil
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
}
}
func loadConfig() (config, error) {
cfg := config{port: envOr("PORT", "8080"), database: os.Getenv("DATABASE_URL"), traceReady: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != ""}
if cfg.database == "" {
return config{}, errors.New("DATABASE_URL is required")
}
chaos, err := strconv.ParseFloat(envOr("CHAOS_ERROR_RATE", "0.0"), 64)
if err != nil || chaos < 0 || chaos > 1 {
return config{}, errors.New("CHAOS_ERROR_RATE must be between 0.0 and 1.0")
}
cfg.chaosRate = chaos
switch envOr("LOG_LEVEL", "info") {
case "debug":
cfg.logLevel = slog.LevelDebug
case "info":
cfg.logLevel = slog.LevelInfo
case "warn":
cfg.logLevel = slog.LevelWarn
case "error":
cfg.logLevel = slog.LevelError
default:
return config{}, errors.New("LOG_LEVEL must be debug, info, warn, or error")
}
return cfg, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func configureTracing(ctx context.Context, enabled bool) (func(context.Context) error, error) {
if !enabled {
return func(context.Context) error { return nil }, nil
}
exporter, err := otlptracegrpc.New(ctx)
if err != nil {
return nil, fmt.Errorf("create trace exporter: %w", err)
}
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName("nereus-api")))
if err != nil {
return nil, fmt.Errorf("create trace resource: %w", err)
}
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
otel.SetTracerProvider(provider)
return provider.Shutdown, nil
}
func migrateUntilReady(ctx context.Context, db store, ready *atomic.Bool, logger *slog.Logger) {
for {
attempt, cancel := context.WithTimeout(ctx, 5*time.Second)
err := db.Migrate(attempt)
cancel()
if err == nil {
ready.Store(true)
logger.Info("database migrations applied")
return
}
logger.Warn("database unavailable; migration will retry", "error", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}