From cb7112cf898dd317973a2ef3fd04bcd3eee738c1 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 09:49:54 +0800 Subject: [PATCH 01/20] Refine main entrypoint for idiomatic Go --- main.go | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/main.go b/main.go index 9b26ff4d..25fb43bb 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,11 @@ package main import ( + "errors" "fmt" "log/slog" "net/http" + "os" "time" "github.com/getsentry/sentry-go" @@ -13,21 +15,23 @@ import ( "github.com/rs/cors" ) -var app *kernel.App +func main() { + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} -func init() { +func run() error { validate := portal.GetDefaultValidator() secrets := kernel.Ignite("./.env", validate) - application, err := kernel.NewApp(secrets, validate) + app, err := kernel.NewApp(secrets, validate) if err != nil { - panic(fmt.Sprintf("init: Error creating application: %s", err)) + return fmt.Errorf("create application: %w", err) } - app = application -} - -func main() { defer sentry.Flush(2 * time.Second) defer app.CloseDB() defer app.CloseLogs() @@ -35,21 +39,29 @@ func main() { app.Boot() - // --- Testing if err := app.GetDB().Ping(); err != nil { slog.Error("database ping failed", "error", 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() + slog.Info("starting server", slog.String("address", env.Network.GetHostURL())) + + if err := http.ListenAndServe(env.Network.GetHostURL(), serverHandler(app)); err != nil { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return fmt.Errorf("listen and serve: %w", err) } + + return nil } -func serverHandler() http.Handler { +func serverHandler(app *kernel.App) http.Handler { + if app == nil { + return http.NotFoundHandler() + } + mux := app.GetMux() if mux == nil { return http.NotFoundHandler() @@ -58,7 +70,8 @@ func serverHandler() http.Handler { var handler http.Handler = mux if !app.IsProduction() { // Caddy handles CORS. - localhost := app.GetEnv().Network.GetHostURL() + env := app.GetEnv() + localhost := env.Network.GetHostURL() headers := []string{ "Accept", @@ -73,7 +86,7 @@ func serverHandler() http.Handler { "X-API-Nonce", "X-Request-ID", "If-None-Match", - "X-API-Intended-Origin", //new + "X-API-Intended-Origin", // new } c := cors.New(cors.Options{ From 8c8276fc53111f5e6c342eb2322e99fd993581ab Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 10:45:49 +0800 Subject: [PATCH 02/20] Improve server lifecycle handling --- main.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 25fb43bb..849552ec 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,14 @@ package main import ( + "context" "errors" "fmt" "log/slog" "net/http" "os" + "os/signal" + "syscall" "time" "github.com/getsentry/sentry-go" @@ -44,16 +47,69 @@ func run() error { } env := app.GetEnv() - slog.Info("starting server", slog.String("address", env.Network.GetHostURL())) + if env == nil { + return errors.New("application environment is nil") + } + addr := env.Network.GetHostURL() + handler := serverHandler(app) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + 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) - if err := http.ListenAndServe(env.Network.GetHostURL(), serverHandler(app)); err != nil { - if errors.Is(err, http.ErrServerClosed) { - return nil + 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 } @@ -67,10 +123,13 @@ func serverHandler(app *kernel.App) http.Handler { return http.NotFoundHandler() } - var handler http.Handler = mux + handler := http.Handler(mux) if !app.IsProduction() { // Caddy handles CORS. env := app.GetEnv() + if env == nil { + return handler + } localhost := env.Network.GetHostURL() headers := []string{ @@ -86,7 +145,7 @@ func serverHandler(app *kernel.App) http.Handler { "X-API-Nonce", "X-Request-ID", "If-None-Match", - "X-API-Intended-Origin", // new + "X-API-Intended-Origin", } c := cors.New(cors.Options{ From 5c2d172602d553e21c0fab678b247467dd65d2e7 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 10:45:59 +0800 Subject: [PATCH 03/20] Refine CLI and seeder lifecycle --- database/seeder/main.go | 67 ++++++++++++-------- metal/cli/main.go | 137 ++++++++++++++++++++-------------------- 2 files changed, 108 insertions(+), 96 deletions(-) diff --git a/database/seeder/main.go b/database/seeder/main.go index 78d116d1..b9feb3b9 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -1,53 +1,68 @@ 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()) - - environment = secrets - sentryHub = kernel.NewSentry(environment) +func main() { + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + cli.Errorln(err.Error()) + os.Exit(1) + } } -func main() { +func run() error { cli.ClearScreen() - dbConnection := kernel.NewDbConnection(environment) - logs := kernel.NewLogs(environment) + validate := portal.GetDefaultValidator() + environment := kernel.Ignite("./.env", validate) + if environment == nil { + return errors.New("environment is nil") + } + + hub := kernel.NewSentry(environment) defer sentry.Flush(2 * time.Second) + defer kernel.RecoverWithSentry(hub) + + dbConnection := kernel.NewDbConnection(environment) + if dbConnection == nil { + return errors.New("database connection is nil") + } + defer dbConnection.Close() + + 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 +81,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 +119,7 @@ func main() { defer wg.Done() cli.Warningln("Seeding views ...") - seeder.SeedPostViews(posts, UserA, UserB) + seeder.SeedPostViews(posts, userA, userB) }() go func() { @@ -122,4 +135,6 @@ func main() { wg.Wait() cli.Magentaln("db seeded as expected ....") + + return nil } diff --git a/metal/cli/main.go b/metal/cli/main.go index ba9b1635..fe0341df 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,88 @@ 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()) - - environment = secrets - dbConn = kernel.NewDbConnection(environment) - sentryHub = kernel.NewSentry(environment) +func main() { + if err := run(); err != nil { + sentry.CurrentHub().CaptureException(err) + cli.Errorln(err.Error()) + os.Exit(1) + } } -func main() { +func run() error { cli.ClearScreen() + validate := portal.GetDefaultValidator() + environment := kernel.Ignite("./.env", validate) + if environment == nil { + return errors.New("environment is nil") + } + + hub := kernel.NewSentry(environment) + defer sentry.Flush(2 * time.Second) - defer kernel.RecoverWithSentry(sentryHub) + 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 + return nil case 2: - if err = createNewApiAccount(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := createNewApiAccount(menu, dbConn, environment); err != nil { + return err } - return + return nil case 3: - if err = showApiAccount(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := showApiAccount(menu, dbConn, environment); err != nil { + return err } - return + return nil case 4: - if err = generateStaticSEO(); err != nil { - cli.Errorln(err.Error()) - continue + if err := generateStaticSEO(dbConn, environment); err != nil { + return err } - return + return nil case 5: - if err = generatePostsSEO(); err != nil { - cli.Errorln(err.Error()) - continue + if err := generatePostsSEO(dbConn, environment); err != nil { + return err } - return + return nil case 6: - if err = generatePostSEOForSlug(menu); err != nil { - cli.Errorln(err.Error()) - continue + if err := generatePostSEOForSlug(menu, dbConn, environment); err != nil { + return err } - return + return nil case 7: - if err = printTimestamp(); err != nil { - cli.Errorln(err.Error()) - continue + if err := printTimestamp(); err != nil { + return err } - return + return nil case 0: cli.Successln("Goodbye!") - return + return nil default: cli.Errorln("Unknown option. Try again.") } @@ -107,9 +110,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 +126,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 +144,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 +162,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 +171,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 } From 8b0dd2969e41a8a7d6268ca7f9d9ff30824cff1c Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 10:57:23 +0800 Subject: [PATCH 04/20] Fail fast when database ping fails --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 849552ec..7cbba484 100644 --- a/main.go +++ b/main.go @@ -43,7 +43,7 @@ func run() error { app.Boot() if err := app.GetDB().Ping(); err != nil { - slog.Error("database ping failed", "error", err) + return fmt.Errorf("database ping failed: %w", err) } env := app.GetEnv() From 5e71dce8500dd749a10accfa7bc7e011b6b5cd46 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 11:13:41 +0800 Subject: [PATCH 05/20] Ensure sentry flush happens after captures --- database/seeder/main.go | 11 +++++++++-- main.go | 11 +++++++++-- metal/cli/main.go | 11 +++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/database/seeder/main.go b/database/seeder/main.go index b9feb3b9..986ef9ed 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -16,11 +16,19 @@ import ( ) func main() { + os.Exit(realMain()) +} + +func realMain() int { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) - os.Exit(1) + return 1 } + + return 0 } func run() error { @@ -34,7 +42,6 @@ func run() error { hub := kernel.NewSentry(environment) - defer sentry.Flush(2 * time.Second) defer kernel.RecoverWithSentry(hub) dbConnection := kernel.NewDbConnection(environment) diff --git a/main.go b/main.go index 7cbba484..66e241ce 100644 --- a/main.go +++ b/main.go @@ -19,11 +19,19 @@ import ( ) func main() { + os.Exit(realMain()) +} + +func realMain() int { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) slog.Error("server exited with error", "error", err) - os.Exit(1) + return 1 } + + return 0 } func run() error { @@ -35,7 +43,6 @@ func run() error { return fmt.Errorf("create application: %w", err) } - defer sentry.Flush(2 * time.Second) defer app.CloseDB() defer app.CloseLogs() defer app.Recover() diff --git a/metal/cli/main.go b/metal/cli/main.go index fe0341df..43ef80ce 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -19,11 +19,19 @@ import ( ) func main() { + os.Exit(realMain()) +} + +func realMain() int { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) - os.Exit(1) + return 1 } + + return 0 } func run() error { @@ -37,7 +45,6 @@ func run() error { hub := kernel.NewSentry(environment) - defer sentry.Flush(2 * time.Second) defer kernel.RecoverWithSentry(hub) dbConn := kernel.NewDbConnection(environment) From 5cba7a2de75f04dc7d9af05aa7368e7798bb7f9b Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 11:25:19 +0800 Subject: [PATCH 06/20] Inline executable sentry flush --- database/seeder/main.go | 11 +++-------- main.go | 11 +++-------- metal/cli/main.go | 11 +++-------- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/database/seeder/main.go b/database/seeder/main.go index 986ef9ed..f38adaa1 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -16,19 +16,14 @@ import ( ) func main() { - os.Exit(realMain()) -} - -func realMain() int { - defer sentry.Flush(2 * time.Second) - if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) - return 1 + sentry.Flush(2 * time.Second) + os.Exit(1) } - return 0 + sentry.Flush(2 * time.Second) } func run() error { diff --git a/main.go b/main.go index 66e241ce..05c07fbb 100644 --- a/main.go +++ b/main.go @@ -19,19 +19,14 @@ import ( ) func main() { - os.Exit(realMain()) -} - -func realMain() int { - defer sentry.Flush(2 * time.Second) - if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) slog.Error("server exited with error", "error", err) - return 1 + sentry.Flush(2 * time.Second) + os.Exit(1) } - return 0 + sentry.Flush(2 * time.Second) } func run() error { diff --git a/metal/cli/main.go b/metal/cli/main.go index 43ef80ce..603001ea 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -19,19 +19,14 @@ import ( ) func main() { - os.Exit(realMain()) -} - -func realMain() int { - defer sentry.Flush(2 * time.Second) - if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) - return 1 + sentry.Flush(2 * time.Second) + os.Exit(1) } - return 0 + sentry.Flush(2 * time.Second) } func run() error { From 7aeb02760ac9c975dbbbca924091d79151adab89 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 11:25:28 +0800 Subject: [PATCH 07/20] Ensure Sentry flush defer runs early --- database/seeder/main.go | 4 ++-- main.go | 4 ++-- metal/cli/main.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/database/seeder/main.go b/database/seeder/main.go index f38adaa1..3a7584c3 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -16,14 +16,14 @@ import ( ) func main() { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) sentry.Flush(2 * time.Second) os.Exit(1) } - - sentry.Flush(2 * time.Second) } func run() error { diff --git a/main.go b/main.go index 05c07fbb..6a9a1be6 100644 --- a/main.go +++ b/main.go @@ -19,14 +19,14 @@ import ( ) func main() { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) slog.Error("server exited with error", "error", err) sentry.Flush(2 * time.Second) os.Exit(1) } - - sentry.Flush(2 * time.Second) } func run() error { diff --git a/metal/cli/main.go b/metal/cli/main.go index 603001ea..6edfc094 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -19,14 +19,14 @@ import ( ) func main() { + defer sentry.Flush(2 * time.Second) + if err := run(); err != nil { sentry.CurrentHub().CaptureException(err) cli.Errorln(err.Error()) sentry.Flush(2 * time.Second) os.Exit(1) } - - sentry.Flush(2 * time.Second) } func run() error { From dd3a8aa25f4ba17cc8f8f42cd914192a18ebede1 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 11:33:59 +0800 Subject: [PATCH 08/20] Flush sentry errors before exiting server --- main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 6a9a1be6..0f436f3a 100644 --- a/main.go +++ b/main.go @@ -23,8 +23,10 @@ func main() { 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) - sentry.Flush(2 * time.Second) os.Exit(1) } } From a13ac592159ba004ca55e0a57e35b49a8087c25d Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 11:52:45 +0800 Subject: [PATCH 09/20] Handle Ignite load failures --- database/seeder/importer/cmd/main.go | 20 +++++++++---------- database/seeder/main.go | 6 +++--- main.go | 5 ++++- metal/cli/main.go | 6 +++--- metal/kernel/ignite.go | 8 +++++--- metal/kernel/kernel_test.go | 29 +++++++++++++--------------- 6 files changed, 37 insertions(+), 37 deletions(-) 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 3a7584c3..f524bcf0 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -30,9 +30,9 @@ func run() error { cli.ClearScreen() validate := portal.GetDefaultValidator() - environment := kernel.Ignite("./.env", validate) - if environment == nil { - return errors.New("environment is nil") + environment, err := kernel.Ignite("./.env", validate) + if err != nil { + return fmt.Errorf("ignite environment: %w", err) } hub := kernel.NewSentry(environment) diff --git a/main.go b/main.go index 0f436f3a..c882ae15 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,10 @@ func main() { func run() error { validate := portal.GetDefaultValidator() - secrets := kernel.Ignite("./.env", validate) + secrets, err := kernel.Ignite("./.env", validate) + if err != nil { + return fmt.Errorf("ignite environment: %w", err) + } app, err := kernel.NewApp(secrets, validate) if err != nil { diff --git a/metal/cli/main.go b/metal/cli/main.go index 6edfc094..25553716 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -33,9 +33,9 @@ func run() error { cli.ClearScreen() validate := portal.GetDefaultValidator() - environment := kernel.Ignite("./.env", validate) - if environment == nil { - return errors.New("environment is nil") + environment, err := kernel.Ignite("./.env", validate) + if err != nil { + return fmt.Errorf("ignite environment: %w", err) } hub := kernel.NewSentry(environment) 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) { From 93a3728e266058c42041c13052c6eb0a948965d6 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 12:32:52 +0800 Subject: [PATCH 10/20] Keep CLI menu loop interactive --- metal/cli/main.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/metal/cli/main.go b/metal/cli/main.go index 25553716..411b07ac 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -61,44 +61,30 @@ func run() error { if err := createBlogPost(menu, dbConn); err != nil { return err } - - return nil case 2: if err := createNewApiAccount(menu, dbConn, environment); err != nil { return err } - - return nil case 3: if err := showApiAccount(menu, dbConn, environment); err != nil { return err } - - return nil case 4: if err := generateStaticSEO(dbConn, environment); err != nil { return err } - - return nil case 5: if err := generatePostsSEO(dbConn, environment); err != nil { return err } - - return nil case 6: if err := generatePostSEOForSlug(menu, dbConn, environment); err != nil { return err } - - return nil case 7: if err := printTimestamp(); err != nil { return err } - - return nil case 0: cli.Successln("Goodbye!") return nil From 60f52ee4e6fd8522bf62746a9e0cf5875a177ec7 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 13:28:54 +0800 Subject: [PATCH 11/20] Lower Go directive to match available toolchain --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 642b8e8b..2ffb13a8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/oullin -go 1.25.3 +go 1.25.1 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 From 15692ebbbeaefa5b706cd6c4ad72b18f109498c7 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 13:46:17 +0800 Subject: [PATCH 12/20] Restore Go 1.25.3 directive --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2ffb13a8..642b8e8b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/oullin -go 1.25.1 +go 1.25.3 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 From d27c61c4a184823a021acedc08ce764ebe85142a Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 13:46:25 +0800 Subject: [PATCH 13/20] chore: bump golang alpine digest --- docker/dockerfile-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3ddf8149f8f59e7770e75e1ed95ee410f81c54ee Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 14:04:00 +0800 Subject: [PATCH 14/20] Adjust compose service definitions --- docker-compose.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6a0ee3d8..fccbd9c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,13 +87,13 @@ services: context: . dockerfile: ./docker/dockerfile-api target: builder - image: api-api-runner volumes: - .:/app - go_mod_cache:/go/pkg/mod - "${ENV_SPA_DIR}:${ENV_SPA_DIR}" - "${ENV_SPA_IMAGES_DIR}:${ENV_SPA_IMAGES_DIR}" working_dir: /app + container_name: api-runner environment: CGO_ENABLED: 1 ENV_DB_HOST: api-db @@ -116,7 +116,6 @@ services: user: root security_opt: - apparmor:unconfined - image: api-api env_file: - .env volumes: @@ -137,7 +136,7 @@ services: - APP_GROUP=${ENV_DOCKER_USER_GROUP} - APP_DIR=/app - BINARY_NAME=oullin_api - container_name: oullin_api + container_name: api restart: unless-stopped secrets: - pg_username From 3a167dd97dc61f86e8acd3082485857368cf850f Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 14:24:38 +0800 Subject: [PATCH 15/20] Remove custom container names from compose --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fccbd9c0..62036d3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,7 +93,6 @@ services: - "${ENV_SPA_DIR}:${ENV_SPA_DIR}" - "${ENV_SPA_IMAGES_DIR}:${ENV_SPA_IMAGES_DIR}" working_dir: /app - container_name: api-runner environment: CGO_ENABLED: 1 ENV_DB_HOST: api-db @@ -136,7 +135,6 @@ services: - APP_GROUP=${ENV_DOCKER_USER_GROUP} - APP_DIR=/app - BINARY_NAME=oullin_api - container_name: api restart: unless-stopped secrets: - pg_username From 856f0b481d53ddb52528793f99085ce972b9c4a7 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 14:24:44 +0800 Subject: [PATCH 16/20] Add runner container name for api-runner service --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 62036d3a..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 From a8069688c0924c2dc557ab2a915aea39299bfd26 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 14:32:29 +0800 Subject: [PATCH 17/20] Extract server lifecycle to endpoint package --- main.go | 52 ++---------------------------- pkg/endpoint/server.go | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 49 deletions(-) create mode 100644 pkg/endpoint/server.go diff --git a/main.go b/main.go index c882ae15..e83f16b2 100644 --- a/main.go +++ b/main.go @@ -1,19 +1,17 @@ package main import ( - "context" "errors" "fmt" "log/slog" "net/http" "os" - "os/signal" - "syscall" "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" ) @@ -69,54 +67,10 @@ func run() error { IdleTimeout: 60 * time.Second, } - 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 := endpoint.RunServer(addr, server); err != nil { + return fmt.Errorf("serve http: %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 } diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go new file mode 100644 index 00000000..ad5b4484 --- /dev/null +++ b/pkg/endpoint/server.go @@ -0,0 +1,72 @@ +package endpoint + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +// 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 +} From cc5d04520f7db9e5f059c595a689145487e3e23c Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 14:42:29 +0800 Subject: [PATCH 18/20] Move server handler helper into endpoint package --- main.go | 67 ++++++++---------------------------------- pkg/endpoint/server.go | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 55 deletions(-) diff --git a/main.go b/main.go index e83f16b2..341bdf75 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,6 @@ import ( "github.com/oullin/metal/kernel" "github.com/oullin/pkg/endpoint" "github.com/oullin/pkg/portal" - "github.com/rs/cors" ) func main() { @@ -56,7 +55,18 @@ func run() error { return errors.New("application environment is nil") } addr := env.Network.GetHostURL() - handler := serverHandler(app) + + var wrap func(http.Handler) http.Handler + if sentry := app.GetSentry(); sentry != nil && sentry.Handler != nil { + wrap = sentry.Handler.Handle + } + + handler := endpoint.ServerHandler(endpoint.ServerHandlerConfig{ + Mux: app.GetMux(), + IsProduction: app.IsProduction(), + DevHost: addr, + Wrap: wrap, + }) server := &http.Server{ Addr: addr, @@ -73,56 +83,3 @@ func run() error { return nil } - -func serverHandler(app *kernel.App) http.Handler { - if app == nil { - return http.NotFoundHandler() - } - - mux := app.GetMux() - if mux == nil { - return http.NotFoundHandler() - } - - handler := http.Handler(mux) - - if !app.IsProduction() { // Caddy handles CORS. - env := app.GetEnv() - if env == nil { - return handler - } - localhost := env.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", - } - - 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) - } - - if sentry := app.GetSentry(); sentry != nil && sentry.Handler != nil { - handler = sentry.Handler.Handle(handler) - } - - return handler -} diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go index ad5b4484..1222f710 100644 --- a/pkg/endpoint/server.go +++ b/pkg/endpoint/server.go @@ -10,6 +10,8 @@ import ( "os/signal" "syscall" "time" + + "github.com/rs/cors" ) // RunServer starts the provided HTTP server, listens for shutdown signals, and @@ -70,3 +72,63 @@ func RunServer(addr string, server *http.Server) error { 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 +} + +// ServerHandler 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 ServerHandler(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 != "" { + 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 +} From 0867eeb6e3cbd31c149b270fa334b98ac1e438eb Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 15:02:05 +0800 Subject: [PATCH 19/20] Rename server handler helper --- main.go | 2 +- pkg/endpoint/server.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 341bdf75..aec9a848 100644 --- a/main.go +++ b/main.go @@ -61,7 +61,7 @@ func run() error { wrap = sentry.Handler.Handle } - handler := endpoint.ServerHandler(endpoint.ServerHandlerConfig{ + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{ Mux: app.GetMux(), IsProduction: app.IsProduction(), DevHost: addr, diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go index 1222f710..bddff2dc 100644 --- a/pkg/endpoint/server.go +++ b/pkg/endpoint/server.go @@ -82,11 +82,11 @@ type ServerHandlerConfig struct { Wrap func(http.Handler) http.Handler } -// ServerHandler constructs the HTTP handler using the provided configuration. +// 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 ServerHandler(cfg ServerHandlerConfig) http.Handler { +func NewServerHandler(cfg ServerHandlerConfig) http.Handler { if cfg.Mux == nil { return http.NotFoundHandler() } From 5fd8066a081784c02b206cdf3dc2b44aa7c96fee Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 15:25:41 +0800 Subject: [PATCH 20/20] Ensure dev CORS origin includes scheme --- pkg/endpoint/server.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go index bddff2dc..1e0cf010 100644 --- a/pkg/endpoint/server.go +++ b/pkg/endpoint/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -112,6 +113,10 @@ func NewServerHandler(cfg ServerHandlerConfig) http.Handler { origins := []string{"http://localhost:5173"} if host := cfg.DevHost; host != "" { + if !strings.Contains(host, "://") { + host = "http://" + host + } + origins = append(origins, host) }