// Package app starts the application under test and stops it again. // // Everything here is shell commands and an HTTP poll, because that is the // only interface every application already has. squint never links against // the thing it photographs. package app import ( "context" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "syscall" "time" "git.mills.io/prologic/squint/internal/config" ) // Instance is a running application: where it lives on disk, what address // it answers on, and the process to kill when the shoot is over. type Instance struct { BaseURL string Port int Dir string cmd *exec.Cmd tmp string log *os.File cancel context.CancelFunc } // Options are what Start needs beyond the config. type Options struct { // Dir is the checkout to build and run in. For a candidate this is // the working tree; for a baseline it is a git worktree. Dir string // Out is where a build or run failure's output is written, so a // failure says what the application printed rather than only that it // did not start. Out io.Writer } // Start builds, launches and seeds the application, and returns once it // answers its ready URL and its setup command has run. // // The order matters and is the whole contract: ready before setup, setup // before any screenshot. Seeding a server that is not listening fails, and // photographing a server that is not seeded produces a picture of an empty // database. func Start(ctx context.Context, cfg *config.Config, opt Options) (*Instance, error) { port, err := freePort() if err != nil { return nil, fmt.Errorf("finding a port: %w", err) } tmp, err := os.MkdirTemp("", "squint-data-") if err != nil { return nil, err } inst := &Instance{ BaseURL: "http://127.0.0.1:" + config.PortString(port), Port: port, Dir: opt.Dir, tmp: tmp, } vars := map[string]string{ "BaseURL": inst.BaseURL, "Port": config.PortString(port), "TmpDir": tmp, } env := os.Environ() for k, v := range cfg.App.Env { env = append(env, k+"="+config.Expand(v, vars)) } if cfg.App.Build != "" { if out, err := run(ctx, opt.Dir, env, config.Expand(cfg.App.Build, vars)); err != nil { inst.Cleanup() return nil, fmt.Errorf("build failed: %w\n%s", err, out) } } // The application's own output goes to a file rather than the // terminal: it is noise on a good run and the first thing you want on // a bad one. logPath := filepath.Join(tmp, "app.log") logFile, err := os.Create(logPath) if err != nil { inst.Cleanup() return nil, err } inst.log = logFile runCtx, cancel := context.WithCancel(ctx) inst.cancel = cancel cmd := exec.CommandContext(runCtx, "sh", "-c", config.Expand(cfg.App.Run, vars)) cmd.Dir = opt.Dir cmd.Env = env cmd.Stdout, cmd.Stderr = logFile, logFile // Its own process group, so stopping it takes any child it spawned // with it. A `go run` or a shell wrapper otherwise leaves the real // server running and holding the port. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := cmd.Start(); err != nil { inst.Cleanup() return nil, fmt.Errorf("starting the application: %w", err) } inst.cmd = cmd ready := config.Expand(cfg.App.Ready, vars) if err := waitReady(ctx, ready, cfg.App.StartTimeout); err != nil { tail, _ := os.ReadFile(logPath) inst.Cleanup() return nil, fmt.Errorf("%w\nthe application's output was:\n%s", err, tail) } if cfg.App.Setup != "" { if out, err := run(ctx, opt.Dir, env, config.Expand(cfg.App.Setup, vars)); err != nil { inst.Cleanup() return nil, fmt.Errorf("setup failed: %w\n%s", err, out) } } return inst, nil } // Cleanup stops the application and removes its data directory. func (i *Instance) Cleanup() { if i.cancel != nil { i.cancel() } if i.cmd != nil && i.cmd.Process != nil { // Negative pid signals the whole group. _ = syscall.Kill(-i.cmd.Process.Pid, syscall.SIGTERM) done := make(chan struct{}) go func() { _, _ = i.cmd.Process.Wait(); close(done) }() select { case <-done: case <-time.After(5 * time.Second): _ = syscall.Kill(-i.cmd.Process.Pid, syscall.SIGKILL) } } if i.log != nil { _ = i.log.Close() } if i.tmp != "" { _ = os.RemoveAll(i.tmp) } } func run(ctx context.Context, dir string, env []string, script string) (string, error) { cmd := exec.CommandContext(ctx, "sh", "-c", script) cmd.Dir, cmd.Env = dir, env out, err := cmd.CombinedOutput() return string(out), err } func waitReady(ctx context.Context, url string, timeout time.Duration) error { deadline := time.Now().Add(timeout) client := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { select { case <-ctx.Done(): return ctx.Err() default: } resp, err := client.Get(url) if err == nil { resp.Body.Close() if resp.StatusCode < 500 { return nil } } time.Sleep(100 * time.Millisecond) } return fmt.Errorf("the application never answered %s within %s", url, timeout) } // freePort asks the kernel for a port and immediately gives it back. There // is a race between here and the application binding it, and it is the // same race every tool of this kind runs; the alternative is making every // application accept a pre-opened socket. func freePort() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, err } defer l.Close() return l.Addr().(*net.TCPAddr).Port, nil }