- 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
355 lines
12 KiB
Go
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)
|
|
}
|
|
}
|