mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
test: managed-local proxied lifecycle smoke lane (Linux, offline) (#5004)
* test: managed-local proxied lifecycle smoke lane (Linux, offline) Every existing proxied CI shard fronts an externally managed Dolt testcontainer, so a green proxied tier has never proven the production local-first path: bd launching its loopback proxy, the proxy launching a locally installed dolt sql-server, and that topology surviving idle shutdown and transparent restart. This adds the first end-to-end lane for exactly that path, test-only (no product behavior changes): - cmd/bd/proxied_local_helpers_test.go: managed-local gate (BEADS_TEST_PROXIED_LOCAL=1; missing dolt FAILS rather than skips), managed init with a short idle window, pidfile/process/artifact inspection primitives, and a held-connection helper that pins the process tree up while it is inspected. Written for reuse by stale-artifact and process-identity lifecycle tests. - cmd/bd/proxied_local_lifecycle_linux_test.go: the smoke test — init/ create/read against the launched local process tree, loopback-only proof for both listeners (generated config.yaml AND live socket enumeration via /proc), idle shutdown observed, then transparent restart with persisted data. - .github/workflows/proxied-local-smoke.yml: pins the Dolt CLI, builds online, then runs the test inside a loopback-only network namespace, proving the lifecycle needs no outbound network after installation. Verified locally on Linux (dolt 2.2.0): plain run passes in ~21s; the same test passes inside unshare -r -n with only loopback up; with dolt removed from PATH the lane fails with a clear error instead of skipping. Agent-Signature: claude-fable-5-high on behalf of matt wilkie * test: make managed-local smoke fail closed Agent-Signature: codex-gpt-5.6-sol-high on behalf of matt wilkie
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# Managed-local proxied smoke lane.
|
||||
#
|
||||
# The proxied shards in main.yml exercise command behavior against an
|
||||
# EXTERNALLY managed Dolt testcontainer. This lane covers the production
|
||||
# local-first path those shards cannot: bd launches its own loopback proxy,
|
||||
# the proxy launches a locally installed `dolt sql-server`, and the whole
|
||||
# lifecycle (create/read, loopback-only listeners, idle shutdown,
|
||||
# transparent restart with persistence) runs INSIDE a network namespace
|
||||
# with only loopback up — proving no outbound network is needed once the
|
||||
# binaries are installed. Linux first; Windows/macOS parity is tracked
|
||||
# separately.
|
||||
|
||||
name: Proxied Local Smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cmd/bd/proxied_local_*'
|
||||
- 'cmd/bd/proxied_server*.go'
|
||||
- 'cmd/bd/*_proxied_server.go'
|
||||
- 'cmd/bd/db_proxy_child.go'
|
||||
- 'cmd/bd/init*.go'
|
||||
- 'cmd/bd/proxied_integration_helpers_test.go'
|
||||
- 'cmd/bd/store_factory*.go'
|
||||
- 'cmd/bd/test_dolt_server_cgo_test.go'
|
||||
- 'cmd/bd/uow_factory.go'
|
||||
- 'internal/configfile/**'
|
||||
- 'internal/doltserver/**'
|
||||
- 'internal/storage/dbproxy/**'
|
||||
- 'internal/storage/uow/**'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.github/workflows/proxied-local-smoke.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cmd/bd/proxied_local_*'
|
||||
- 'cmd/bd/proxied_server*.go'
|
||||
- 'cmd/bd/*_proxied_server.go'
|
||||
- 'cmd/bd/db_proxy_child.go'
|
||||
- 'cmd/bd/init*.go'
|
||||
- 'cmd/bd/proxied_integration_helpers_test.go'
|
||||
- 'cmd/bd/store_factory*.go'
|
||||
- 'cmd/bd/test_dolt_server_cgo_test.go'
|
||||
- 'cmd/bd/uow_factory.go'
|
||||
- 'internal/configfile/**'
|
||||
- 'internal/doltserver/**'
|
||||
- 'internal/storage/dbproxy/**'
|
||||
- 'internal/storage/uow/**'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.github/workflows/proxied-local-smoke.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
managed-local-smoke:
|
||||
name: Managed-local proxied lifecycle (Linux, offline)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
# Pinned so the lane tests a known dolt CLI, not whatever the installer
|
||||
# resolves on a given day. Bump deliberately.
|
||||
DOLT_VERSION: 2.2.2
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Install pinned Dolt CLI
|
||||
run: |
|
||||
curl -fsSL "https://github.com/dolthub/dolt/releases/download/v${DOLT_VERSION}/dolt-linux-amd64.tar.gz" -o /tmp/dolt.tar.gz
|
||||
tar -xzf /tmp/dolt.tar.gz -C /tmp
|
||||
sudo install /tmp/dolt-linux-amd64/bin/dolt /usr/local/bin/dolt
|
||||
dolt version
|
||||
|
||||
- name: Build bd and compile the test binary (online)
|
||||
run: |
|
||||
go build -tags gms_pure_go -o /tmp/bd-under-test ./cmd/bd
|
||||
go test -tags gms_pure_go -c -o /tmp/proxied-local-smoke.test ./cmd/bd
|
||||
|
||||
- name: Assert the managed-local smoke test is compiled in
|
||||
run: |
|
||||
listed="$(
|
||||
BEADS_TEST_PROXIED_LOCAL=1 \
|
||||
BEADS_TEST_SKIP=dolt \
|
||||
/tmp/proxied-local-smoke.test \
|
||||
-test.list '^TestManagedLocalProxiedLifecycleSmoke$'
|
||||
)"
|
||||
if [[ "$listed" != 'TestManagedLocalProxiedLifecycleSmoke' ]]; then
|
||||
echo "managed-local smoke test missing or ambiguous; got: '$listed'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run managed-local lifecycle smoke offline (loopback-only netns)
|
||||
run: |
|
||||
sudo env \
|
||||
"PATH=$PATH" \
|
||||
BEADS_TEST_PROXIED_LOCAL=1 \
|
||||
BEADS_TEST_SKIP=dolt \
|
||||
BEADS_TEST_BD_BINARY=/tmp/bd-under-test \
|
||||
unshare --net -- bash -euxc '
|
||||
ip link set lo up
|
||||
# Prove the namespace really has no outbound network before
|
||||
# trusting anything the test reports.
|
||||
if timeout 3 bash -c "exec 3<>/dev/tcp/1.1.1.1/443" 2>/dev/null; then
|
||||
echo "outbound network unexpectedly available inside the netns" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd cmd/bd
|
||||
/tmp/proxied-local-smoke.test \
|
||||
-test.run "^TestManagedLocalProxiedLifecycleSmoke$" \
|
||||
-test.v -test.timeout 15m
|
||||
'
|
||||
@@ -0,0 +1,240 @@
|
||||
//go:build cgo && unix
|
||||
|
||||
package main
|
||||
|
||||
// Helpers for the MANAGED-LOCAL proxied-server lane: bd launches its own
|
||||
// loopback proxy (a detached `bd db-proxy-child` process) and that proxy
|
||||
// launches a locally installed `dolt sql-server`. No external host/port and
|
||||
// no testcontainer are involved, which is the production local-first
|
||||
// topology that the external-harness shards (BEADS_TEST_PROXIED_SERVER)
|
||||
// deliberately do not cover.
|
||||
//
|
||||
// The lane is gated by its own environment variable so CI can run the two
|
||||
// proxied lanes independently. These helpers are written to be reusable by
|
||||
// lifecycle/failure-injection tests (stale artifacts, process identity,
|
||||
// port safety) beyond the initial smoke test.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"github.com/steveyegge/beads/internal/storage/dbproxy/pidfile"
|
||||
"github.com/steveyegge/beads/internal/storage/dbproxy/proxy"
|
||||
"github.com/steveyegge/beads/internal/storage/dbproxy/server"
|
||||
)
|
||||
|
||||
const managedLocalProxiedEnvVar = "BEADS_TEST_PROXIED_LOCAL"
|
||||
|
||||
// requireManagedLocalProxiedEnv gates the managed-local proxied lane.
|
||||
// Unlike requireProxiedServerEnv, a missing dolt binary FAILS the test when
|
||||
// the lane is explicitly requested: this lane exists to prove that bd can
|
||||
// launch and supervise a local Dolt child, so skipping on a broken
|
||||
// prerequisite would report a green run without testing anything.
|
||||
func requireManagedLocalProxiedEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv(managedLocalProxiedEnvVar) != "1" {
|
||||
t.Skipf("set %s=1 to run managed-local proxied lifecycle tests", managedLocalProxiedEnvVar)
|
||||
}
|
||||
if _, err := exec.LookPath("dolt"); err != nil {
|
||||
t.Fatalf("%s=1 but dolt is not in PATH; this lane must fail rather than skip: %v",
|
||||
managedLocalProxiedEnvVar, err)
|
||||
}
|
||||
}
|
||||
|
||||
// bdManagedLocalInit initializes a disposable managed-local proxied project:
|
||||
// `bd init --proxied-server` with NO external host/port flags, so command
|
||||
// dispatch launches the loopback proxy and the proxy launches dolt.
|
||||
// idleTimeout is passed through --proxied-server-idle-timeout so tests can
|
||||
// observe idle shutdown without waiting out the 30s production default.
|
||||
func bdManagedLocalInit(t *testing.T, bd, prefix string, idleTimeout time.Duration, extraInitArgs ...string) proxiedProject {
|
||||
t.Helper()
|
||||
args := append([]string{"--proxied-server-idle-timeout", idleTimeout.String()}, extraInitArgs...)
|
||||
p := bdProxiedInit(t, bd, prefix, args...)
|
||||
|
||||
info, err := configfile.LoadProxiedServerClientInfo(p.beadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProxiedServerClientInfo(%s): %v", p.beadsDir, err)
|
||||
}
|
||||
if info == nil {
|
||||
t.Fatalf("missing %s in %s after managed-local init", configfile.ProxiedServerClientInfoFileName, p.beadsDir)
|
||||
}
|
||||
if info.External != nil {
|
||||
t.Fatalf("expected managed-local topology (no External block), got %+v", info.External)
|
||||
}
|
||||
if info.IdleTimeout != idleTimeout {
|
||||
t.Fatalf("persisted managed-local idle timeout: got %s, want %s", info.IdleTimeout, idleTimeout)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// readManagedProxyPidFile returns the loopback proxy's pidfile (proxy.pid:
|
||||
// the detached `bd db-proxy-child` process and the port the proxy listens
|
||||
// on), or nil when it does not exist.
|
||||
func readManagedProxyPidFile(t *testing.T, p proxiedProject) *pidfile.PidFile {
|
||||
t.Helper()
|
||||
pf, err := pidfile.Read(p.proxyRoot, proxy.PIDFileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("read %s in %s: %v", proxy.PIDFileName, p.proxyRoot, err)
|
||||
}
|
||||
return pf
|
||||
}
|
||||
|
||||
// readManagedBackendPidFile returns the supervised Dolt backend's pidfile
|
||||
// (proxy-child.pid: the `dolt sql-server` process and its listener port),
|
||||
// or nil when it does not exist.
|
||||
func readManagedBackendPidFile(t *testing.T, p proxiedProject) *pidfile.PidFile {
|
||||
t.Helper()
|
||||
pf, err := pidfile.Read(p.proxyRoot, server.PIDFileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("read %s in %s: %v", server.PIDFileName, p.proxyRoot, err)
|
||||
}
|
||||
return pf
|
||||
}
|
||||
|
||||
// processAlive reports whether pid refers to a live process we can signal
|
||||
// (kill -0). A pid that exists but is owned by another user would report
|
||||
// alive=false here; in this lane every process is spawned by the test user.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
// managedProxiedArtifacts is a point-in-time snapshot of which lifecycle
|
||||
// artifacts exist under the proxy root. Lock files legitimately persist on
|
||||
// disk after a clean shutdown (the flock is what matters, not the file), so
|
||||
// assertions should usually key on the pid files.
|
||||
type managedProxiedArtifacts struct {
|
||||
ProxyLock bool
|
||||
ProxyPid bool
|
||||
BackendLock bool
|
||||
BackendPid bool
|
||||
ConfigYAML bool
|
||||
}
|
||||
|
||||
func snapshotManagedProxiedArtifacts(p proxiedProject) managedProxiedArtifacts {
|
||||
exists := func(name string) bool {
|
||||
_, err := os.Stat(filepath.Join(p.proxyRoot, name))
|
||||
return err == nil
|
||||
}
|
||||
return managedProxiedArtifacts{
|
||||
ProxyLock: exists(proxy.LockFileName),
|
||||
ProxyPid: exists(proxy.PIDFileName),
|
||||
BackendLock: exists(server.LockFileName),
|
||||
BackendPid: exists(server.PIDFileName),
|
||||
ConfigYAML: exists(proxiedServerConfigName),
|
||||
}
|
||||
}
|
||||
|
||||
// waitForManagedProxied polls cond until it reports done or timeout elapses,
|
||||
// failing the test with the condition's last reported state.
|
||||
func waitForManagedProxied(t *testing.T, timeout time.Duration, desc string, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
last := "(condition never evaluated)"
|
||||
for {
|
||||
done, state := cond()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
last = state
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out after %s waiting for %s; last state: %s", timeout, desc, last)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForManagedProxiedShutdown waits until both pid files are gone and the
|
||||
// previously observed proxy and backend processes are dead — the observable
|
||||
// contract of a clean idle shutdown.
|
||||
func waitForManagedProxiedShutdown(t *testing.T, p proxiedProject, proxyPID, backendPID int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitForManagedProxied(t, timeout, "idle shutdown of managed proxy and dolt backend", func() (bool, string) {
|
||||
arts := snapshotManagedProxiedArtifacts(p)
|
||||
proxyDead := !processAlive(proxyPID)
|
||||
backendDead := !processAlive(backendPID)
|
||||
if !arts.ProxyPid && !arts.BackendPid && proxyDead && backendDead {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf(
|
||||
"proxy.pid exists=%v proxy-child.pid exists=%v proxy(pid %d) alive=%v dolt(pid %d) alive=%v",
|
||||
arts.ProxyPid, arts.BackendPid, proxyPID, !proxyDead, backendPID, !backendDead)
|
||||
})
|
||||
}
|
||||
|
||||
// heldProxiedConn is a single checked-out SQL connection through the
|
||||
// managed proxy. While it is held, the proxy's idle watcher sees an active
|
||||
// connection and will not shut the topology down — the intended way to keep
|
||||
// the process tree stable while inspecting it. Release() must close the
|
||||
// entire pool, not just return the conn to it: a pooled-but-idle connection
|
||||
// keeps its TCP session to the proxy open, which still counts as active and
|
||||
// blocks idle shutdown indefinitely.
|
||||
type heldProxiedConn struct {
|
||||
Conn *sql.Conn
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Release drops every TCP connection this holder has to the proxy, allowing
|
||||
// the idle countdown to begin. Safe to call more than once.
|
||||
func (h *heldProxiedConn) Release() {
|
||||
if h.Conn != nil {
|
||||
_ = h.Conn.Close()
|
||||
h.Conn = nil
|
||||
}
|
||||
if h.db != nil {
|
||||
_ = h.db.Close()
|
||||
h.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
// openHeldManagedProxiedConn ensures the managed proxy is running (issuing a
|
||||
// cheap bd command to start or restart it when needed), then returns a held
|
||||
// connection through it. Callers must Release() it to let the topology idle
|
||||
// out; a t.Cleanup Release is registered as a backstop.
|
||||
func openHeldManagedProxiedConn(t *testing.T, bd string, p proxiedProject) *heldProxiedConn {
|
||||
t.Helper()
|
||||
|
||||
var held *heldProxiedConn
|
||||
waitForManagedProxied(t, 60*time.Second, "held connection through managed proxy", func() (bool, string) {
|
||||
pf := readManagedProxyPidFile(t, p)
|
||||
if pf == nil || !processAlive(pf.Pid) {
|
||||
// Proxy not up (yet, or idled out between commands): any data
|
||||
// command restarts it transparently.
|
||||
if out, err := bdProxiedRun(t, bd, p.dir, "list", "--json"); err != nil {
|
||||
return false, fmt.Sprintf("bd list to (re)start proxy failed: %v\n%s", err, out)
|
||||
}
|
||||
return false, "proxy pidfile absent; issued bd list to start it"
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/%s?parseTime=true", pf.Port, p.database)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("sql.Open %s: %v", dsn, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
c, err := db.Conn(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return false, fmt.Sprintf("checkout conn via %s: %v", dsn, err)
|
||||
}
|
||||
held = &heldProxiedConn{Conn: c, db: db}
|
||||
t.Cleanup(held.Release)
|
||||
return true, ""
|
||||
})
|
||||
return held
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//go:build cgo && linux
|
||||
|
||||
package main
|
||||
|
||||
// Managed-local proxied lifecycle smoke lane (Linux first).
|
||||
//
|
||||
// This is the first end-to-end test of the production local-first proxied
|
||||
// topology: bd spawns a detached loopback proxy, the proxy spawns a local
|
||||
// `dolt sql-server`, and no external host/port or testcontainer is
|
||||
// involved. It proves, on one disposable repository:
|
||||
//
|
||||
// 1. init/create/read work against the launched local process tree;
|
||||
// 2. the proxy and the default generated backend listeners are
|
||||
// loopback-only (both statically, via the generated config.yaml, and
|
||||
// dynamically, by enumerating the live processes' listening sockets);
|
||||
// 3. the topology shuts itself down after the configured idle window;
|
||||
// 4. a later command transparently restarts it and the data persisted.
|
||||
//
|
||||
// Run locally with:
|
||||
//
|
||||
// BEADS_TEST_PROXIED_LOCAL=1 go test -tags gms_pure_go ./cmd/bd \
|
||||
// -run TestManagedLocalProxiedLifecycleSmoke -v
|
||||
//
|
||||
// CI additionally runs it inside a network namespace with only loopback up
|
||||
// (see .github/workflows/proxied-local-smoke.yml), proving the whole
|
||||
// lifecycle needs no outbound network once bd and dolt are installed.
|
||||
// When BEADS_TEST_PROXIED_LOCAL=1 is set, missing prerequisites or a Dolt
|
||||
// child that fails to launch FAIL the test rather than skipping.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dolthub/dolt/go/libraries/doltcore/servercfg"
|
||||
)
|
||||
|
||||
func TestManagedLocalProxiedLifecycleSmoke(t *testing.T) {
|
||||
requireManagedLocalProxiedEnv(t)
|
||||
bd := buildEmbeddedBD(t)
|
||||
|
||||
// Short idle window so the shutdown phase is observable in test time;
|
||||
// the production default is 30s.
|
||||
const idleTimeout = 5 * time.Second
|
||||
p := bdManagedLocalInit(t, bd, "mlp", idleTimeout)
|
||||
|
||||
// Hold a connection through the proxy for the whole inspection phase:
|
||||
// with an active connection the idle watcher never arms, so the process
|
||||
// tree cannot shut down under us between assertions.
|
||||
held := openHeldManagedProxiedConn(t, bd, p)
|
||||
|
||||
issue := bdProxiedCreate(t, bd, p.dir, "managed local smoke issue")
|
||||
if issue.ID == "" {
|
||||
t.Fatal("bd create returned an empty issue ID")
|
||||
}
|
||||
shown := bdProxiedShow(t, bd, p.dir, issue.ID)
|
||||
if shown.Title != "managed local smoke issue" {
|
||||
t.Errorf("bd show title: got %q, want %q", shown.Title, "managed local smoke issue")
|
||||
}
|
||||
|
||||
// --- Live topology inspection -------------------------------------
|
||||
|
||||
proxyPF := readManagedProxyPidFile(t, p)
|
||||
if proxyPF == nil {
|
||||
t.Fatal("proxy.pid missing while a proxied connection is held open")
|
||||
}
|
||||
backendPF := readManagedBackendPidFile(t, p)
|
||||
if backendPF == nil {
|
||||
t.Fatal("proxy-child.pid missing while a proxied connection is held open")
|
||||
}
|
||||
if !processAlive(proxyPF.Pid) {
|
||||
t.Fatalf("proxy process %d is not alive", proxyPF.Pid)
|
||||
}
|
||||
if !processAlive(backendPF.Pid) {
|
||||
t.Fatalf("dolt backend process %d is not alive", backendPF.Pid)
|
||||
}
|
||||
|
||||
// Identity sanity: the pidfiles must point at the kinds of processes
|
||||
// they claim to (the full identity handshake is separate hardening
|
||||
// work; this catches gross mismatches).
|
||||
if cl := procCmdline(proxyPF.Pid); !strings.Contains(cl, "db-proxy-child") {
|
||||
t.Errorf("proxy pid %d cmdline %q does not look like a bd db-proxy-child", proxyPF.Pid, cl)
|
||||
}
|
||||
if cl := procCmdline(backendPF.Pid); !strings.Contains(cl, "sql-server") {
|
||||
t.Errorf("backend pid %d cmdline %q does not look like dolt sql-server", backendPF.Pid, cl)
|
||||
}
|
||||
|
||||
// Static loopback proof: the generated config.yaml is Beads-managed and
|
||||
// binds the backend listener to 127.0.0.1 on the backend pidfile's port.
|
||||
assertManagedConfigLoopback(t, p, backendPF.Port)
|
||||
|
||||
// Dynamic loopback proof: each live process owns a 127.0.0.1 listener
|
||||
// on the exact port its pidfile advertises, and neither process holds a
|
||||
// TCP listener on a non-loopback address.
|
||||
assertExpectedLoopbackListener(t, proxyPF.Pid, proxyPF.Port, "proxy (bd db-proxy-child)")
|
||||
assertExpectedLoopbackListener(t, backendPF.Pid, backendPF.Port, "dolt sql-server backend")
|
||||
|
||||
// The held connection must reach the same data bd wrote, through the
|
||||
// proxy listener the pidfile advertises.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var title string
|
||||
if err := held.Conn.QueryRowContext(ctx, "SELECT title FROM issues WHERE id = ?", issue.ID).Scan(&title); err != nil {
|
||||
t.Fatalf("SELECT through held proxy connection for %s: %v", issue.ID, err)
|
||||
}
|
||||
if title != "managed local smoke issue" {
|
||||
t.Errorf("title via proxy SQL: got %q, want %q", title, "managed local smoke issue")
|
||||
}
|
||||
|
||||
arts := snapshotManagedProxiedArtifacts(p)
|
||||
if !arts.ProxyLock || !arts.BackendLock || !arts.ConfigYAML {
|
||||
t.Errorf("expected proxy.lock, proxy-child.lock, and config.yaml under %s while running; got %+v",
|
||||
p.proxyRoot, arts)
|
||||
}
|
||||
|
||||
// --- Idle shutdown --------------------------------------------------
|
||||
|
||||
held.Release()
|
||||
waitForManagedProxiedShutdown(t, p, proxyPF.Pid, backendPF.Pid, 90*time.Second)
|
||||
|
||||
// --- Transparent restart and persistence ----------------------------
|
||||
|
||||
reread := bdProxiedShow(t, bd, p.dir, issue.ID)
|
||||
if reread.ID != issue.ID || reread.Title != "managed local smoke issue" {
|
||||
t.Fatalf("after restart: got id=%q title=%q, want id=%q title=%q",
|
||||
reread.ID, reread.Title, issue.ID, "managed local smoke issue")
|
||||
}
|
||||
|
||||
held2 := openHeldManagedProxiedConn(t, bd, p)
|
||||
defer held2.Release()
|
||||
|
||||
proxyPF2 := readManagedProxyPidFile(t, p)
|
||||
if proxyPF2 == nil {
|
||||
t.Fatal("proxy.pid missing after transparent restart")
|
||||
}
|
||||
if proxyPF2.Pid == proxyPF.Pid {
|
||||
t.Errorf("restarted proxy reused pid %d of the shut-down proxy; expected a new process", proxyPF.Pid)
|
||||
}
|
||||
if !processAlive(proxyPF2.Pid) {
|
||||
t.Fatalf("restarted proxy process %d is not alive", proxyPF2.Pid)
|
||||
}
|
||||
assertExpectedLoopbackListener(t, proxyPF2.Pid, proxyPF2.Port, "restarted proxy (bd db-proxy-child)")
|
||||
}
|
||||
|
||||
// assertManagedConfigLoopback verifies the generated backend config.yaml
|
||||
// carries the Beads-managed marker, binds its listener host to 127.0.0.1,
|
||||
// and agrees with the port the backend pidfile advertises.
|
||||
func assertManagedConfigLoopback(t *testing.T, p proxiedProject, backendPort int) {
|
||||
t.Helper()
|
||||
path := proxiedServerConfigPath(p.beadsDir)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated backend config %s: %v", path, err)
|
||||
}
|
||||
if !isManagedProxiedServerConfig(body) {
|
||||
t.Errorf("%s lacks the Beads-managed marker; the default lane must use a generated config", path)
|
||||
}
|
||||
cfg, err := servercfg.NewYamlConfig(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse generated backend config %s: %v", path, err)
|
||||
}
|
||||
if got := cfg.Host(); got != "127.0.0.1" {
|
||||
t.Errorf("generated backend listener host: got %q, want %q", got, "127.0.0.1")
|
||||
}
|
||||
if got := cfg.Port(); got != backendPort {
|
||||
t.Errorf("generated backend port %d disagrees with proxy-child.pid port %d", got, backendPort)
|
||||
}
|
||||
}
|
||||
|
||||
// assertExpectedLoopbackListener fails unless pid owns 127.0.0.1 on the exact
|
||||
// port its pidfile advertises, or if it owns any listening TCP socket bound to
|
||||
// a non-loopback address.
|
||||
func assertExpectedLoopbackListener(t *testing.T, pid, advertisedPort int, label string) {
|
||||
t.Helper()
|
||||
listeners, err := listeningTCPAddrs(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate listeners of %s (pid %d): %v", label, pid, err)
|
||||
}
|
||||
expected := netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), uint16(advertisedPort))
|
||||
if err := validateExpectedLoopbackListener(listeners, expected); err != nil {
|
||||
t.Errorf("%s (pid %d): %v", label, pid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateExpectedLoopbackListener(listeners []netip.AddrPort, expected netip.AddrPort) error {
|
||||
expected = unmapAddrPort(expected)
|
||||
foundExpected := false
|
||||
for _, ap := range listeners {
|
||||
normalized := unmapAddrPort(ap)
|
||||
if !normalized.Addr().IsLoopback() {
|
||||
return fmt.Errorf("listens on non-loopback address %s", ap)
|
||||
}
|
||||
if normalized == expected {
|
||||
foundExpected = true
|
||||
}
|
||||
}
|
||||
if !foundExpected {
|
||||
return fmt.Errorf("does not listen on advertised address %s; listeners: %v", expected, listeners)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmapAddrPort(ap netip.AddrPort) netip.AddrPort {
|
||||
return netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port())
|
||||
}
|
||||
|
||||
func TestValidateExpectedLoopbackListener(t *testing.T) {
|
||||
v4 := netip.MustParseAddr
|
||||
tests := []struct {
|
||||
name string
|
||||
listeners []netip.AddrPort
|
||||
expected netip.AddrPort
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "exact advertised listener",
|
||||
listeners: []netip.AddrPort{netip.AddrPortFrom(v4("127.0.0.1"), 3307)},
|
||||
expected: netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped listener matches",
|
||||
listeners: []netip.AddrPort{netip.AddrPortFrom(v4("::ffff:127.0.0.1"), 3307)},
|
||||
expected: netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
},
|
||||
{
|
||||
name: "additional loopback listener allowed",
|
||||
listeners: []netip.AddrPort{
|
||||
netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
netip.AddrPortFrom(v4("::1"), 3308),
|
||||
},
|
||||
expected: netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
},
|
||||
{
|
||||
name: "missing advertised port",
|
||||
listeners: []netip.AddrPort{netip.AddrPortFrom(v4("127.0.0.1"), 3308)},
|
||||
expected: netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
wantErr: "does not listen on advertised address 127.0.0.1:3307",
|
||||
},
|
||||
{
|
||||
name: "non-loopback listener rejected",
|
||||
listeners: []netip.AddrPort{
|
||||
netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
netip.AddrPortFrom(v4("0.0.0.0"), 3307),
|
||||
},
|
||||
expected: netip.AddrPortFrom(v4("127.0.0.1"), 3307),
|
||||
wantErr: "listens on non-loopback address 0.0.0.0:3307",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateExpectedLoopbackListener(tt.listeners, tt.expected)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validateExpectedLoopbackListener() error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validateExpectedLoopbackListener() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// listeningTCPAddrs returns every TCP address pid is LISTENing on, by
|
||||
// joining the socket inodes in /proc/<pid>/fd against /proc/net/tcp and
|
||||
// /proc/net/tcp6. Reusable for port-safety and stale-listener scenarios.
|
||||
func listeningTCPAddrs(pid int) ([]netip.AddrPort, error) {
|
||||
inodes, err := socketInodes(pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []netip.AddrPort
|
||||
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||||
addrs, err := listenersInTable(table, inodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addrs...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// socketInodes returns the set of socket inodes held open by pid.
|
||||
func socketInodes(pid int) (map[string]bool, error) {
|
||||
fdDir := fmt.Sprintf("/proc/%d/fd", pid)
|
||||
entries, err := os.ReadDir(fdDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", fdDir, err)
|
||||
}
|
||||
inodes := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
target, err := os.Readlink(fmt.Sprintf("%s/%s", fdDir, e.Name()))
|
||||
if err != nil {
|
||||
continue // fd closed while iterating
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(target, "socket:["); ok {
|
||||
inodes[strings.TrimSuffix(rest, "]")] = true
|
||||
}
|
||||
}
|
||||
return inodes, nil
|
||||
}
|
||||
|
||||
// listenersInTable parses a /proc/net/tcp{,6} table and returns the local
|
||||
// addresses of rows in LISTEN state whose inode is in inodes.
|
||||
func listenersInTable(path string, inodes map[string]bool) ([]netip.AddrPort, error) {
|
||||
const tcpListen = "0A"
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // e.g. tcp6 absent on ipv6-less kernels
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var out []netip.AddrPort
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines[1:] { // skip header
|
||||
fields := strings.Fields(line)
|
||||
// sl local_address rem_address st ... inode is field index 9
|
||||
if len(fields) < 10 || fields[3] != tcpListen || !inodes[fields[9]] {
|
||||
continue
|
||||
}
|
||||
ap, err := parseProcNetAddr(fields[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: parse local address %q: %w", path, fields[1], err)
|
||||
}
|
||||
out = append(out, ap)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseProcNetAddr decodes a /proc/net/tcp{,6} local_address column
|
||||
// ("HEXIP:HEXPORT", with the IP stored as little-endian 32-bit words).
|
||||
func parseProcNetAddr(s string) (netip.AddrPort, error) {
|
||||
ipHex, portHex, ok := strings.Cut(s, ":")
|
||||
if !ok {
|
||||
return netip.AddrPort{}, fmt.Errorf("no port separator in %q", s)
|
||||
}
|
||||
port, err := strconv.ParseUint(portHex, 16, 16)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("port %q: %w", portHex, err)
|
||||
}
|
||||
raw, err := hex.DecodeString(ipHex)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("ip %q: %w", ipHex, err)
|
||||
}
|
||||
switch len(raw) {
|
||||
case 4:
|
||||
return netip.AddrPortFrom(
|
||||
netip.AddrFrom4([4]byte{raw[3], raw[2], raw[1], raw[0]}), uint16(port)), nil
|
||||
case 16:
|
||||
var b [16]byte
|
||||
for word := 0; word < 4; word++ {
|
||||
for i := 0; i < 4; i++ {
|
||||
b[word*4+i] = raw[word*4+3-i]
|
||||
}
|
||||
}
|
||||
return netip.AddrPortFrom(netip.AddrFrom16(b), uint16(port)), nil
|
||||
default:
|
||||
return netip.AddrPort{}, fmt.Errorf("ip %q: unexpected length %d", ipHex, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// procCmdline returns the space-joined command line of pid via procfs, or
|
||||
// "" when unreadable. A cheap identity check that a pidfile points at the
|
||||
// kind of process it claims to.
|
||||
func procCmdline(pid int) string {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(string(data), "\x00", " "))
|
||||
}
|
||||
@@ -27,6 +27,9 @@ var testSharedConn *sql.DB
|
||||
// from creating testdb_* databases on the production Dolt server.
|
||||
// Returns a cleanup function that stops the server and removes the container.
|
||||
func startTestDoltServer() func() {
|
||||
if os.Getenv("BEADS_TEST_PROXIED_LOCAL") == "1" {
|
||||
return func() {}
|
||||
}
|
||||
if os.Getenv("BEADS_TEST_EMBEDDED_DOLT") == "1" {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user