package main import ( "context" "errors" "fmt" "io" "os" "os/exec" "os/signal" "path/filepath" "syscall" "git.golem15.com/golem15/summercms/internal/argmask" "git.golem15.com/golem15/summercms/internal/build" "git.golem15.com/golem15/summercms/internal/dev" "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, a hint naming the // common flags or arguments the app binary accepts, and two examples. func delegateCommands() []bonfire.Command { ex := func(cmd, desc string) bonfire.Example { return bonfire.Example{Command: cmd, Description: desc} } rows := []struct { name, description, hint string examples []bonfire.Example }{ {"migrate", "Run plugin migrations in the app binary", "--pretend, --force", []bonfire.Example{ ex("migrate", "Apply every pending migration"), ex("migrate --pretend", "List the pending migrations without applying them")}}, {"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin , --pretend, --force", []bonfire.Example{ ex("migrate:rollback --plugin acme.blog", "Roll back the last migration of acme.blog"), ex("migrate:rollback --plugin acme.blog --pretend", "Name the migration that would roll back")}}, {"migrate:status", "Show per-plugin migration history in the app binary", "--json", []bonfire.Example{ ex("migrate:status", "Show the applied migrations per plugin"), ex("migrate:status --json", "Machine-readable status")}}, {"serve", "Run the app HTTP server", "--addr (default :8080)", []bonfire.Example{ ex("serve", "Serve on port 8080"), ex("serve --addr 127.0.0.1:8123", "Serve on loopback port 8123")}}, {"queue:work", "Run background job workers in the app binary", "--queue (repeatable)", []bonfire.Example{ ex("queue:work", "Work every known queue"), ex("queue:work --queue mail", "Work only the mail queue")}}, {"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default"), --force`, []bonfire.Example{ ex("queue:clear", "Clear the default queue"), ex("queue:clear imports --force", "Clear the imports queue without the production question")}}, {"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once", []bonfire.Example{ ex("schedule:run", "Run the scheduler in the foreground"), ex("schedule:run --once", "Run the entries due now and exit")}}, {"route:list", "List the app's routes from the app binary", "--json", []bonfire.Example{ ex("route:list", "Show every route"), ex("route:list --json", "Machine-readable route list")}}, {"key:generate", "Print a fresh application key from the app binary", "--json", []bonfire.Example{ ex("key:generate", "Print a new key"), ex("key:generate -q", "Print only the key")}}, {"admin:create", "Create a backend administrator in the app binary", "--email (required), --login, --role , --superuser; the password is prompted or read from stdin", []bonfire.Example{ ex("admin:create --email admin@example.com --superuser", "Create a superuser; the password is asked for"), ex("admin:create --email editor@example.com --role editor", "Create an administrator with a role")}}, {"admin:reset-password", "Reset a backend administrator's password in the app binary", " (login or email); the password is prompted or read from stdin", []bonfire.Example{ ex("admin:reset-password admin@example.com", "Reset a password; it is asked for"), ex("admin:reset-password admin", "Reset by login")}}, {"admin:token:create", "Create a personal API token for a backend administrator in the app binary", " --name --scope (repeatable) [--expires 30d|90d|365d|never|YYYY-MM-DD] [--json]", []bonfire.Example{ ex("admin:token:create jane --name agent --scope journal.posts", "A token limited to one scope"), ex("admin:token:create jane --scope journal.posts --json", "The token and its details as JSON")}}, {"admin:token:list", "List personal API tokens in the app binary", "[] [--revoked] [--json]", []bonfire.Example{ ex("admin:token:list", "Every active token"), ex("admin:token:list jane --revoked", "Every token of one administrator")}}, {"admin:token:revoke", "Revoke a personal API token by id in the app binary", "", []bonfire.Example{ ex("admin:token:revoke 12", "Revoke token 12"), ex("admin:token:revoke 7 -q", "Revoke token 7 and print nothing but errors")}}, {"admin:token:scopes", "List available personal API token scopes in the app binary", "--json", []bonfire.Example{ ex("admin:token:scopes", "Show every scope"), ex("admin:token:scopes --json", "Machine-readable scope list")}}, } cmds := make([]bonfire.Command, 0, len(rows)) for _, r := range rows { c := delegateCommand(r.name, r.description, r.hint) c.Examples = r.examples cmds = append(cmds, c) } 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/ " + 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, and so are the global options (-v, -q, --ansi, --no-ansi, -n, --env). " + "When the app binary fails, summer exits with its exit code." 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) } } stdout, stderr := appStreams(out, bonfire.IsTerminal(out)) return runAppCmd(newAppCmd(ctx, bin, appDir, argv, stdout, stderr), argv) } // appStreams picks the app binary's stdout and stderr. On a terminal // (terminal is bonfire.IsTerminal(out)) the binary gets summer's own // streams, so it sees the terminal: it asks the production question, prints // styled summaries and animates its widgets. Otherwise its stdout goes // through out (tests and bonfire.Call roots capture it) and its stderr to // summer's stderr, so log lines and error blocks never mix into data such // as --json output. func appStreams(out bonfire.Output, terminal bool) (stdout, stderr io.Writer) { if terminal { return os.Stdout, os.Stderr } return out, os.Stderr } // runAppCmd runs cmd, the app binary started with argv, forwarding SIGINT // and SIGTERM to it. When the binary exits with a code, the error carries // that code (bonfire.WithExitCode) and is marked as already reported. The // error names the command through argmask.Join, with the values of // secret-looking flags masked, on every branch: a binary that fails to // start or is ended by a signal gets summer's error block, and a signal // death keeps exit code 1. func runAppCmd(cmd *exec.Cmd, argv []string) error { sigs := make(chan os.Signal, 2) signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigs) if err := runChild(cmd, sigs); err != nil { err = fmt.Errorf("summer: %s: %w", argmask.Join(argv), err) var exit *exec.ExitError if errors.As(err, &exit) && exit.Exited() { // The binary printed its own error; summer exits with its // code and prints nothing more. A binary ended by a signal // printed nothing, so it gets summer's error block. return bonfire.Reported(bonfire.WithExitCode(err, exit.ExitCode())) } return err } return nil } // appShutdownGrace is how long a cancelled app binary may take to exit after // SIGTERM before it is killed: the grace summer dev gives serve as well, // longer than the 10-second shutdown timeout of serve and the workers. const appShutdownGrace = dev.ShutdownGrace // newAppCmd builds the command that runs the app binary bin with argv in // dir, writing to stdout and stderr (see appStreams). 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, stdout, stderr io.Writer) *exec.Cmd { cmd := exec.CommandContext(ctx, bin, argv...) cmd.Dir = dir cmd.Stdout = stdout cmd.Stderr = stderr 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 }