212 lines
5.8 KiB
Go
212 lines
5.8 KiB
Go
|
|
// Command go-places runs the go-places HTTP service: the GraphQL endpoint,
|
||
|
|
// the picture upload endpoint, media serving and the health probes.
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/signal"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"go-places-api/internal/analysis"
|
||
|
|
"go-places-api/internal/auth"
|
||
|
|
"go-places-api/internal/blobstore"
|
||
|
|
"go-places-api/internal/config"
|
||
|
|
"go-places-api/internal/events"
|
||
|
|
"go-places-api/internal/service"
|
||
|
|
"go-places-api/internal/store"
|
||
|
|
"go-places-api/internal/transport"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
if err := run(); err != nil {
|
||
|
|
slog.Error("startup failed", "error", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func run() error {
|
||
|
|
cfg, err := config.Load()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
logger := newLogger()
|
||
|
|
slog.SetDefault(logger)
|
||
|
|
|
||
|
|
logger.Info("starting go-places",
|
||
|
|
"addr", cfg.Addr,
|
||
|
|
"database", cfg.DatabaseURL,
|
||
|
|
"mediaDir", cfg.MediaDir,
|
||
|
|
"authMode", cfg.AuthMode,
|
||
|
|
"analyzerMode", cfg.AnalyzerMode,
|
||
|
|
)
|
||
|
|
|
||
|
|
// The store is opened before anything else so a bad database path fails
|
||
|
|
// startup rather than the first request.
|
||
|
|
db, err := store.OpenSQL(cfg.DatabaseURL)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer db.Close()
|
||
|
|
|
||
|
|
blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
publisher, err := newPublisher(cfg, logger)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer publisher.Close()
|
||
|
|
|
||
|
|
svc := service.New(service.Options{
|
||
|
|
Store: db,
|
||
|
|
Blobs: blobs,
|
||
|
|
Analyzer: newAnalyzer(cfg),
|
||
|
|
Events: publisher,
|
||
|
|
Config: cfg,
|
||
|
|
Logger: slogAdapter{logger},
|
||
|
|
})
|
||
|
|
|
||
|
|
verifier := newVerifier(cfg, logger)
|
||
|
|
logger.Info("token verifier ready", "strategy", verifier.Name())
|
||
|
|
|
||
|
|
server, err := transport.NewServer(transport.Options{
|
||
|
|
Service: svc,
|
||
|
|
Config: cfg,
|
||
|
|
Auth: auth.NewMiddleware(verifier),
|
||
|
|
Blobs: blobs,
|
||
|
|
Logger: logger,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return serve(cfg, server.Handler(), logger)
|
||
|
|
}
|
||
|
|
|
||
|
|
// newPublisher returns the configured event sink. Without AMQP_URL the events go
|
||
|
|
// to the log, which keeps the service runnable on its own.
|
||
|
|
func newPublisher(cfg config.Config, logger *slog.Logger) (events.Publisher, error) {
|
||
|
|
if cfg.AMQPURL == "" {
|
||
|
|
logger.Info("no AMQP_URL configured, publishing events to the log only")
|
||
|
|
return events.NewLogPublisher(logger), nil
|
||
|
|
}
|
||
|
|
p, err := events.NewAMQP(cfg.AMQPURL, cfg.EventsExchange, logger)
|
||
|
|
if err != nil {
|
||
|
|
// A broker that is down at boot should not stop the service: events are
|
||
|
|
// best effort and the log publisher keeps them visible.
|
||
|
|
logger.Warn("could not connect to the event broker, falling back to the log",
|
||
|
|
"error", err)
|
||
|
|
return events.NewLogPublisher(logger), nil
|
||
|
|
}
|
||
|
|
logger.Info("connected to the event broker", "exchange", cfg.EventsExchange)
|
||
|
|
return p, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// newAnalyzer builds the image analyzer. In remote mode the local pass is still
|
||
|
|
// chained in front, so a picture gets badges even if the inference service is
|
||
|
|
// unreachable.
|
||
|
|
func newAnalyzer(cfg config.Config) analysis.Analyzer {
|
||
|
|
local := analysis.NewLocal(analysis.DefaultMaxDimension)
|
||
|
|
if cfg.AnalyzerMode != "remote" {
|
||
|
|
return local
|
||
|
|
}
|
||
|
|
remote := analysis.NewRemote(cfg.AnalyzerURL, cfg.AnalyzerKey, cfg.AnalyzerTimeout)
|
||
|
|
return analysis.NewComposite(local, remote)
|
||
|
|
}
|
||
|
|
|
||
|
|
// newVerifier selects how bearer tokens are checked. The dev verifier is
|
||
|
|
// refused unless it was asked for explicitly, so it cannot be reached by
|
||
|
|
// accident in a deployed environment.
|
||
|
|
func newVerifier(cfg config.Config, logger *slog.Logger) auth.Verifier {
|
||
|
|
switch cfg.AuthMode {
|
||
|
|
case "jwt":
|
||
|
|
return auth.NewJWTVerifier(cfg.JWTSecret, cfg.JWTAudience, "")
|
||
|
|
default:
|
||
|
|
logger.Warn("AUTH_MODE=dev: tokens are checked against a fixed table, " +
|
||
|
|
"do not use this outside development")
|
||
|
|
return auth.NewDevVerifier(nil)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// serve runs the HTTP server until the process is asked to stop, then drains
|
||
|
|
// in-flight requests before returning.
|
||
|
|
func serve(cfg config.Config, handler http.Handler, logger *slog.Logger) error {
|
||
|
|
srv := &http.Server{
|
||
|
|
Addr: cfg.Addr,
|
||
|
|
Handler: handler,
|
||
|
|
// Generous timeouts: uploads of several megabytes over a slow link are
|
||
|
|
// legitimate, so the write window has to outlast one.
|
||
|
|
ReadHeaderTimeout: 10 * time.Second,
|
||
|
|
ReadTimeout: 2 * time.Minute,
|
||
|
|
WriteTimeout: 2 * time.Minute,
|
||
|
|
IdleTimeout: 2 * time.Minute,
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
errc := make(chan error, 1)
|
||
|
|
go func() {
|
||
|
|
logger.Info("listening", "addr", cfg.Addr)
|
||
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
|
|
errc <- fmt.Errorf("http server: %w", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
errc <- nil
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case err := <-errc:
|
||
|
|
return err
|
||
|
|
case <-ctx.Done():
|
||
|
|
logger.Info("shutdown signal received, draining connections")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A second signal aborts the drain immediately.
|
||
|
|
stop()
|
||
|
|
|
||
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
|
|
return fmt.Errorf("http shutdown: %w", err)
|
||
|
|
}
|
||
|
|
logger.Info("shutdown complete")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// newLogger returns a structured logger reading LOG_LEVEL (debug, info, warn,
|
||
|
|
// error) and LOG_FORMAT (json or text).
|
||
|
|
func newLogger() *slog.Logger {
|
||
|
|
level := slog.LevelInfo
|
||
|
|
switch os.Getenv("LOG_LEVEL") {
|
||
|
|
case "debug":
|
||
|
|
level = slog.LevelDebug
|
||
|
|
case "warn", "warning":
|
||
|
|
level = slog.LevelWarn
|
||
|
|
case "error":
|
||
|
|
level = slog.LevelError
|
||
|
|
}
|
||
|
|
|
||
|
|
opts := &slog.HandlerOptions{Level: level}
|
||
|
|
if os.Getenv("LOG_FORMAT") == "text" {
|
||
|
|
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||
|
|
}
|
||
|
|
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||
|
|
}
|
||
|
|
|
||
|
|
// slogAdapter narrows *slog.Logger to the small logging surface the service
|
||
|
|
// declares, which keeps slog out of the service package.
|
||
|
|
type slogAdapter struct{ l *slog.Logger }
|
||
|
|
|
||
|
|
func (a slogAdapter) Warn(msg string, args ...any) { a.l.Warn(msg, args...) }
|
||
|
|
func (a slogAdapter) Info(msg string, args ...any) { a.l.Info(msg, args...) }
|