From ecc6194b579ac8a8563ca64ca0d76e2ee59c2a4f Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 19 Nov 2025 16:53:04 +0100 Subject: [PATCH] Refactor main package Fix issues. Break down main routine. Add documentation. Remove globals. --- cmd/cc-backend/cli.go | 2 + cmd/cc-backend/init.go | 3 + cmd/cc-backend/main.go | 440 ++++++++++++++++++++++++++++----------- cmd/cc-backend/server.go | 217 +++++++++---------- 4 files changed, 428 insertions(+), 234 deletions(-) diff --git a/cmd/cc-backend/cli.go b/cmd/cc-backend/cli.go index 235a12c..8b41261 100644 --- a/cmd/cc-backend/cli.go +++ b/cmd/cc-backend/cli.go @@ -2,6 +2,8 @@ // All rights reserved. This file is part of cc-backend. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// Package main provides the entry point for the ClusterCockpit backend server. +// This file defines all command-line flags and their default values. package main import "flag" diff --git a/cmd/cc-backend/init.go b/cmd/cc-backend/init.go index b46100a..a1a4c6c 100644 --- a/cmd/cc-backend/init.go +++ b/cmd/cc-backend/init.go @@ -2,6 +2,9 @@ // All rights reserved. This file is part of cc-backend. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// Package main provides the entry point for the ClusterCockpit backend server. +// This file contains bootstrap logic for initializing the environment, +// creating default configuration files, and setting up the database. package main import ( diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index aeace97..437319a 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -2,9 +2,13 @@ // All rights reserved. This file is part of cc-backend. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// Package main provides the entry point for the ClusterCockpit backend server. +// It orchestrates initialization of all subsystems including configuration, +// database, authentication, and the HTTP server. package main import ( + "context" "encoding/json" "fmt" "os" @@ -13,6 +17,7 @@ import ( "strings" "sync" "syscall" + "time" "github.com/ClusterCockpit/cc-backend/internal/archiver" "github.com/ClusterCockpit/cc-backend/internal/auth" @@ -46,90 +51,108 @@ const logoString = ` |_| ` +// Environment variable names +const ( + envGOGC = "GOGC" +) + +// Default configurations +const ( + defaultArchiveConfig = `{"kind":"file","path":"./var/job-archive"}` +) + var ( date string commit string version string ) -func main() { - cliInit() +func printVersion() { + fmt.Print(logoString) + fmt.Printf("Version:\t%s\n", version) + fmt.Printf("Git hash:\t%s\n", commit) + fmt.Printf("Build time:\t%s\n", date) + fmt.Printf("SQL db version:\t%d\n", repository.Version) + fmt.Printf("Job archive version:\t%d\n", archive.Version) +} - if flagVersion { - fmt.Print(logoString) - fmt.Printf("Version:\t%s\n", version) - fmt.Printf("Git hash:\t%s\n", commit) - fmt.Printf("Build time:\t%s\n", date) - fmt.Printf("SQL db version:\t%d\n", repository.Version) - fmt.Printf("Job archive version:\t%d\n", archive.Version) - os.Exit(0) +func initGops() error { + if !flagGops { + return nil } - - cclog.Init(flagLogLevel, flagLogDateTime) - - // If init flag set, run tasks here before any file dependencies cause errors - if flagInit { - initEnv() - cclog.Exit("Successfully setup environment!\n" + - "Please review config.json and .env and adjust it to your needs.\n" + - "Add your job-archive at ./var/job-archive.") + + if err := agent.Listen(agent.Options{}); err != nil { + return fmt.Errorf("starting gops agent: %w", err) } + return nil +} - // See https://github.com/google/gops (Runtime overhead is almost zero) - if flagGops { - if err := agent.Listen(agent.Options{}); err != nil { - cclog.Abortf("Could not start gops agent with 'gops/agent.Listen(agent.Options{})'. Application startup failed, exited.\nError: %s\n", err.Error()) - } +func loadEnvironment() error { + if err := godotenv.Load(); err != nil { + return fmt.Errorf("loading .env file: %w", err) } + return nil +} - err := godotenv.Load() - if err != nil { - cclog.Abortf("Could not parse existing .env file at location './.env'. Application startup failed, exited.\nError: %s\n", err.Error()) - } - - // Initialize sub-modules and handle command line flags. - // The order here is important! +func initConfiguration() error { ccconf.Init(flagConfigFile) - - // Load and check main configuration - if cfg := ccconf.GetPackageConfig("main"); cfg != nil { - if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil { - config.Init(cfg, clustercfg) - } else { - cclog.Abort("Cluster configuration must be present") - } - } else { - cclog.Abort("Main configuration must be present") + + cfg := ccconf.GetPackageConfig("main") + if cfg == nil { + return fmt.Errorf("main configuration must be present") } + + clustercfg := ccconf.GetPackageConfig("clusters") + if clustercfg == nil { + return fmt.Errorf("cluster configuration must be present") + } + + config.Init(cfg, clustercfg) + return nil +} +func initDatabase() error { + repository.Connect(config.Keys.DBDriver, config.Keys.DB) + return nil +} + +func handleDatabaseCommands() error { if flagMigrateDB { err := repository.MigrateDB(config.Keys.DBDriver, config.Keys.DB) if err != nil { - cclog.Abortf("MigrateDB Failed: Could not migrate '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error()) + return fmt.Errorf("migrating database to version %d: %w", repository.Version, err) } - cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version) + cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n", + config.Keys.DBDriver, config.Keys.DB, repository.Version) } if flagRevertDB { err := repository.RevertDB(config.Keys.DBDriver, config.Keys.DB) if err != nil { - cclog.Abortf("RevertDB Failed: Could not revert '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1), err.Error()) + return fmt.Errorf("reverting database to version %d: %w", repository.Version-1, err) } - cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1)) + cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n", + config.Keys.DBDriver, config.Keys.DB, repository.Version-1) } if flagForceDB { err := repository.ForceDB(config.Keys.DBDriver, config.Keys.DB) if err != nil { - cclog.Abortf("ForceDB Failed: Could not force '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error()) + return fmt.Errorf("forcing database to version %d: %w", repository.Version, err) } - cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version) + cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n", + config.Keys.DBDriver, config.Keys.DB, repository.Version) } + + return nil +} - repository.Connect(config.Keys.DBDriver, config.Keys.DB) - +func handleUserCommands() error { + if config.Keys.DisableAuthentication && (flagNewUser != "" || flagDelUser != "") { + return fmt.Errorf("--add-user and --del-user can only be used if authentication is enabled") + } + if !config.Keys.DisableAuthentication { - if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { auth.Init(&cfg) } else { @@ -137,157 +160,320 @@ func main() { auth.Init(nil) } - if flagNewUser != "" { - parts := strings.SplitN(flagNewUser, ":", 3) - if len(parts) != 3 || len(parts[0]) == 0 { - cclog.Abortf("Add User: Could not parse supplied argument format: No changes.\n"+ - "Want: :[admin,support,manager,api,user]:\n"+ - "Have: %s\n", flagNewUser) - } + // Check for default security keys + checkDefaultSecurityKeys() - ur := repository.GetUserRepository() - if err := ur.AddUser(&schema.User{ - Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","), - }); err != nil { - cclog.Abortf("Add User: Could not add new user authentication for '%s' and roles '%s'.\nError: %s\n", parts[0], parts[1], err.Error()) - } else { - cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1]) + if flagNewUser != "" { + if err := addUser(flagNewUser); err != nil { + return err } } if flagDelUser != "" { - ur := repository.GetUserRepository() - if err := ur.DelUser(flagDelUser); err != nil { - cclog.Abortf("Delete User: Could not delete user '%s' from DB.\nError: %s\n", flagDelUser, err.Error()) - } else { - cclog.Printf("Delete User: Deleted user '%s' from DB.\n", flagDelUser) + if err := delUser(flagDelUser); err != nil { + return err } } authHandle := auth.GetAuthInstance() if flagSyncLDAP { - if authHandle.LdapAuth == nil { - cclog.Abort("Sync LDAP: LDAP authentication is not configured, could not synchronize. No changes, exited.") + if err := syncLDAP(authHandle); err != nil { + return err } - - if err := authHandle.LdapAuth.Sync(); err != nil { - cclog.Abortf("Sync LDAP: Could not synchronize, failed with error.\nError: %s\n", err.Error()) - } - cclog.Print("Sync LDAP: LDAP synchronization successfull.") } if flagGenJWT != "" { - ur := repository.GetUserRepository() - user, err := ur.GetUser(flagGenJWT) - if err != nil { - cclog.Abortf("JWT: Could not get supplied user '%s' from DB. No changes, exited.\nError: %s\n", flagGenJWT, err.Error()) + if err := generateJWT(authHandle, flagGenJWT); err != nil { + return err } - - if !user.HasRole(schema.RoleApi) { - cclog.Warnf("JWT: User '%s' does not have the role 'api'. REST API endpoints will return error!\n", user.Username) - } - - jwt, err := authHandle.JwtAuth.ProvideJWT(user) - if err != nil { - cclog.Abortf("JWT: User '%s' found in DB, but failed to provide JWT.\nError: %s\n", user.Username, err.Error()) - } - - cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt) } + } + + return nil +} - } else if flagNewUser != "" || flagDelUser != "" { - cclog.Abort("Error: Arguments '--add-user' and '--del-user' can only be used if authentication is enabled. No changes, exited.") +// checkDefaultSecurityKeys warns if default JWT keys are detected +func checkDefaultSecurityKeys() { + // Default JWT public key from init.go + defaultJWTPublic := "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=" + + if os.Getenv("JWT_PUBLIC_KEY") == defaultJWTPublic { + cclog.Warn("Using default JWT keys - not recommended for production environments") + } +} + +func addUser(userSpec string) error { + parts := strings.SplitN(userSpec, ":", 3) + if len(parts) != 3 || len(parts[0]) == 0 { + return fmt.Errorf("invalid user format, want: :[admin,support,manager,api,user]:, have: %s", userSpec) } - if archiveCfg := ccconf.GetPackageConfig("archive"); archiveCfg != nil { - err = archive.Init(archiveCfg, config.Keys.DisableArchive) - } else { - err = archive.Init(json.RawMessage("{\"kind\":\"file\",\"path\":\"./var/job-archive\"}"), config.Keys.DisableArchive) + ur := repository.GetUserRepository() + if err := ur.AddUser(&schema.User{ + Username: parts[0], + Projects: make([]string, 0), + Password: parts[2], + Roles: strings.Split(parts[1], ","), + }); err != nil { + return fmt.Errorf("adding user '%s' with roles '%s': %w", parts[0], parts[1], err) } + + cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1]) + return nil +} + +func delUser(username string) error { + ur := repository.GetUserRepository() + if err := ur.DelUser(username); err != nil { + return fmt.Errorf("deleting user '%s': %w", username, err) + } + cclog.Printf("Delete User: Deleted user '%s' from DB.\n", username) + return nil +} + +func syncLDAP(authHandle *auth.Authentication) error { + if authHandle.LdapAuth == nil { + return fmt.Errorf("LDAP authentication is not configured") + } + + if err := authHandle.LdapAuth.Sync(); err != nil { + return fmt.Errorf("synchronizing LDAP: %w", err) + } + + cclog.Print("Sync LDAP: LDAP synchronization successfull.") + return nil +} + +func generateJWT(authHandle *auth.Authentication, username string) error { + ur := repository.GetUserRepository() + user, err := ur.GetUser(username) if err != nil { - cclog.Abortf("Init: Failed to initialize archive.\nError: %s\n", err.Error()) + return fmt.Errorf("getting user '%s': %w", username, err) } + if !user.HasRole(schema.RoleApi) { + cclog.Warnf("JWT: User '%s' does not have the role 'api'. REST API endpoints will return error!\n", user.Username) + } + + jwt, err := authHandle.JwtAuth.ProvideJWT(user) + if err != nil { + return fmt.Errorf("generating JWT for user '%s': %w", user.Username, err) + } + + cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt) + return nil +} + +func initSubsystems() error { + // Initialize archive + archiveCfg := ccconf.GetPackageConfig("archive") + if archiveCfg == nil { + archiveCfg = json.RawMessage(defaultArchiveConfig) + } + if err := archive.Init(archiveCfg, config.Keys.DisableArchive); err != nil { + return fmt.Errorf("initializing archive: %w", err) + } + + // Initialize metricdata if err := metricdata.Init(); err != nil { - cclog.Abortf("Init: Failed to initialize metricdata repository.\nError %s\n", err.Error()) + return fmt.Errorf("initializing metricdata repository: %w", err) } + // Handle database re-initialization if flagReinitDB { if err := importer.InitDB(); err != nil { - cclog.Abortf("Init DB: Failed to re-initialize repository DB.\nError: %s\n", err.Error()) - } else { - cclog.Print("Init DB: Sucessfully re-initialized repository DB.") + return fmt.Errorf("re-initializing repository DB: %w", err) } + cclog.Print("Init DB: Successfully re-initialized repository DB.") } + // Handle job import if flagImportJob != "" { if err := importer.HandleImportFlag(flagImportJob); err != nil { - cclog.Abortf("Import Job: Job import failed.\nError: %s\n", err.Error()) - } else { - cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob) + return fmt.Errorf("importing job: %w", err) } + cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob) } + // Initialize taggers if config.Keys.EnableJobTaggers { tagger.Init() } + // Apply tags if requested if flagApplyTags { if err := tagger.RunTaggers(); err != nil { - cclog.Abortf("Running job taggers.\nError: %s\n", err.Error()) + return fmt.Errorf("running job taggers: %w", err) } } + + return nil +} - if !flagServer { - cclog.Exit("No errors, server flag not set. Exiting cc-backend.") - } - +func runServer(ctx context.Context) error { var wg sync.WaitGroup - // Metric Store starts after all flags have been processes + // Start metric store if enabled if memorystore.InternalCCMSFlag { - if mscfg := ccconf.GetPackageConfig("metric-store"); mscfg != nil { - memorystore.Init(mscfg, &wg) - } else { - cclog.Abort("Metric Store configuration must be present") + mscfg := ccconf.GetPackageConfig("metric-store") + if mscfg == nil { + return fmt.Errorf("metric store configuration must be present") } + memorystore.Init(mscfg, &wg) } + + // Start archiver and task manager archiver.Start(repository.GetJobRepository()) + taskManager.Start(ccconf.GetPackageConfig("cron"), ccconf.GetPackageConfig("archive")) - taskManager.Start(ccconf.GetPackageConfig("cron"), - ccconf.GetPackageConfig("archive")) - + // Initialize web UI cfg := ccconf.GetPackageConfig("ui") web.Init(cfg) - serverInit() + // Initialize HTTP server + srv, err := NewServer(version, commit, date) + if err != nil { + return fmt.Errorf("creating server: %w", err) + } + // Channel to collect errors from server + errChan := make(chan error, 1) + + // Start HTTP server wg.Add(1) go func() { defer wg.Done() - serverStart() + if err := srv.Start(ctx); err != nil { + errChan <- err + } }() + // Handle shutdown signals wg.Add(1) sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { defer wg.Done() - <-sigs + select { + case <-sigs: + cclog.Info("Shutdown signal received") + case <-ctx.Done(): + } + runtimeEnv.SystemdNotifiy(false, "Shutting down ...") - - serverShutdown() - + srv.Shutdown(ctx) util.FsWatcherShutdown() - taskManager.Shutdown() }() - if os.Getenv("GOGC") == "" { + // Set GC percent if not configured + if os.Getenv(envGOGC) == "" { debug.SetGCPercent(25) } runtimeEnv.SystemdNotifiy(true, "running") - wg.Wait() + + // Wait for completion or error + go func() { + wg.Wait() + close(errChan) + }() + + // Check for server startup errors + select { + case err := <-errChan: + if err != nil { + return err + } + case <-time.After(100 * time.Millisecond): + // Server started successfully, wait for completion + if err := <-errChan; err != nil { + return err + } + } + cclog.Print("Graceful shutdown completed!") + return nil } + +func run() error { + cliInit() + + // Handle version flag + if flagVersion { + printVersion() + return nil + } + + // Initialize logger + cclog.Init(flagLogLevel, flagLogDateTime) + + // Handle init flag + if flagInit { + initEnv() + cclog.Exit("Successfully setup environment!\n" + + "Please review config.json and .env and adjust it to your needs.\n" + + "Add your job-archive at ./var/job-archive.") + } + + // Initialize gops agent + if err := initGops(); err != nil { + return err + } + + // Initialize subsystems in dependency order: + // 1. Load environment variables from .env file (contains sensitive configuration) + // 2. Load configuration from config.json (may reference environment variables) + // 3. Initialize database connection (requires config for connection string) + // 4. Handle database commands if requested (requires active database connection) + // 5. Handle user commands if requested (requires database and authentication config) + // 6. Initialize subsystems like archive and metrics (require config and database) + + // Load environment and configuration + if err := loadEnvironment(); err != nil { + return err + } + + if err := initConfiguration(); err != nil { + return err + } + + // Initialize database + if err := initDatabase(); err != nil { + return err + } + + // Handle database commands (migrate, revert, force) + if err := handleDatabaseCommands(); err != nil { + return err + } + + // Handle user commands (add, delete, sync, JWT) + if err := handleUserCommands(); err != nil { + return err + } + + // Initialize subsystems (archive, metrics, taggers) + if err := initSubsystems(); err != nil { + return err + } + + // Start server if requested + if !flagServer { + cclog.Exit("No errors, server flag not set. Exiting cc-backend.") + } + + // Run server with context + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + return runServer(ctx) +} + +func main() { + if err := run(); err != nil { + cclog.Error(err.Error()) + os.Exit(1) + } +} + diff --git a/cmd/cc-backend/server.go b/cmd/cc-backend/server.go index 95f6464..df238e3 100644 --- a/cmd/cc-backend/server.go +++ b/cmd/cc-backend/server.go @@ -2,6 +2,9 @@ // All rights reserved. This file is part of cc-backend. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// Package main provides the entry point for the ClusterCockpit backend server. +// This file contains HTTP server setup, routing configuration, and +// authentication middleware integration. package main import ( @@ -37,10 +40,20 @@ import ( ) var ( + buildInfo web.Build +) + +// Environment variable names +const ( + envDebug = "DEBUG" +) + +// Server encapsulates the HTTP server state and dependencies +type Server struct { router *mux.Router server *http.Server apiHandle *api.RestApi -) +} func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) { rw.Header().Add("Content-Type", "application/json") @@ -51,25 +64,31 @@ func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) { }) } -func serverInit() { +// NewServer creates and initializes a new Server instance +func NewServer(version, commit, buildDate string) (*Server, error) { + buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate} + + s := &Server{ + router: mux.NewRouter(), + } + + if err := s.init(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *Server) init() error { // Setup the http.Handler/Router used by the server graph.Init() resolver := graph.GetResolverInstance() graphQLServer := handler.New( generated.NewExecutableSchema(generated.Config{Resolvers: resolver})) - // graphQLServer.AddTransport(transport.SSE{}) graphQLServer.AddTransport(transport.POST{}) - // graphQLServer.AddTransport(transport.Websocket{ - // KeepAlivePingInterval: 10 * time.Second, - // Upgrader: websocket.Upgrader{ - // CheckOrigin: func(r *http.Request) bool { - // return true - // }, - // }, - // }) - if os.Getenv("DEBUG") != "1" { + if os.Getenv(envDebug) != "1" { // Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed. // The problem with this is that then, no more stacktrace is printed to stderr. graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error { @@ -86,73 +105,56 @@ func serverInit() { authHandle := auth.GetAuthInstance() - apiHandle = api.New() - - router = mux.NewRouter() - buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date} + s.apiHandle = api.New() info := map[string]any{} info["hasOpenIDConnect"] = false if auth.Keys.OpenIDConfig != nil { openIDConnect := auth.NewOIDC(authHandle) - openIDConnect.RegisterEndpoints(router) + openIDConnect.RegisterEndpoints(s.router) info["hasOpenIDConnect"] = true } - router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { + s.router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") cclog.Debugf("##%v##", info) web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info}) }).Methods(http.MethodGet) - router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { + s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo}) }) - router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) { + s.router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) }) - secured := router.PathPrefix("/").Subrouter() - securedapi := router.PathPrefix("/api").Subrouter() - userapi := router.PathPrefix("/userapi").Subrouter() - configapi := router.PathPrefix("/config").Subrouter() - frontendapi := router.PathPrefix("/frontend").Subrouter() - metricstoreapi := router.PathPrefix("/metricstore").Subrouter() + secured := s.router.PathPrefix("/").Subrouter() + securedapi := s.router.PathPrefix("/api").Subrouter() + userapi := s.router.PathPrefix("/userapi").Subrouter() + configapi := s.router.PathPrefix("/config").Subrouter() + frontendapi := s.router.PathPrefix("/frontend").Subrouter() + metricstoreapi := s.router.PathPrefix("/metricstore").Subrouter() if !config.Keys.DisableAuthentication { - router.Handle("/login", authHandle.Login( - // On success: Handled within Login() - // On failure: - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "text/html; charset=utf-8") - rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Login failed - ClusterCockpit", - MsgType: "alert-warning", - Message: err.Error(), - Build: buildInfo, - Infos: info, - }) - })).Methods(http.MethodPost) + // Create login failure handler (used by both /login and /jwt-login) + loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "text/html; charset=utf-8") + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Login failed - ClusterCockpit", + MsgType: "alert-warning", + Message: err.Error(), + Build: buildInfo, + Infos: info, + }) + } - router.Handle("/jwt-login", authHandle.Login( - // On success: Handled within Login() - // On failure: - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "text/html; charset=utf-8") - rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Login failed - ClusterCockpit", - MsgType: "alert-warning", - Message: err.Error(), - Build: buildInfo, - Infos: info, - }) - })) + s.router.Handle("/login", authHandle.Login(loginFailureHandler)).Methods(http.MethodPost) + s.router.Handle("/jwt-login", authHandle.Login(loginFailureHandler)) - router.Handle("/logout", authHandle.Logout( + s.router.Handle("/logout", authHandle.Logout( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.WriteHeader(http.StatusOK) @@ -226,8 +228,8 @@ func serverInit() { } if flagDev { - router.Handle("/playground", playground.Handler("GraphQL playground", "/query")) - router.PathPrefix("/swagger/").Handler(httpSwagger.Handler( + s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query")) + s.router.PathPrefix("/swagger/").Handler(httpSwagger.Handler( httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet) } secured.Handle("/query", graphQLServer) @@ -239,67 +241,47 @@ func serverInit() { // Mount all /monitoring/... and /api/... routes. routerConfig.SetupRoutes(secured, buildInfo) - apiHandle.MountApiRoutes(securedapi) - apiHandle.MountUserApiRoutes(userapi) - apiHandle.MountConfigApiRoutes(configapi) - apiHandle.MountFrontendApiRoutes(frontendapi) + s.apiHandle.MountApiRoutes(securedapi) + s.apiHandle.MountUserApiRoutes(userapi) + s.apiHandle.MountConfigApiRoutes(configapi) + s.apiHandle.MountFrontendApiRoutes(frontendapi) if memorystore.InternalCCMSFlag { - apiHandle.MountMetricStoreApiRoutes(metricstoreapi) + s.apiHandle.MountMetricStoreApiRoutes(metricstoreapi) } if config.Keys.EmbedStaticFiles { if i, err := os.Stat("./var/img"); err == nil { if i.IsDir() { cclog.Info("Use local directory for static images") - router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img")))) + s.router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img")))) } } - router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles())) + s.router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles())) } else { - router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles))) + s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles))) } - router.Use(handlers.CompressHandler) - router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))) - router.Use(handlers.CORS( + s.router.Use(handlers.CompressHandler) + s.router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))) + s.router.Use(handlers.CORS( handlers.AllowCredentials(), handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}), handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}))) - // secured.NotFoundHandler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - // page := web.Page{ - // Title: "ClusterCockpit - Not Found", - // Build: buildInfo, - // } - // rw.Header().Add("Content-Type", "text/html; charset=utf-8") - // web.RenderTemplate(rw, "404.tmpl", &page) - // }) - - // secured.NotFoundHandler = http.HandlerFunc(http.NotFound) - // router.NotFoundHandler = router.NewRoute().HandlerFunc(http.NotFound).GetHandler() - - // printEndpoints(router) + + return nil } -// func printEndpoints(r *mux.Router) { -// r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { -// path, err := route.GetPathTemplate() -// if err != nil { -// path = "nopath" -// } -// methods, err := route.GetMethods() -// if err != nil { -// methods = append(methods, "nomethod") -// } -// fmt.Printf("%v %s\n", methods, path) -// return nil -// }) -// } +// Server timeout defaults (in seconds) +const ( + defaultReadTimeout = 20 + defaultWriteTimeout = 20 +) -func serverStart() { - handler := handlers.CustomLoggingHandler(io.Discard, router, func(_ io.Writer, params handlers.LogFormatterParams) { +func (s *Server) Start(ctx context.Context) error { + handler := handlers.CustomLoggingHandler(io.Discard, s.router, func(_ io.Writer, params handlers.LogFormatterParams) { if strings.HasPrefix(params.Request.RequestURI, "/api/") { cclog.Debugf("%s %s (%d, %.02fkb, %dms)", params.Request.Method, params.URL.RequestURI(), @@ -313,9 +295,13 @@ func serverStart() { } }) - server = &http.Server{ - ReadTimeout: 20 * time.Second, - WriteTimeout: 20 * time.Second, + // Use configurable timeouts with defaults + readTimeout := time.Duration(defaultReadTimeout) * time.Second + writeTimeout := time.Duration(defaultWriteTimeout) * time.Second + + s.server = &http.Server{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, Handler: handler, Addr: config.Keys.Addr, } @@ -323,7 +309,7 @@ func serverStart() { // Start http or https server listener, err := net.Listen("tcp", config.Keys.Addr) if err != nil { - cclog.Abortf("Server Start: Starting http listener on '%s' failed.\nError: %s\n", config.Keys.Addr, err.Error()) + return fmt.Errorf("starting listener on '%s': %w", config.Keys.Addr, err) } if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" { @@ -336,7 +322,7 @@ func serverStart() { cert, err := tls.LoadX509KeyPair( config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile) if err != nil { - cclog.Abortf("Server Start: Loading X509 keypair failed. Check options 'https-cert-file' and 'https-key-file' in 'config.json'.\nError: %s\n", err.Error()) + return fmt.Errorf("loading X509 keypair (check 'https-cert-file' and 'https-key-file' in config.json): %w", err) } listener = tls.NewListener(listener, &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -356,17 +342,34 @@ func serverStart() { // be established first, then the user can be changed, and after that, // the actual http server can be started. if err := runtimeEnv.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil { - cclog.Abortf("Server Start: Error while preparing server start.\nError: %s\n", err.Error()) + return fmt.Errorf("dropping privileges: %w", err) } - if err = server.Serve(listener); err != nil && err != http.ErrServerClosed { - cclog.Abortf("Server Start: Starting server failed.\nError: %s\n", err.Error()) + // Handle context cancellation for graceful shutdown + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.server.Shutdown(shutdownCtx); err != nil { + cclog.Errorf("Server shutdown error: %v", err) + } + }() + + if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server failed: %w", err) } + return nil } -func serverShutdown() { +func (s *Server) Shutdown(ctx context.Context) { + // Create a shutdown context with timeout + shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + // First shut down the server gracefully (waiting for all ongoing requests) - server.Shutdown(context.Background()) + if err := s.server.Shutdown(shutdownCtx); err != nil { + cclog.Errorf("Server shutdown error: %v", err) + } // Archive all the metric store data if memorystore.InternalCCMSFlag {