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:
Jakub Zych
2026-10-21 12:59:07 +02:00
parent f0ee26f39f
commit c38dc1fe48
23 changed files with 1333 additions and 94 deletions

View File

@@ -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 {