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 , 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) } }