//go:build linux
package main
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"database/sql/driver"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/carlosframework/platform/internal/lease/leasetest"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/schedule"
_ "modernc.org/sqlite"
)
const (
testHost = "h1.example.com"
adminToken = "admin-token-for-tests"
probeSecret = "probe-secret-for-tests"
platformKey = "platform-instance-key-for-tests"
accessKey = "AKIDMOVEPROBETEST"
bucketName = "probe-bucket"
storeRegion = "eu-west-1"
)
// binPath is the moveprobe binary the tests start, built once in TestMain,
// so every test starts the app the way the exec backend does: a separate
// process given --socket and --db.
var binPath string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "mpbin")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
binPath = filepath.Join(dir, "moveprobe")
if out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "build moveprobe: %v\n%s", err, out)
os.RemoveAll(dir)
os.Exit(1)
}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
// shortTempDir keeps unix socket paths under the kernel's length limit
// (as internal/activator/rolelaunch_root_linux_test.go does); t.TempDir's
// names carry the test's name and can pass 107 bytes.
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "mp")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
// fixture is one instance's world: its data directory (the flat layout,
//
/h1.db and /h1/), a test S3 holding its store, and a control
// socket served by the platform's own schedule verbs over a registry.
// Each test makes its own, and shares nothing else but the read-only
// binPath, so every test runs in parallel.
type fixture struct {
t *testing.T
sockDir string
dataDir string
db string
bucket *leasetest.VersionedBucket
store http.Handler
storeURL string
reg *registry.Registry
control string
env map[string]string
mu sync.Mutex
auths []string
storeReq atomic.Int64
// storeDelay slows every store request, in milliseconds.
storeDelay atomic.Int64
// controlConns counts the connections the control socket accepted.
controlConns atomic.Int64
starts int
}
func newFixture(t *testing.T) *fixture {
t.Helper()
f := &fixture{t: t, sockDir: shortTempDir(t), dataDir: t.TempDir()}
f.db = filepath.Join(f.dataDir, "h1.db")
f.bucket = leasetest.NewVersionedBucket()
f.bucket.AddBucket(bucketName)
s3h, _ := leasetest.NewS3Server(f.bucket)
f.store = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.storeReq.Add(1)
time.Sleep(time.Duration(f.storeDelay.Load()) * time.Millisecond)
f.mu.Lock()
f.auths = append(f.auths, r.Header.Get("Authorization"))
f.mu.Unlock()
s3h.ServeHTTP(w, r)
})
srv := httptest.NewServer(f.store)
t.Cleanup(srv.Close)
f.storeURL = srv.URL
reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { reg.Close() })
f.reg = reg
f.control = filepath.Join(f.sockDir, "c.sock")
ln, err := net.Listen("unix", f.control)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
schedule.Mount(mux, testHost, reg, func() (string, error) { return adminToken, nil }, time.Now)
cs := &http.Server{Handler: mux, ConnState: func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
f.controlConns.Add(1)
}
}}
go cs.Serve(ln)
t.Cleanup(func() { cs.Close() })
// The environment agentOwnedVars composes over .env: the control
// socket (when the listener bound), the purge channel, the region (when
// the box has one) and the host; then the env file's own variables.
f.env = map[string]string{
"CARLOS_CONTROL_SOCKET": f.control,
"CARLOS_PURGE": "header",
"CARLOS_REGION": "uk",
"CARLOS_HOST": testHost,
"CARLOS_ADMIN_TOKEN": adminToken,
"CARLOS_INSTANCE_KEY": platformKey,
"MOVEPROBE_BUCKET": bucketName,
"MOVEPROBE_REGION": storeRegion,
"MOVEPROBE_ENDPOINT": f.storeURL,
"MOVEPROBE_ACCESS_KEY": accessKey,
"MOVEPROBE_SECRET_KEY": "secret-key-for-tests",
"MOVEPROBE_SECRET": probeSecret,
}
// Registered first, so it runs last: every probe's main is gone by then.
t.Cleanup(func() { killWorkers(f.db) })
return f
}
func (f *fixture) seenAuth() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.auths...)
}
// envWith is the fixture's environment with changes applied; an empty
// value removes the variable.
func (f *fixture) envWith(changes map[string]string) map[string]string {
out := map[string]string{}
for k, v := range f.env {
out[k] = v
}
for k, v := range changes {
if v == "" {
delete(out, k)
} else {
out[k] = v
}
}
return out
}
// childEnv mirrors the backend's: the parent's environment without any
// CARLOS_ variable, then the instance's variables. The test also drops the
// probe's and AWS's own names and every proxy setting, so nothing from the
// shell running the tests reaches the app.
func childEnv(vars map[string]string) []string {
var out []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
switch {
case strings.HasPrefix(k, "CARLOS_"), strings.HasPrefix(k, "MOVEPROBE_"), strings.HasPrefix(k, "AWS_"):
continue
case strings.EqualFold(k, "HTTP_PROXY"), strings.EqualFold(k, "HTTPS_PROXY"), strings.EqualFold(k, "NO_PROXY"), k == "SSL_CERT_FILE", k == "SSL_CERT_DIR":
continue
}
out = append(out, kv)
}
for k, v := range vars {
out = append(out, k+"="+v)
}
return out
}
type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lockedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuffer) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.String()
}
type probe struct {
t *testing.T
cmd *exec.Cmd
sock string
out *lockedBuffer
done chan struct{}
}
// start runs the binary exactly as spawnChildFrom does:
// exec.Command(bin, "--socket", sock, "--db", dbPath) with the composed env.
// Its stdout and stderr are pipes, as the backend's tee makes them.
func (f *fixture) start(env map[string]string) *probe {
f.t.Helper()
return f.startBin(binPath, env)
}
// startBin is start with another build of moveprobe.
func (f *fixture) startBin(bin string, env map[string]string) *probe {
f.t.Helper()
f.starts++
p := &probe{t: f.t, sock: filepath.Join(f.sockDir, fmt.Sprintf("a%d.sock", f.starts)), out: &lockedBuffer{}, done: make(chan struct{})}
p.cmd = exec.Command(bin, "--socket", p.sock, "--db", f.db)
p.cmd.Env = childEnv(env)
p.cmd.Stdout = p.out
p.cmd.Stderr = p.out
if err := p.cmd.Start(); err != nil {
f.t.Fatal(err)
}
go func() { p.cmd.Wait(); close(p.done) }()
f.t.Cleanup(p.kill)
return p
}
func (p *probe) exited() bool {
select {
case <-p.done:
return true
default:
return false
}
}
func (p *probe) kill() {
if p.exited() {
return
}
p.cmd.Process.Kill()
<-p.done
}
// stop is the backend's Stop: SIGTERM to the leader only.
func (p *probe) stop() {
p.t.Helper()
if p.exited() {
return
}
p.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-p.done:
case <-time.After(10 * time.Second):
p.t.Errorf("moveprobe did not exit on SIGTERM within 10s; output:\n%s", p.out)
p.kill()
}
}
func unixClient(sock string) *http.Client {
return &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
},
}}
}
func (p *probe) do(method, path, bearer string, hdr map[string]string) (int, string, error) {
req, err := http.NewRequest(method, "http://"+testHost+path, nil)
if err != nil {
return 0, "", err
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := unixClient(p.sock).Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b), nil
}
// wait polls path until it answers want, and fails the test with the last
// answer and the app's output otherwise.
func (p *probe) wait(path string, want int) string {
p.t.Helper()
deadline := time.Now().Add(20 * time.Second)
var last string
for time.Now().Before(deadline) {
code, body, err := p.do(http.MethodGet, path, "", nil)
if err == nil && code == want {
return body
}
last = fmt.Sprintf("code %d body %q err %v", code, body, err)
if p.exited() {
break
}
time.Sleep(100 * time.Millisecond)
}
p.t.Fatalf("GET %s never answered %d; last: %s; output:\n%s", path, want, last, p.out)
return ""
}
type reportDoc struct {
Region string `json:"region"`
RegionFrom string `json:"region_from"`
Mode string `json:"mode"`
Rows []struct {
Kind string `json:"kind"`
Region string `json:"region"`
Count int64 `json:"count"`
MinSeq int64 `json:"min_seq"`
MaxSeq int64 `json:"max_seq"`
} `json:"rows"`
Fired []struct {
Kind string `json:"kind"`
Name string `json:"name"`
DueAt int64 `json:"due_at"`
Count int64 `json:"count"`
} `json:"fired"`
FileKeySHA256 string `json:"file_key_sha256"`
PlatformKeySHA256 string `json:"platform_key_sha256"`
Object struct {
Key string `json:"key"`
Body string `json:"body"`
Error string `json:"error"`
} `json:"object"`
HasSecret bool `json:"has_secret"`
WorkerPID int `json:"worker_pid"`
WorkerWritesFailed int64 `json:"worker_writes_failed"`
WorkerWritesCommitted int64 `json:"worker_writes_committed"`
WorkerFailedInARow int64 `json:"worker_failed_in_a_row"`
WorkerLastError string `json:"worker_last_error"`
WorkerExit string `json:"worker_exit"`
WorkerLastStatusAt int64 `json:"worker_last_status_at"`
}
func (r reportDoc) count(kind, region string) int64 {
for _, row := range r.Rows {
if row.Kind == kind && row.Region == region {
return row.Count
}
}
return 0
}
func (p *probe) report() reportDoc {
p.t.Helper()
code, body, err := p.do(http.MethodGet, "/report", probeSecret, nil)
if err != nil || code != http.StatusOK {
p.t.Fatalf("/report: code %d err %v body %q; output:\n%s", code, err, body, p.out)
}
var r reportDoc
if err := json.Unmarshal([]byte(body), &r); err != nil {
p.t.Fatalf("/report: %v in %q", err, body)
}
return r
}
// workerPIDs lists the pids whose command line starts with a moveprobe
// build the tests made (binPath, or a test's own, all named moveprobe*) and
// "worker", and names db.
func workerPIDs(db string) []int {
var pids []int
ents, _ := os.ReadDir("/proc")
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
raw, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
if err != nil {
continue
}
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
if len(args) < 2 || !strings.HasPrefix(filepath.Base(args[0]), "moveprobe") || args[1] != "worker" || !strings.Contains(string(raw), db) {
continue
}
if alive(pid) {
pids = append(pids, pid)
}
}
return pids
}
// alive is true while pid exists and is neither a zombie nor dead.
func alive(pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return false
}
i := strings.LastIndexByte(string(raw), ')')
if i < 0 || i+2 >= len(raw) {
return false
}
state := raw[i+2]
return state != 'Z' && state != 'X'
}
func killWorkers(db string) {
for _, pid := range workerPIDs(db) {
syscall.Kill(pid, syscall.SIGKILL)
}
deadline := time.Now().Add(5 * time.Second)
for len(workerPIDs(db)) > 0 && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
}
func testDB(t *testing.T, db string) *sql.DB {
t.Helper()
d, err := sql.Open("sqlite", "file:"+db+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
return d
}
func countRows(t *testing.T, db, kind, region string) int64 {
t.Helper()
d := testDB(t, db)
defer d.Close()
var n int64
if err := d.QueryRow(`SELECT count(*) FROM rows WHERE kind=? AND region=?`, kind, region).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// checkpoint folds the WAL into the database file and closes, so the file
// is what a move would install.
func checkpoint(t *testing.T, db string) {
t.Helper()
d := testDB(t, db)
if _, err := d.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
t.Fatal(err)
}
d.Close()
}
// snapshot is every entry under dir with its size and SHA-256: the
// database file's digest and the files directory's listing, and anything
// else that appears beside them (a -wal or -shm file included).
func snapshot(t *testing.T, dir string) string {
t.Helper()
var lines []string
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
if d.IsDir() {
lines = append(lines, rel+"/")
return nil
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
lines = append(lines, fmt.Sprintf("%s %d %x", rel, len(b), sum))
return nil
})
if err != nil {
t.Fatal(err)
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}
func sha(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// fire delivers a timer the way the platform's runner does: the request
// schedule.BuildRequest builds, sent over the instance's socket.
func (p *probe) fire(tm registry.Timer, token string) (int, string) {
p.t.Helper()
req, err := schedule.BuildRequest(registry.Route{Host: testHost}, tm, token, "test")
if err != nil {
p.t.Fatal(err)
}
resp, err := unixClient(p.sock).Do(req)
if err != nil {
p.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// ---- the launch contract --------------------------------------------------
func TestMoveprobeLaunchContract(t *testing.T) {
t.Parallel()
t.Run("the agent's variables and a store endpoint", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.env)
if body := p.wait("/ready", http.StatusOK); !strings.Contains(body, "ready") {
t.Errorf("/ready body %q", body)
}
// The platform probes "/" when moves.ready_path is unset.
p.wait("/", http.StatusOK)
vs, err := f.bucket.ListVersions(context.Background(), bucketName, "probe/")
if err != nil || len(vs) != 1 {
t.Fatalf("probe objects: %v %v", vs, err)
}
for _, a := range f.seenAuth() {
if !strings.Contains(a, "Credential="+accessKey+"/") {
t.Errorf("a store request was not signed with MOVEPROBE_ACCESS_KEY: %q", a)
}
}
// /arm-once and /report need the probe's bearer.
if code, _, _ := p.do(http.MethodGet, "/report", "", nil); code != http.StatusUnauthorized {
t.Errorf("GET /report without a bearer: %d, want 401", code)
}
if code, _, _ := p.do(http.MethodGet, "/report", "wrong", nil); code != http.StatusUnauthorized {
t.Errorf("GET /report with the wrong bearer: %d, want 401", code)
}
if code, _, _ := p.do(http.MethodPost, "/arm-once?in=600", "", nil); code != http.StatusUnauthorized {
t.Errorf("POST /arm-once without a bearer: %d, want 401", code)
}
before := time.Now().Unix()
code, body, err := p.do(http.MethodPost, "/arm-once?in=600", probeSecret, nil)
if err != nil || code != http.StatusOK {
t.Fatalf("POST /arm-once: %d %q %v", code, body, err)
}
tm, err := f.reg.GetTimer(testHost, "once-1")
if err != nil {
t.Fatalf("once-1 not registered through the control socket: %v (arm answered %q)", err, body)
}
if tm.Kind != schedule.KindOnce || tm.Path != "/once" || tm.DueAt < before+600 || tm.DueAt > time.Now().Unix()+601 {
t.Errorf("once-1 = %+v, want a once-timer for /once due in 600s", tm)
}
if code, body, _ := p.do(http.MethodPost, "/arm-once?in=600", probeSecret, nil); code != http.StatusOK {
t.Errorf("the second arm: %d %q", code, body)
}
if _, err := f.reg.GetTimer(testHost, "once-2"); err != nil {
t.Errorf("the second arm is not once-2: %v", err)
}
r := p.report()
if r.Region != "uk" || r.RegionFrom != "CARLOS_REGION" {
t.Errorf("report region %q from %q, want uk from CARLOS_REGION", r.Region, r.RegionFrom)
}
if r.PlatformKeySHA256 != sha(platformKey) || r.FileKeySHA256 == "" || !r.HasSecret || r.Object.Body == "" || r.Object.Error != "" {
t.Errorf("report = %+v", r)
}
p.stop()
})
t.Run("no CARLOS_REGION and no control socket", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.envWith(map[string]string{"CARLOS_REGION": "", "CARLOS_CONTROL_SOCKET": ""}))
p.wait("/ready", http.StatusOK)
code, body, _ := p.do(http.MethodPost, "/arm-once?in=60", probeSecret, nil)
if code != http.StatusServiceUnavailable || !strings.Contains(body, "CARLOS_CONTROL_SOCKET") {
t.Errorf("/arm-once with no control socket: %d %q, want 503 naming CARLOS_CONTROL_SOCKET", code, body)
}
p.wait("/ready", http.StatusOK) // unaffected
r := p.report()
if r.Region != "home" || !strings.Contains(r.RegionFrom, "unset") {
t.Errorf("report region %q from %q, want home, with CARLOS_REGION unset", r.Region, r.RegionFrom)
}
time.Sleep(700 * time.Millisecond)
if n := countRows(t, f.db, "bg", "home"); n == 0 {
t.Errorf("no bg rows written as region home")
}
p.stop()
})
t.Run("no MOVEPROBE_ENDPOINT: AWS's own host, the delivered keys", func(t *testing.T) {
f := newFixture(t)
proxy, caFile, connects := awsLookalike(t, f.store)
p := f.start(f.envWith(map[string]string{
"MOVEPROBE_ENDPOINT": "",
"HTTPS_PROXY": proxy,
"SSL_CERT_FILE": caFile,
}))
p.wait("/ready", http.StatusOK)
want := bucketName + ".s3." + storeRegion + ".amazonaws.com:443"
got := connects()
if len(got) == 0 {
t.Fatalf("no store request went to AWS's host")
}
for _, h := range got {
if h != want {
t.Errorf("store request to %q, want %q (virtual-hosted, no endpoint)", h, want)
}
}
for _, a := range f.seenAuth() {
if !strings.Contains(a, "Credential="+accessKey+"/") {
t.Errorf("a store request was not signed with MOVEPROBE_ACCESS_KEY: %q", a)
}
}
p.stop()
})
t.Run("the store keys come only from MOVEPROBE_", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.envWith(map[string]string{
"MOVEPROBE_ACCESS_KEY": "",
"AWS_ACCESS_KEY_ID": accessKey,
"AWS_SECRET_ACCESS_KEY": "x",
"AWS_ENDPOINT_URL": f.storeURL,
"AWS_ENDPOINT_URL_S3": f.storeURL,
}))
body := p.wait("/ready", http.StatusInternalServerError)
if !strings.Contains(body, "MOVEPROBE_ACCESS_KEY") {
t.Errorf("/ready body %q does not name MOVEPROBE_ACCESS_KEY", body)
}
if n := f.storeReq.Load(); n != 0 {
t.Errorf("%d store requests made without the binding's keys", n)
}
p.stop()
})
t.Run("the sealed secret is required", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.envWith(map[string]string{"MOVEPROBE_SECRET": ""}))
body := p.wait("/ready", http.StatusInternalServerError)
if !strings.Contains(body, "MOVEPROBE_SECRET") {
t.Errorf("/ready body %q does not name MOVEPROBE_SECRET", body)
}
p.stop()
})
}
// awsLookalike stands in for S3's regional host: an HTTPS proxy whose
// CONNECT tunnels end at a TLS server holding a certificate for
// *.s3..amazonaws.com, signed by a CA written to the returned file.
// The tunnelled requests are virtual-hosted; they reach store path-style.
func awsLookalike(t *testing.T, store http.Handler) (proxyURL, caFile string, connects func() []string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "moveprobe test s3"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"*.s3." + storeRegion + ".amazonaws.com", "s3." + storeRegion + ".amazonaws.com"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
caFile = filepath.Join(t.TempDir(), "ca.pem")
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
t.Fatal(err)
}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}
vhost := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bucket, _, _ := strings.Cut(r.Host, ".")
r.URL.Path = "/" + bucket + r.URL.Path
r.URL.RawPath = ""
store.ServeHTTP(w, r)
})
var mu sync.Mutex
var hosts []string
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "CONNECT only", http.StatusMethodNotAllowed)
return
}
mu.Lock()
hosts = append(hosts, r.Host)
mu.Unlock()
conn, brw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
brw.WriteString("HTTP/1.1 200 Connection established\r\n\r\n")
brw.Flush()
srv := &http.Server{Handler: vhost}
srv.Serve(&oneConn{c: tls.Server(&bufferedConn{Conn: conn, r: brw.Reader}, tlsCfg)})
}))
t.Cleanup(proxy.Close)
return proxy.URL, caFile, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), hosts...)
}
}
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) }
// oneConn is a listener that yields one connection; Serve returns once it
// has been handed out and the connection is served on its own goroutine.
type oneConn struct {
c net.Conn
used bool
}
func (o *oneConn) Accept() (net.Conn, error) {
if o.used {
return nil, io.EOF
}
o.used = true
return o.c, nil
}
func (o *oneConn) Close() error { return nil }
func (o *oneConn) Addr() net.Addr { return o.c.LocalAddr() }
// ---- the forked worker ------------------------------------------------------
// The worker's grace is a minute in every build but this test's: it links
// its own moveprobe with an 8 s grace (workerGraceOverride) and watches
// that build's worker outlive its main, keep writing, and leave on its own
// at the grace, as the shipped build does at a minute.
func TestMoveprobeWorkerOutlivesMain(t *testing.T) {
t.Parallel()
if workerGrace != 60*time.Second || workerGraceOverride != "" || orphanGrace() != workerGrace {
t.Fatalf("a plain build's worker grace is %s (const %s, override %q), want a minute", orphanGrace(), workerGrace, workerGraceOverride)
}
const grace = 8 * time.Second
bin := filepath.Join(t.TempDir(), "moveprobe-grace")
build := exec.Command("go", "build", "-ldflags", "-X main.workerGraceOverride="+grace.String(), "-o", bin, ".")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build moveprobe with an %s grace: %v\n%s", grace, err, out)
}
f := newFixture(t)
p := f.startBin(bin, f.env)
p.wait("/ready", http.StatusOK)
r := p.report()
if r.WorkerPID <= 0 || !alive(r.WorkerPID) {
t.Fatalf("no live worker in the report: %d", r.WorkerPID)
}
if got := workerPIDs(f.db); len(got) != 1 || got[0] != r.WorkerPID {
t.Fatalf("worker processes %v, report says %d", got, r.WorkerPID)
}
// The main dies outright. Its stdout and stderr are pipes; a worker
// still holding them would keep Wait from returning (the backend's tee
// waits up to its drain grace for exactly that).
killed := time.Now()
p.cmd.Process.Kill()
select {
case <-p.done:
case <-time.After(2 * time.Second):
t.Fatalf("the main's Wait did not return within 2s of its death: the worker holds its stdio")
}
n0 := countRows(t, f.db, "worker", "uk")
time.Sleep(3 * time.Second)
if !alive(r.WorkerPID) {
t.Fatalf("the worker died with the main")
}
if n1 := countRows(t, f.db, "worker", "uk"); n1 < n0+2 {
t.Fatalf("the worker stopped writing after the main died: %d then %d rows", n0, n1)
}
// It exits on its own about its grace after its parent has gone.
for alive(r.WorkerPID) && time.Since(killed) < grace+20*time.Second {
time.Sleep(50 * time.Millisecond)
}
gone := time.Since(killed)
if alive(r.WorkerPID) {
t.Fatalf("the worker is still alive %s after the main died", gone.Round(time.Second))
}
// The worker notices its parent has gone on its next 1 s tick and
// leaves on the first tick at or past the grace, so it exits between
// the grace and about 2 s after it. 4 s of slack still refuses a worker
// that waits twice its grace.
if gone < grace-time.Second || gone > grace+4*time.Second {
t.Errorf("the worker exited %s after the main died, want about %s", gone.Round(time.Second), grace)
}
}
// ---- the failure switch ---------------------------------------------------
func TestReadyFailsWhereTold(t *testing.T) {
t.Parallel()
f := newFixture(t)
// The installed state: a normal boot in uk, stopped, its worker killed
// (the stop evidence does that on a real box) and the WAL folded in.
seed := f.start(f.env)
seed.wait("/ready", http.StatusOK)
time.Sleep(time.Second)
seed.stop()
killWorkers(f.db)
checkpoint(t, f.db)
installed := snapshot(t, f.dataDir)
if !strings.Contains(installed, "h1/instance.key") || !strings.Contains(installed, "h1.db ") {
t.Fatalf("the seed boot left no database or key file:\n%s", installed)
}
timers, _ := f.reg.TimersFor(testHost)
t.Run("before-write writes nothing", func(t *testing.T) {
p := f.start(f.envWith(map[string]string{"CARLOS_REGION": "ie", "MOVEPROBE_FAIL_READY_IN": "ie"}))
p.wait("/ready", http.StatusServiceUnavailable)
p.wait("/", http.StatusServiceUnavailable)
if code, _, _ := p.do(http.MethodPost, "/arm-once?in=60", probeSecret, nil); code != http.StatusServiceUnavailable {
t.Errorf("/arm-once in before-write: %d, want 503", code)
}
code, _ := p.fire(registry.Timer{Host: testHost, Name: "every-minute", Kind: schedule.KindSchedule, Path: "/tick", DueAt: time.Now().Unix()}, adminToken)
if code != http.StatusServiceUnavailable {
t.Errorf("/tick in before-write: %d, want 503", code)
}
code, _ = p.fire(registry.Timer{Host: testHost, Name: "once-9", Kind: schedule.KindOnce, Path: "/once", DueAt: time.Now().Unix()}, adminToken)
if code != http.StatusServiceUnavailable {
t.Errorf("/once in before-write: %d, want 503", code)
}
time.Sleep(2 * time.Second)
if w := workerPIDs(f.db); len(w) != 0 {
t.Errorf("before-write forked a worker: %v", w)
}
p.stop()
if got := snapshot(t, f.dataDir); got != installed {
t.Errorf("before-write changed the data directory\ninstalled:\n%s\nafter:\n%s", installed, got)
}
if got, _ := f.reg.TimersFor(testHost); len(got) != len(timers) {
t.Errorf("before-write changed the timers: %v then %v", timers, got)
}
})
t.Run("after-write writes one bg row, then fails", func(t *testing.T) {
p := f.start(f.envWith(map[string]string{"CARLOS_REGION": "ie", "MOVEPROBE_FAIL_READY_IN": "ie,after-write"}))
p.wait("/ready", http.StatusServiceUnavailable)
p.wait("/", http.StatusServiceUnavailable)
time.Sleep(2 * time.Second)
r := p.report()
if r.Mode != "after-write" {
t.Errorf("mode %q, want after-write", r.Mode)
}
if n := r.count("bg", "ie"); n != 1 {
t.Errorf("after-write wrote %d bg rows in ie, want exactly 1", n)
}
if n := r.count("worker", "ie"); n < 1 {
t.Errorf("after-write's worker wrote no rows in ie")
}
p.stop()
killWorkers(f.db)
})
t.Run("another region's switch does not apply", func(t *testing.T) {
p := f.start(f.envWith(map[string]string{"MOVEPROBE_FAIL_READY_IN": "ie"}))
p.wait("/ready", http.StatusOK)
p.stop()
killWorkers(f.db)
})
t.Run("a box with no region is home", func(t *testing.T) {
checkpoint(t, f.db)
before := snapshot(t, f.dataDir)
p := f.start(f.envWith(map[string]string{"CARLOS_REGION": "", "MOVEPROBE_FAIL_READY_IN": "home"}))
p.wait("/ready", http.StatusServiceUnavailable)
time.Sleep(time.Second)
p.stop()
if got := snapshot(t, f.dataDir); got != before {
t.Errorf("before-write at home changed the data directory")
}
})
}
// ---- the report -----------------------------------------------------------
func TestReportCountsRowsByRegion(t *testing.T) {
t.Parallel()
f := newFixture(t)
uk := f.start(f.env)
uk.wait("/ready", http.StatusOK)
code, body, _ := uk.do(http.MethodPost, "/arm-once?in=1", probeSecret, nil)
if code != http.StatusOK {
t.Fatalf("/arm-once: %d %q", code, body)
}
once, err := f.reg.GetTimer(testHost, "once-1")
if err != nil {
t.Fatal(err)
}
// A tick and the once-timer, delivered as the runner delivers them.
tick := registry.Timer{Host: testHost, Name: "every-minute", Kind: schedule.KindSchedule, Path: "/tick", DueAt: 1_700_000_040}
if code, body := uk.fire(tick, adminToken); code != http.StatusOK {
t.Fatalf("/tick: %d %q", code, body)
}
if code, body := uk.fire(*once, adminToken); code != http.StatusOK {
t.Fatalf("/once: %d %q", code, body)
}
// Neither fires without the platform's token.
if code, _ := uk.fire(tick, "wrong"); code != http.StatusUnauthorized {
t.Errorf("/tick with the wrong token: %d, want 401", code)
}
if code, _ := uk.fire(*once, ""); code != http.StatusUnauthorized {
t.Errorf("/once with no token: %d, want 401", code)
}
time.Sleep(1500 * time.Millisecond)
ukReport := uk.report()
uk.stop()
killWorkers(f.db)
// The same database, now in ie.
ie := f.start(f.envWith(map[string]string{"CARLOS_REGION": "ie"}))
ie.wait("/ready", http.StatusOK)
time.Sleep(1500 * time.Millisecond)
r := ie.report()
ie.stop()
if r.Region != "ie" {
t.Errorf("region %q, want ie", r.Region)
}
for _, c := range []struct {
kind, region string
min int64
}{{"bg", "uk", 2}, {"bg", "ie", 2}, {"worker", "uk", 1}, {"worker", "ie", 1}, {"tick", "uk", 1}, {"once", "uk", 1}} {
if n := r.count(c.kind, c.region); n < c.min {
t.Errorf("%s rows in %s: %d, want at least %d (rows %+v)", c.kind, c.region, n, c.min, r.Rows)
}
}
if n := r.count("tick", "ie") + r.count("once", "ie"); n != 0 {
t.Errorf("%d timer rows in ie, none fired there", n)
}
// No background row is missing: the sequence runs on across boots.
var bgCount, bgMax int64
for _, row := range r.Rows {
if row.Kind == "bg" {
bgCount += row.Count
bgMax = max(bgMax, row.MaxSeq)
}
}
if bgCount != bgMax {
t.Errorf("bg rows %d, highest seq %d: a row is missing or repeated", bgCount, bgMax)
}
var gotOnce, gotTick bool
for _, fr := range r.Fired {
switch {
case fr.Kind == "once" && fr.Name == "once-1":
gotOnce = fr.Count == 1 && fr.DueAt == once.DueAt
case fr.Kind == "tick" && fr.Name == "every-minute":
gotTick = fr.Count == 1 && fr.DueAt == 1_700_000_040
}
}
if !gotOnce || !gotTick {
t.Errorf("fired = %+v, want once-1 once at %d and every-minute once at 1700000040", r.Fired, once.DueAt)
}
if r.FileKeySHA256 == "" || r.FileKeySHA256 != ukReport.FileKeySHA256 {
t.Errorf("file key fingerprint %q in ie, %q in uk", r.FileKeySHA256, ukReport.FileKeySHA256)
}
if r.PlatformKeySHA256 != sha(platformKey) {
t.Errorf("platform key fingerprint %q, want %q", r.PlatformKeySHA256, sha(platformKey))
}
if !r.HasSecret || r.Object.Body == "" || !strings.HasPrefix(r.Object.Key, "probe/") {
t.Errorf("report = %+v", r)
}
}
// ---- the instance key -----------------------------------------------------
func TestReadyChecksBothKeys(t *testing.T) {
t.Parallel()
f := newFixture(t)
p := f.start(f.env)
p.wait("/ready", http.StatusOK)
p.stop()
killWorkers(f.db)
keyFile := filepath.Join(strings.TrimSuffix(f.db, ".db"), "instance.key")
orig, err := os.ReadFile(keyFile)
if err != nil {
t.Fatalf("first boot wrote no key file beside the database: %v", err)
}
if b, err := hex.DecodeString(strings.TrimSpace(string(orig))); err != nil || len(b) != 32 {
t.Errorf("instance.key is not 32 bytes of hex: %q", orig)
}
check := func(name string, env map[string]string, want int, says string) {
t.Helper()
p := f.start(env)
body := p.wait("/ready", want)
if says != "" && !strings.Contains(body, says) {
t.Errorf("%s: /ready body %q, want it to say %q", name, body, says)
}
p.stop()
killWorkers(f.db)
}
if err := os.WriteFile(keyFile, []byte(strings.Repeat("ab", 32)), 0o600); err != nil {
t.Fatal(err)
}
check("changed file", f.env, http.StatusInternalServerError, "key mismatch")
if err := os.Remove(keyFile); err != nil {
t.Fatal(err)
}
check("missing file", f.env, http.StatusInternalServerError, "key mismatch")
if err := os.WriteFile(keyFile, orig, 0o600); err != nil {
t.Fatal(err)
}
check("restored file", f.env, http.StatusOK, "")
check("platform key changed", f.envWith(map[string]string{"CARLOS_INSTANCE_KEY": "another-key"}), http.StatusInternalServerError, "CARLOS_INSTANCE_KEY")
check("platform key absent", f.envWith(map[string]string{"CARLOS_INSTANCE_KEY": ""}), http.StatusInternalServerError, "CARLOS_INSTANCE_KEY")
check("both restored", f.env, http.StatusOK, "")
}
// ---- write failures -------------------------------------------------------
// failInserts makes every insert of kind into rows abort, through a trigger
// the test adds to the instance's own database: a real write failure, with
// nothing in the app to switch it on.
func failInserts(t *testing.T, db, kind string) {
t.Helper()
d := testDB(t, db)
defer d.Close()
q := fmt.Sprintf(`CREATE TRIGGER fail_%[1]s BEFORE INSERT ON rows WHEN NEW.kind = '%[1]s' BEGIN SELECT RAISE(ABORT, 'forced %[1]s failure'); END`, kind)
if _, err := d.Exec(q); err != nil {
t.Fatal(err)
}
}
func allowInserts(t *testing.T, db, kind string) {
t.Helper()
d := testDB(t, db)
defer d.Close()
if _, err := d.Exec(`DROP TRIGGER fail_` + kind); err != nil {
t.Fatal(err)
}
}
// TestReadyFollowsTheWorkersLastWrite: /ready is 200 only once a worker row
// has committed since this start and the worker's latest write succeeded.
func TestReadyFollowsTheWorkersLastWrite(t *testing.T) {
t.Parallel()
f := newFixture(t)
seed := f.start(f.env)
seed.wait("/ready", http.StatusOK)
seed.stop()
killWorkers(f.db)
// Worker rows from the earlier boot are in the database; none can
// commit in this one.
failInserts(t, f.db, "worker")
p := f.start(f.env)
body := p.wait("/ready", http.StatusInternalServerError)
if !strings.Contains(body, "worker: no row committed since start") {
t.Errorf("/ready before the first worker row: %q", body)
}
time.Sleep(1500 * time.Millisecond)
if code, body, _ := p.do(http.MethodGet, "/ready", "", nil); code != http.StatusInternalServerError {
t.Errorf("/ready with no worker row yet: %d %q, want 500", code, body)
}
r := p.report()
if r.WorkerWritesFailed == 0 || r.WorkerWritesCommitted != 0 || !strings.Contains(r.WorkerLastError, "forced worker failure") {
t.Errorf("report: committed %d, failed %d, last %q", r.WorkerWritesCommitted, r.WorkerWritesFailed, r.WorkerLastError)
}
// The first good write makes it ready.
allowInserts(t, f.db, "worker")
p.wait("/ready", http.StatusOK)
// One failed write is enough to fail it.
failInserts(t, f.db, "worker")
deadline := time.Now().Add(5 * time.Second)
for {
r = p.report()
if r.WorkerFailedInARow > 0 || time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
code, body, _ := p.do(http.MethodGet, "/ready", "", nil)
if r.WorkerFailedInARow != 1 || code != http.StatusInternalServerError || !strings.Contains(body, "worker: the last write failed") {
t.Errorf("after one failed write (in a row %d): /ready %d %q, want 500", r.WorkerFailedInARow, code, body)
}
// The next good write makes it ready again; the counters stay.
allowInserts(t, f.db, "worker")
p.wait("/ready", http.StatusOK)
if r := p.report(); r.WorkerFailedInARow != 0 || r.WorkerWritesFailed == 0 || r.WorkerWritesCommitted == 0 {
t.Errorf("after recovery: committed %d, failed %d, in a row %d", r.WorkerWritesCommitted, r.WorkerWritesFailed, r.WorkerFailedInARow)
}
p.stop()
}
func TestAfterWriteExitsWhenItsRowFails(t *testing.T) {
t.Parallel()
f := newFixture(t)
seed := f.start(f.env)
seed.wait("/ready", http.StatusOK)
seed.stop()
killWorkers(f.db)
failInserts(t, f.db, "bg")
p := f.start(f.envWith(map[string]string{"CARLOS_REGION": "ie", "MOVEPROBE_FAIL_READY_IN": "ie,after-write"}))
select {
case <-p.done:
case <-time.After(15 * time.Second):
t.Fatalf("after-write kept running without its bg row; output:\n%s", p.out)
}
if code := p.cmd.ProcessState.ExitCode(); code == 0 {
t.Errorf("after-write exited 0 without its bg row; output:\n%s", p.out)
}
if !strings.Contains(p.out.String(), "forced bg failure") {
t.Errorf("the exit does not say why; output:\n%s", p.out)
}
if w := workerPIDs(f.db); len(w) != 0 {
t.Errorf("after-write forked a worker before its row committed: %v", w)
}
if n := countRows(t, f.db, "bg", "ie"); n != 0 {
t.Errorf("%d bg rows in ie", n)
}
}
// ---- report query failures ------------------------------------------------
// failDriver is a database/sql driver whose query matching the DSN (a
// substring) yields one good row and then fails, as a read that breaks part
// way through would. Every other query yields no rows.
type failDriver struct{}
func (failDriver) Open(failOn string) (driver.Conn, error) { return failConn{failOn}, nil }
type failConn struct{ failOn string }
func (c failConn) Prepare(q string) (driver.Stmt, error) { return failStmt{c.failOn, q}, nil }
func (failConn) Close() error { return nil }
func (failConn) Begin() (driver.Tx, error) { return nil, errors.New("no transactions") }
type failStmt struct{ failOn, q string }
func (failStmt) Close() error { return nil }
func (failStmt) NumInput() int { return -1 }
func (failStmt) Exec([]driver.Value) (driver.Result, error) {
return nil, errors.New("no writes")
}
func (s failStmt) Query([]driver.Value) (driver.Rows, error) {
cols := []string{"kind", "region", "count", "min", "max"}
row := []driver.Value{"bg", "uk", int64(3), int64(1), int64(3)}
if strings.Contains(s.q, "IN ('tick', 'once')") {
cols = []string{"kind", "name", "due_at", "count"}
row = []driver.Value{"once", "once-1", int64(1_700_000_000), int64(1)}
}
return &failRows{cols: cols, row: row, fail: strings.Contains(s.q, s.failOn)}, nil
}
type failRows struct {
cols []string
row []driver.Value
fail bool
n int
}
func (r *failRows) Columns() []string { return r.cols }
func (r *failRows) Close() error { return nil }
func (r *failRows) Next(dest []driver.Value) error {
r.n++
switch {
case !r.fail:
return io.EOF
case r.n == 1:
copy(dest, r.row)
return nil
default:
return errors.New("forced read failure")
}
}
func init() { sql.Register("moveprobe-fail", failDriver{}) }
func TestReportNeverAnswersPartial(t *testing.T) {
t.Parallel()
report := func(t *testing.T, db *sql.DB) (int, string) {
t.Helper()
a := &app{db: db, filesDir: t.TempDir(), region: "uk", mode: modeNormal, storeErr: errors.New("store: not under test")}
rec := httptest.NewRecorder()
a.handleReport(rec, httptest.NewRequest(http.MethodGet, "/report", nil))
return rec.Code, rec.Body.String()
}
for _, failOn := range []string{"GROUP BY kind, region", "IN ('tick', 'once')"} {
t.Run("rows fail part way: "+failOn, func(t *testing.T) {
db, err := sql.Open("moveprobe-fail", failOn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
code, body := report(t, db)
if code != http.StatusInternalServerError || !strings.Contains(body, "forced read failure") {
t.Errorf("/report: %d %q, want 500 naming the read failure", code, body)
}
})
}
t.Run("a closed database", func(t *testing.T) {
db := testDB(t, filepath.Join(t.TempDir(), "closed.db"))
db.Close()
if code, body := report(t, db); code != http.StatusInternalServerError {
t.Errorf("/report on a closed database: %d %q, want 500", code, body)
}
})
t.Run("nothing fails", func(t *testing.T) {
db, err := sql.Open("moveprobe-fail", "matches no query")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if code, body := report(t, db); code != http.StatusOK {
t.Errorf("/report: %d %q, want 200", code, body)
}
})
}
func TestArmOnceReusesOneControlConnection(t *testing.T) {
t.Parallel()
f := newFixture(t)
p := f.start(f.env)
p.wait("/ready", http.StatusOK)
for i := 0; i < 3; i++ {
if code, body, _ := p.do(http.MethodPost, "/arm-once?in=600", probeSecret, nil); code != http.StatusOK {
t.Fatalf("arm %d: %d %q", i+1, code, body)
}
}
if n := f.controlConns.Load(); n != 1 {
t.Errorf("three arms opened %d connections to the control socket, want 1", n)
}
p.stop()
}
// TestReadyFailsWhenTheWorkerStops: a worker that has died, or has sent no
// status for 3 s, fails readiness, and the report says so.
func TestReadyFailsWhenTheWorkerStops(t *testing.T) {
t.Parallel()
t.Run("the worker dies after its first good write", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.env)
p.wait("/ready", http.StatusOK)
r := p.report()
if r.WorkerWritesCommitted == 0 || r.WorkerExit != "" || r.WorkerLastStatusAt == 0 {
t.Fatalf("before: committed %d, exit %q, last status %d", r.WorkerWritesCommitted, r.WorkerExit, r.WorkerLastStatusAt)
}
if err := syscall.Kill(r.WorkerPID, syscall.SIGKILL); err != nil {
t.Fatal(err)
}
body := p.wait("/ready", http.StatusInternalServerError)
if !strings.Contains(body, "worker: exited") {
t.Errorf("/ready after the worker died: %q", body)
}
if r := p.report(); !strings.Contains(r.WorkerExit, "exited") {
t.Errorf("report worker_exit %q after the worker died", r.WorkerExit)
}
p.stop()
})
t.Run("the worker sends no status for 3 s", func(t *testing.T) {
f := newFixture(t)
p := f.start(f.env)
p.wait("/ready", http.StatusOK)
pid := p.report().WorkerPID
if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil {
t.Fatal(err)
}
stopped := time.Now()
body := p.wait("/ready", http.StatusInternalServerError)
if waited := time.Since(stopped); waited < 2*time.Second {
t.Errorf("/ready failed %s after the worker stopped, before 3 s without status", waited)
}
if !strings.Contains(body, "worker: no status for") {
t.Errorf("/ready with a stalled worker: %q", body)
}
syscall.Kill(pid, syscall.SIGCONT)
p.wait("/ready", http.StatusOK)
p.stop()
})
}
// TestReadyCoalescesTheStoreCheck: anonymous /ready traffic costs at most one
// store check per 5 s, however many requests arrive, and requests that land
// while a check is running share its result.
func TestReadyCoalescesTheStoreCheck(t *testing.T) {
t.Parallel()
f := newFixture(t)
p := f.start(f.env)
p.wait("/ready", http.StatusOK)
burst := func() {
t.Helper()
var wg sync.WaitGroup
codes := make(chan int, 50)
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
code, _, err := p.do(http.MethodGet, "/ready", "", nil)
if err != nil {
code = 0
}
codes <- code
}()
}
wg.Wait()
close(codes)
for c := range codes {
if c != http.StatusOK {
t.Errorf("a /ready in the burst answered %d", c)
}
}
}
// A slow store, so the burst lands while a check is in flight.
f.storeDelay.Store(300)
time.Sleep(5100 * time.Millisecond) // past any cached result
n0 := f.storeReq.Load()
burst()
n1 := f.storeReq.Load()
if n1-n0 > 1 {
t.Errorf("50 concurrent /ready calls made %d store requests, want at most 1", n1-n0)
}
burst()
if n2 := f.storeReq.Load(); n2 != n1 {
t.Errorf("a second burst inside the 5 s window made %d store requests, want 0", n2-n1)
}
time.Sleep(5100 * time.Millisecond)
n3 := f.storeReq.Load()
burst()
if n4 := f.storeReq.Load(); n4-n3 > 1 {
t.Errorf("the next window's burst made %d store requests, want at most 1", n4-n3)
}
p.stop()
}