feat(surf): serve startup summary, shutdown report and http request log
- serve prints a startup summary on a terminal (version, environment, URL, admin, database host and name, pending migrations, plugins, drivers) and logs it as a 'startup summary' record plus 'listening on <addr>' - on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and returns nil (exit 0), or the drain errors (exit 1) - the request log is on by default under diary component http with request_id; the route pattern, never the path, is logged - lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY, bonfire.IsTerminal and bonfire.Details back the summary and report
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
@@ -25,16 +27,40 @@ import (
|
||||
)
|
||||
|
||||
// ServeCommand starts a signal-aware HTTP server on the assembled router.
|
||||
// Once it listens it prints a startup summary on a terminal and logs it as
|
||||
// records (component serve); on SIGINT or SIGTERM it drains requests and the
|
||||
// in-process worker for up to 10 seconds, reports what it did and returns
|
||||
// nil, or the drain errors.
|
||||
func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "serve",
|
||||
Description: "Serve the application HTTP API",
|
||||
Long: `Opens the database and the uploads bucket, installs the cache, assembles every
|
||||
plugin's routes, binds the address and starts the in-process job worker
|
||||
(queue.work_in_serve). Once it listens it prints a startup summary: version,
|
||||
environment, URL and admin URL, database and pending migrations, plugins, and
|
||||
the queue, scheduler, mail, cache, realtime and search drivers. -v adds one
|
||||
line per plugin; -q prints only warnings and errors. Off a terminal the same
|
||||
facts are logged as a "startup summary" record and a "listening on <addr>"
|
||||
record, so journald and the log files keep them.
|
||||
|
||||
Each request is logged as an "http request" record of component http (route
|
||||
pattern, never the path). On Ctrl+C or SIGTERM serve drains requests and the
|
||||
worker for up to 10 seconds and prints a short report. It exits 0 after a
|
||||
clean stop and 1 when draining fails.`,
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "addr",
|
||||
Description: "Listen address",
|
||||
Default: ":8080",
|
||||
}},
|
||||
Examples: []bonfire.Example{
|
||||
{Command: "serve", Description: "Listen on every interface, port 8080"},
|
||||
{Command: "serve --addr 127.0.0.1:8096", Description: "Listen on loopback only, during development"},
|
||||
{Command: "serve -v", Description: "Add one summary line per plugin and info logs of every component"},
|
||||
{Command: "serve -q", Description: "Print only warnings and errors"},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
started := time.Now()
|
||||
addr, _ := in.Flag("addr")
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
addr = ":8080"
|
||||
@@ -67,6 +93,7 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
counter := newRequestCounter(h)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,16 +103,33 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
if worker != nil {
|
||||
out.Info(fmt.Sprintf("job worker started on queues: %s", strings.Join(worker.Queues(), ", ")))
|
||||
}
|
||||
prefix, mounted := r.AdminPrefix()
|
||||
admins := adminsUnknown
|
||||
if mounted {
|
||||
admins = countAdmins(ctx, gdb)
|
||||
}
|
||||
printBanner(out, serveBanner(addr, prefix, mounted, admins))
|
||||
srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
pending, pendingErr := lagoon.PendingMigrations(gdb, plugins)
|
||||
facts := buildFacts(app, factInputs{
|
||||
addr: listenAddr(addr, ln),
|
||||
bin: binName(),
|
||||
plugins: plugins,
|
||||
queues: worker.Queues(),
|
||||
worker: worker != nil,
|
||||
adminPrefix: prefix,
|
||||
mounted: mounted,
|
||||
admins: admins,
|
||||
pending: pending,
|
||||
pendingErr: pendingErr,
|
||||
version: bonfire.BuildVersion(),
|
||||
})
|
||||
styled := bonfire.IsTerminal(out) && !bonfire.IsQuiet(out)
|
||||
logCtx := ctx
|
||||
if styled {
|
||||
printSummary(out, facts)
|
||||
logCtx = diary.HideOnTTY(ctx)
|
||||
}
|
||||
logSummary(logCtx, serveLog, facts)
|
||||
srv := &http.Server{Addr: addr, Handler: counter, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- srv.Serve(ln)
|
||||
@@ -95,18 +139,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
report := shutdownReport{Draining: counter.inFlight.Load()}
|
||||
if styled {
|
||||
out.Info(fmt.Sprintf("Shutting down: draining %d requests", report.Draining))
|
||||
}
|
||||
sctx, cancel := shutdownCtx()
|
||||
defer cancel()
|
||||
shutdownErr := srv.Shutdown(sctx)
|
||||
workerErr := stopWorker(sctx, worker)
|
||||
if shutdownErr != nil {
|
||||
return shutdownErr
|
||||
if err := srv.Shutdown(sctx); err != nil {
|
||||
report.Aborted = counter.inFlight.Load()
|
||||
_ = srv.Close()
|
||||
report.Errors = append(report.Errors, fmt.Errorf("surf: drain requests: %w", err))
|
||||
}
|
||||
err := <-errCh
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
if err := stopWorker(sctx, worker); err != nil {
|
||||
report.Errors = append(report.Errors, err)
|
||||
}
|
||||
return workerErr
|
||||
if err := <-errCh; err != nil && err != http.ErrServerClosed {
|
||||
report.Errors = append(report.Errors, err)
|
||||
}
|
||||
if worker != nil {
|
||||
stats := worker.Stats()
|
||||
report.Worker = &stats
|
||||
}
|
||||
report.Served = counter.served.Load()
|
||||
report.Uptime = time.Since(started)
|
||||
reportCtx := context.WithoutCancel(ctx)
|
||||
if styled {
|
||||
bonfire.Details(out, "Shutdown report", report.details())
|
||||
reportCtx = diary.HideOnTTY(reportCtx)
|
||||
}
|
||||
logReport(reportCtx, serveLog, report)
|
||||
return report.Err()
|
||||
case err := <-errCh:
|
||||
sctx, cancel := shutdownCtx()
|
||||
defer cancel()
|
||||
@@ -120,6 +182,20 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// listenAddr is addr, or the bound address when addr asks for any port.
|
||||
func listenAddr(addr string, ln net.Listener) string {
|
||||
if _, port, err := net.SplitHostPort(addr); err == nil && port == "0" && ln != nil {
|
||||
return ln.Addr().String()
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// binName is how a person runs this binary from the application root:
|
||||
// bin/<name of the running executable>.
|
||||
func binName() string {
|
||||
return "bin/" + filepath.Base(os.Args[0])
|
||||
}
|
||||
|
||||
// stopWorker stops the in-process job worker, if serve started one.
|
||||
func stopWorker(ctx context.Context, w *conga.Worker) error {
|
||||
if w == nil {
|
||||
@@ -232,44 +308,39 @@ func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence {
|
||||
return adminsPresent
|
||||
}
|
||||
|
||||
// bannerLine is one startup line; warn lines print with the warning symbol.
|
||||
// bannerLine is one startup note; warn notes print with the warning symbol.
|
||||
type bannerLine struct {
|
||||
warn bool
|
||||
text string
|
||||
}
|
||||
|
||||
// serveBanner builds the lines serve prints once the port is bound: the
|
||||
// listen address with a browser URL, then the admin status.
|
||||
func serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence) []bannerLine {
|
||||
url := serveURL(addr)
|
||||
listen := "listening on " + addr
|
||||
if url != "" {
|
||||
listen += ", open " + url
|
||||
}
|
||||
lines := []bannerLine{{text: listen}}
|
||||
// adminStatus is the summary's Admin value and the notes printed after the
|
||||
// summary: the admin URL (url is serve's browser URL, "" when the address
|
||||
// does not parse) with a warning naming summer admin:create when no
|
||||
// administrator exists yet, or "off" with the steps to add an admin
|
||||
// controller.
|
||||
func adminStatus(url, adminPrefix string, mounted bool, admins adminPresence) (string, []bannerLine) {
|
||||
if !mounted {
|
||||
return append(lines,
|
||||
bannerLine{text: adminOffText},
|
||||
bannerLine{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"},
|
||||
)
|
||||
return strings.TrimPrefix(adminOffText, "admin: "), []bannerLine{
|
||||
{text: "to add an admin, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"},
|
||||
}
|
||||
}
|
||||
value := "mounted at " + adminPrefix
|
||||
if url != "" {
|
||||
lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix})
|
||||
} else {
|
||||
lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix})
|
||||
value = strings.TrimSuffix(url, "/") + adminPrefix
|
||||
}
|
||||
switch admins {
|
||||
case adminsNone:
|
||||
lines = append(lines, bannerLine{warn: true, text: "no administrators yet; create one with " + adminCreateCommand})
|
||||
return value, []bannerLine{{warn: true, text: "no administrators yet; create one with " + adminCreateCommand}}
|
||||
case adminsMissingTable:
|
||||
lines = append(lines, bannerLine{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand})
|
||||
return value, []bannerLine{{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand}}
|
||||
case adminsUnknown:
|
||||
lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"})
|
||||
return value, []bannerLine{{text: "sign in with an administrator; " + adminCreateCommand + " creates one"}}
|
||||
}
|
||||
return lines
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// printBanner writes banner lines as info or warning output.
|
||||
// printBanner writes notes as info or warning output.
|
||||
func printBanner(out bonfire.Output, lines []bannerLine) {
|
||||
for _, l := range lines {
|
||||
if l.warn {
|
||||
|
||||
Reference in New Issue
Block a user