Files
summercms/modules/surf/serve.go
Jakub Zych da21670870 feat(surf): print the serve URL and admin status on startup
- Add surf.Router.AdminPrefix (additive): the admin mount path and true
  when BuildRouter mounted the cabana admin, false otherwise
- serve binds with net.Listen before printing, so a busy port fails
  without a misleading URL, then prints the listen address with a
  browser URL and an admin status line: the admin URL, a warning naming
  summer admin:create when backend_users is empty or missing, or
  admin: off with the steps to add an admin controller
- The administrator count is bounded by a 2 s timeout and never fails boot
- route:list ends with the same admin status line
- surf and cabana READMEs and the console and routing docs describe the output
2026-10-08 16:36:21 +02:00

257 lines
7.3 KiB
Go

package surf
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
"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/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.
func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return bonfire.Command{
Name: "serve",
Description: "Serve the application HTTP API",
Flags: []bonfire.Flag{{
Name: "addr",
Description: "Listen address",
Default: ":8080",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
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()
r, err := BuildRouter(app, plugins)
if err != nil {
return err
}
h, err := r.compile()
if err != nil {
return err
}
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
}
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 }}
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():
sctx, cancel := shutdownCtx()
defer cancel()
shutdownErr := srv.Shutdown(sctx)
workerErr := stopWorker(sctx, worker)
if shutdownErr != nil {
return shutdownErr
}
err := <-errCh
if err != nil && err != http.ErrServerClosed {
return err
}
return workerErr
case err := <-errCh:
sctx, cancel := shutdownCtx()
defer cancel()
workerErr := stopWorker(sctx, worker)
if err != nil && err != http.ErrServerClosed {
return err
}
return workerErr
}
},
}
}
// 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, err
}
if err := attach.Publish(app, bucket); err != nil {
_ = bucket.Close()
return nil, err
}
return bucket, 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 line; warn lines 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}}
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"},
)
}
if url != "" {
lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix})
} else {
lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix})
}
switch admins {
case adminsNone:
lines = append(lines, 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})
case adminsUnknown:
lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"})
}
return lines
}
// printBanner writes banner lines 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)
}
}