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 " 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/. 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 , 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) } }