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:
@@ -157,6 +157,8 @@ if catalog.Has("blog:import") {
|
||||
| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). |
|
||||
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
|
||||
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
|
||||
| `bonfire.IsTerminal` | Whether the Output writes to a terminal; long-running commands print a summary block for a person there and log records otherwise. |
|
||||
| `bonfire.Details`, `bonfire.Detail` | Prints a title and aligned label/value lines in the palette (a `Warn` value in the warning color); nothing under `-q`. Used for startup summaries and shutdown reports. |
|
||||
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
|
||||
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
|
||||
| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. |
|
||||
|
||||
@@ -3,6 +3,7 @@ package bonfire
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
@@ -13,12 +14,15 @@ import (
|
||||
type consoleState interface {
|
||||
consoleGlobals() Globals
|
||||
consoleInteractive() bool
|
||||
consoleTerminal() bool
|
||||
}
|
||||
|
||||
func (c *console) consoleGlobals() Globals { return c.globals }
|
||||
|
||||
func (c *console) consoleInteractive() bool { return c.canPrompt() }
|
||||
|
||||
func (c *console) consoleTerminal() bool { return c.tty }
|
||||
|
||||
// GlobalsOf returns the global options of the command out belongs to, or
|
||||
// the zero Globals when out is not a bonfire console.
|
||||
func GlobalsOf(out Output) Globals {
|
||||
@@ -48,6 +52,66 @@ func Interactive(out Output) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTerminal reports whether out writes to a terminal (its stdout is one).
|
||||
// It is false for other Output implementations and inside bonfire.Call.
|
||||
// Long-running commands use it to choose between a styled summary block for
|
||||
// a person and log records for journald.
|
||||
func IsTerminal(out Output) bool {
|
||||
if s, ok := out.(consoleState); ok {
|
||||
return s.consoleTerminal()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detail is one line of a Details block: a label and its value. Warn
|
||||
// prints the value in the warning color.
|
||||
type Detail struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool
|
||||
}
|
||||
|
||||
// Details prints title (when not empty) and rows as aligned label/value
|
||||
// lines in the console palette: muted labels padded to the longest one,
|
||||
// values in the default color or, with Warn, in the warning color. It
|
||||
// prints nothing under -q. serve, queue:work and summer dev use it for
|
||||
// their startup summaries and shutdown reports.
|
||||
func Details(out Output, title string, rows []Detail) {
|
||||
if IsQuiet(out) {
|
||||
return
|
||||
}
|
||||
width := 0
|
||||
for _, r := range rows {
|
||||
width = max(width, len([]rune(r.Label)))
|
||||
}
|
||||
c, styled := out.(*console)
|
||||
var b strings.Builder
|
||||
if title != "" {
|
||||
if styled {
|
||||
b.WriteString(c.palette.Header.Render(title))
|
||||
} else {
|
||||
b.WriteString(title)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
for _, r := range rows {
|
||||
label := r.Label + strings.Repeat(" ", width-len([]rune(r.Label)))
|
||||
value := r.Value
|
||||
if styled {
|
||||
label = c.palette.Muted.Render(label)
|
||||
if r.Warn {
|
||||
value = c.palette.Warning.Render(value)
|
||||
}
|
||||
}
|
||||
b.WriteString(" " + label + " " + value + "\n")
|
||||
}
|
||||
if styled {
|
||||
c.emit(c.out, b.String())
|
||||
return
|
||||
}
|
||||
out.Printf("%s", b.String())
|
||||
}
|
||||
|
||||
// ConfirmProceed asks question before a destructive step, defaulting to
|
||||
// no, only when out is Interactive. Off a terminal, with -n or -q and in
|
||||
// bonfire.Call it returns true without asking, so deploys over SSH and
|
||||
|
||||
@@ -34,7 +34,7 @@ Use cases:
|
||||
- The WinterCMS job manager operations with the same semantics: `conga.Manager.StartJob`, `conga.Manager.UpdateJobState`, `conga.Manager.UpdateMetadata`, `conga.Manager.CompleteJob`, `conga.Manager.FailJob`, `conga.Manager.CheckIfCanceled` and `conga.Manager.GetMetadata`. Updates are raw column writes, so `updated_at` changes only on dispatch and `conga.Manager.StartJob`.
|
||||
- Cancellation in two parts: `conga.Manager.CancelJob` is the outside cancel (sets `is_canceled` and `conga.StatusStopped`, then cancels the River job, so a queued job never starts and a running job's context is cancelled); `conga.Manager.StopJob` is what a job calls on its own row after `conga.Manager.CheckIfCanceled` reports true (status only, the WinterCMS `cancelJob`).
|
||||
- Outcome rules in the worker: an error on an attempt before the last leaves the row in progress so River can retry; the final failed attempt, or a recovered panic on it, sets `conga.StatusError` with the error text under the metadata key `error`; an error on a row that was stopped or cancelled cancels the River job instead. A job that returns nil without completing its row leaves it as it is. Skipped work is recorded as complete with `{"skipped": true}` metadata.
|
||||
- Workers: `conga.StartWorker` registers every plugin job and starts one River client; `conga.WorkerOptions.Queues` limits it to some queues, and an unknown queue is `conga.ErrUnknownQueue` listing the known ones. `conga.Worker.Stop` stops it gracefully and cancels running jobs when its context ends. `conga.StartServeWorker` is the variant the `serve` command uses: it starts nothing when `queue.work_in_serve` is false. An app without jobs still gets a worker that starts and idles.
|
||||
- Workers: `conga.StartWorker` registers every plugin job and starts one River client; `conga.WorkerOptions.Queues` limits it to some queues, and an unknown queue is `conga.ErrUnknownQueue` listing the known ones. `conga.Worker.Stop` stops it gracefully and cancels running jobs when its context ends; `conga.Worker.Stats` counts attempts done, failed, in flight and handed back to the queue by that cancellation. `conga.StartServeWorker` is the variant the `serve` command uses: it starts nothing when `queue.work_in_serve` is false. An app without jobs still gets a worker that starts and idles.
|
||||
- Scheduled commands: every worker carries one River periodic job per `pact.HasSchedule` entry, in plugin activation order then declaration order, with the id `<plugin id>[<index>]:<command>`. Only the elected leader enqueues. Each run is a `conga.ScheduledCommandArgs` job on `conga.QueueScheduled` with one attempt (an interrupted run is not retried; the next period runs normally) and unique by args within its cadence period, so a leader failover cannot double-enqueue a period. The worker runs a job only when its entry exists in the compiled schedule and its command and arguments match that entry exactly, so a forged `river_job` row cannot run an arbitrary command. An unregistered command, or an app with no published `bonfire.Catalog`, is logged at Warn (`schedule: command not registered; skipping`) and skipped without failing the worker or other entries. Command output is logged line by line at Info with a `command` attribute; failures are logged with the duration.
|
||||
- Framework schedule entries: before the plugin entries, every worker carries the framework's own entries from `lagoon.FrameworkSchedule`, listed under the plugin id `lagoon.FrameworkScheduleID`. Today that is the daily `deferred:purge` at `database.deferred_bindings.purge_at` (default `03:00`), with the id `summercms.lagoon[0]:deferred:purge`; it runs, appears in `schedule:run --once` and is protected by the compiled entry table exactly like a plugin entry. An empty `purge_at` removes it, and a malformed one fails the worker start.
|
||||
- Wall-clock schedules: `conga.Daily` fires at the next `hh:mm` in its location and `conga.Every` at the next multiple of its interval since local midnight, so a restart never delays a daily run by up to a day the way `river.PeriodicInterval(24h)` would. On a DST day `conga.Daily` keeps the wall-clock time. A schedule entry with an empty command, a zero cadence, a daily time out of range, or an interval under one second or not dividing 24h fails the worker start with an error naming the plugin id and entry index.
|
||||
@@ -183,7 +183,8 @@ defer w.Stop(context.Background())
|
||||
| `conga.StartServeWorker` | The worker of the `serve` command; nil when `queue.work_in_serve` is false. |
|
||||
| `conga.WorkerOptions` | Selects the queues a worker runs. |
|
||||
| `conga.RuntimeCommands` | Returns the `queue:work`, `schedule:run` and `queue:clear` commands. |
|
||||
| `conga.Worker` | A running worker; `conga.Worker.Stop` stops it and `conga.Worker.Queues` lists its queues. |
|
||||
| `conga.Worker` | A running worker; `conga.Worker.Stop` stops it, `conga.Worker.Queues` lists its queues and `conga.Worker.Stats` returns its attempt counters. |
|
||||
| `conga.WorkerStats` | Attempt counters since the worker started: `Done`, `Failed`, `InFlight` and `HandedBack` (attempts cancelled when `Stop` reached its deadline; River records them as errored attempts and retries them). The shutdown reports of `serve` and `queue:work` print them. |
|
||||
| `conga.Daily` | `river.PeriodicSchedule` firing at `Hour:Minute` every day in `Loc` (UTC when nil). |
|
||||
| `conga.Every` | `river.PeriodicSchedule` firing at every multiple of `Interval` since local midnight in `Loc` (UTC when nil). |
|
||||
| `conga.ScheduledCommandArgs` | Args of one scheduled run: compiled `Entry` id, `Command` and `Args`; kind `summer.scheduled_command`. |
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -55,6 +56,9 @@ type Manager struct {
|
||||
// compiled entry table of the running worker, keyed by entry id.
|
||||
scheduledJob pact.Job
|
||||
schedule map[string]pact.ScheduledCommand
|
||||
|
||||
// counters are the attempt counters of the running worker.
|
||||
counters atomic.Pointer[workerCounters]
|
||||
}
|
||||
|
||||
// From returns the app's Manager, publishing a new one on first use.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -38,6 +39,44 @@ type Worker struct {
|
||||
client *river.Client[*sql.Tx]
|
||||
listener *pgxpool.Pool
|
||||
queues []string
|
||||
counters *workerCounters
|
||||
}
|
||||
|
||||
// WorkerStats counts the job attempts a worker ran since it started.
|
||||
type WorkerStats struct {
|
||||
// Done is the number of attempts that returned no error.
|
||||
Done int64
|
||||
// Failed is the number of attempts that returned an error, other than
|
||||
// those cancelled by Stop (HandedBack).
|
||||
Failed int64
|
||||
// InFlight is the number of attempts running now.
|
||||
InFlight int64
|
||||
// HandedBack is the number of attempts cancelled by Stop after its
|
||||
// deadline (River's StopAndCancel). River records the cancellation as
|
||||
// an errored attempt, so the job is retried later, or fails for good
|
||||
// when it was its last attempt.
|
||||
HandedBack int64
|
||||
}
|
||||
|
||||
// workerCounters are the live counters behind WorkerStats.
|
||||
type workerCounters struct {
|
||||
done, failed, inFlight, handedBack atomic.Int64
|
||||
// stopping is set by Stop before running jobs are cancelled.
|
||||
stopping atomic.Bool
|
||||
}
|
||||
|
||||
// Stats returns the worker's attempt counters. A nil worker has none.
|
||||
func (w *Worker) Stats() WorkerStats {
|
||||
if w == nil || w.counters == nil {
|
||||
return WorkerStats{}
|
||||
}
|
||||
c := w.counters
|
||||
return WorkerStats{
|
||||
Done: c.done.Load(),
|
||||
Failed: c.failed.Load(),
|
||||
InFlight: c.inFlight.Load(),
|
||||
HandedBack: c.handedBack.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Queues returns the sorted queue names the worker runs.
|
||||
@@ -152,9 +191,11 @@ func StartWorker(ctx context.Context, app *backpack.App, plugins []party.Plugin,
|
||||
closePool(listener)
|
||||
return nil, fmt.Errorf("conga: start worker: %w", err)
|
||||
}
|
||||
counters := &workerCounters{}
|
||||
m.worker = client
|
||||
m.schedule = table
|
||||
return &Worker{m: m, client: client, listener: listener, queues: sortedQueueNames(queues)}, nil
|
||||
m.counters.Store(counters)
|
||||
return &Worker{m: m, client: client, listener: listener, queues: sortedQueueNames(queues), counters: counters}, nil
|
||||
}
|
||||
|
||||
// StartServeWorker starts the in-process worker of the serve command on
|
||||
@@ -175,6 +216,9 @@ func (w *Worker) Stop(ctx context.Context) error {
|
||||
}
|
||||
err := w.client.Stop(ctx)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
if w.counters != nil {
|
||||
w.counters.stopping.Store(true)
|
||||
}
|
||||
hardCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
err = w.client.StopAndCancel(hardCtx)
|
||||
cancel()
|
||||
@@ -235,8 +279,8 @@ func selectQueues(known map[string]int, want []string) (map[string]int, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runAttempt runs one River attempt of a job and applies the summer_jobs
|
||||
// rules: the row id goes into ctx; a panic becomes an error; an error on a
|
||||
// runAttempt runs one River attempt of a job, counts it in the running
|
||||
// worker's WorkerStats and applies the summer_jobs rules: the row id goes into ctx; a panic becomes an error; an error on a
|
||||
// STOPPED or canceled row cancels the River job; an error on the final
|
||||
// attempt sets StatusError with the error text under metadata "error". An
|
||||
// error on an earlier attempt leaves the row as it is so River can retry.
|
||||
@@ -245,7 +289,22 @@ func (m *Manager) runAttempt(ctx context.Context, row *rivertype.JobRow, fn func
|
||||
if hasRow {
|
||||
ctx = withJobID(ctx, id)
|
||||
}
|
||||
c := m.counters.Load()
|
||||
if c != nil {
|
||||
c.inFlight.Add(1)
|
||||
}
|
||||
err := m.call(ctx, row, fn)
|
||||
if c != nil {
|
||||
c.inFlight.Add(-1)
|
||||
switch {
|
||||
case err == nil:
|
||||
c.done.Add(1)
|
||||
case c.stopping.Load() && ctx.Err() != nil:
|
||||
c.handedBack.Add(1)
|
||||
default:
|
||||
c.failed.Add(1)
|
||||
}
|
||||
}
|
||||
if err == nil || !hasRow {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar
|
||||
| `diary.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`. |
|
||||
| `diary.ApplyGlobals` | Applies parsed `bonfire.Globals` to the running setup: verbosity and quiet, and the console color mode when it changed. |
|
||||
| `diary.Verbosity` | Returns the current verbosity. |
|
||||
| `diary.HideOnTTY` | Marks a context so a console channel writing to a terminal skips the records logged with it; files, stderr channels and a console that is not a terminal still write them. Used for facts a command has just printed as a block, such as `serve`'s startup summary. |
|
||||
| `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. |
|
||||
| `diary.Close` | Closes the open daily files; later records open them again. |
|
||||
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ` and `Now`. |
|
||||
@@ -147,7 +148,7 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: `bonfire` (palette and color detection), `compass` (configuration), `sunscreen` (redaction).
|
||||
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`), `golang.org/x/term` (whether a console channel writes to a terminal, for `diary.HideOnTTY`).
|
||||
- Standard library: `context`, `errors`, `fmt`, `io`, `io/fs`, `log`, `log/slog`, `maps`, `os`, `path/filepath`, `runtime`, `slices`, `strconv`, `strings`, `sync`, `sync/atomic`, `time`, `unicode`.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -110,6 +110,9 @@ func (b *channelBuilder) build(name string) (slog.Handler, error) {
|
||||
leaf = slog.NewTextHandler(w, hopts)
|
||||
}
|
||||
}
|
||||
if _, styled := leaf.(*consoleHandler); !styled && isTerminal(w) {
|
||||
leaf = ttyFilter{leaf}
|
||||
}
|
||||
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
|
||||
wrapped := sunscreen.Wrap(leaf)
|
||||
if b.warnTo == nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// componentWidth is the column width of the component prefix.
|
||||
@@ -113,7 +114,12 @@ func (h *consoleHandler) WithGroup(name string) slog.Handler {
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
func (h *consoleHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
// The styled console is read by a person; facts already shown as a
|
||||
// block (HideOnTTY) are not repeated as log lines.
|
||||
if hiddenOnTTY(ctx) {
|
||||
return nil
|
||||
}
|
||||
component := h.component
|
||||
prefix := groupPrefix(h.groups)
|
||||
var recAttrs []consoleAttr
|
||||
@@ -168,6 +174,37 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ttyFilter drops records marked with HideOnTTY; it wraps a plain (JSON
|
||||
// or logfmt) console channel whose writer is a terminal.
|
||||
type ttyFilter struct {
|
||||
slog.Handler
|
||||
}
|
||||
|
||||
func (f ttyFilter) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return !hiddenOnTTY(ctx) && f.Handler.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (f ttyFilter) Handle(ctx context.Context, r slog.Record) error {
|
||||
if hiddenOnTTY(ctx) {
|
||||
return nil
|
||||
}
|
||||
return f.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (f ttyFilter) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return ttyFilter{f.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (f ttyFilter) WithGroup(name string) slog.Handler {
|
||||
return ttyFilter{f.Handler.WithGroup(name)}
|
||||
}
|
||||
|
||||
// isTerminal reports whether w is a terminal.
|
||||
func isTerminal(w io.Writer) bool {
|
||||
f, ok := w.(interface{ Fd() uintptr })
|
||||
return ok && term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) {
|
||||
switch {
|
||||
case l < slog.LevelInfo:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
@@ -225,6 +226,30 @@ func ApplyGlobals(g bonfire.Globals) {
|
||||
SetVerbosity(g.Verbosity, g.Quiet)
|
||||
}
|
||||
|
||||
type hideOnTTYKey struct{}
|
||||
|
||||
// HideOnTTY marks ctx so that a console channel writing to a terminal skips
|
||||
// the records logged with it (Logger.InfoContext and the like); daily files,
|
||||
// stderr channels and a console that is not a terminal still write them. A
|
||||
// command that has just printed the same facts as a styled block (serve's
|
||||
// startup summary) logs them with it, so a person does not read them twice
|
||||
// while journald and the log files keep them.
|
||||
func HideOnTTY(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, hideOnTTYKey{}, true)
|
||||
}
|
||||
|
||||
// hiddenOnTTY reports whether ctx carries the HideOnTTY mark.
|
||||
func hiddenOnTTY(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(hideOnTTYKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// Verbosity returns the current verbosity (0 by default).
|
||||
func Verbosity() int {
|
||||
return int(verbosity.Load())
|
||||
|
||||
@@ -136,6 +136,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.Migrate` | Runs framework and plugin migrations in order. |
|
||||
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
|
||||
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
|
||||
| `lagoon.PendingMigrations` | Lists the migrations `lagoon.Migrate` would apply, as `lagoon.MigrationRef` values (`Plugin`, `ID`): the framework sets first, then each plugin in order, each in declaration order. A missing history table means the whole set is pending. Read-only; `serve` reports the count in its startup summary. |
|
||||
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
|
||||
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, the `backend_api_tokens` table for personal API tokens, seeds the system roles, adds a case-insensitive unique index on `backend_users.email` and adds WinterCMS's nullable jsonable `backend_users.metadata` column (TEXT; the admin subtitle lives under its `subtitle` key). The index migration refuses to run while emails differ only in case and names the clashing logins. Rolling back the metadata migration drops only that column. |
|
||||
| `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. |
|
||||
|
||||
@@ -201,3 +201,79 @@ func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// MigrationRef names one migration: the plugin (or framework set) that
|
||||
// declares it and its ID.
|
||||
type MigrationRef struct {
|
||||
Plugin string
|
||||
ID string
|
||||
}
|
||||
|
||||
// migrationSet is one history table's declared migrations.
|
||||
type migrationSet struct {
|
||||
id string
|
||||
migrations []*gormigrate.Migration
|
||||
}
|
||||
|
||||
// migrationSets lists the sets Migrate applies, in its order: the framework
|
||||
// sets (system files, backend admin, deferred bindings, job queue), then each
|
||||
// plugin's HasMigrations set in plugin order.
|
||||
func migrationSets(gdb *gorm.DB, plugins []party.Plugin) ([]migrationSet, error) {
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: migration sets: %w", err)
|
||||
}
|
||||
sets := []migrationSet{
|
||||
{"summercms.attach", attach.Migrations},
|
||||
{"summercms.cabana", BackendAdminMigrations},
|
||||
{DeferredHistoryID, DeferredBindingMigrations},
|
||||
{QueueHistoryID, QueueMigrations(sqlDB)},
|
||||
}
|
||||
for _, p := range plugins {
|
||||
hm, ok := p.(pact.HasMigrations)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ms := hm.Migrations(); len(ms) > 0 {
|
||||
sets = append(sets, migrationSet{p.ID(), ms})
|
||||
}
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// PendingMigrations returns the migrations Migrate would apply: each set's
|
||||
// declared IDs that its history table does not hold, framework sets first,
|
||||
// then plugins in order, each in declaration order. A missing history table
|
||||
// means every migration of that set is pending. It only reads.
|
||||
func PendingMigrations(gdb *gorm.DB, plugins []party.Plugin) ([]MigrationRef, error) {
|
||||
if gdb == nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
sets, err := migrationSets(gdb, plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pending []MigrationRef
|
||||
for _, s := range sets {
|
||||
table, err := HistoryTableName(s.id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applied := map[string]bool{}
|
||||
if gdb.Migrator().HasTable(table) {
|
||||
var ids []string
|
||||
if err := gdb.Table(table).Pluck("id", &ids).Error; err != nil {
|
||||
return nil, fmt.Errorf("lagoon: pending %s: %w", s.id, err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
applied[id] = true
|
||||
}
|
||||
}
|
||||
for _, m := range s.migrations {
|
||||
if m != nil && !applied[m.ID] {
|
||||
pending = append(pending, MigrationRef{Plugin: s.id, ID: m.ID})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ Use cases:
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET <storage.uploads.public_path_prefix>/{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`).
|
||||
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a `Content-Security-Policy` whose only script source is a per-request nonce; every script tag, the two jsDelivr scripts included, carries it, so no other jsDelivr script can run.
|
||||
- Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies.
|
||||
- Request log, on by default: every request writes one Info record `http request` from the [diary](../diary/README.md) component `http` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status`, `duration_ms` and `request_id` (the `X-Request-Id` header, trimmed and cut to 128 bytes, left out when empty). A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else the `http` component logger, whose channels all redact through [sunscreen](../sunscreen/README.md). Silence it with `log.components.http: warn` or turn it off with `http.access_log: false`.
|
||||
- Startup summary and shutdown report for `serve`: once it listens, a block on a terminal (version, environment, URL and admin URL, database host and name, pending migrations, plugins, queue, scheduler, mail, cache, realtime and search drivers; `-v` adds one line per plugin) and, off a terminal, the same facts as a `startup summary` record and a `listening on <addr>` record of component `serve`; after SIGINT or SIGTERM, a report of requests served, drained and aborted, worker jobs, uptime and drain errors. Never a DSN user or password.
|
||||
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
## Usage
|
||||
@@ -144,7 +145,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ErrorPage` | What a browser error page shows: status, title, message, home link, and (only with `ShowDetails`) method, path and request id. |
|
||||
| `surf.ErrorPageRenderer` | Optional backpack-published override of the HTML error page; `RenderErrorPage` returns the full document, or `ok=false` to keep the built-in page. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
| `surf.ServeCommand` | The `serve` console command, with its startup summary, request counter and shutdown report. |
|
||||
| `surf.RouteListCommand` | The `route:list` console command. |
|
||||
|
||||
## Configuration
|
||||
@@ -171,7 +172,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `http.api_docs.enabled` | `false` | Mounts `GET <http.api_docs.path>` (Swagger UI page) and `GET <http.api_docs.path>.json` (the spec of the one `surf.APIDocsProvider` plugin). Both are public. Environment: `SUMMER_HTTP__API_DOCS__ENABLED`. |
|
||||
| `http.api_docs.path` | `/api-docs` | Page path; the JSON route adds `.json`. Must be an absolute URL path; a plugin route on either path fails boot. |
|
||||
| `http.api_docs.title` | `API documentation` | Page title. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `http.access_log` | `true` | Logs one `http request` record per request under the `http` component (method, matched pattern, status, duration in milliseconds, request id; never the path or query). Set to `false` to turn it off, or raise `log.components.http` to `warn` to silence it. Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `http.error_pages.enabled` | `true` | Answers browser navigations that hit a framework-generated 404, 405 or 500 with the HTML error page (or a published `surf.ErrorPageRenderer`'s page). `false` restores the plain stdlib and JSON bodies for every request. Environment: `SUMMER_HTTP__ERROR_PAGES__ENABLED`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
@@ -195,15 +196,15 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, installs cooler (the shared rate-limit store when `http.rate_limit.store` is `redis`), assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, installs cooler (the shared rate-limit store when `http.rate_limit.store` is `redis`), assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM. Once it listens it prints the startup summary on a terminal (unless `-q`), with the admin URL and a warning naming `summer admin:create` when no administrator exists yet, or `off` with the steps to add an admin controller; off a terminal it logs the summary and `listening on <addr>` as records. On a signal it drains requests and the worker within 10 seconds, prints and logs the shutdown report, and exits 0, or 1 when draining fails. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion).
|
||||
- Standard library: `bytes`, `context`, `crypto/rand`, `embed`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container).
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [diary](../diary/README.md) (the `http` and `serve` component loggers), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion), `github.com/jackc/pgx/v5/pgconn` (the database host and name in the startup summary).
|
||||
- Standard library: `bytes`, `context`, `crypto/rand`, `embed`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `path/filepath`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `sync/atomic`, `syscall`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis and Postgres containers).
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -211,4 +212,4 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
go test ./modules/surf/...
|
||||
```
|
||||
|
||||
The tests use `net/http/httptest` and in-memory stores. `TestRateLimitAcrossInstancesSmoke` starts a Redis container through testcontainers-go and needs Docker; `go test -short ./modules/surf/...` skips it.
|
||||
The tests use `net/http/httptest` and in-memory stores. `TestRateLimitAcrossInstancesSmoke` starts a Redis container and `TestServeLifecycle` a Postgres container (it runs `serve`, sends one request and stops it) through testcontainers-go; both need Docker, and `go test -short ./modules/surf/...` skips them. `TestServeSummarySmoke` and `TestShutdownReportSmoke` check the summary and report builders without a database.
|
||||
|
||||
@@ -8,11 +8,16 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
)
|
||||
|
||||
// accessLogKey is the config key that turns the request log on.
|
||||
// accessLogKey is the config key that turns the request log off (false).
|
||||
const accessLogKey = "http.access_log"
|
||||
|
||||
// httpLog is the request log's component logger (D-23): silence it with
|
||||
// log.components.http: warn or http.access_log: false.
|
||||
var httpLog = diary.For("http")
|
||||
|
||||
// accessLogMessage is the message of every request log record.
|
||||
const accessLogMessage = "http request"
|
||||
|
||||
@@ -34,14 +39,19 @@ type accessEntryKey struct{}
|
||||
// had gone away, when the error came from that cancellation.
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
// accessLogEnabled reports whether http.access_log is true.
|
||||
// accessLogEnabled reports whether the request log is on: it is unless
|
||||
// http.access_log is set to false.
|
||||
func accessLogEnabled(app *backpack.App) bool {
|
||||
return app != nil && app.Config != nil && app.Config.Bool(accessLogKey)
|
||||
if app == nil || app.Config == nil || !app.Config.Has(accessLogKey) {
|
||||
return true
|
||||
}
|
||||
return app.Config.Bool(accessLogKey)
|
||||
}
|
||||
|
||||
// accessLog logs one Info record "http request" per request after it is
|
||||
// served, with method, pattern (the matched route, "" when none matched),
|
||||
// status and duration_ms (whole milliseconds). The URL path is never
|
||||
// status, duration_ms (whole milliseconds) and request_id (the X-Request-Id
|
||||
// header trimmed and cut to 128 bytes, left out when empty). The URL path is never
|
||||
// logged, not even for a request that matched no route: a ServeMux 405 for
|
||||
// a token route reached with another method, or a 404 for its trailing-slash
|
||||
// variant, would otherwise carry the token, because share and invitation
|
||||
@@ -55,9 +65,10 @@ func accessLogEnabled(app *backpack.App) bool {
|
||||
// failed because of the cancellation, and no client received the error. A
|
||||
// 5xx written before the client went away keeps its status, and a recovered
|
||||
// panic is always 500, so a search for 5xx records still finds every real
|
||||
// server error. The logger is the app's published *slog.Logger, else
|
||||
// slog.Default (which the generated main wraps in the redacting sunscreen
|
||||
// handler).
|
||||
// server error. The logger is the app's published *slog.Logger, else the
|
||||
// diary component logger http, whose channels all redact through sunscreen.
|
||||
// The route pattern, never the raw path, is what D-23 settled on: the log
|
||||
// is on by default, so it must be safe to keep in journald and daily files.
|
||||
func accessLog(app *backpack.App, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -77,6 +88,9 @@ func accessLog(app *backpack.App, next http.Handler) http.Handler {
|
||||
slog.Int("status", status),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
}
|
||||
if id := requestID(req); id != "" {
|
||||
attrs = append(attrs, slog.String("request_id", id))
|
||||
}
|
||||
if clientGone(req.Context()) {
|
||||
attrs = append(attrs, slog.Bool("client_gone", true))
|
||||
}
|
||||
@@ -97,14 +111,14 @@ func markPanicked(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// accessLogger is the app's published logger, else slog.Default.
|
||||
// accessLogger is the app's published logger, else httpLog.
|
||||
func accessLogger(app *backpack.App) *slog.Logger {
|
||||
if app != nil {
|
||||
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
|
||||
return log
|
||||
}
|
||||
}
|
||||
return slog.Default()
|
||||
return httpLog
|
||||
}
|
||||
|
||||
// recordPattern stores pattern in the request's access log entry, when the
|
||||
|
||||
@@ -33,13 +33,14 @@ func (blogPostsPlugin) Routes(r pact.Router) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessLogRouter assembles the blog plugin with http.access_log set to on
|
||||
// and a published JSON logger writing into the returned buffer.
|
||||
// accessLogRouter assembles the blog plugin with http.access_log left at
|
||||
// its default (on) or set to false, and a published JSON logger writing
|
||||
// into the returned buffer.
|
||||
func accessLogRouter(t *testing.T, on bool) (http.Handler, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
y := "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"
|
||||
if on {
|
||||
y += "access_log: true\n"
|
||||
if !on {
|
||||
y += "access_log: false\n"
|
||||
}
|
||||
app := backpack.New(writeHTTPConfig(t, y))
|
||||
var buf bytes.Buffer
|
||||
@@ -76,18 +77,23 @@ func TestAccessLogSmoke(t *testing.T) {
|
||||
h, buf := accessLogRouter(t, true)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/blog/posts/7?token=secret", nil))
|
||||
req := httptest.NewRequest(http.MethodGet, "/blog/posts/7?token=secret", nil)
|
||||
req.Header.Set("X-Request-Id", " req-42 ")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /blog/posts/7 = %d", rec.Code)
|
||||
}
|
||||
recs := accessRecords(t, buf)
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("records = %d, want 1:\n%s", len(recs), buf.String())
|
||||
t.Fatalf("records = %d, want 1 (the request log is on by default):\n%s", len(recs), buf.String())
|
||||
}
|
||||
r := recs[0]
|
||||
if r["method"] != "GET" || r["pattern"] != "GET /blog/posts/{id}" || r["status"] != float64(200) || r["level"] != "INFO" {
|
||||
t.Fatalf("matched record = %v", r)
|
||||
}
|
||||
if r["request_id"] != "req-42" {
|
||||
t.Fatalf("request_id = %v, want the trimmed X-Request-Id", r["request_id"])
|
||||
}
|
||||
if _, ok := r["duration_ms"].(float64); !ok {
|
||||
t.Fatalf("matched record has no duration_ms: %v", r)
|
||||
}
|
||||
@@ -112,6 +118,17 @@ func TestAccessLogSmoke(t *testing.T) {
|
||||
if r["pattern"] != "" || r["status"] != float64(404) {
|
||||
t.Fatalf("unmatched record = %v", r)
|
||||
}
|
||||
if _, ok := r["request_id"]; ok {
|
||||
t.Fatalf("a request without X-Request-Id has a request_id: %v", r)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
long := httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil)
|
||||
long.Header.Set("X-Request-Id", strings.Repeat("x", 300))
|
||||
h.ServeHTTP(httptest.NewRecorder(), long)
|
||||
if recs = accessRecords(t, buf); len(recs) != 1 || len(recs[0]["request_id"].(string)) != errorPageMaxRequestID {
|
||||
t.Fatalf("a long X-Request-Id is not cut to %d bytes: %v", errorPageMaxRequestID, recs)
|
||||
}
|
||||
if _, ok := r["path"]; ok {
|
||||
t.Fatalf("unmatched record carries path: %v", r)
|
||||
}
|
||||
@@ -242,6 +259,8 @@ func accessLogEdgesRouter(t *testing.T, on, locale bool, flushErr *error) (http.
|
||||
y := "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"
|
||||
if on {
|
||||
y += "access_log: true\n"
|
||||
} else {
|
||||
y += "access_log: false\n"
|
||||
}
|
||||
app := backpack.New(writeHTTPConfig(t, y))
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -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 {
|
||||
|
||||
198
modules/surf/serve_lifecycle_test.go
Normal file
198
modules/surf/serve_lifecycle_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
// lifecyclePlugin serves GET /probe/items/{id}.
|
||||
type lifecyclePlugin struct{}
|
||||
|
||||
func (lifecyclePlugin) ID() string { return "acme.probe" }
|
||||
func (lifecyclePlugin) Requires() []string { return nil }
|
||||
func (lifecyclePlugin) Register(*backpack.App) error { return nil }
|
||||
func (lifecyclePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (lifecyclePlugin) Routes(r pact.Router) error {
|
||||
r.Get("/probe/items/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for the log writes of serve's
|
||||
// goroutines and the test's reads.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// TestServeLifecycle runs serve against Postgres: it binds, logs the
|
||||
// startup summary and "listening on <addr>", logs a request by its route
|
||||
// pattern under component http with its request id, and on cancellation
|
||||
// drains, logs the shutdown report and returns nil (exit 0).
|
||||
func TestServeLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
ctx := t.Context()
|
||||
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
||||
postgres.WithDatabase("surf"),
|
||||
postgres.WithUsername("surf"),
|
||||
postgres.WithPassword("surf-pass"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("testcontainers postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"app.yaml": "name: probe\n",
|
||||
"database.yaml": fmt.Sprintf("dsn: %q\n", dsn),
|
||||
"storage.yaml": fmt.Sprintf("uploads:\n bucket_url: %q\n", "file://"+t.TempDir()),
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins := []party.Plugin{lifecyclePlugin{}}
|
||||
|
||||
// Migrate first so the in-process worker finds River's tables.
|
||||
sqlDB, gdb, err := lagoon.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = sqlDB.Close()
|
||||
|
||||
var logs syncBuffer
|
||||
diary.Bootstrap(&logs, diary.Options{})
|
||||
t.Cleanup(diary.Reset)
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
var out bytes.Buffer
|
||||
go func() {
|
||||
done <- bonfire.Call(runCtx, []bonfire.Command{ServeCommand(app, plugins)}, "serve", []string{"--addr", addr}, &out)
|
||||
}()
|
||||
|
||||
url := "http://" + addr + "/probe/items/secret-777"
|
||||
var resp *http.Response
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("X-Request-Id", "lifecycle-1")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("serve returned before listening: %v\n%s", err, logs.String())
|
||||
default:
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("serve did not listen on %s: %v\n%s", addr, err, logs.String())
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("serve returned %v after a graceful stop, want nil\n%s", err, logs.String())
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("serve did not stop")
|
||||
}
|
||||
|
||||
got := logs.String()
|
||||
for _, want := range []string{
|
||||
"listening on " + addr,
|
||||
"msg=\"startup summary\"",
|
||||
"migrations_pending=0",
|
||||
"msg=\"shutdown report\"",
|
||||
"served=1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("logs lack %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
var requests []string
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
if strings.Contains(line, "msg=\"http request\"") {
|
||||
requests = append(requests, line)
|
||||
}
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("want one http request record, got %d:\n%s", len(requests), got)
|
||||
}
|
||||
for _, want := range []string{"component=http", "pattern=\"GET /probe/items/{id}\"", "request_id=lifecycle-1", "status=200"} {
|
||||
if !strings.Contains(requests[0], want) {
|
||||
t.Errorf("request record lacks %s: %s", want, requests[0])
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "secret-777") {
|
||||
t.Fatalf("the request path reached the logs:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "surf-pass") {
|
||||
t.Fatalf("the DSN password reached the logs:\n%s", got)
|
||||
}
|
||||
}
|
||||
235
modules/surf/serve_summary_smoke_test.go
Normal file
235
modules/surf/serve_summary_smoke_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// summaryPlugin is a plugin with an ID and required plugins.
|
||||
type summaryPlugin struct {
|
||||
id string
|
||||
req []string
|
||||
}
|
||||
|
||||
func (p summaryPlugin) ID() string { return p.id }
|
||||
func (p summaryPlugin) Requires() []string { return p.req }
|
||||
func (p summaryPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p summaryPlugin) Boot(*backpack.App) error { return nil }
|
||||
|
||||
// summaryApp is an app whose config holds a DSN with a user and password.
|
||||
func summaryApp(t *testing.T) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"app.yaml": "name: acme\n",
|
||||
"database.yaml": "dsn: postgres://summer_user:pa55-secret@db.internal:5433/acme_prod?sslmode=disable\n",
|
||||
"mail.yaml": "driver: smtp\n",
|
||||
"cache.yaml": "driver: redis\n",
|
||||
"realtime.yaml": "driver: centrifugo\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "staging", Environ: []string{"SUMMER_ENV=staging"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func TestServeSummarySmoke(t *testing.T) {
|
||||
plugins := []party.Plugin{
|
||||
summaryPlugin{id: "acme.user"},
|
||||
summaryPlugin{id: "acme.blog", req: []string{"acme.user"}},
|
||||
}
|
||||
facts := buildFacts(summaryApp(t), factInputs{
|
||||
addr: "127.0.0.1:8096",
|
||||
bin: "bin/acme",
|
||||
plugins: plugins,
|
||||
queues: []string{"default", "mail"},
|
||||
worker: true,
|
||||
adminPrefix: "/backend",
|
||||
mounted: true,
|
||||
admins: adminsNone,
|
||||
pending: []lagoon.MigrationRef{
|
||||
{Plugin: "summercms.cabana", ID: "0001"},
|
||||
{Plugin: "acme.blog", ID: "0001_posts"},
|
||||
{Plugin: "acme.blog", ID: "0002_tags"},
|
||||
},
|
||||
version: bonfire.Version{App: "v1.4.0", Framework: "v0.4.0"},
|
||||
})
|
||||
|
||||
var block bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &block, &block)
|
||||
printSummary(out, facts)
|
||||
got := block.String()
|
||||
for _, want := range []string{
|
||||
"SummerCMS acme v1.4.0 (framework v0.4.0)",
|
||||
"Environment staging",
|
||||
"http://127.0.0.1:8096/",
|
||||
"http://127.0.0.1:8096/backend",
|
||||
"db.internal:5433/acme_prod",
|
||||
"3 pending — run bin/acme migrate",
|
||||
"2 (acme.user, acme.blog)",
|
||||
"default, mail (worker in serve)",
|
||||
"Mail smtp",
|
||||
"Cache redis",
|
||||
"Realtime centrifugo",
|
||||
"Search null",
|
||||
"no administrators yet",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("summary block lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "requires acme.user") {
|
||||
t.Errorf("verbosity 0 prints per-plugin lines:\n%s", got)
|
||||
}
|
||||
verbose := facts.details(1)
|
||||
var perPlugin bool
|
||||
for _, d := range verbose {
|
||||
if strings.TrimSpace(d.Label) == "acme.blog" && strings.Contains(d.Value, "requires acme.user; 2 pending migrations") {
|
||||
perPlugin = true
|
||||
}
|
||||
}
|
||||
if !perPlugin {
|
||||
t.Errorf("verbosity 1 lacks the acme.blog line: %#v", verbose)
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
logSummary(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), facts)
|
||||
var summary map[string]any
|
||||
listening := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("log line %q: %v", line, err)
|
||||
}
|
||||
switch rec["msg"] {
|
||||
case summaryMessage:
|
||||
summary = rec
|
||||
case "listening on 127.0.0.1:8096":
|
||||
listening++
|
||||
}
|
||||
}
|
||||
if listening != 1 {
|
||||
t.Fatalf("want one record \"listening on 127.0.0.1:8096\":\n%s", logs.String())
|
||||
}
|
||||
if summary == nil {
|
||||
t.Fatalf("no %q record:\n%s", summaryMessage, logs.String())
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"environment": "staging",
|
||||
"database": "db.internal:5433/acme_prod",
|
||||
"migrations_pending": float64(3),
|
||||
"plugins": float64(2),
|
||||
"plugin_ids": "acme.user,acme.blog",
|
||||
"mail": "smtp",
|
||||
"cache": "redis",
|
||||
"realtime": "centrifugo",
|
||||
"search": "null",
|
||||
} {
|
||||
if summary[key] != want {
|
||||
t.Errorf("summary record %s = %v, want %v", key, summary[key], want)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"pa55-secret", "summer_user"} {
|
||||
if strings.Contains(got, secret) || strings.Contains(logs.String(), secret) {
|
||||
t.Fatalf("summary leaks %q:\n%s\n%s", secret, got, logs.String())
|
||||
}
|
||||
}
|
||||
if databaseFact("host=db port=5432 dbname=acme user=u password=p") != "db:5432/acme" {
|
||||
t.Errorf("keyword DSN = %q", databaseFact("host=db port=5432 dbname=acme user=u password=p"))
|
||||
}
|
||||
if databaseFact("postgres://u:p@[::1:bad") != "configured" {
|
||||
t.Errorf("unparsable DSN = %q, want configured", databaseFact("postgres://u:p@[::1:bad"))
|
||||
}
|
||||
|
||||
probe := []bonfire.Command{{
|
||||
Name: "probe:summary",
|
||||
Run: func(_ context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
printSummary(out, facts)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
block bool
|
||||
}{{nil, true}, {[]string{"-q"}, false}} {
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(context.Background(), probe, "probe:summary", tc.args, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "Environment") != tc.block {
|
||||
t.Errorf("args %v: block printed = %v, want %v:\n%s", tc.args, !tc.block, tc.block, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownReportSmoke(t *testing.T) {
|
||||
report := shutdownReport{
|
||||
Draining: 3,
|
||||
Aborted: 1,
|
||||
Served: 120,
|
||||
Worker: &conga.WorkerStats{Done: 7, Failed: 2, HandedBack: 1},
|
||||
Uptime: 3*time.Minute + 4*time.Second,
|
||||
Errors: []error{errors.New("surf: drain requests: context deadline exceeded")},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
bonfire.Details(out, "Shutdown report", report.details())
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
"Shutdown report",
|
||||
"Requests 120 served, 2 drained, 1 aborted",
|
||||
"Worker 7 done, 2 failed, 1 handed back to the queue",
|
||||
"Uptime 3m4s",
|
||||
"Errors surf: drain requests: context deadline exceeded",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("report lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if report.Err() == nil {
|
||||
t.Fatal("a drain error must make the report fail")
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
logReport(context.Background(), slog.New(slog.NewJSONHandler(&logs, nil)), report)
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal(logs.Bytes(), &rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec["msg"] != reportMessage || rec["level"] != "ERROR" || rec["served"] != float64(120) ||
|
||||
rec["drained"] != float64(2) || rec["aborted"] != float64(1) || rec["jobs_done"] != float64(7) ||
|
||||
rec["jobs_handed_back"] != float64(1) {
|
||||
t.Fatalf("report record = %v", rec)
|
||||
}
|
||||
|
||||
clean := shutdownReport{Served: 5, Uptime: time.Second}
|
||||
if clean.Err() != nil {
|
||||
t.Fatal("a stop without errors must exit 0")
|
||||
}
|
||||
buf.Reset()
|
||||
bonfire.Details(out, "", clean.details())
|
||||
if !strings.Contains(buf.String(), "Worker not running in serve") || !strings.Contains(buf.String(), "Errors none") {
|
||||
t.Fatalf("clean report:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ func TestServeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeBanner checks the summary's Admin value and the notes printed
|
||||
// after the summary for each admin state.
|
||||
func TestServeBanner(t *testing.T) {
|
||||
listen := bannerLine{text: "listening on :8123, open http://127.0.0.1:8123/"}
|
||||
mounted := bannerLine{text: "admin: http://127.0.0.1:8123/backend"}
|
||||
create := "summer admin:create --email you@example.com --superuser"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -83,25 +83,24 @@ func TestServeBanner(t *testing.T) {
|
||||
prefix string
|
||||
mounted bool
|
||||
admins adminPresence
|
||||
want []bannerLine
|
||||
value string
|
||||
notes []bannerLine
|
||||
}{
|
||||
{"present", ":8123", "/backend", true, adminsPresent, []bannerLine{listen, mounted}},
|
||||
{"none", ":8123", "/backend", true, adminsNone, []bannerLine{listen, mounted,
|
||||
{"present", ":8123", "/backend", true, adminsPresent, "http://127.0.0.1:8123/backend", nil},
|
||||
{"none", ":8123", "/backend", true, adminsNone, "http://127.0.0.1:8123/backend", []bannerLine{
|
||||
{warn: true, text: "no administrators yet; create one with " + create}}},
|
||||
{"missing table", ":8123", "/backend", true, adminsMissingTable, []bannerLine{listen, mounted,
|
||||
{"missing table", ":8123", "/backend", true, adminsMissingTable, "http://127.0.0.1:8123/backend", []bannerLine{
|
||||
{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + create}}},
|
||||
{"unknown", ":8123", "/backend", true, adminsUnknown, []bannerLine{listen, mounted,
|
||||
{"unknown", ":8123", "/backend", true, adminsUnknown, "http://127.0.0.1:8123/backend", []bannerLine{
|
||||
{text: "sign in with an administrator; " + create + " creates one"}}},
|
||||
{"off", ":8123", "", false, adminsUnknown, []bannerLine{listen,
|
||||
{text: "admin: off — no plugin registers an admin controller"},
|
||||
{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}},
|
||||
{"unparsable address", "8080", "/backend", true, adminsPresent, []bannerLine{
|
||||
{text: "listening on 8080"}, {text: "admin: mounted at /backend"}}},
|
||||
{"off", ":8123", "", false, adminsUnknown, "off — no plugin registers an admin controller", []bannerLine{
|
||||
{text: "to add an admin, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}},
|
||||
{"unparsable address", "8080", "/backend", true, adminsPresent, "mounted at /backend", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := serveBanner(tc.addr, tc.prefix, tc.mounted, tc.admins)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("serveBanner:\n got %#v\nwant %#v", got, tc.want)
|
||||
value, notes := adminStatus(serveURL(tc.addr), tc.prefix, tc.mounted, tc.admins)
|
||||
if value != tc.value || !slices.Equal(notes, tc.notes) {
|
||||
t.Fatalf("adminStatus:\n got %q %#v\nwant %q %#v", value, notes, tc.value, tc.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
382
modules/surf/summary.go
Normal file
382
modules/surf/summary.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"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/party"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// serveLog is the logger of serve's startup summary and shutdown report.
|
||||
var serveLog = diary.For("serve")
|
||||
|
||||
// Messages of the records serve logs.
|
||||
const (
|
||||
summaryMessage = "startup summary"
|
||||
reportMessage = "shutdown report"
|
||||
listenPrefix = "listening on "
|
||||
)
|
||||
|
||||
// requestCounter wraps the compiled handler and counts requests in flight
|
||||
// and served, whatever http.access_log says, for the shutdown report.
|
||||
type requestCounter struct {
|
||||
next http.Handler
|
||||
inFlight atomic.Int64
|
||||
served atomic.Int64
|
||||
}
|
||||
|
||||
func newRequestCounter(next http.Handler) *requestCounter {
|
||||
return &requestCounter{next: next}
|
||||
}
|
||||
|
||||
func (c *requestCounter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
c.inFlight.Add(1)
|
||||
defer func() {
|
||||
c.inFlight.Add(-1)
|
||||
c.served.Add(1)
|
||||
}()
|
||||
c.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// pluginFact is one plugin's line of the verbose summary.
|
||||
type pluginFact struct {
|
||||
ID string
|
||||
Requires []string
|
||||
Pending int
|
||||
}
|
||||
|
||||
// summaryFacts is what serve reports once it is listening. It holds no DSN,
|
||||
// user, password, key or token: the database is its host, port and name.
|
||||
type summaryFacts struct {
|
||||
Header string
|
||||
Environment string
|
||||
Addr string
|
||||
URL string
|
||||
Admin string
|
||||
AdminNotes []bannerLine
|
||||
Database string
|
||||
// Pending is the number of pending migrations; -1 when it could not
|
||||
// be read.
|
||||
Pending int
|
||||
MigrateCmd string
|
||||
Plugins []pluginFact
|
||||
Queue string
|
||||
Scheduler string
|
||||
Mail string
|
||||
Cache string
|
||||
Realtime string
|
||||
Search string
|
||||
}
|
||||
|
||||
// factInputs are the values serve learned while booting.
|
||||
type factInputs struct {
|
||||
addr string
|
||||
bin string
|
||||
plugins []party.Plugin
|
||||
queues []string
|
||||
worker bool
|
||||
adminPrefix string
|
||||
mounted bool
|
||||
admins adminPresence
|
||||
pending []lagoon.MigrationRef
|
||||
pendingErr error
|
||||
version bonfire.Version
|
||||
}
|
||||
|
||||
// buildFacts collects the summary from the app's config and in.
|
||||
func buildFacts(app *backpack.App, in factInputs) summaryFacts {
|
||||
name := strings.TrimSpace(configString(app, "app.name"))
|
||||
if name == "" {
|
||||
name = strings.TrimPrefix(in.bin, "bin/")
|
||||
}
|
||||
f := summaryFacts{
|
||||
Header: in.version.Header(name),
|
||||
Environment: "unknown",
|
||||
Addr: in.addr,
|
||||
URL: serveURL(in.addr),
|
||||
Database: databaseFact(configString(app, "database.dsn")),
|
||||
Pending: len(in.pending),
|
||||
MigrateCmd: in.bin + " migrate",
|
||||
Mail: driverFact(app, "mail.driver", "memory"),
|
||||
Cache: driverFact(app, cooler.CacheDriverKey, "memory"),
|
||||
Realtime: driverFact(app, "realtime.driver", "null"),
|
||||
Search: driverFact(app, "search.driver", "null"),
|
||||
}
|
||||
if app != nil && app.Config != nil && app.Config.Environment() != "" {
|
||||
f.Environment = app.Config.Environment()
|
||||
}
|
||||
if in.pendingErr != nil {
|
||||
f.Pending = -1
|
||||
}
|
||||
f.Admin, f.AdminNotes = adminStatus(f.URL, in.adminPrefix, in.mounted, in.admins)
|
||||
perPlugin := map[string]int{}
|
||||
for _, ref := range in.pending {
|
||||
perPlugin[ref.Plugin]++
|
||||
}
|
||||
for _, p := range in.plugins {
|
||||
f.Plugins = append(f.Plugins, pluginFact{ID: p.ID(), Requires: p.Requires(), Pending: perPlugin[p.ID()]})
|
||||
}
|
||||
if in.worker {
|
||||
f.Queue = strings.Join(in.queues, ", ") + " (worker in serve)"
|
||||
f.Scheduler = "in serve"
|
||||
} else {
|
||||
f.Queue = "off in serve (queue.work_in_serve is false; run queue:work)"
|
||||
f.Scheduler = "off in serve (run schedule:run)"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func configString(app *backpack.App, key string) string {
|
||||
if app == nil || app.Config == nil {
|
||||
return ""
|
||||
}
|
||||
return app.Config.String(key)
|
||||
}
|
||||
|
||||
// driverFact is the lower-cased driver named by key, or def when empty.
|
||||
func driverFact(app *backpack.App, key, def string) string {
|
||||
if v := strings.ToLower(strings.TrimSpace(configString(app, key))); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// databaseFact is host:port/name parsed from dsn with pgconn.ParseConfig,
|
||||
// never the user or password. A DSN that does not parse is "configured",
|
||||
// an empty one "not configured".
|
||||
func databaseFact(dsn string) string {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return "not configured"
|
||||
}
|
||||
cfg, err := pgconn.ParseConfig(dsn)
|
||||
if err != nil || cfg.Host == "" {
|
||||
return "configured"
|
||||
}
|
||||
host := cfg.Host
|
||||
if strings.HasPrefix(host, "/") {
|
||||
host = "socket " + host
|
||||
} else if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
fact := host + ":" + strconv.Itoa(int(cfg.Port))
|
||||
if cfg.Database != "" {
|
||||
fact += "/" + cfg.Database
|
||||
}
|
||||
return fact
|
||||
}
|
||||
|
||||
// migrations is the migration line: "up to date", "N pending — run ..." or
|
||||
// "unknown".
|
||||
func (f summaryFacts) migrations() (string, bool) {
|
||||
switch {
|
||||
case f.Pending < 0:
|
||||
return "unknown (the migration history could not be read)", true
|
||||
case f.Pending == 0:
|
||||
return "up to date", false
|
||||
default:
|
||||
return fmt.Sprintf("%d pending — run %s", f.Pending, f.MigrateCmd), true
|
||||
}
|
||||
}
|
||||
|
||||
func (f summaryFacts) pluginIDs() []string {
|
||||
ids := make([]string, 0, len(f.Plugins))
|
||||
for _, p := range f.Plugins {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// details is the block printed on a terminal; verbosity 1 or more adds one
|
||||
// line per plugin with its required plugins and pending migrations.
|
||||
func (f summaryFacts) details(verbosity int) []bonfire.Detail {
|
||||
url := f.URL
|
||||
if url == "" {
|
||||
url = f.Addr
|
||||
}
|
||||
migrations, warn := f.migrations()
|
||||
plugins := strconv.Itoa(len(f.Plugins))
|
||||
if len(f.Plugins) > 0 {
|
||||
plugins += " (" + strings.Join(f.pluginIDs(), ", ") + ")"
|
||||
}
|
||||
rows := []bonfire.Detail{
|
||||
{Label: "Environment", Value: f.Environment},
|
||||
{Label: "URL", Value: url},
|
||||
{Label: "Admin", Value: f.Admin},
|
||||
{Label: "Database", Value: f.Database},
|
||||
{Label: "Migrations", Value: migrations, Warn: warn},
|
||||
{Label: "Plugins", Value: plugins},
|
||||
}
|
||||
if verbosity >= 1 {
|
||||
for _, p := range f.Plugins {
|
||||
requires := "requires nothing"
|
||||
if len(p.Requires) > 0 {
|
||||
requires = "requires " + strings.Join(p.Requires, ", ")
|
||||
}
|
||||
rows = append(rows, bonfire.Detail{
|
||||
Label: " " + p.ID,
|
||||
Value: fmt.Sprintf("%s; %d pending migrations", requires, p.Pending),
|
||||
Warn: p.Pending > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return append(rows,
|
||||
bonfire.Detail{Label: "Queue", Value: f.Queue},
|
||||
bonfire.Detail{Label: "Scheduler", Value: f.Scheduler},
|
||||
bonfire.Detail{Label: "Mail", Value: f.Mail},
|
||||
bonfire.Detail{Label: "Cache", Value: f.Cache},
|
||||
bonfire.Detail{Label: "Realtime", Value: f.Realtime},
|
||||
bonfire.Detail{Label: "Search", Value: f.Search},
|
||||
)
|
||||
}
|
||||
|
||||
// attrs are the attributes of the "startup summary" record.
|
||||
func (f summaryFacts) attrs() []slog.Attr {
|
||||
return []slog.Attr{
|
||||
slog.String("app", f.Header),
|
||||
slog.String("environment", f.Environment),
|
||||
slog.String("url", f.URL),
|
||||
slog.String("admin", f.Admin),
|
||||
slog.String("database", f.Database),
|
||||
slog.Int("migrations_pending", f.Pending),
|
||||
slog.Int("plugins", len(f.Plugins)),
|
||||
slog.String("plugin_ids", strings.Join(f.pluginIDs(), ",")),
|
||||
slog.String("queue", f.Queue),
|
||||
slog.String("scheduler", f.Scheduler),
|
||||
slog.String("mail", f.Mail),
|
||||
slog.String("cache", f.Cache),
|
||||
slog.String("realtime", f.Realtime),
|
||||
slog.String("search", f.Search),
|
||||
}
|
||||
}
|
||||
|
||||
// printSummary prints the block and the admin notes through out.
|
||||
func printSummary(out bonfire.Output, f summaryFacts) {
|
||||
bonfire.Details(out, f.Header, f.details(bonfire.VerbosityOf(out)))
|
||||
printBanner(out, f.AdminNotes)
|
||||
}
|
||||
|
||||
// logSummary logs one INFO record "startup summary" with every fact, one
|
||||
// whose message is exactly "listening on <addr>" (application smoke scripts
|
||||
// wait for it) and the admin notes (warnings at WARN).
|
||||
func logSummary(ctx context.Context, log *slog.Logger, f summaryFacts) {
|
||||
log.LogAttrs(ctx, slog.LevelInfo, summaryMessage, f.attrs()...)
|
||||
log.LogAttrs(ctx, slog.LevelInfo, listenPrefix+f.Addr, slog.String("url", f.URL))
|
||||
for _, n := range f.AdminNotes {
|
||||
level := slog.LevelInfo
|
||||
if n.warn {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
log.LogAttrs(ctx, level, n.text)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownReport is what serve did, printed and logged after a signal.
|
||||
type shutdownReport struct {
|
||||
// Draining is the number of requests in flight when the signal came.
|
||||
Draining int64
|
||||
// Aborted is the number still running at the drain deadline.
|
||||
Aborted int64
|
||||
Served int64
|
||||
Worker *conga.WorkerStats
|
||||
Uptime time.Duration
|
||||
Errors []error
|
||||
}
|
||||
|
||||
// Drained is the number of in-flight requests that finished in time.
|
||||
func (r shutdownReport) Drained() int64 {
|
||||
return max(0, r.Draining-r.Aborted)
|
||||
}
|
||||
|
||||
// Err joins the drain errors; nil means a clean stop (exit 0).
|
||||
func (r shutdownReport) Err() error {
|
||||
return errors.Join(r.Errors...)
|
||||
}
|
||||
|
||||
func (r shutdownReport) requestsLine() string {
|
||||
return fmt.Sprintf("%d served, %d drained, %d aborted", r.Served, r.Drained(), r.Aborted)
|
||||
}
|
||||
|
||||
func workerLine(s *conga.WorkerStats) string {
|
||||
if s == nil {
|
||||
return "not running in serve"
|
||||
}
|
||||
return fmt.Sprintf("%d done, %d failed, %d handed back to the queue", s.Done, s.Failed, s.HandedBack+s.InFlight)
|
||||
}
|
||||
|
||||
func errorsLine(errs []error) string {
|
||||
if len(errs) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, 0, len(errs))
|
||||
for _, e := range errs {
|
||||
parts = append(parts, e.Error())
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// uptime rounds d for the report.
|
||||
func uptime(d time.Duration) string {
|
||||
switch {
|
||||
case d >= time.Minute:
|
||||
return d.Round(time.Second).String()
|
||||
case d >= time.Second:
|
||||
return d.Round(100 * time.Millisecond).String()
|
||||
default:
|
||||
return d.Round(time.Millisecond).String()
|
||||
}
|
||||
}
|
||||
|
||||
// details is the report as label/value lines.
|
||||
func (r shutdownReport) details() []bonfire.Detail {
|
||||
return []bonfire.Detail{
|
||||
{Label: "Requests", Value: r.requestsLine(), Warn: r.Aborted > 0},
|
||||
{Label: "Worker", Value: workerLine(r.Worker)},
|
||||
{Label: "Uptime", Value: uptime(r.Uptime)},
|
||||
{Label: "Errors", Value: errorsLine(r.Errors), Warn: len(r.Errors) > 0},
|
||||
}
|
||||
}
|
||||
|
||||
// attrs are the attributes of the "shutdown report" record.
|
||||
func (r shutdownReport) attrs() []slog.Attr {
|
||||
attrs := []slog.Attr{
|
||||
slog.Int64("served", r.Served),
|
||||
slog.Int64("drained", r.Drained()),
|
||||
slog.Int64("aborted", r.Aborted),
|
||||
slog.Int64("uptime_ms", r.Uptime.Milliseconds()),
|
||||
}
|
||||
if r.Worker != nil {
|
||||
attrs = append(attrs,
|
||||
slog.Int64("jobs_done", r.Worker.Done),
|
||||
slog.Int64("jobs_failed", r.Worker.Failed),
|
||||
slog.Int64("jobs_handed_back", r.Worker.HandedBack+r.Worker.InFlight),
|
||||
)
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
attrs = append(attrs, slog.String("errors", errorsLine(r.Errors)))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// logReport logs the report as one record: INFO, or ERROR with drain errors.
|
||||
func logReport(ctx context.Context, log *slog.Logger, r shutdownReport) {
|
||||
level := slog.LevelInfo
|
||||
if len(r.Errors) > 0 {
|
||||
level = slog.LevelError
|
||||
}
|
||||
log.LogAttrs(ctx, level, reportMessage, r.attrs()...)
|
||||
}
|
||||
Reference in New Issue
Block a user