Files
summercms/modules/surf/serve.go
Jakub Zych 9d06a78f62 feat: Artisan checklist for application commands, hints and coverage gate
- migrate, migrate:rollback and deferred:purge gain --pretend and --force;
  they and queue:clear confirm only in production on an interactive
  terminal, so deploys over SSH proceed as before
- result lines: Applied N migrations, Rolled back <id> of <plugin>,
  Cleared N jobs from <queue>
- --json for migrate:status, key:generate, route:list, admin:token:create,
  admin:token:list, admin:token:scopes and websockets:health; -q keeps the
  key and the token
- hints for an empty DSN, an unreachable database, an invalid app.key, a
  missing uploads bucket and an application binary run below its root
- flare and websockets:health return bonfire.Reported after printing
- long help and examples for every application command, greeter:hello and
  blog:publish; cmd/summer TestCommandsDocumented fails on any gap
2026-10-21 13:16:21 +02:00

355 lines
12 KiB
Go

package surf
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"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"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// 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"
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return err
}
defer sqlDB.Close()
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
return err
}
bucket, err := publishUploads(ctx, app)
if err != nil {
return err
}
defer bucket.Close()
coolerHandle, err := installCooler(ctx, app)
if err != nil {
return err
}
defer coolerHandle.Close()
r, err := BuildRouter(app, plugins)
if err != nil {
return err
}
h, err := r.compile()
if err != nil {
return err
}
counter := newRequestCounter(h)
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
worker, err := conga.StartServeWorker(ctx, app, plugins)
if err != nil {
_ = ln.Close()
return err
}
prefix, mounted := r.AdminPrefix()
admins := adminsUnknown
if mounted {
admins = countAdmins(ctx, gdb)
}
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)
}()
shutdownCtx := func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
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()
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))
}
if err := stopWorker(sctx, worker); err != nil {
report.Errors = append(report.Errors, err)
}
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()
workerErr := stopWorker(sctx, worker)
if err != nil && err != http.ErrServerClosed {
return err
}
return workerErr
}
},
}
}
// 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 {
return nil
}
return w.Stop(ctx)
}
// publishUploads opens storage.uploads.bucket_url and publishes *blob.Bucket.
// An empty URL fails boot the same way an empty JWT secret does.
func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error) {
if app == nil {
return nil, fmt.Errorf("surf: app is nil")
}
bucket, err := attach.OpenBucket(ctx, app.Config)
if err != nil {
return nil, bonfire.WithHint(err,
"serve stores uploads in the bucket named by storage.uploads.bucket_url in config/storage.yaml (or SUMMER_STORAGE__UPLOADS__BUCKET_URL).",
"SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://./storage/app/uploads {bin} serve")
}
if err := attach.Publish(app, bucket); err != nil {
_ = bucket.Close()
return nil, err
}
return bucket, nil
}
// installCooler installs cooler and, when http.rate_limit.store is redis,
// publishes its Redis-backed Store (falling back to a per-instance
// MemoryStore while Redis does not answer) so BuildRouter's limiter shares
// counters across instances. Only serve calls it: route:list never dials Redis.
func installCooler(ctx context.Context, app *backpack.App) (*cooler.Handle, error) {
h, err := cooler.Install(ctx, app)
if err != nil {
return nil, err
}
if h.RateLimitRedis() {
// longest bucket decay is 1 minute; sweep at 2x (as BuildRouter)
if err := app.Publish[Store](h.RateLimitStore(NewMemoryStore(2 * time.Minute))); err != nil {
_ = h.Close()
return nil, fmt.Errorf("surf: %w", err)
}
}
return h, nil
}
// adminCreateCommand is the command the startup hints name for creating the
// first backend administrator.
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"
// adminOffText is the admin status line serve and route:list print when no
// plugin registers an admin controller.
const adminOffText = "admin: off — no plugin registers an admin controller"
// serveURL turns a listen address into a URL a browser can open. An empty or
// unspecified host (0.0.0.0, ::) becomes 127.0.0.1, port 80 is left out and
// an IPv6 host is bracketed. An address that does not parse gives "".
func serveURL(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return ""
}
if host == "" {
host = "127.0.0.1"
} else if ip, err := netip.ParseAddr(host); err == nil && ip.IsUnspecified() {
host = "127.0.0.1"
}
hostport := net.JoinHostPort(host, port)
if port == "80" {
hostport = host
if strings.Contains(host, ":") {
hostport = "[" + host + "]"
}
}
return "http://" + hostport + "/"
}
// adminPresence is what serve could learn about backend administrators.
type adminPresence int
const (
adminsUnknown adminPresence = iota
adminsMissingTable
adminsNone
adminsPresent
)
// countAdmins checks whether backend_users exists and holds a row. It is
// bounded by a 2 second timeout and maps every failure to adminsUnknown, so
// it never fails or blocks boot. HasTable queries information_schema, so a
// missing table does not reach the GORM error log.
func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence {
if gdb == nil {
return adminsUnknown
}
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
db := gdb.WithContext(c)
if !db.Migrator().HasTable(&cabana.BackendUser{}) {
if c.Err() != nil {
return adminsUnknown
}
return adminsMissingTable
}
var n int64
if err := db.Model(&cabana.BackendUser{}).Count(&n).Error; err != nil {
return adminsUnknown
}
if n == 0 {
return adminsNone
}
return adminsPresent
}
// bannerLine is one startup note; warn notes print with the warning symbol.
type bannerLine struct {
warn bool
text string
}
// 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 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 != "" {
value = strings.TrimSuffix(url, "/") + adminPrefix
}
switch admins {
case adminsNone:
return value, []bannerLine{{warn: true, text: "no administrators yet; create one with " + adminCreateCommand}}
case adminsMissingTable:
return value, []bannerLine{{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand}}
case adminsUnknown:
return value, []bannerLine{{text: "sign in with an administrator; " + adminCreateCommand + " creates one"}}
}
return value, nil
}
// printBanner writes notes as info or warning output.
func printBanner(out bonfire.Output, lines []bannerLine) {
for _, l := range lines {
if l.warn {
out.Warning(l.text)
continue
}
out.Info(l.text)
}
}