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:
matt wilkie
2026-07-23 23:28:22 -07:00
committed by GitHub
parent 9ffe6a7fc7
commit bb9bb74871
4 changed files with 743 additions and 0 deletions
+121
View File
@@ -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
'
+240
View File
@@ -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", " "))
}
+3
View File
@@ -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() {}
}