test(surf,conga,lagoon): summaries, reports, stats and checklist flags
- serve summary lines at -v and -q, records and HideOnTTY, DSN labels - shutdown report: aborted requests fail the stop, a drained stop is nil - request log: request_id cap, default on, off by config or env, never the path for matched, 404 and 405 routes, component http; route:list --json - WorkerStats through runAttempt: done, failed, in flight, handed back - queue:clear and lagoon's production question on a pseudo-terminal: no cancels with exit 1, --force, -n and other environments skip it - Docker: PendingMigrations, migrate/rollback/deferred:purge --pretend, production runs off a terminal never read stdin, migrate:status --json - key:generate --json and -q
This commit is contained in:
174
modules/conga/confirm_linux_test.go
Normal file
174
modules/conga/confirm_linux_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
//go:build linux
|
||||
|
||||
package conga
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// ptyOutput collects what the program writes to a pseudo-terminal.
|
||||
type ptyOutput struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (p *ptyOutput) String() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.buf.String()
|
||||
}
|
||||
|
||||
// openPTY opens a pseudo-terminal pair: the slave is a terminal for the
|
||||
// console (stdin and stdout), input written to the master reaches the
|
||||
// slave's stdin, and everything the slave prints is collected.
|
||||
func openPTY(t *testing.T) (master, slave *os.File, output *ptyOutput) {
|
||||
t.Helper()
|
||||
m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open /dev/ptmx: %v", err)
|
||||
}
|
||||
var unlock int32
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); e != 0 {
|
||||
t.Fatalf("unlock pty: %v", e)
|
||||
}
|
||||
var n uint32
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); e != 0 {
|
||||
t.Fatalf("pty number: %v", e)
|
||||
}
|
||||
s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open pty slave: %v", err)
|
||||
}
|
||||
output = &ptyOutput{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := m.Read(buf)
|
||||
output.mu.Lock()
|
||||
output.buf.Write(buf[:n])
|
||||
output.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = s.Close()
|
||||
_ = m.Close()
|
||||
<-done
|
||||
})
|
||||
return m, s, output
|
||||
}
|
||||
|
||||
// productionApp is an app in the production environment without a DSN.
|
||||
func productionApp(t *testing.T, env string) *backpack.App {
|
||||
t.Helper()
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: env, Environ: []string{"SUMMER_ENV=" + env}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
// runQueueClear runs queue:clear with args on stdin and stdout of the
|
||||
// caller's choosing and returns the exit code and the error stream.
|
||||
func runQueueClear(t *testing.T, app *backpack.App, in io.Reader, out, errW io.Writer, args ...string) int {
|
||||
t.Helper()
|
||||
var clear bonfire.Command
|
||||
for _, c := range RuntimeCommands(app, nil) {
|
||||
if c.Name == "queue:clear" {
|
||||
clear = c
|
||||
}
|
||||
}
|
||||
if clear.Run == nil {
|
||||
t.Fatal("queue:clear not registered")
|
||||
}
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{clear}, in, out, errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bonfire.Execute(context.Background(), root, append([]string{"queue:clear"}, args...))
|
||||
}
|
||||
|
||||
// TestQueueClearConfirm: in production queue:clear asks on an interactive
|
||||
// terminal and a "no" ends it with exit 1 and no error block, before the
|
||||
// database is opened; --force, -n, a non-production environment and a
|
||||
// non-terminal stdin skip the question (the run then reaches the database,
|
||||
// which these apps do not configure).
|
||||
func TestQueueClearConfirm(t *testing.T) {
|
||||
prod := productionApp(t, "production")
|
||||
|
||||
master, slave, output := openPTY(t)
|
||||
if _, err := master.Write([]byte("n\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := runQueueClear(t, prod, slave, slave, slave, "--no-ansi", "imports"); code != bonfire.ExitFailure {
|
||||
t.Fatalf("answer no: exit %d\n%s", code, output.String())
|
||||
}
|
||||
waitFor(t, output, "Cancelled.")
|
||||
got := output.String()
|
||||
if !strings.Contains(got, `Delete every pending job of queue "imports" in production? [y/N]`) {
|
||||
t.Errorf("question not asked:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR") || strings.Contains(got, "database.dsn") {
|
||||
t.Errorf("a cancelled run printed an error block or reached the database:\n%s", got)
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
app *backpack.App
|
||||
args []string
|
||||
}{
|
||||
"--force": {prod, []string{"--no-ansi", "--force"}},
|
||||
"-n": {prod, []string{"--no-ansi", "-n"}},
|
||||
"not production": {productionApp(t, "staging"), []string{"--no-ansi"}},
|
||||
} {
|
||||
master, slave, output := openPTY(t)
|
||||
if _, err := master.Write([]byte("n\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := runQueueClear(t, tc.app, slave, slave, slave, tc.args...); code != bonfire.ExitFailure {
|
||||
t.Fatalf("%s: exit %d", name, code)
|
||||
}
|
||||
waitFor(t, output, "database.dsn")
|
||||
if strings.Contains(output.String(), "[y/N]") {
|
||||
t.Errorf("%s: the question was asked:\n%s", name, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Off a terminal: no question, stdin is never read.
|
||||
in := strings.NewReader("n\n")
|
||||
var out, errW strings.Builder
|
||||
if code := runQueueClear(t, prod, in, &out, &errW); code != bonfire.ExitFailure {
|
||||
t.Fatalf("non-terminal: exit %d", code)
|
||||
}
|
||||
if in.Len() != 2 || strings.Contains(out.String(), "[y/N]") || !strings.Contains(errW.String(), "database.dsn") {
|
||||
t.Errorf("non-terminal run asked or read stdin (left %d):\n%s\n%s", in.Len(), out.String(), errW.String())
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor waits until the pty output contains want.
|
||||
func waitFor(t *testing.T, output *ptyOutput, want string) {
|
||||
t.Helper()
|
||||
for range 200 {
|
||||
if strings.Contains(output.String(), want) {
|
||||
return
|
||||
}
|
||||
<-time.After(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("pty output lacks %q:\n%s", want, output.String())
|
||||
}
|
||||
87
modules/conga/stats_test.go
Normal file
87
modules/conga/stats_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package conga
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
)
|
||||
|
||||
// TestWorkerStats: runAttempt counts every attempt of the running worker:
|
||||
// done on nil, failed on an error or a panic, in flight while it runs, and
|
||||
// handed back when Stop cancelled it; a nil worker has no counters.
|
||||
func TestWorkerStats(t *testing.T) {
|
||||
// The panicking attempt logs its stack; keep it out of the test output.
|
||||
t.Cleanup(diary.Reset)
|
||||
diary.Bootstrap(io.Discard, diary.Options{})
|
||||
m := &Manager{}
|
||||
counters := &workerCounters{}
|
||||
m.counters.Store(counters)
|
||||
w := &Worker{counters: counters}
|
||||
row := &rivertype.JobRow{ID: 1, Kind: "acme_probe", Attempt: 1, MaxAttempts: 3}
|
||||
ctx := context.Background()
|
||||
|
||||
for range 3 {
|
||||
if err := m.runAttempt(ctx, row, func(context.Context) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
boom := errors.New("boom")
|
||||
if err := m.runAttempt(ctx, row, func(context.Context) error { return boom }); !errors.Is(err, boom) {
|
||||
t.Fatalf("failing attempt returned %v", err)
|
||||
}
|
||||
if err := m.runAttempt(ctx, row, func(context.Context) error { panic("kaboom") }); err == nil {
|
||||
t.Fatal("a panic did not become an error")
|
||||
}
|
||||
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- m.runAttempt(ctx, row, func(context.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-started
|
||||
if got := w.Stats(); got.InFlight != 1 || got.Done != 3 || got.Failed != 2 {
|
||||
t.Fatalf("while running: %+v, want 1 in flight, 3 done, 2 failed", got)
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stop cancels the running attempts: they are handed back, not failed.
|
||||
counters.stopping.Store(true)
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if err := m.runAttempt(cctx, row, func(c context.Context) error { return c.Err() }); err == nil {
|
||||
t.Fatal("cancelled attempt returned nil")
|
||||
}
|
||||
// An error unrelated to the cancellation while stopping still fails.
|
||||
if err := m.runAttempt(ctx, row, func(context.Context) error { return boom }); err == nil {
|
||||
t.Fatal("failing attempt returned nil")
|
||||
}
|
||||
|
||||
want := WorkerStats{Done: 4, Failed: 3, InFlight: 0, HandedBack: 1}
|
||||
if got := w.Stats(); got != want {
|
||||
t.Fatalf("Stats() = %+v, want %+v", got, want)
|
||||
}
|
||||
var none *Worker
|
||||
if got := none.Stats(); got != (WorkerStats{}) {
|
||||
t.Fatalf("nil worker Stats() = %+v", got)
|
||||
}
|
||||
if got := (&Worker{}).Stats(); got != (WorkerStats{}) {
|
||||
t.Fatalf("worker without counters Stats() = %+v", got)
|
||||
}
|
||||
|
||||
// Without a running worker, attempts run and nothing is counted.
|
||||
idle := &Manager{}
|
||||
if err := idle.runAttempt(ctx, row, func(context.Context) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user