package main import ( "context" "database/sql" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" "cmroubao/backend-api/internal/config" "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/password" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/transport/httpapi" "cmroubao/backend-api/internal/transport/webui" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) func main() { if err := run(); err != nil { log.Printf("cmroubao API stopped: %v", err) os.Exit(1) } } func run() error { cfg, err := config.Load(os.LookupEnv) if err != nil { return err } startupContext, cancelStartup := context.WithTimeout( context.Background(), cfg.ReadTimeout, ) defer cancelStartup() db, err := database.Open(startupContext, cfg.DatabasePath) if err != nil { return errors.New("database startup failed") } defer func() { if err := db.Close(); err != nil { log.Print("database close failed") } }() router, err := buildRouter(startupContext, cfg, db) if err != nil { if errors.Is(err, errPendingMigrations) { return err } return errors.New("HTTP router setup failed") } server := httpapi.NewServer(cfg, router) signalContext, stopSignals := signal.NotifyContext( context.Background(), os.Interrupt, syscall.SIGTERM, ) defer stopSignals() serverErrors := make(chan error, 1) go func() { if cfg.TLSCertificate != "" { serverErrors <- server.ListenAndServeTLS( cfg.TLSCertificate, cfg.TLSPrivateKey, ) return } serverErrors <- server.ListenAndServe() }() select { case err := <-serverErrors: if errors.Is(err, http.ErrServerClosed) { return nil } return errors.New("HTTP server failed") case <-signalContext.Done(): return shutdownServer(server, serverErrors, cfg.ShutdownTimeout) } } func shutdownServer( server *http.Server, serverErrors <-chan error, timeout time.Duration, ) error { shutdownContext, cancelShutdown := context.WithTimeout( context.Background(), timeout, ) defer cancelShutdown() shutdownErr := server.Shutdown(shutdownContext) if shutdownErr != nil { if err := server.Close(); err != nil { return errors.New("HTTP server force close failed") } } waitTimeout := timeout if waitTimeout > time.Second { waitTimeout = time.Second } timer := time.NewTimer(waitTimeout) defer timer.Stop() select { case err := <-serverErrors: if err != nil && !errors.Is(err, http.ErrServerClosed) { return errors.New("HTTP server failed during shutdown") } case <-timer.C: _ = server.Close() return errors.New("HTTP server did not stop") } if shutdownErr != nil { return errors.New("HTTP server graceful shutdown timed out") } return nil } type migrationStatusReader interface { Status(context.Context) ([]migration.Status, error) } var errPendingMigrations = errors.New( "database migrations are pending; run migrate up", ) func buildRouter( ctx context.Context, cfg config.Config, db *sql.DB, ) (http.Handler, error) { runner, err := migration.New(db) if err != nil { return nil, err } if err := requireCurrentMigrations(ctx, runner); err != nil { return nil, err } store, err := repository.New(db) if err != nil { return nil, err } files, err := assetstore.New(cfg.AssetDirectory) if err != nil { return nil, err } clock := usecase.SystemClock{} ids := usecase.UUIDGenerator{} assets, err := usecase.NewAssetService(store, files, clock, ids) if err != nil { return nil, err } tasks, err := usecase.NewTaskService(store, clock, ids) if err != nil { return nil, err } lifecycle, err := usecase.NewLifecycleService( store, clock, ids, cfg.ClaimLease, cfg.RunningLease, cfg.ReadinessTTL, ) if err != nil { return nil, err } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err } registerDeviceRoutes, err := httpapi.NewDeviceRouteRegistrar( httpapi.DeviceServices{ Lifecycle: lifecycle, Assets: assets, }, ) if err != nil { return nil, err } auth, err := usecase.NewAuthService( store, passwords, clock, ids, usecase.CryptoTokenGenerator{}, ) if err != nil { return nil, err } webService, err := webui.NewUsecaseAdapter(tasks, assets) if err != nil { return nil, err } renderer, err := webui.NewRenderer() if err != nil { return nil, err } webHandler, err := webui.NewHandler(webService, renderer) if err != nil { return nil, err } authWebService, err := webui.NewAuthUsecaseAdapter(auth) if err != nil { return nil, err } loginLimiter, err := authcommon.NewAttemptLimiter( 10, 5*time.Minute, 4096, ) if err != nil { return nil, err } authWebHandler, err := webui.NewAuthHandler( authWebService, renderer, loginLimiter, ) if err != nil { return nil, err } registerPublicAuth, err := httpapi.NewPublicAuthRegistrar( auth, loginLimiter, ) if err != nil { return nil, err } registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar( httpapi.AdminServices{ Assets: assets, Tasks: tasks, }, webHandler, ) if err != nil { return nil, err } registerProtectedRoutes := func(routes gin.IRoutes) error { if err := registerAdminRoutes(routes); err != nil { return err } authWebHandler.RegisterProtected(routes) return nil } return httpapi.NewRouter(httpapi.RouterDependencies{ Database: db, RegisterPublicRoutes: func(routes gin.IRoutes) error { webHandler.RegisterStatic(routes) authWebHandler.RegisterPublic(routes) return registerPublicAuth(routes) }, RegisterAdminRoutes: registerProtectedRoutes, RegisterDeviceRoutes: registerDeviceRoutes, AdminSessions: auth, DeviceAccess: auth, LogEvent: func(event string) { log.Print(event) }, }) } func requireCurrentMigrations( ctx context.Context, reader migrationStatusReader, ) error { statuses, err := reader.Status(ctx) if err != nil { return errors.New("database migration status failed") } if len(statuses) == 0 { return errors.New("database has no known migrations") } for _, status := range statuses { if !status.Applied { return errPendingMigrations } } return nil }