Add a runnable dev environment, and fix the pins that stopped it booting
The edge route had never been run. Adding a way to run it turned up that the server could not start at all, for two independent dependency reasons that typechecking could not see: - `elysia: ^1.1.26` had resolved to 1.4.30, which calls `t.Module` - a TypeBox 0.34 API - while this repo pinned TypeBox 0.33. `t.Module is not a function`. - `drizzle-typebox: ^0.2.1` resolved to a version whose peers demand `drizzle-orm >= 0.36`, against the `^0.33.0` pinned here. Importing it threw `Export named 'getViewSelectedFields' not found`. Both are caret ranges that drifted into combinations nobody chose. elysia, @sinclair/typebox and the drizzle packages are now pinned tightly, TypeBox forward to 0.34.52 (which satisfies Elysia and drizzle-typebox 0.1.1 alike), and tests/dependency_identity.test.ts fails if the TypeBox version ever differs between here and @continuum/ts-types - the skew that made every shared schema invalid, with an error message pointing at Standard Schema instead. Dropping to drizzle-typebox 0.1.1 widens `jsonb` in the generated validators, so `metadata` needs an explicit object shape in validators.ts; the column is declared `$type<Record<string, unknown>>()` and a validated body otherwise is not assignable to `.values()`. scripts/: - `install-docker.sh` - one-time host setup, kept separate because it touches the machine rather than containers. - `dev-env.sh up|down|reset|status|psql|schema|env` - Valkey + Postgres/pgvector. Uses docker-compose.yml when a daemon answers, and otherwise runs the same two services from apt. That fallback is not hypothetical: Docker cannot run in this sandbox at all. dockerd needs CAP_NET_ADMIN to build its bridge; with `--bridge=none --iptables=false` it starts but cannot unpack an image without CAP_SYS_ADMIN; with `--storage-driver=vfs` it still fails because registering a layer needs user namespaces, which are blocked outright. All three are host-level grants, so no script can work around them - hence a second path rather than a broken first one. - `e2e-edge.sh` - the test the unit tests cannot be. Seeds a farm, claims its printers, mints an edge token, runs the real continuum-proxy, and asserts live telemetry reached Postgres and a command reached a printer. It only sends a fan speed, only to a printer reporting IDLE, and puts it back afterwards. - `capture-gateway-frames.js` - records what a gateway really sends, for refreshing tests/fixtures/. Read-only unless FAN_TEST_PRINTER is set. The test comment referred to this before it existed. Verified end to end, from a dropped and freshly migrated database: `h2c` persisted as printing/RUNNING 22% layer 2/25 nozzle 230 bed 35 with 3 HMS alerts, `p1p` as idle/IDLE, and `POST /printers/:id/commands` reached the P1P's fan and came back ok:true. An unclaimed printer is refused 409 PRINTER_UNREACHABLE rather than guessed at. Two things the e2e script got wrong first, both now fixed in it: it waited for the first printer row instead of all of them, which silently tested only the v2 printer (a v1 Bambu reports one tick later); and `$!` after `( cd ... ) &` is the subshell, so cleanup left an orphaned gateway reconnecting with a token for a deleted farm and polluting the next run. Note: `drizzle/` is gitignored, so the generated migration is not committed and there is no migration history in the repo. Left as-is deliberately - that is a workflow call, flagged in PLAN.md.
This commit is contained in:
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end test of the /ws/edge/v1 contract against a real database and real
|
||||
# printers: seed a farm, claim its printers, mint an edge token, run the actual
|
||||
# continuum-proxy daemon, and assert that live telemetry reached Postgres and
|
||||
# that a command sent over HTTP reached a printer.
|
||||
#
|
||||
# This is the test that the unit tests cannot be: tests/edge_protocol.test.ts
|
||||
# only proves the two ends agree about the wire format. Nothing but this proves
|
||||
# the printer-id translation, persistTelemetry and sendPrinterCommand actually
|
||||
# work against a database.
|
||||
#
|
||||
# SAFETY: the only command it sends is a fan speed, and only to the printer
|
||||
# named by PRINTER_ID (default p1p), which it refuses to send unless that
|
||||
# printer reports IDLE. It never starts, pauses or cancels a print. Point it at
|
||||
# a printer you can see.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||
# scripts/e2e-edge.sh
|
||||
#
|
||||
# PRINTER_ID=p1p which printer to send the fan command to
|
||||
# PROXY_DIR=../continuum-proxy
|
||||
# KEEP_DATA=1 leave the seeded farm behind for inspection
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
PRINTER_ID="${PRINTER_ID:-p1p}"
|
||||
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||
PORT="${PORT:-3010}"
|
||||
BASE="http://localhost:$PORT"
|
||||
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
BACKEND_PID=""
|
||||
PROXY_PID=""
|
||||
|
||||
psql_q() { PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -tAq -c "$1"; }
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
for pid in "$PROXY_PID" "$BACKEND_PID"; do
|
||||
[ -z "$pid" ] && continue
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
# A gateway that outlives this script keeps reconnecting with a token for a
|
||||
# farm that is about to be deleted, and pollutes the next run's log.
|
||||
for _ in $(seq 1 10); do
|
||||
kill -0 "${PROXY_PID:-0}" 2>/dev/null || break
|
||||
sleep 0.3
|
||||
done
|
||||
kill -9 "${PROXY_PID:-0}" 2>/dev/null || true
|
||||
kill -9 "${BACKEND_PID:-0}" 2>/dev/null || true
|
||||
if [ -z "${KEEP_DATA:-}" ] && [ -n "${FARM_ID:-}" ]; then
|
||||
# Printers cascade from farms.
|
||||
psql_q "DELETE FROM farms WHERE id = '$FARM_ID'" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo
|
||||
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
|
||||
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
ok() { echo " ok: $*"; }
|
||||
|
||||
# --- 0. preconditions ------------------------------------------------------
|
||||
|
||||
echo "==> checking the database is up and migrated"
|
||||
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||
psql_q "SELECT gateway_printer_id FROM printers LIMIT 0" >/dev/null 2>&1 \
|
||||
|| fail "printers.gateway_printer_id is missing - run scripts/dev-env.sh schema"
|
||||
ok "schema has the edge columns"
|
||||
|
||||
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml - the proxy needs real printers"
|
||||
|
||||
# --- 1. seed ---------------------------------------------------------------
|
||||
|
||||
echo "==> seeding a farm and claiming its printers"
|
||||
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
|
||||
FARM_ID="$(psql_q "INSERT INTO farms (name, owner_id, edge_proxy_token)
|
||||
VALUES ('e2e farm', gen_random_uuid(), '$EDGE_PROXY_TOKEN') RETURNING id")"
|
||||
[ -n "$FARM_ID" ] || fail "could not create a farm"
|
||||
ok "farm $FARM_ID"
|
||||
|
||||
# gateway_printer_id is the whole point: it maps the gateway's own ids from
|
||||
# printers.toml onto these rows. Telemetry for an id nothing claims is skipped.
|
||||
while read -r pid pname; do
|
||||
[ -z "$pid" ] && continue
|
||||
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id)
|
||||
VALUES ('$FARM_ID', '$pname', 'bambu', '$pid')" >/dev/null
|
||||
ok "claimed '$pid' as '$pname'"
|
||||
done < <(awk -F'"' '/^id *=/ {id=$2} /^name *=/ {print id, $2}' "$PROXY_DIR/printers.toml")
|
||||
|
||||
# --- 2. backend ------------------------------------------------------------
|
||||
|
||||
echo "==> starting continuum-backend on :$PORT"
|
||||
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
HEALTH="$(curl -sf "$BASE/health")" || fail "backend did not become healthy"
|
||||
echo "$HEALTH" | grep -q '"db":true' || fail "backend reports the database is down: $HEALTH"
|
||||
echo "$HEALTH" | grep -q '"valkey":true' || fail "backend reports valkey is down: $HEALTH"
|
||||
ok "health: $HEALTH"
|
||||
|
||||
# --- 3. edge token ---------------------------------------------------------
|
||||
|
||||
echo "==> minting an edge token"
|
||||
TOKEN="$(curl -sf -X POST "$BASE/auth/edge-token" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$EDGE_PROXY_TOKEN\"}" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))')"
|
||||
[ -n "$TOKEN" ] || fail "no token returned from POST /auth/edge-token"
|
||||
ok "token minted (${#TOKEN} chars)"
|
||||
|
||||
# --- 4. the real gateway ---------------------------------------------------
|
||||
|
||||
echo "==> starting the real continuum-proxy against it"
|
||||
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
||||
# env -C rather than a `( cd ... ) &` subshell: $! has to be the daemon itself,
|
||||
# or cleanup kills only the wrapper and leaves an orphan reconnecting with a
|
||||
# stale token. An earlier version did exactly that, and the orphan then
|
||||
# reported a deleted farm's printers into the next run.
|
||||
env -C "$PROXY_DIR" \
|
||||
CONTINUUM_GATEWAY_ID=e2e-gw \
|
||||
CONTINUUM_FARM_ID="$FARM_ID" \
|
||||
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
|
||||
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
|
||||
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml \
|
||||
CONTINUUM_PRINTERS_PATH=printers.toml \
|
||||
RUST_LOG=info \
|
||||
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
|
||||
# Wait for *every* claimed printer, not just the first. Telemetry is on a 5s
|
||||
# timer and a Bambu on protocol v1 reports one tick behind the rest (it only
|
||||
# sends a full status when asked, so each tick reads the previous tick's
|
||||
# answer) - stopping at the first row that appears is how an earlier version of
|
||||
# this script silently tested only the v2 printer.
|
||||
CLAIMED="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL")"
|
||||
echo "==> waiting for live telemetry from all $CLAIMED claimed printer(s)"
|
||||
for _ in $(seq 1 45); do
|
||||
COUNT="$(psql_q "SELECT count(*) FROM printers
|
||||
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
|
||||
AND last_seen_at IS NOT NULL AND vendor_state IS NOT NULL")"
|
||||
[ "${COUNT:-0}" -ge "$CLAIMED" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
[ "${COUNT:-0}" -ge 1 ] || fail "no telemetry was persisted at all (see the proxy log below)"
|
||||
|
||||
if [ "${COUNT:-0}" -lt "$CLAIMED" ]; then
|
||||
# Not fatal: a printer that is genuinely switched off cannot report, and that
|
||||
# should not fail a run against the ones that can. Name them so the
|
||||
# difference between "off" and "broken" is visible.
|
||||
echo " WARNING: only $COUNT of $CLAIMED printers reported. Silent:"
|
||||
psql_q "SELECT ' - ' || gateway_printer_id FROM printers
|
||||
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
|
||||
AND (last_seen_at IS NULL OR vendor_state IS NULL)"
|
||||
else
|
||||
ok "all $COUNT claimed printer(s) reported live telemetry"
|
||||
fi
|
||||
|
||||
if grep "reported unknown printer" "$WORK/backend.log" | grep -q "$FARM_ID"; then
|
||||
fail "the backend could not map a gateway printer id for this farm - check gateway_printer_id"
|
||||
fi
|
||||
ok "every printer id this gateway reported mapped to a row"
|
||||
|
||||
echo "==> what landed in the database:"
|
||||
PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -c \
|
||||
"SELECT gateway_printer_id AS gw, status, vendor_state, progress_percent AS pct,
|
||||
current_layer || '/' || total_layers AS layers, remaining_minutes AS mins,
|
||||
nozzle_temp_c AS nozzle, bed_temp_c AS bed, active_alerts AS alerts,
|
||||
coalesce(current_job_name, '-') AS job
|
||||
FROM printers WHERE farm_id = '$FARM_ID' ORDER BY gateway_printer_id"
|
||||
|
||||
# Proves the telemetry is real rather than a row touched with nulls.
|
||||
psql_q "SELECT 1 FROM printers WHERE farm_id='$FARM_ID' AND vendor_state IS NOT NULL
|
||||
AND nozzle_temp_c IS NOT NULL LIMIT 1" | grep -q 1 \
|
||||
|| fail "rows were touched but carry no actual telemetry"
|
||||
ok "rows carry real vendor state and temperatures"
|
||||
|
||||
# --- 5. a command, the whole way down --------------------------------------
|
||||
|
||||
TARGET_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id='$PRINTER_ID'")"
|
||||
[ -n "$TARGET_UUID" ] || fail "no printer claimed as '$PRINTER_ID'"
|
||||
|
||||
GW="$(curl -sf "$BASE/printers/$TARGET_UUID/gateway")"
|
||||
echo "$GW" | grep -q '"gatewayOnline":true' || fail "the gateway is not registered as online: $GW"
|
||||
ok "gateway reports online for $PRINTER_ID"
|
||||
|
||||
COMMAND_TESTED=no
|
||||
STATE="$(psql_q "SELECT vendor_state FROM printers WHERE id='$TARGET_UUID'")"
|
||||
if [ "$STATE" != "IDLE" ] && [ "$STATE" != "FINISH" ]; then
|
||||
echo " SKIPPED the command test: $PRINTER_ID reports '${STATE:-no telemetry yet}', not IDLE/FINISH."
|
||||
echo " Refusing to touch a printer that may be mid-print."
|
||||
else
|
||||
echo "==> sending set_fan_speed to $PRINTER_ID ($STATE) over HTTP"
|
||||
RESP="$(curl -s -o "$WORK/cmd.json" -w '%{http_code}' -X POST \
|
||||
"$BASE/printers/$TARGET_UUID/commands" -H 'content-type: application/json' \
|
||||
-d '{"action":"set_fan_speed","percent":40}')"
|
||||
[ "$RESP" = "200" ] || fail "command returned HTTP $RESP: $(cat "$WORK/cmd.json")"
|
||||
grep -q '"ok":true' "$WORK/cmd.json" || fail "command was not acknowledged: $(cat "$WORK/cmd.json")"
|
||||
ok "command acknowledged by the printer: $(cat "$WORK/cmd.json")"
|
||||
|
||||
# Put it back, so this leaves the printer as it found it.
|
||||
curl -s -X POST "$BASE/printers/$TARGET_UUID/commands" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"action":"set_fan_speed","percent":0}' > /dev/null
|
||||
ok "fan returned to 0"
|
||||
COMMAND_TESTED=yes
|
||||
fi
|
||||
|
||||
# --- 6. the negative case --------------------------------------------------
|
||||
|
||||
echo "==> an unclaimed printer must be refused, not guessed at"
|
||||
ORPHAN="$(psql_q "INSERT INTO printers (farm_id, name, vendor)
|
||||
VALUES ('$FARM_ID', 'unclaimed', 'bambu') RETURNING id")"
|
||||
CODE="$(curl -s -o "$WORK/orphan.json" -w '%{http_code}' -X POST \
|
||||
"$BASE/printers/$ORPHAN/commands" -H 'content-type: application/json' \
|
||||
-d '{"action":"set_fan_speed","percent":0}')"
|
||||
[ "$CODE" = "409" ] || fail "expected HTTP 409 for an unclaimed printer, got $CODE"
|
||||
grep -q 'PRINTER_UNREACHABLE' "$WORK/orphan.json" || fail "wrong error: $(cat "$WORK/orphan.json")"
|
||||
ok "unclaimed printer refused with 409 PRINTER_UNREACHABLE"
|
||||
|
||||
echo
|
||||
# Say what was actually exercised. Claiming commands work when the command test
|
||||
# was skipped is worse than not running it.
|
||||
if [ "$COMMAND_TESTED" = yes ]; then
|
||||
echo "PASS - telemetry and commands both verified against a real database and real printers."
|
||||
else
|
||||
echo "PARTIAL PASS - telemetry verified against a real database and real printers."
|
||||
echo " The command path was NOT exercised: no printer was idle enough to send to."
|
||||
echo " Rerun with PRINTER_ID=<an idle printer> once one is free."
|
||||
fi
|
||||
Reference in New Issue
Block a user