Files
summercms/cmd/summer/runtime.go

184 lines
6.7 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"git.golem15.com/golem15/summercms/internal/build"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// appRunner runs the app binary with argv; tests replace it.
var appRunner = runAppBinary
// delegateCommands returns every command summer runs on the app binary. One
// table is the single source: name, base description and a hint naming the
// common flags or arguments the app binary accepts.
func delegateCommands() []bonfire.Command {
rows := []struct{ name, description, hint string }{
{"migrate", "Run plugin migrations in the app binary", ""},
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
{"route:list", "List the app's routes from the app binary", ""},
{"key:generate", "Print a fresh application key from the app binary", ""},
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
{"admin:token:create", "Create a personal API token for a backend administrator in the app binary", "<identifier> --name <text> --scope <name> (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD]"},
{"admin:token:list", "List personal API tokens in the app binary", "[<identifier>] [--revoked]"},
{"admin:token:revoke", "Revoke a personal API token by id in the app binary", "<id>"},
{"admin:token:scopes", "List available personal API token scopes in the app binary", ""},
}
cmds := make([]bonfire.Command, 0, len(rows))
for _, r := range rows {
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
}
return cmds
}
// delegateCommand returns a passthrough command that runs name on the app
// binary with every argument summer was given.
func delegateCommand(name, description, hint string) bonfire.Command {
long := description + ".\n\nsummer runs bin/<binary> " + name +
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
name + ", including --help, is passed to the app binary unchanged."
if hint != "" {
long += "\n\nCommon flags: " + hint + "."
}
long += "\n\nRun summer " + name + " --help inside an application to list all of them."
return bonfire.Command{
Name: name,
Description: description + " (flags pass to the app binary)",
Long: long,
Passthrough: true,
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
args := in.Args()
// One leading "--" is dropped so the older
// `summer serve -- --addr :8123` form keeps working.
if len(args) > 0 && args[0] == "--" {
args = args[1:]
}
argv := append([]string{name}, args...)
return appRunner(ctx, argv, out)
},
}
}
func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error {
if len(argv) == 0 {
return fmt.Errorf("summer: missing app command")
}
dir, err := os.Getwd()
if err != nil {
return err
}
appDir, err := build.FindAppDir(dir)
if err != nil {
return err
}
m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml"))
if err != nil {
return err
}
bin := filepath.Join(appDir, "bin", m.Binary)
if _, err := os.Stat(bin); err != nil {
if err := build.App(ctx, appDir, out); err != nil {
return err
}
}
self, err := os.Executable()
if err == nil {
if same, _ := sameFile(self, bin); same {
return fmt.Errorf("summer: refusing to recurse into %s", bin)
}
}
cmd := newAppCmd(ctx, bin, appDir, argv, out)
sigs := make(chan os.Signal, 2)
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigs)
if err := runChild(cmd, sigs); err != nil {
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
}
return nil
}
// appShutdownGrace is how long a cancelled app binary may take to exit after
// SIGTERM before it is killed. It is longer than the 10-second shutdown
// timeout of serve and the workers, so they finish a graceful shutdown.
const appShutdownGrace = 15 * time.Second
// newAppCmd builds the command that runs the app binary bin with argv in dir.
// The child inherits summer's stdin, so the hidden password prompts of
// admin:create and admin:reset-password read from the terminal. Cancelling
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
cmd := exec.CommandContext(ctx, bin, argv...)
cmd.Dir = dir
cmd.Stdout = out
cmd.Stderr = out
cmd.Stdin = os.Stdin
cmd.Env = os.Environ()
cmd.Cancel = func() error {
err := cmd.Process.Signal(syscall.SIGTERM)
if err != nil && !errors.Is(err, os.ErrProcessDone) {
// SIGTERM is not supported everywhere (Windows); kill instead.
return cmd.Process.Kill()
}
return err
}
cmd.WaitDelay = appShutdownGrace
return cmd
}
// runChild starts cmd, forwards every signal received on sigs to it and
// waits for it to exit, returning Wait's error. It never escalates to a kill:
// the app binary decides how to shut down.
//
// Ctrl+C in a terminal delivers SIGINT to both summer and the child, because
// they share the foreground process group, so the child may see SIGINT twice.
// The app's NotifyContext-based shutdown tolerates that. The child is
// deliberately not put in its own process group (Setpgid): a background
// process group that reads the terminal gets SIGTTIN, which would break the
// password prompts of admin:create and admin:reset-password.
func runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error {
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
for {
select {
case err := <-done:
return err
case sig := <-sigs:
// Best effort: the child may already have exited
// (os.ErrProcessDone) or the platform may not support sig.
_ = cmd.Process.Signal(sig)
}
}
}
func sameFile(a, b string) (bool, error) {
ai, err := os.Stat(a)
if err != nil {
return false, err
}
bi, err := os.Stat(b)
if err != nil {
return false, err
}
return os.SameFile(ai, bi), nil
}