package httpserver import ( "context" "fmt" "io" "net/http" "os" "os/signal" "syscall" "time" rice "github.com/GeertJohan/go.rice" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/viper" "github.com/getsentry/sentry-go" "github.com/go-chi/chi" // spooky action at a distance! // this populates the environment // from a ./.env file automatically // for development configuration. // .env contents should be things like // `DBURL=postgres://user:pass@.../` // (without the backticks, of course) _ "github.com/joho/godotenv/autoload" ) type server struct { appname string version string buildarch string databaseURL string startupTime time.Time port int exitCode int sentryEnabled bool log *zerolog.Logger ctx context.Context cancelFunc context.CancelFunc httpServer *http.Server router *chi.Mux staticFiles *rice.Box templateFiles *rice.Box } func NewServer(options ...func(s *server)) *server { n := new(server) n.startupTime = time.Now() n.version = "unknown" for _, opt := range options { opt(n) } return n } // this is where we come in from package main. func Run(appname, version, buildarch string) int { s := NewServer(func(i *server) { i.appname = appname if version != "" { i.version = version } i.buildarch = buildarch i.staticFiles = rice.MustFindBox("static") i.templateFiles = rice.MustFindBox("templates") }) // this does nothing if SENTRY_DSN is unset in env. // TODO remove: if s.sentryEnabled { sentry.CaptureMessage("It works!") } s.configure() s.setupLogging() // logging before sentry, because sentry logs s.enableSentry() s.databaseURL = viper.GetString("DBURL") s.port = viper.GetInt("PORT") return s.serve() } func (s *server) enableSentry() { s.sentryEnabled = false if viper.GetString("SENTRY_DSN") == "" { return } err := sentry.Init(sentry.ClientOptions{ Dsn: viper.GetString("SENTRY_DSN"), Release: fmt.Sprintf("%s-%s", s.appname, s.version), }) if err != nil { log.Fatal().Err(err).Msg("sentry init failure") return } s.log.Info().Msg("sentry error reporting activated") s.sentryEnabled = true } func (s *server) serve() int { s.ctx, s.cancelFunc = context.WithCancel(context.Background()) // signal watcher go func() { c := make(chan os.Signal, 1) signal.Ignore(syscall.SIGPIPE) signal.Notify(c, os.Interrupt, syscall.SIGTERM) // block and wait for signal sig := <-c s.log.Info().Msgf("signal received: %+v", sig) if s.cancelFunc != nil { // cancelling the main context will trigger a clean // shutdown. s.cancelFunc() } }() go s.serveUntilShutdown() for range s.ctx.Done() { // aforementioned clean shutdown upon main context // cancellation } s.cleanShutdown() return s.exitCode } func (s *server) cleanupForExit() { s.log.Info().Msg("cleaning up") // FIXME unimplemented // close database connections or whatever } func (s *server) cleanShutdown() { // initiate clean shutdown s.exitCode = 0 ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) if err := s.httpServer.Shutdown(ctxShutdown); err != nil { s.log.Error(). Err(err). Msg("server clean shutdown failed") } if shutdownCancel != nil { shutdownCancel() } s.cleanupForExit() if s.sentryEnabled { sentry.Flush(2 * time.Second) } } func (s *server) uptime() time.Duration { return time.Since(s.startupTime) } func (s *server) maintenance() bool { return viper.GetBool("MAINTENANCE_MODE") } func (s *server) configure() { viper.SetConfigName(s.appname) viper.SetConfigType("yaml") // path to look for the config file in: viper.AddConfigPath(fmt.Sprintf("/etc/%s", s.appname)) // call multiple times to add many search paths: viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", s.appname)) // viper.SetEnvPrefix(strings.ToUpper(s.appname)) viper.AutomaticEnv() viper.SetDefault("DEBUG", "false") viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("PORT", "8080") viper.SetDefault("DBURL", "") viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { // Config file not found; ignore error if desired } else { // Config file was found but another error was produced log.Panic(). Err(err). Msg("config file malformed") } } // if viper.GetBool("DEBUG") { // pp.Print(viper.AllSettings()) // } } func (s *server) setupLogging() { // always log in UTC zerolog.TimestampFunc = func() time.Time { return time.Now().UTC() } zerolog.SetGlobalLevel(zerolog.InfoLevel) tty := false if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { tty = true } var writers []io.Writer if tty { // this does cool colorization for console/dev consoleWriter := zerolog.NewConsoleWriter( func(w *zerolog.ConsoleWriter) { // Customize time format w.TimeFormat = time.RFC3339Nano }, ) writers = append(writers, consoleWriter) } else { // log json in prod for the machines writers = append(writers, os.Stdout) } /* // this is how you log to a file, if you do that // sort of thing still logfile := viper.GetString("Logfile") if logfile != "" { logfileDir := filepath.Dir(logfile) err := goutil.Mkdirp(logfileDir) if err != nil { log.Error().Err(err).Msg("unable to create log dir") } hp.logfh, err = os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { panic("unable to open logfile: " + err.Error()) } writers = append(writers, hp.logfh) */ multi := zerolog.MultiLevelWriter(writers...) logger := zerolog.New(multi).With().Timestamp().Logger().With().Caller().Logger() s.log = &logger // log.Logger = logger if viper.GetBool("debug") { zerolog.SetGlobalLevel(zerolog.DebugLevel) s.log.Debug().Bool("debug", true).Send() } s.identify() } func (s *server) identify() { s.log.Info(). Str("appname", s.appname). Str("version", s.version). Str("buildarch", s.buildarch). Msg("starting") }