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
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -13,11 +14,13 @@ import (
|
||||
|
||||
"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.
|
||||
@@ -50,22 +53,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return err
|
||||
}
|
||||
defer bucket.Close()
|
||||
h, err := Assemble(app, plugins)
|
||||
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() {
|
||||
out.Info(fmt.Sprintf("listening on %s", addr))
|
||||
errCh <- srv.ListenAndServe()
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
shutdownCtx := func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -121,3 +138,119 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user