diff --git a/database/seeder/importer/cmd/main.go b/database/seeder/importer/cmd/main.go index b525a8a6..49d7f7f2 100644 --- a/database/seeder/importer/cmd/main.go +++ b/database/seeder/importer/cmd/main.go @@ -16,19 +16,18 @@ import ( const defaultSQLDumpPath = "./storage/sql/dump.sql" -var ( - environment *env.Environment - sentryHub *portal.Sentry -) +func main() { + defer sentry.Flush(2 * time.Second) -func init() { - secrets := kernel.Ignite("./.env", portal.GetDefaultValidator()) + validate := portal.GetDefaultValidator() + environment, err := kernel.Ignite("./.env", validate) + if err != nil { + cli.Errorln(fmt.Errorf("ignite environment: %w", err).Error()) + os.Exit(1) + } - environment = secrets - sentryHub = kernel.NewSentry(environment) -} + sentryHub := kernel.NewSentry(environment) -func main() { if err := run(defaultSQLDumpPath, environment, sentryHub); err != nil { cli.Errorln(err.Error()) os.Exit(1) @@ -52,7 +51,6 @@ func run(filePath string, environment *env.Environment, sentryHub *portal.Sentry dbConnection := kernel.NewDbConnection(environment) logs := kernel.NewLogs(environment) - defer sentry.Flush(2 * time.Second) defer logs.Close() defer dbConnection.Close() defer kernel.RecoverWithSentry(sentryHub) diff --git a/database/seeder/main.go b/database/seeder/main.go index 78d116d1..f524bcf0 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -1,53 +1,70 @@ package main import ( + "errors" + "fmt" + "os" "sync" "time" "github.com/getsentry/sentry-go" "github.com/oullin/database" "github.com/oullin/database/seeder/seeds" - "github.com/oullin/metal/env" "github.com/oullin/metal/kernel" "github.com/oullin/pkg/cli" "github.com/oullin/pkg/portal" ) -var sentryHub *portal.Sentry -var environment *env.Environment - -func init() { - secrets := kernel.Ignite("./.env", portal.GetDefaultValidator()) +func main() { + defer sentry.Flush(2 * time.Second) - environment = secrets - sentryHub = kernel.NewSentry(environment) + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + cli.Errorln(err.Error()) + sentry.Flush(2 * time.Second) + os.Exit(1) + } } -func main() { +func run() error { cli.ClearScreen() + validate := portal.GetDefaultValidator() + environment, err := kernel.Ignite("./.env", validate) + if err != nil { + return fmt.Errorf("ignite environment: %w", err) + } + + hub := kernel.NewSentry(environment) + + defer kernel.RecoverWithSentry(hub) + dbConnection := kernel.NewDbConnection(environment) - logs := kernel.NewLogs(environment) + if dbConnection == nil { + return errors.New("database connection is nil") + } + defer dbConnection.Close() - defer sentry.Flush(2 * time.Second) + logs := kernel.NewLogs(environment) + if logs == nil { + return errors.New("logs driver is nil") + } defer logs.Close() - defer (*dbConnection).Close() - defer kernel.RecoverWithSentry(sentryHub) - // [1] --- Create the Seeder Runner. seeder := seeds.NewSeeder(dbConnection, environment) + if seeder == nil { + return errors.New("seeder is nil") + } - // [2] --- Truncate the db. if err := seeder.TruncateDB(); err != nil { - panic(err) - } else { - cli.Successln("db Truncated successfully ...") - time.Sleep(2 * time.Second) + return fmt.Errorf("truncate database: %w", err) } - // [3] --- Seed users and posts sequentially because the below seeders depend on them. - UserA, UserB := seeder.SeedUsers() - posts := seeder.SeedPosts(UserA, UserB) + cli.Successln("db Truncated successfully ...") + time.Sleep(2 * time.Second) + + userA, userB := seeder.SeedUsers() + posts := seeder.SeedPosts(userA, userB) categoriesChan := make(chan []database.Category) tagsChan := make(chan []database.Tag) @@ -66,11 +83,9 @@ func main() { tagsChan <- seeder.SeedTags() }() - // [4] Use channels to concurrently seed categories and tags since they are main dependencies. categories := <-categoriesChan tags := <-tagsChan - // [5] Use a WaitGroup to run independent seeding tasks concurrently. var wg sync.WaitGroup wg.Add(6) @@ -106,7 +121,7 @@ func main() { defer wg.Done() cli.Warningln("Seeding views ...") - seeder.SeedPostViews(posts, UserA, UserB) + seeder.SeedPostViews(posts, userA, userB) }() go func() { @@ -122,4 +137,6 @@ func main() { wg.Wait() cli.Magentaln("db seeded as expected ....") + + return nil } diff --git a/docker-compose.yml b/docker-compose.yml index 6a0ee3d8..513b806a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: # A dedicated service for running one-off Go commands api-runner: + container_name: runner restart: no env_file: - ./.env @@ -87,7 +88,6 @@ services: context: . dockerfile: ./docker/dockerfile-api target: builder - image: api-api-runner volumes: - .:/app - go_mod_cache:/go/pkg/mod @@ -116,7 +116,6 @@ services: user: root security_opt: - apparmor:unconfined - image: api-api env_file: - .env volumes: @@ -137,7 +136,6 @@ services: - APP_GROUP=${ENV_DOCKER_USER_GROUP} - APP_DIR=/app - BINARY_NAME=oullin_api - container_name: oullin_api restart: unless-stopped secrets: - pg_username diff --git a/docker/dockerfile-api b/docker/dockerfile-api index ac4869a3..48025e3e 100644 --- a/docker/dockerfile-api +++ b/docker/dockerfile-api @@ -17,7 +17,7 @@ ARG BINARY_NAME=oullin_api ARG GO_VERSION=1.25.3 ARG GO_IMAGE_VARIANT=alpine3.22 ARG GO_TOOLCHAIN=go1.25.3 -ARG GOLANG_ALPINE_DIGEST=sha256:c3dc5d5e8cf34ccb2172fb8d1aa399aa13cd8b60d27bba891d18e3b436a0c5f6 +ARG GOLANG_ALPINE_DIGEST=sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34 ARG ALPINE_VERSION=3.22 ARG ALPINE_DIGEST=sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 diff --git a/main.go b/main.go index 9b26ff4d..aec9a848 100644 --- a/main.go +++ b/main.go @@ -1,95 +1,85 @@ package main import ( + "errors" "fmt" "log/slog" "net/http" + "os" "time" "github.com/getsentry/sentry-go" _ "github.com/lib/pq" "github.com/oullin/metal/kernel" + "github.com/oullin/pkg/endpoint" "github.com/oullin/pkg/portal" - "github.com/rs/cors" ) -var app *kernel.App +func main() { + defer sentry.Flush(2 * time.Second) -func init() { - validate := portal.GetDefaultValidator() - secrets := kernel.Ignite("./.env", validate) - application, err := kernel.NewApp(secrets, validate) + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + if !sentry.Flush(2 * time.Second) { + slog.Warn("sentry flush timed out after capture") + } + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} +func run() error { + validate := portal.GetDefaultValidator() + secrets, err := kernel.Ignite("./.env", validate) if err != nil { - panic(fmt.Sprintf("init: Error creating application: %s", err)) + return fmt.Errorf("ignite environment: %w", err) } - app = application -} + app, err := kernel.NewApp(secrets, validate) + if err != nil { + return fmt.Errorf("create application: %w", err) + } -func main() { - defer sentry.Flush(2 * time.Second) defer app.CloseDB() defer app.CloseLogs() defer app.Recover() app.Boot() - // --- Testing if err := app.GetDB().Ping(); err != nil { - slog.Error("database ping failed", "error", err) + return fmt.Errorf("database ping failed: %w", err) } - slog.Info("Starting new server on :" + app.GetEnv().Network.HttpPort) - // --- - if err := http.ListenAndServe(app.GetEnv().Network.GetHostURL(), serverHandler()); err != nil { - sentry.CurrentHub().CaptureException(err) - slog.Error("Error starting server", "error", err) - panic("Error starting server." + err.Error()) + env := app.GetEnv() + if env == nil { + return errors.New("application environment is nil") } -} + addr := env.Network.GetHostURL() -func serverHandler() http.Handler { - mux := app.GetMux() - if mux == nil { - return http.NotFoundHandler() + var wrap func(http.Handler) http.Handler + if sentry := app.GetSentry(); sentry != nil && sentry.Handler != nil { + wrap = sentry.Handler.Handle } - var handler http.Handler = mux - - if !app.IsProduction() { // Caddy handles CORS. - localhost := app.GetEnv().Network.GetHostURL() - - headers := []string{ - "Accept", - "Authorization", - "Content-Type", - "X-CSRF-Token", - "User-Agent", - "X-API-Key", - "X-API-Username", - "X-API-Signature", - "X-API-Timestamp", - "X-API-Nonce", - "X-Request-ID", - "If-None-Match", - "X-API-Intended-Origin", //new - } - - c := cors.New(cors.Options{ - AllowedOrigins: []string{localhost, "http://localhost:5173"}, - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions}, - AllowedHeaders: headers, - AllowCredentials: true, - Debug: true, - }) - - handler = c.Handler(handler) + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{ + Mux: app.GetMux(), + IsProduction: app.IsProduction(), + DevHost: addr, + Wrap: wrap, + }) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, } - if sentry := app.GetSentry(); sentry != nil && sentry.Handler != nil { - handler = sentry.Handler.Handle(handler) + if err := endpoint.RunServer(addr, server); err != nil { + return fmt.Errorf("serve http: %w", err) } - return handler + return nil } diff --git a/metal/cli/main.go b/metal/cli/main.go index ba9b1635..411b07ac 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -1,7 +1,9 @@ package main import ( + "errors" "fmt" + "os" "time" "github.com/getsentry/sentry-go" @@ -16,87 +18,76 @@ import ( "github.com/oullin/pkg/portal" ) -var environment *env.Environment -var dbConn *database.Connection -var sentryHub *portal.Sentry - -func init() { - secrets := kernel.Ignite("./.env", portal.GetDefaultValidator()) +func main() { + defer sentry.Flush(2 * time.Second) - environment = secrets - dbConn = kernel.NewDbConnection(environment) - sentryHub = kernel.NewSentry(environment) + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + cli.Errorln(err.Error()) + sentry.Flush(2 * time.Second) + os.Exit(1) + } } -func main() { +func run() error { cli.ClearScreen() - defer sentry.Flush(2 * time.Second) - defer kernel.RecoverWithSentry(sentryHub) + validate := portal.GetDefaultValidator() + environment, err := kernel.Ignite("./.env", validate) + if err != nil { + return fmt.Errorf("ignite environment: %w", err) + } + + hub := kernel.NewSentry(environment) + + defer kernel.RecoverWithSentry(hub) + + dbConn := kernel.NewDbConnection(environment) + if dbConn == nil { + return errors.New("database connection is nil") + } + defer dbConn.Close() menu := panel.NewMenu() for { - err := menu.CaptureInput() - - if err != nil { + if err := menu.CaptureInput(); err != nil { cli.Errorln(err.Error()) continue } switch menu.GetChoice() { case 1: - if err = createBlogPost(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := createBlogPost(menu, dbConn); err != nil { + return err } - - return case 2: - if err = createNewApiAccount(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := createNewApiAccount(menu, dbConn, environment); err != nil { + return err } - - return case 3: - if err = showApiAccount(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := showApiAccount(menu, dbConn, environment); err != nil { + return err } - - return case 4: - if err = generateStaticSEO(); err != nil { - cli.Errorln(err.Error()) - continue + if err := generateStaticSEO(dbConn, environment); err != nil { + return err } - - return case 5: - if err = generatePostsSEO(); err != nil { - cli.Errorln(err.Error()) - continue + if err := generatePostsSEO(dbConn, environment); err != nil { + return err } - - return case 6: - if err = generatePostSEOForSlug(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := generatePostSEOForSlug(menu, dbConn, environment); err != nil { + return err } - - return case 7: - if err = printTimestamp(); err != nil { - cli.Errorln(err.Error()) - continue + if err := printTimestamp(); err != nil { + return err } - - return case 0: cli.Successln("Goodbye!") - return + return nil default: cli.Errorln("Unknown option. Try again.") } @@ -107,9 +98,8 @@ func main() { } } -func createBlogPost(menu panel.Menu) error { +func createBlogPost(menu panel.Menu, dbConn *database.Connection) error { input, err := menu.CapturePostURL() - if err != nil { return err } @@ -124,16 +114,14 @@ func createBlogPost(menu panel.Menu) error { return nil } -func createNewApiAccount(menu panel.Menu) error { - var err error - var account string - var handler *accounts.Handler - - if account, err = menu.CaptureAccountName(); err != nil { +func createNewApiAccount(menu panel.Menu, dbConn *database.Connection, environment *env.Environment) error { + account, err := menu.CaptureAccountName() + if err != nil { return err } - if handler, err = accounts.NewHandler(dbConn, environment); err != nil { + handler, err := accounts.NewHandler(dbConn, environment) + if err != nil { return err } @@ -144,16 +132,14 @@ func createNewApiAccount(menu panel.Menu) error { return nil } -func showApiAccount(menu panel.Menu) error { - var err error - var account string - var handler *accounts.Handler - - if account, err = menu.CaptureAccountName(); err != nil { +func showApiAccount(menu panel.Menu, dbConn *database.Connection, environment *env.Environment) error { + account, err := menu.CaptureAccountName() + if err != nil { return err } - if handler, err = accounts.NewHandler(dbConn, environment); err != nil { + handler, err := accounts.NewHandler(dbConn, environment) + if err != nil { return err } @@ -164,8 +150,8 @@ func showApiAccount(menu panel.Menu) error { return nil } -func runSEOGeneration(genFunc func(*seo.Generator) error) error { - gen, err := newSEOGenerator() +func runSEOGeneration(dbConn *database.Connection, environment *env.Environment, genFunc func(*seo.Generator) error) error { + gen, err := newSEOGenerator(dbConn, environment) if err != nil { return err } @@ -173,32 +159,31 @@ func runSEOGeneration(genFunc func(*seo.Generator) error) error { return genFunc(gen) } -func generateStaticSEO() error { - return runSEOGeneration((*seo.Generator).GenerateStaticPages) +func generateStaticSEO(dbConn *database.Connection, environment *env.Environment) error { + return runSEOGeneration(dbConn, environment, (*seo.Generator).GenerateStaticPages) } -func generatePostsSEO() error { - return runSEOGeneration((*seo.Generator).GeneratePosts) +func generatePostsSEO(dbConn *database.Connection, environment *env.Environment) error { + return runSEOGeneration(dbConn, environment, (*seo.Generator).GeneratePosts) } -func generatePostSEOForSlug(menu panel.Menu) error { +func generatePostSEOForSlug(menu panel.Menu, dbConn *database.Connection, environment *env.Environment) error { slug, err := menu.CapturePostSlug() if err != nil { return err } - return runSEOGeneration(func(gen *seo.Generator) error { + return runSEOGeneration(dbConn, environment, func(gen *seo.Generator) error { return gen.GeneratePost(slug) }) } -func newSEOGenerator() (*seo.Generator, error) { +func newSEOGenerator(dbConn *database.Connection, environment *env.Environment) (*seo.Generator, error) { gen, err := seo.NewGenerator( dbConn, environment, portal.GetDefaultValidator(), ) - if err != nil { return nil, err } diff --git a/metal/kernel/ignite.go b/metal/kernel/ignite.go index ed086292..f4330c16 100644 --- a/metal/kernel/ignite.go +++ b/metal/kernel/ignite.go @@ -1,15 +1,17 @@ package kernel import ( + "fmt" + "github.com/joho/godotenv" "github.com/oullin/metal/env" "github.com/oullin/pkg/portal" ) -func Ignite(envPath string, validate *portal.Validator) *env.Environment { +func Ignite(envPath string, validate *portal.Validator) (*env.Environment, error) { if err := godotenv.Load(envPath); err != nil { - panic("failed to read the .env file/values: " + err.Error()) + return nil, fmt.Errorf("load environment from %s: %w", envPath, err) } - return NewEnv(validate) + return NewEnv(validate), nil } diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index 8a6d3402..bc81dc0c 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -111,30 +111,27 @@ func TestIgnite(t *testing.T) { f.WriteString(content) f.Close() - env := Ignite(f.Name(), portal.GetDefaultValidator()) + env, err := Ignite(f.Name(), portal.GetDefaultValidator()) + if err != nil { + t.Fatalf("ignite environment: %v", err) + } if env.Network.HttpPort != "8080" { t.Fatalf("env not loaded") } } -func TestIgnitePanicsOnMissingFile(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatalf("expected panic when env file is missing") - } else { - msg, ok := r.(string) - if !ok { - t.Fatalf("unexpected panic type: %T", r) - } +func TestIgniteReturnsErrorOnMissingFile(t *testing.T) { + t.Parallel() - if !strings.Contains(msg, "failed to read the .env file/values") { - t.Fatalf("unexpected panic message: %v", r) - } - } - }() + _, err := Ignite("/nonexistent/.env", portal.GetDefaultValidator()) + if err == nil { + t.Fatalf("expected error when env file is missing") + } - Ignite("/nonexistent/.env", portal.GetDefaultValidator()) + if !strings.Contains(err.Error(), "load environment") { + t.Fatalf("unexpected error message: %v", err) + } } func TestAppBootNil(t *testing.T) { diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go new file mode 100644 index 00000000..1e0cf010 --- /dev/null +++ b/pkg/endpoint/server.go @@ -0,0 +1,139 @@ +package endpoint + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/rs/cors" +) + +// RunServer starts the provided HTTP server, listens for shutdown signals, and +// coordinates a graceful shutdown. The addr parameter is used for structured +// logging to identify the server instance. +func RunServer(addr string, server *http.Server) error { + if server == nil { + return errors.New("nil http server") + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.ListenAndServe() + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + slog.Info("starting server", slog.String("address", addr)) + + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("listen and serve: %w", err) + } + + return nil + case sig := <-sigCh: + slog.Info("shutdown signal received", slog.Any("signal", sig)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + slog.Info("shutting down server", slog.String("address", addr)) + + if err := server.Shutdown(ctx); err != nil { + switch { + case errors.Is(err, context.Canceled), errors.Is(err, http.ErrServerClosed): + // expected shutdown path + case errors.Is(err, context.DeadlineExceeded): + slog.Warn("graceful shutdown timed out, forcing close", slog.String("address", addr)) + + if closeErr := server.Close(); closeErr != nil { + slog.Error("force close server failed", slog.String("address", addr), "error", closeErr) + } + default: + return fmt.Errorf("shutdown server: %w", err) + } + } + + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("listen and serve: %w", err) + } + + slog.Info("server stopped", slog.String("address", addr)) + + return nil +} + +// ServerHandlerConfig describes the dependencies required to construct the +// HTTP handler exposed by the API server. +type ServerHandlerConfig struct { + Mux http.Handler + IsProduction bool + DevHost string + Wrap func(http.Handler) http.Handler +} + +// NewServerHandler constructs the HTTP handler using the provided configuration. +// In development environments it applies permissive CORS settings so the +// client app can communicate with the API, and it optionally wraps the handler +// with Sentry instrumentation when supplied. +func NewServerHandler(cfg ServerHandlerConfig) http.Handler { + if cfg.Mux == nil { + return http.NotFoundHandler() + } + + handler := cfg.Mux + + if !cfg.IsProduction { + headers := []string{ + "Accept", + "Authorization", + "Content-Type", + "X-CSRF-Token", + "User-Agent", + "X-API-Key", + "X-API-Username", + "X-API-Signature", + "X-API-Timestamp", + "X-API-Nonce", + "X-Request-ID", + "If-None-Match", + "X-API-Intended-Origin", + } + + origins := []string{"http://localhost:5173"} + if host := cfg.DevHost; host != "" { + if !strings.Contains(host, "://") { + host = "http://" + host + } + + origins = append(origins, host) + } + + c := cors.New(cors.Options{ + AllowedOrigins: origins, + AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions}, + AllowedHeaders: headers, + AllowCredentials: true, + Debug: true, + }) + + handler = c.Handler(handler) + } + + if cfg.Wrap != nil { + handler = cfg.Wrap(handler) + } + + return handler +}