// Package main is the entry point for the webhooker application. package main import ( "fmt" "io" "os" "time" "go.uber.org/fx" "go.uber.org/fx/fxevent" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/session" ) // stopTimeout bounds the whole fx stop sequence, not each hook. // // fx defaults to 15s, which is longer than Docker's 10s default // stop grace: the container would be SIGKILLed before the bound // could fire, so nothing bounded by it would ever be observed. // 5s leaves headroom inside that grace for signal delivery and // process exit; the observed wedge case already exits at ~5.3s, // so a larger bound would trade a rare skipped database close for // a more common hard kill. // // The server's stop hook must fit inside it with room to spare: a // hook that used the whole budget would exhaust it at that instant, // and fx would skip every hook after the server — the delivery // engine, the healthcheck, the webhook DB manager and the database // close. That hook is the 3s HTTP drain plus the Sentry flush that // follows it in the same hook, so the flush is clamped to the stop // context's remaining time less server.TailHookReserve rather than // running for its own fixed 2s; the reserve is what the tail hooks // live on, and they are microsecond-scale in normal operation. // TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic // across every drain length. // // This does not make the database close unconditional: the // ArchiveSweeper and RetentionReaper hooks run before the server // and can still consume the whole budget on their own. const stopTimeout = 5 * time.Second // exitUsage is the status for a command line this binary cannot make // sense of, kept distinct from the 1 a refusal exits with so that a // caller can tell "called wrong" from "declined". const exitUsage = 2 // helpCommand is the subcommand that prints usage. The flag spellings // beside it in the switch are aliases; this is the name the usage text // documents and the one tests invoke. const helpCommand = "help" // Build-time variables set via -ldflags. // //nolint:gochecknoglobals // Build-time variables injected by the linker. var ( version = "dev" appname = "webhooker" ) func main() { globals.Appname = appname globals.Version = version os.Exit(dispatch(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // dispatch routes the command line to a subcommand. // // No arguments runs the server, which is what the image's CMD and // every existing deployment invoke; that path is unchanged, including // where the DATA_DIR lock is taken relative to building the fx graph // and how fx propagates a non-zero exit itself. // // The optional .env file is read here, before any subcommand and so // before anything reads the environment — config.DataDir, which both // the DATA_DIR lock and resetpw call outside the fx graph, above all. // It used to be read from an init() in internal/config, which put it // earlier still but threw the error away: a single malformed line // applied none of the file and said nothing about it. A file that is // not there stays fine, since .env is optional and most deployments // do not have one. func dispatch( args []string, stdin io.Reader, stdout, stderr io.Writer, ) int { err := config.LoadDotEnv() if err != nil { _, _ = fmt.Fprintf(stderr, "%s: %v\n", appname, err) return 1 } if len(args) == 0 { return run(stderr) } switch args[0] { case resetpw.Name: return resetpw.Run(args[1:], stdin, stdout, stderr) case helpCommand, "-h", "-help", "--help": usage(stdout) return 0 default: _, _ = fmt.Fprintf( stderr, "%s: unknown subcommand %q\n", appname, args[0], ) usage(stderr) return exitUsage } } // usage lists what the binary can be asked to do. func usage(w io.Writer) { _, _ = fmt.Fprintf(w, `usage: %s [subcommand] With no subcommand, runs the webhooker server. Subcommands: %s [-generate] Set an existing account's password on a stopped deployment. Recovers an admin account whose bootstrap password was lost. help Print this message. `, appname, resetpw.Name) } // run takes the exclusive DATA_DIR lock, then runs the application // under it, and returns the process exit status. // // The lock is taken here rather than inside the fx graph because it has // to be held before anything opens a database, and because a refusal // has to reach the operator as a plain line on standard error rather // than as one entry in an fx failure dump. It is released by the defer // on a clean shutdown, and by the kernel closing the descriptor on any // other exit — including the one fx performs itself when a start or // stop hook fails, which skips deferred calls. func run(stderr io.Writer) int { lock, err := datadir.Acquire(config.DataDir()) if err != nil { _, _ = fmt.Fprintf(stderr, "%s: %v\n", appname, err) return 1 } defer func() { _ = lock.Release() }() newApp().Run() return 0 } // newApp builds the application graph. It is separate from main so // a test can assert the options it carries. func newApp() *fx.App { return fx.New( fx.StopTimeout(stopTimeout), // fx's own events go through the service's logger, not fx's // console logger on standard error. The exception is a failure // before this logger is built, such as an invalid configuration // value, which fx's console logger still prints there. fx holds // its events back until this logger is built and then replays // them, so it takes the configuration, which sets the level // DEBUG=true asks for: without it the replay would run at INFO // and drop every record of how the graph was built. fx.WithLogger( func(l *logger.Logger, _ *config.Config) fxevent.Logger { return logger.NewFxLogger(l.Get()) }, ), fx.Provide( globals.New, logger.New, config.New, database.New, database.NewWebhookDBManager, database.NewRetentionReaper, healthcheck.New, session.New, handlers.New, // The registry /metrics serves, and the delivery // collectors registered on it. metrics.NewRegistry, metrics.New, middleware.New, // The one SSRF guard both target-creation validation // and the delivery dialer consult, so they cannot // disagree about a destination. delivery.NewGuard, delivery.New, delivery.NewArchiveSweeper, // Wire *delivery.Engine as delivery.Notifier so the // webhook handler can notify the engine of new deliveries. func(e *delivery.Engine) delivery.Notifier { return e }, // Wire *delivery.Engine as delivery.Archives so deleting // or renaming a webhook or target reaches its archive // files. func(e *delivery.Engine) delivery.Archives { return e }, server.New, ), fx.Invoke( func( *server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper, ) { }, ), ) }