From 09a350565eb4b0a451973eb2b76e33367bdbb04e Mon Sep 17 00:00:00 2001 From: bagas Date: Fri, 1 May 2026 13:07:22 +0700 Subject: [PATCH] Refactor: Centralize App Startup and Config Loading (#33) Reviewed-on: https://git.fossy.my.id/bagas/tunnel-please-controller/pulls/33 --- internal/bootstrap/bootstrap.go | 97 +++++++++++++++++++ internal/config/config.go | 85 +++++++++++++++- .../migrations/000001_init_schema.down.sql | 0 .../sqlc/migrations/000001_init_schema.up.sql | 0 {db => internal/db}/sqlc/queries/query.sql | 0 {db => internal/db}/sqlc/repository/db.go | 0 {db => internal/db}/sqlc/repository/models.go | 0 .../db}/sqlc/repository/query.sql.go | 0 {db => internal/db}/sqlc/sqlc.yaml | 0 {server => internal/server}/server.go | 18 ++-- main.go | 93 ++---------------- 11 files changed, 197 insertions(+), 96 deletions(-) create mode 100644 internal/bootstrap/bootstrap.go rename {db => internal/db}/sqlc/migrations/000001_init_schema.down.sql (100%) rename {db => internal/db}/sqlc/migrations/000001_init_schema.up.sql (100%) rename {db => internal/db}/sqlc/queries/query.sql (100%) rename {db => internal/db}/sqlc/repository/db.go (100%) rename {db => internal/db}/sqlc/repository/models.go (100%) rename {db => internal/db}/sqlc/repository/query.sql.go (100%) rename {db => internal/db}/sqlc/sqlc.yaml (100%) rename {server => internal/server}/server.go (97%) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..96c1da7 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,97 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "git.fossy.my.id/bagas/tunnel-please-controller/internal/config" + "git.fossy.my.id/bagas/tunnel-please-controller/internal/db/sqlc/repository" + "git.fossy.my.id/bagas/tunnel-please-controller/internal/server" + "github.com/jackc/pgx/v5" + "github.com/lestrrat-go/httprc/v3" + "github.com/lestrrat-go/jwx/v3/jwk" +) + +type bootstrap struct { + config config.Config + errChan chan error + signalChan chan os.Signal +} + +type Bootstrap interface { + Run() error +} + +func New(config config.Config) Bootstrap { + errChan := make(chan error, 5) + signalChan := make(chan os.Signal, 1) + return &bootstrap{ + config: config, + errChan: errChan, + signalChan: signalChan, + } +} + +func (b *bootstrap) Run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + signal.Notify(b.signalChan, os.Interrupt, syscall.SIGTERM) + + dbURL := b.config.DatabaseURL() + repo, err := startDatabase(ctx, dbURL) + if err != nil { + return err + } + + client := httprc.NewClient() + jwkCache, err := jwk.NewCache(ctx, client) + if err != nil { + return err + } + s := server.New(repo, jwkCache, b.config) + + b.errChan = make(chan error, 2) + + go func() { + if err = s.StartAPI(ctx, b.config.ApiAddr()); err != nil && !errors.Is(err, context.Canceled) { + b.errChan <- err + } + }() + + go func() { + if err = s.StartController(ctx, b.config.ControllerAddr()); err != nil && !errors.Is(err, context.Canceled) { + b.errChan <- err + } + }() + + select { + case err = <-b.errChan: + return fmt.Errorf("service error: %w", err) + case sig := <-b.signalChan: + log.Printf("Received signal %s, initiating graceful shutdown", sig) + cancel() + return nil + } +} + +func startDatabase(ctx context.Context, dbURL string) (*repository.Queries, error) { + connect, err := pgx.Connect(ctx, dbURL) + if err != nil { + return nil, err + } + + defer func(connect *pgx.Conn, ctx context.Context) { + err = connect.Close(ctx) + if err != nil { + panic(err) + } + }(connect, ctx) + + return repository.New(connect), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 5e5ae08..e364ad0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,81 @@ package config -import "os" +import ( + "crypto/rand" + "encoding/base64" + "errors" + "log" + "os" -func Getenv(key, defaultValue string) string { + "github.com/joho/godotenv" +) + +type config struct { + databaseUrl string + controllerAddr string + apiAddr string + authToken string + jwksUrl string +} + +type Config interface { + DatabaseURL() string + ControllerAddr() string + ApiAddr() string + AuthToken() string + JwksURL() string +} + +func (c *config) DatabaseURL() string { return c.databaseUrl } +func (c *config) ControllerAddr() string { return c.controllerAddr } +func (c *config) ApiAddr() string { return c.apiAddr } +func (c *config) AuthToken() string { return c.authToken } +func (c *config) JwksURL() string { return c.jwksUrl } + +func MustLoad() (Config, error) { + if err := loadEnvFile(); err != nil { + return nil, err + } + + cfg, err := parse() + if err != nil { + return nil, err + } + + return cfg, nil +} + +func loadEnvFile() error { + if _, err := os.Stat(".env"); err == nil { + return godotenv.Load(".env") + } + return nil +} + +func parse() (*config, error) { + databaseUrl := getenv("DATABASE_URL", "") + if databaseUrl == "" { + return nil, errors.New("DATABASE_URL environment variable not set") + } + controllerAddr := getenv("CONTROLLER_ADDR", ":8080") + apiAddr := getenv("API_ADDR", ":8081") + authToken := getenv("AUTH_TOKEN", "") + if authToken == "" { + authToken = generateAuthToken() + log.Printf("No AUTH_TOKEN provided. Generated token: %s", authToken) + } + jwksUrl := getenv("JWKS_URL", "") + + return &config{ + databaseUrl: databaseUrl, + controllerAddr: controllerAddr, + apiAddr: apiAddr, + authToken: authToken, + jwksUrl: jwksUrl, + }, nil +} + +func getenv(key, defaultValue string) string { val := os.Getenv(key) if val == "" { val = defaultValue @@ -10,3 +83,11 @@ func Getenv(key, defaultValue string) string { return val } + +func generateAuthToken() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(buf) +} diff --git a/db/sqlc/migrations/000001_init_schema.down.sql b/internal/db/sqlc/migrations/000001_init_schema.down.sql similarity index 100% rename from db/sqlc/migrations/000001_init_schema.down.sql rename to internal/db/sqlc/migrations/000001_init_schema.down.sql diff --git a/db/sqlc/migrations/000001_init_schema.up.sql b/internal/db/sqlc/migrations/000001_init_schema.up.sql similarity index 100% rename from db/sqlc/migrations/000001_init_schema.up.sql rename to internal/db/sqlc/migrations/000001_init_schema.up.sql diff --git a/db/sqlc/queries/query.sql b/internal/db/sqlc/queries/query.sql similarity index 100% rename from db/sqlc/queries/query.sql rename to internal/db/sqlc/queries/query.sql diff --git a/db/sqlc/repository/db.go b/internal/db/sqlc/repository/db.go similarity index 100% rename from db/sqlc/repository/db.go rename to internal/db/sqlc/repository/db.go diff --git a/db/sqlc/repository/models.go b/internal/db/sqlc/repository/models.go similarity index 100% rename from db/sqlc/repository/models.go rename to internal/db/sqlc/repository/models.go diff --git a/db/sqlc/repository/query.sql.go b/internal/db/sqlc/repository/query.sql.go similarity index 100% rename from db/sqlc/repository/query.sql.go rename to internal/db/sqlc/repository/query.sql.go diff --git a/db/sqlc/sqlc.yaml b/internal/db/sqlc/sqlc.yaml similarity index 100% rename from db/sqlc/sqlc.yaml rename to internal/db/sqlc/sqlc.yaml diff --git a/server/server.go b/internal/server/server.go similarity index 97% rename from server/server.go rename to internal/server/server.go index 4b6a0b4..52e0d32 100644 --- a/server/server.go +++ b/internal/server/server.go @@ -12,8 +12,8 @@ import ( "sync" "time" - "git.fossy.my.id/bagas/tunnel-please-controller/db/sqlc/repository" "git.fossy.my.id/bagas/tunnel-please-controller/internal/config" + "git.fossy.my.id/bagas/tunnel-please-controller/internal/db/sqlc/repository" proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" @@ -42,19 +42,19 @@ type Server struct { Database *repository.Queries Subscribers map[string]*Subscriber mu *sync.RWMutex - authToken string jwkCache *jwk.Cache + config config.Config proto.UnimplementedEventServiceServer proto.UnimplementedUserServiceServer } -func New(database *repository.Queries, authToken string, jwkCache *jwk.Cache) *Server { +func New(database *repository.Queries, jwkCache *jwk.Cache, config config.Config) *Server { return &Server{ Database: database, Subscribers: make(map[string]*Subscriber), mu: new(sync.RWMutex), - authToken: authToken, jwkCache: jwkCache, + config: config, } } @@ -94,7 +94,7 @@ func (s *Server) Subscribe(event grpc.BidiStreamingServer[proto.Node, proto.Even return status.Error(codes.InvalidArgument, "missing identity") } token := payload.AuthEvent.AuthToken - if token != s.authToken { + if token != s.config.AuthToken() { return status.Error(codes.Unauthenticated, "invalid auth token") } @@ -332,7 +332,7 @@ func (s *Server) StartAPI(ctx context.Context, Addr string) error { WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } - jwkURL := config.Getenv("JWKS_URL", "") + jwkURL := s.config.JwksURL() if jwkURL != "" { registerCtx, cancel := context.WithTimeout(ctx, jwkRegisterTimeout) defer cancel() @@ -629,6 +629,7 @@ func (s *Server) StartAPI(ctx context.Context, Addr string) error { errCh := make(chan error, 1) go func() { + log.Printf("Listening api on %s", httpServer.Addr) errCh <- httpServer.ListenAndServe() }() @@ -697,18 +698,19 @@ func (s *Server) StartController(ctx context.Context, Addr string) error { serveErr := make(chan error, 1) go func() { + log.Printf("Listening controller on %s", s.config.ControllerAddr()) serveErr <- grpcServer.Serve(listener) }() select { case <-ctx.Done(): grpcServer.GracefulStop() - err := <-serveErr + err = <-serveErr if err != nil { return err } return ctx.Err() - case err := <-serveErr: + case err = <-serveErr: return err } } diff --git a/main.go b/main.go index b0275e7..dcadff3 100644 --- a/main.go +++ b/main.go @@ -1,104 +1,25 @@ package main import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" "log" "os" - "os/signal" - "syscall" - "git.fossy.my.id/bagas/tunnel-please-controller/db/sqlc/repository" - "git.fossy.my.id/bagas/tunnel-please-controller/server" - "github.com/jackc/pgx/v5" - "github.com/joho/godotenv" - "github.com/lestrrat-go/httprc/v3" - "github.com/lestrrat-go/jwx/v3/jwk" + "git.fossy.my.id/bagas/tunnel-please-controller/internal/bootstrap" + "git.fossy.my.id/bagas/tunnel-please-controller/internal/config" ) func main() { - if _, err := os.Stat(".env"); err == nil { - if err = godotenv.Load(".env"); err != nil { - log.Printf("Warning: Failed to load .env file: %s", err) - } - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - log.SetOutput(os.Stdout) log.SetFlags(log.LstdFlags | log.Lshortfile) - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - log.Fatal("DATABASE_URL is required") - } - - controllerAddr := getenv("CONTROLLER_ADDR", ":8080") - apiAddr := getenv("API_ADDR", ":8081") - authToken := getenv("AUTH_TOKEN", "") - if authToken == "" { - authToken = generateAuthToken() - log.Printf("No AUTH_TOKEN provided. Generated token: %s", authToken) - } - - connect, err := pgx.Connect(ctx, dbURL) + conf, err := config.MustLoad() if err != nil { - panic(err) + log.Fatalf("Config load error: %v", err) } - defer func(connect *pgx.Conn, ctx context.Context) { - err = connect.Close(ctx) - if err != nil { - panic(err) - } - }(connect, ctx) - repo := repository.New(connect) - client := httprc.NewClient() - jwkCache, err := jwk.NewCache(ctx, client) - if err != nil { - log.Printf("failed to initialize jwk cache : %s", err) - } - s := server.New(repo, authToken, jwkCache) + boot := bootstrap.New(conf) - log.Printf("Listening controller on %s", controllerAddr) - log.Printf("Listening api on %s", apiAddr) - - errCh := make(chan error, 2) - - go func() { - if err = s.StartAPI(ctx, apiAddr); err != nil && !errors.Is(err, context.Canceled) { - errCh <- err - } - }() - - go func() { - if err = s.StartController(ctx, controllerAddr); err != nil && !errors.Is(err, context.Canceled) { - errCh <- err - } - }() - - select { - case <-ctx.Done(): - log.Printf("shutting down: %v", ctx.Err()) - case err = <-errCh: - log.Fatalf("server error: %v", err) + if err = boot.Run(); err != nil { + log.Fatalf("Application error: %v", err) } } - -func getenv(key, def string) string { - if val := os.Getenv(key); val != "" { - return val - } - return def -} - -func generateAuthToken() string { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - panic(err) - } - return base64.StdEncoding.EncodeToString(buf) -}