Files
pdbmux/e2e_backend_test.go
T
unkin-agent bfe28b488d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Replay a unanimous upstream rejection instead of a 502
Every backend gets the same query, so one they all refuse is the client's
mistake; flattening it into "all backends failed" threw openvoxdb's own
explanation away and logged a typo as an outage.

- Carry status, content type and body on a typed upstreamError
- Replay the status and explanation when every backend refuses alike
- Redact backend addresses from replayed bodies
- Keep a refused query out of the partial counters and the cache
2026-09-07 22:39:58 +10:00

382 lines
12 KiB
Go

//go:build e2e
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/moby/moby/api/types/container"
mobynet "github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go"
tcnet "github.com/testcontainers/testcontainers-go/network"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
// openvoxdb refuses to start unless pg_trgm already exists in its database
// (scf/migrate.clj require-extensions), so the harness creates it before boot.
pgTrgmSQL = "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
// Current wire versions accepted by openvoxdb 8 (command/constants.clj
// supported-command-versions).
cmdReplaceFacts = "replace_facts"
verReplaceFacts = 5
cmdStoreReport = "store_report"
verStoreReport = 8
cmdReplaceCatalog = "replace_catalog"
verReplaceCatalog = 9
cmdDeactivateNode = "deactivate_node"
verDeactivateNode = 3
// Blocking command submission: the POST returns only once the queued command
// has been processed, so fixtures need no sleeps.
commandWait = 90 * time.Second
backendBoot = 5 * time.Minute
)
func imageFor(envVar, fallback string) string {
if v := os.Getenv(envVar); v != "" {
return v
}
return fallback
}
// backend is one PuppetDB stack: a PostgreSQL container and the openvoxdb
// container in front of it, reachable from the host on a fixed port so a
// stop/start cycle keeps the same URL.
type backend struct {
name string
url string
pg testcontainers.Container
pdb testcontainers.Container
}
// reservePort picks a free host port and releases it, so the container can be
// published on a port that survives a restart.
func reservePort(t fatalf) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserving a host port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
t.Fatalf("releasing the reserved port: %v", err)
}
return port
}
func startBackend(ctx context.Context, t fatalf, name, netName string) *backend {
t.Helper()
pgAlias := name + "-pg"
pgImage := imageFor("PDBMUX_E2E_POSTGRES_IMAGE", "docker.io/library/postgres:17-alpine")
pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: pgImage,
Networks: []string{netName},
NetworkAliases: map[string][]string{
netName: {pgAlias},
},
Env: map[string]string{
"POSTGRES_USER": "openvoxdb",
"POSTGRES_PASSWORD": "openvoxdb",
"POSTGRES_DB": "openvoxdb",
},
// Postgres restarts once during first-boot init, so the log line has to
// be seen twice before the server is really accepting connections.
WaitingFor: wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(2 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("starting postgres for %s: %v", name, err)
}
// Nothing owns this postgres until the backend is fully built, so a failure
// past this point has to take it down itself.
fail := func(format string, args ...any) {
_ = testcontainers.TerminateContainer(pg)
t.Fatalf(format, args...)
}
code, out, err := pg.Exec(ctx, []string{"psql", "-U", "openvoxdb", "-d", "openvoxdb", "-c", pgTrgmSQL})
if err != nil || code != 0 {
body, _ := io.ReadAll(out)
fail("creating pg_trgm for %s: code=%d err=%v out=%s", name, code, err, body)
}
port := reservePort(t)
pdbImage := imageFor("PDBMUX_E2E_OPENVOXDB_IMAGE", "ghcr.io/openvoxproject/openvoxdb:8.15.0")
pdb, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: pdbImage,
Networks: []string{netName},
ExposedPorts: []string{"8080/tcp"},
// A fixed host port, so the backend keeps its URL across the stop/start
// the health test does.
HostConfigModifier: func(hc *container.HostConfig) {
hc.PortBindings = mobynet.PortMap{
mobynet.MustParsePort("8080/tcp"): []mobynet.PortBinding{
{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: strconv.Itoa(port)},
},
}
},
Env: map[string]string{
// Without this the entrypoint waits for a puppetserver and switches
// jetty to HTTPS; false leaves the default clear-text 8080 listener.
"USE_OPENVOXSERVER": "false",
"OPENVOXDB_POSTGRES_HOSTNAME": pgAlias,
"OPENVOXDB_POSTGRES_USER": "openvoxdb",
"OPENVOXDB_POSTGRES_PASSWORD": "openvoxdb",
"OPENVOXDB_POSTGRES_DATABASE": "openvoxdb",
},
WaitingFor: waitForPuppetDBRunning(),
},
Started: true,
})
if err != nil {
_ = testcontainers.TerminateContainer(pdb)
fail("starting openvoxdb for %s: %v", name, err)
}
return &backend{name: name, url: fmt.Sprintf("http://127.0.0.1:%d", port), pg: pg, pdb: pdb}
}
// waitForPuppetDBRunning gates on the trapperkeeper status service reporting
// every service running, which is the same signal pdbmux's own prober reads.
func waitForPuppetDBRunning() wait.Strategy {
return wait.ForHTTP("/status/v1/services").
WithPort("8080/tcp").
WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }).
WithResponseMatcher(func(body io.Reader) bool {
var services map[string]struct {
State string `json:"state"`
}
if json.NewDecoder(body).Decode(&services) != nil || len(services) == 0 {
return false
}
for _, svc := range services {
if svc.State != "running" {
return false
}
}
return true
}).
WithStartupTimeout(backendBoot)
}
func (b *backend) terminate(ctx context.Context) {
_ = testcontainers.TerminateContainer(b.pdb)
_ = testcontainers.TerminateContainer(b.pg)
_ = ctx
}
// stop kills the PuppetDB process so the port refuses connections, which is what
// pdbmux's prober and fan-out see when a backend dies.
func (b *backend) stop(ctx context.Context, t fatalf) {
t.Helper()
timeout := 30 * time.Second
if err := b.pdb.Stop(ctx, &timeout); err != nil {
t.Fatalf("stopping backend %s: %v", b.name, err)
}
}
func (b *backend) start(ctx context.Context, t fatalf) {
t.Helper()
if err := b.pdb.Start(ctx); err != nil {
t.Fatalf("starting backend %s: %v", b.name, err)
}
b.waitReady(ctx, t)
}
func (b *backend) waitReady(ctx context.Context, t fatalf) {
t.Helper()
deadline := time.Now().Add(backendBoot)
for time.Now().Before(deadline) {
if b.queueDepth(ctx) >= 0 {
return
}
time.Sleep(time.Second)
}
t.Fatalf("backend %s did not become ready within %s", b.name, backendBoot)
}
// queueDepth reads the command queue depth the status service publishes
// (status.clj's :queue_depth), or -1 when the backend is not answering or has
// not finished starting. Draining is a real signal, not a sleep.
func (b *backend) queueDepth(ctx context.Context) int {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.url+"/status/v1/services", nil)
if err != nil {
return -1
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return -1
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return -1
}
var services struct {
PuppetDB struct {
State string `json:"state"`
Status struct {
QueueDepth *int `json:"queue_depth"`
MaintenanceMode bool `json:"maintenance_mode?"`
} `json:"status"`
} `json:"puppetdb-status"`
}
if json.NewDecoder(resp.Body).Decode(&services) != nil {
return -1
}
s := services.PuppetDB
if s.State != "running" || s.Status.MaintenanceMode || s.Status.QueueDepth == nil {
return -1
}
return *s.Status.QueueDepth
}
func (b *backend) waitQueueDrained(ctx context.Context, t fatalf) {
t.Helper()
deadline := time.Now().Add(2 * time.Minute)
for time.Now().Before(deadline) {
if b.queueDepth(ctx) == 0 {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("backend %s command queue did not drain", b.name)
}
// commandResult is the blocking-submit reply: processed/timed_out say whether
// the command actually landed, and error carries a processing failure.
type commandResult struct {
UUID string `json:"uuid"`
Processed bool `json:"processed"`
TimedOut bool `json:"timed_out"`
Error string `json:"error"`
}
// submit posts one command and blocks until openvoxdb has processed it, so the
// caller can query for its effect immediately afterwards.
func (b *backend) submit(ctx context.Context, t fatalf, command string, version int, certname, producerTimestamp string, payload any) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("encoding %s payload for %s: %v", command, certname, err)
}
params := url.Values{
"certname": {certname},
"command": {command},
"version": {fmt.Sprint(version)},
"producer-timestamp": {producerTimestamp},
"secondsToWaitForCompletion": {fmt.Sprint(int(commandWait.Seconds()))},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
b.url+"/pdb/cmd/v1?"+params.Encode(), bytes.NewReader(body))
if err != nil {
t.Fatalf("building %s request for %s: %v", command, certname, err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: commandWait + 30*time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("submitting %s for %s to %s: %v", command, certname, b.name, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
var res commandResult
if json.Unmarshal(raw, &res) != nil {
t.Fatalf("unreadable %s reply for %s from %s: HTTP %d %s", command, certname, b.name, resp.StatusCode, raw)
}
if !res.Processed || res.TimedOut || res.Error != "" {
t.Fatalf("%s for %s on %s was not processed: HTTP %d processed=%v timed_out=%v error=%s",
command, certname, b.name, resp.StatusCode, res.Processed, res.TimedOut, res.Error)
}
}
// query runs a GET against this backend directly, bypassing pdbmux, so a test
// can compare the merged answer with the raw ones.
func (b *backend) query(ctx context.Context, t fatalf, path string, params url.Values) []map[string]any {
t.Helper()
target := b.url + path
if len(params) > 0 {
target += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
t.Fatalf("building query for %s: %v", b.name, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("querying %s%s: %v", b.name, path, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("querying %s%s: HTTP %d: %s", b.name, path, resp.StatusCode, strings.TrimSpace(string(body)))
}
var rows []map[string]any
if err := json.Unmarshal(body, &rows); err != nil {
t.Fatalf("decoding %s%s: %v: %s", b.name, path, err, body)
}
return rows
}
// queryStatus is query without the body, for asserting what a backend rejects.
func (b *backend) queryStatus(ctx context.Context, t fatalf, path string, params url.Values) int {
t.Helper()
status, _ := b.queryRaw(ctx, t, path, params)
return status
}
// queryRaw returns a backend's own status and body, so a test can compare what
// pdbmux served against what the backend actually said.
func (b *backend) queryRaw(ctx context.Context, t fatalf, path string, params url.Values) (int, []byte) {
t.Helper()
target := b.url + path
if len(params) > 0 {
target += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
t.Fatalf("building query for %s: %v", b.name, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("querying %s%s: %v", b.name, path, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading %s%s: %v", b.name, path, err)
}
return resp.StatusCode, body
}
func newNetwork(ctx context.Context, t fatalf) (string, func()) {
t.Helper()
nw, err := tcnet.New(ctx)
if err != nil {
t.Fatalf("creating the harness network: %v", err)
}
return nw.Name, func() { _ = nw.Remove(ctx) }
}