277 lines
5.8 KiB
Go
277 lines
5.8 KiB
Go
package harness
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultStartupWait = 10 * time.Second
|
|
defaultPollInterval = 25 * time.Millisecond
|
|
defaultStopWait = 5 * time.Second
|
|
)
|
|
|
|
// Process represents one long-lived external service process started by an
|
|
// integration suite.
|
|
type Process struct {
|
|
name string
|
|
cmd *exec.Cmd
|
|
|
|
logsMu sync.Mutex
|
|
logs bytes.Buffer
|
|
|
|
doneCh chan struct{}
|
|
waitErr error
|
|
}
|
|
|
|
// StartProcess starts binaryPath with envOverrides and registers cleanup that
|
|
// stops the process and prints captured logs on failed tests.
|
|
func StartProcess(t testing.TB, name string, binaryPath string, envOverrides map[string]string) *Process {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command(binaryPath)
|
|
cmd.Env = mergeEnvironment(os.Environ(), envOverrides)
|
|
|
|
process := &Process{
|
|
name: name,
|
|
cmd: cmd,
|
|
doneCh: make(chan struct{}),
|
|
}
|
|
cmd.Stdout = process.logWriter()
|
|
cmd.Stderr = process.logWriter()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start %s: %v", name, err)
|
|
}
|
|
|
|
go func() {
|
|
process.waitErr = cmd.Wait()
|
|
close(process.doneCh)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
process.Stop(t)
|
|
if t.Failed() {
|
|
t.Logf("%s logs:\n%s", name, process.Logs())
|
|
}
|
|
})
|
|
|
|
return process
|
|
}
|
|
|
|
// Stop asks the process to terminate gracefully and waits for completion.
|
|
func (p *Process) Stop(t testing.TB) {
|
|
t.Helper()
|
|
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-p.doneCh:
|
|
err := p.waitErr
|
|
if err != nil && !isExpectedProcessExit(err) {
|
|
t.Errorf("%s exited unexpectedly: %v", p.name, err)
|
|
}
|
|
return
|
|
default:
|
|
}
|
|
|
|
if p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
|
|
select {
|
|
case <-p.doneCh:
|
|
err := p.waitErr
|
|
if err != nil && !isExpectedProcessExit(err) {
|
|
t.Errorf("%s exited unexpectedly: %v", p.name, err)
|
|
}
|
|
case <-time.After(defaultStopWait):
|
|
if p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
}
|
|
<-p.doneCh
|
|
err := p.waitErr
|
|
if err != nil && !isExpectedProcessExit(err) {
|
|
t.Errorf("%s exited unexpectedly: %v", p.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Logs returns the captured combined stdout/stderr output of the process.
|
|
func (p *Process) Logs() string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
|
|
p.logsMu.Lock()
|
|
defer p.logsMu.Unlock()
|
|
return p.logs.String()
|
|
}
|
|
|
|
// FreeTCPAddress reserves one ephemeral loopback TCP address and releases it
|
|
// immediately so a service process can bind to it.
|
|
func FreeTCPAddress(t testing.TB) string {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("reserve free TCP address: %v", err)
|
|
}
|
|
|
|
addr := listener.Addr().String()
|
|
if err := listener.Close(); err != nil {
|
|
t.Fatalf("release reserved TCP address: %v", err)
|
|
}
|
|
|
|
return addr
|
|
}
|
|
|
|
// WaitForHTTPStatus waits until url responds with wantStatus or fails when the
|
|
// backing process exits early.
|
|
func WaitForHTTPStatus(t testing.TB, process *Process, url string, wantStatus int) {
|
|
t.Helper()
|
|
|
|
client := &http.Client{
|
|
Timeout: 250 * time.Millisecond,
|
|
Transport: &http.Transport{
|
|
DisableKeepAlives: true,
|
|
},
|
|
}
|
|
defer client.CloseIdleConnections()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultStartupWait)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(defaultPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if err := processErr(process); err != nil {
|
|
t.Fatalf("%s exited before %s became ready: %v\n%s", process.name, url, err, process.Logs())
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
t.Fatalf("build readiness request for %s: %v", url, err)
|
|
}
|
|
|
|
response, err := client.Do(request)
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, response.Body)
|
|
response.Body.Close()
|
|
if response.StatusCode == wantStatus {
|
|
return
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("wait for %s status %d: %v\n%s", url, wantStatus, ctx.Err(), process.Logs())
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// WaitForTCP waits until address accepts TCP connections or fails when the
|
|
// backing process exits early.
|
|
func WaitForTCP(t testing.TB, process *Process, address string) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultStartupWait)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(defaultPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if err := processErr(process); err != nil {
|
|
t.Fatalf("%s exited before %s became reachable: %v\n%s", process.name, address, err, process.Logs())
|
|
}
|
|
|
|
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("wait for %s TCP readiness: %v\n%s", address, ctx.Err(), process.Logs())
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Process) logWriter() io.Writer {
|
|
return writerFunc(func(data []byte) (int, error) {
|
|
p.logsMu.Lock()
|
|
defer p.logsMu.Unlock()
|
|
return p.logs.Write(data)
|
|
})
|
|
}
|
|
|
|
func mergeEnvironment(base []string, overrides map[string]string) []string {
|
|
values := make(map[string]string, len(base)+len(overrides))
|
|
for _, entry := range base {
|
|
name, value, ok := strings.Cut(entry, "=")
|
|
if ok {
|
|
values[name] = value
|
|
}
|
|
}
|
|
for name, value := range overrides {
|
|
values[name] = value
|
|
}
|
|
|
|
merged := make([]string, 0, len(values))
|
|
for name, value := range values {
|
|
merged = append(merged, fmt.Sprintf("%s=%s", name, value))
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func processErr(process *Process) error {
|
|
if process == nil {
|
|
return errors.New("nil process")
|
|
}
|
|
|
|
select {
|
|
case <-process.doneCh:
|
|
return process.waitErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func isExpectedProcessExit(err error) bool {
|
|
if err == nil {
|
|
return true
|
|
}
|
|
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
return false
|
|
}
|
|
|
|
return exitErr.ExitCode() == -1
|
|
}
|
|
|
|
type writerFunc func([]byte) (int, error)
|
|
|
|
func (f writerFunc) Write(data []byte) (int, error) {
|
|
return f(data)
|
|
}
|