From d53c22b64168af9921a739261db0b43b47286c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 30 Oct 2026 07:49:31 +0000 Subject: [PATCH] Add Klipper, OctoPrint and PrusaLink printers from the API POST /farms/:id/printers now takes any of the four vendors the gateway can drive. Credentials depend on the vendor: Bambu needs an access code and serial, OctoPrint and PrusaLink an API key, Klipper nothing. A missing one is a 422 naming what is needed. The API key is relayed to the gateway and kept out of logs and errors like the access code. The printer_vendor enum gains octoprint (migration 0006); PrusaLink is stored as prusa, the name the enum already had. scripts/e2e-vendors.sh adds each kind through the API against a fake REST printer (scripts/fake-http-printer.ts) and runs a job through the real scheduler to completion. The fake shares its author's reading of the docs with the driver, so this proves the plumbing, not the protocols. --- drizzle/0006_octoprint_vendor.sql | 1 + drizzle/meta/0006_snapshot.json | 849 ++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/e2e-vendors.sh | 137 +++++ scripts/fake-http-printer.ts | 103 ++++ src/db/schema/printers.ts | 2 +- src/provision/plan.ts | 33 +- src/routes/farms.ts | 31 +- tests/provision.test.ts | 20 +- 9 files changed, 1171 insertions(+), 12 deletions(-) create mode 100644 drizzle/0006_octoprint_vendor.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100755 scripts/e2e-vendors.sh create mode 100644 scripts/fake-http-printer.ts diff --git a/drizzle/0006_octoprint_vendor.sql b/drizzle/0006_octoprint_vendor.sql new file mode 100644 index 0000000..f0cf859 --- /dev/null +++ b/drizzle/0006_octoprint_vendor.sql @@ -0,0 +1 @@ +ALTER TYPE "printer_vendor" ADD VALUE 'octoprint'; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..cf0d58f --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,849 @@ +{ + "id": "97bbd615-092b-4859-9b89-de9c6295a4f9", + "prevId": "aef1c8fd-6a0a-48b5-9957-26b3630990f3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organisations_id_fk": { + "name": "org_members_org_id_organisations_id_fk", + "tableFrom": "org_members", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organisations": { + "name": "organisations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.farms": { + "name": "farms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "edge_proxy_token": { + "name": "edge_proxy_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "farms_org_id_idx": { + "name": "farms_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "farms_org_id_organisations_id_fk": { + "name": "farms_org_id_organisations_id_fk", + "tableFrom": "farms", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printers": { + "name": "printers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "printer_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "printer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "lan_address": { + "name": "lan_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_printer_id": { + "name": "gateway_printer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nozzle_temp_c": { + "name": "nozzle_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "nozzle_target_temp_c": { + "name": "nozzle_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_temp_c": { + "name": "bed_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_target_temp_c": { + "name": "bed_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress_percent": { + "name": "progress_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_layer": { + "name": "current_layer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_layers": { + "name": "total_layers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining_minutes": { + "name": "remaining_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_job_name": { + "name": "current_job_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_alerts": { + "name": "active_alerts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "camera_stream": { + "name": "camera_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "camera_snapshot": { + "name": "camera_snapshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "vendor_state": { + "name": "vendor_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printers_farm_gateway_printer_id_idx": { + "name": "printers_farm_gateway_printer_id_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_printer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printers_farm_id_farms_id_fk": { + "name": "printers_farm_id_farms_id_fk", + "tableFrom": "printers", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.print_jobs": { + "name": "print_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "print_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "progress_pct": { + "name": "progress_pct", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_time_s": { + "name": "estimated_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "elapsed_time_s": { + "name": "elapsed_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "print_jobs_farm_id_farms_id_fk": { + "name": "print_jobs_farm_id_farms_id_fk", + "tableFrom": "print_jobs", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "print_jobs_printer_id_printers_id_fk": { + "name": "print_jobs_printer_id_printers_id_fk", + "tableFrom": "print_jobs", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.frame_embeddings": { + "name": "frame_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "print_job_id": { + "name": "print_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(512)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "frame_embeddings_embedding_ivfflat_idx": { + "name": "frame_embeddings_embedding_ivfflat_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": { + "frame_embeddings_print_job_id_print_jobs_id_fk": { + "name": "frame_embeddings_print_job_id_print_jobs_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "print_jobs", + "columnsFrom": [ + "print_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frame_embeddings_printer_id_printers_id_fk": { + "name": "frame_embeddings_printer_id_printers_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printer_alerts": { + "name": "printer_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "alert_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printer_alerts_farm_open_idx": { + "name": "printer_alerts_farm_open_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "acknowledged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printer_alerts_farm_id_farms_id_fk": { + "name": "printer_alerts_farm_id_farms_id_fk", + "tableFrom": "printer_alerts", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "printer_alerts_printer_id_printers_id_fk": { + "name": "printer_alerts_printer_id_printers_id_fk", + "tableFrom": "printer_alerts", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "viewer", + "operator", + "owner" + ] + }, + "public.printer_status": { + "name": "printer_status", + "schema": "public", + "values": [ + "offline", + "idle", + "printing", + "paused", + "error", + "maintenance" + ] + }, + "public.printer_vendor": { + "name": "printer_vendor", + "schema": "public", + "values": [ + "bambu", + "prusa", + "klipper", + "octoprint" + ] + }, + "public.print_job_status": { + "name": "print_job_status", + "schema": "public", + "values": [ + "queued", + "uploading", + "printing", + "paused", + "completed", + "failed", + "cancelled" + ] + }, + "public.alert_kind": { + "name": "alert_kind", + "schema": "public", + "values": [ + "print_failed", + "print_paused", + "printer_error", + "ai_detection" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6a5dbdd..de5d88b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1793289367708, "tag": "0005_bouncy_cannonball", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1793346305671, + "tag": "0006_octoprint_vendor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/e2e-vendors.sh b/scripts/e2e-vendors.sh new file mode 100755 index 0000000..064c6e8 --- /dev/null +++ b/scripts/e2e-vendors.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# The non-Bambu vendors end to end, against FAKE printers: add each one through +# the API (so the provision frame, the real proxy's driver and its store are +# all exercised), then run a job through the real scheduler and watch it go +# queued -> uploading -> printing -> completed from the fake's own telemetry. +# +# What this proves is the plumbing around the drivers. It does NOT prove the +# drivers speak the real Klipper / OctoPrint / PrusaLink protocols: the fake +# (scripts/fake-http-printer.ts) is written from the same reading of the docs +# as the driver, so a shared misreading passes here and fails on hardware. +# +# Needs the dev stack (scripts/dev-env.sh up && schema) and the object store +# credentials in ../dev-secrets.sh (the job file goes through the real +# presigned URLs). No real printer is touched. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" +[ -f "$REPO_ROOT/../dev-secrets.sh" ] && . "$REPO_ROOT/../dev-secrets.sh" + +KINDS="${KINDS:-klipper octoprint prusa_link}" +PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}" +PORT="${PORT:-3062}"; AUTH_PORT="${AUTH_PORT:-3063}"; FAKE_PORT="${FAKE_PORT:-7141}" +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 R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT +WORK="$(mktemp -d)" +BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; FAKE_PID=""; ORGS="" +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo " ok: $*"; } +psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } +new_uuid() { cat /proc/sys/kernel/random/uuid; } +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } + +stop() { for pid in "$@"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done; sleep 0.5; for pid in "$@"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done; } +cleanup() { + local rc=$? + stop "$PROXY_PID" "$FAKE_PID" "$BACKEND_PID" "$AUTH_PID" + for org in $ORGS; do psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true; done + if [ $rc -ne 0 ]; then + echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true + echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true + echo "--- fake printer log (tail) ---"; tail -15 "$WORK/fake.log" 2>/dev/null || true + fi + rm -rf "$WORK"; exit $rc +} +trap cleanup EXIT + +[ -n "${R2_ENDPOINT:-}" ] || fail "no object store credentials - ../dev-secrets.sh" +( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build" + +bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & +AUTH_PID=$! +AUTH_URL="http://localhost:$AUTH_PORT" +for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done +export SUPABASE_URL="$AUTH_URL" +USER_ID="$(new_uuid)" +TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | json 'd["access_token"]')" +auth=(-H "authorization: Bearer $TOKEN") + +DISPATCH_ENABLED=true 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 +curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy" + +for KIND in $KINDS; do + echo "==> $KIND" + KEY="key-$(new_uuid | cut -c1-8)" + bun scripts/fake-http-printer.ts "$KIND" "$FAKE_PORT" "$KEY" > "$WORK/fake.log" 2>&1 & + FAKE_PID=$! + for _ in $(seq 1 40); do grep -q listening "$WORK/fake.log" && break; sleep 0.25; done + + ORG_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"e2e vendors $KIND\"}" "$BASE/orgs" | json 'd["id"]')" + ORGS="$ORGS $ORG_ID" + FARM_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"farm $KIND\",\"orgId\":\"$ORG_ID\"}" "$BASE/farms" | json 'd["id"]')" + GW_TOKEN="$(curl -sf "${auth[@]}" -X POST "$BASE/farms/$FARM_ID/edge-proxy-token" | json 'd["edgeProxyToken"]')" + + env -C "$PROXY_DIR" \ + CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true CONTINUUM_GATEWAY_ID=e2e-vendors-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$GW_TOKEN" \ + CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ + CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" CONTINUUM_STORE_PATH="$WORK/store-$KIND/continuum.db" RUST_LOG=info \ + "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & + PROXY_PID=$! + # This farm's own connect line: the log is shared across the loop. + for _ in $(seq 1 60); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done + grep -q "connected for farm $FARM_ID" "$WORK/backend.log" || fail "the proxy never connected" + + # Klipper needs no credential; the others take an API key. + CRED=""; [ "$KIND" != klipper ] && CRED=",\"apiKey\":\"$KEY\"" + CODE="$(curl -s -o "$WORK/add.json" -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \ + -d "{\"name\":\"Fake $KIND\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"$CRED}" "$BASE/farms/$FARM_ID/printers")" + [ "$CODE" = 201 ] || fail "adding the $KIND printer returned $CODE: $(head -c 300 "$WORK/add.json")" + PRN_UUID="$(json 'd["id"]' < "$WORK/add.json")" + ok "added through the API and connected by the proxy" + grep -q "$KEY" "$WORK/proxy.log" "$WORK/backend.log" && fail "the API key is in a log" + ok "the API key is in neither log" + + if [ "$KIND" != klipper ]; then + CODE="$(curl -s -o /dev/null -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \ + -d "{\"name\":\"No key\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"}" "$BASE/farms/$FARM_ID/printers")" + [ "$CODE" = 422 ] && ok "a missing API key is refused (422)" || fail "a keyless add returned $CODE" + fi + + for _ in $(seq 1 30); do [ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] && break; sleep 1; done + [ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] || fail "the cloud never saw the printer idle" + ok "the cloud sees it idle (telemetry from the driver's poller)" + + J="$(curl -sf "${auth[@]}" -H 'content-type: application/json' \ + -d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"e2e-part.gcode\",\"contentType\":\"application/octet-stream\"}" "$BASE/jobs")" + JOB_ID="$(printf '%s' "$J" | json 'd["job"]["id"]')" + OBJECT_KEY="$(printf '%s' "$J" | json 'd["job"]["r2ObjectKey"]')" + UPLOAD_URL="$(printf '%s' "$J" | json 'd["uploadUrl"]')" + printf 'G28\nG1 X10 Y10 F3000\nM104 S0\n' > "$WORK/part.gcode" + CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/part.gcode" "$UPLOAD_URL")" + [ "$CODE" = 200 ] || fail "job file upload returned $CODE" + + SEEN=""; START=$SECONDS; STATUS="" + while [ $((SECONDS - START)) -lt 90 ]; do + STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")" + case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac + [ "$STATUS" = completed ] || [ "$STATUS" = failed ] && break + sleep 1 + done + echo " job status history:$SEEN" + [ "$STATUS" = completed ] || fail "the $KIND job ended as '$STATUS'" + ok "$KIND: queued, scheduled, uploaded, printed and completed (fake printer)" + + bun -e " +import { s3 } from './src/s3'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] })); +" "$OBJECT_KEY" >/dev/null 2>&1 || true + stop "$PROXY_PID" "$FAKE_PID"; PROXY_PID=""; FAKE_PID="" +done +echo "PASS - $KINDS: added from the API, scheduled and completed against fake printers." diff --git a/scripts/fake-http-printer.ts b/scripts/fake-http-printer.ts new file mode 100644 index 0000000..05f1730 --- /dev/null +++ b/scripts/fake-http-printer.ts @@ -0,0 +1,103 @@ +// A stand-in for the REST printers (Klipper's Moonraker, OctoPrint, PrusaLink) +// so the real proxy can be driven end to end without owning one. It is only as +// good as my reading of those APIs - it proves the plumbing, not the hardware. +// +// bun scripts/fake-http-printer.ts [apiKey] +// +// A file uploaded with "print" set starts a fake print that runs for RUN_S +// seconds (default 8) and then finishes. Every request is logged to stdout. +const [kind, portArg, apiKey = "e2e-key"] = process.argv.slice(2); +if (!["klipper", "octoprint", "prusa_link"].includes(kind ?? "")) { + console.error("usage: fake-http-printer.ts [apiKey]"); + process.exit(2); +} +const RUN_S = Number(process.env.RUN_S ?? 8); + +let file: string | undefined; +let startedAt: number | undefined; +let cancelled = false; + +const progress = () => (startedAt === undefined ? 0 : cancelled ? 0 : Math.min(1, (Date.now() - startedAt) / (RUN_S * 1000))); +const phase = () => (startedAt === undefined ? "idle" : cancelled ? "cancelled" : progress() >= 1 ? "done" : "printing"); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +function startFrom(body: string, name?: string) { + file = name ?? /filename="([^"]+)"/.exec(body)?.[1] ?? "unknown"; + startedAt = Date.now(); + cancelled = false; +} + +Bun.serve({ + port: Number(portArg), + async fetch(req) { + const url = new URL(req.url); + const body = req.method === "GET" ? "" : new TextDecoder().decode(await req.arrayBuffer()); + console.log(`${req.method} ${url.pathname} ${body.length}b`); + const route = `${req.method} ${url.pathname}`; + + if (kind === "klipper") { + if (route === "GET /printer/info") return json({ result: { state: "ready" } }); + if (route === "GET /printer/objects/query") { + const p = progress(); + const state = { idle: "standby", printing: "printing", done: "complete", cancelled: "cancelled" }[phase()]; + return json({ + result: { + status: { + print_stats: { state, filename: file ?? "", print_duration: p * RUN_S, info: { current_layer: Math.ceil(p * 10), total_layer: 10 } }, + virtual_sdcard: { progress: p }, + extruder: { temperature: phase() === "printing" ? 210 : 24, target: phase() === "printing" ? 210 : 0 }, + heater_bed: { temperature: phase() === "printing" ? 60 : 23, target: phase() === "printing" ? 60 : 0 }, + }, + }, + }); + } + if (route === "POST /server/files/upload") { + if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400); + startFrom(body); + return json({ print_started: true }, 201); + } + if (route === "POST /printer/print/cancel") { cancelled = true; return json({ result: "ok" }); } + if (route === "POST /printer/gcode/script") return json({ result: "ok" }); + } else { + if (req.headers.get("x-api-key") !== apiKey) return json({ error: "bad key" }, 403); + const ph = phase(); + if (kind === "octoprint") { + if (route === "GET /api/job") { + return json({ + state: ph === "printing" ? "Printing" : "Operational", + job: { file: { name: file ?? null, display: file ?? null } }, + progress: { completion: ph === "idle" || ph === "cancelled" ? null : progress() * 100, printTimeLeft: Math.round((1 - progress()) * RUN_S) }, + }); + } + if (route === "GET /api/printer") { + return json({ temperature: { tool0: { actual: ph === "printing" ? 210 : 24, target: ph === "printing" ? 210 : 0 }, bed: { actual: 60, target: ph === "printing" ? 60 : 0 } } }); + } + if (route === "POST /api/files/local") { + if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400); + startFrom(body); + return json({ done: true }, 201); + } + if (route === "POST /api/job") { if (body.includes("cancel")) cancelled = true; return new Response(null, { status: 204 }); } + if (route === "POST /api/printer/command") return new Response(null, { status: 204 }); + } else { + const state = { idle: "IDLE", printing: "PRINTING", done: "FINISHED", cancelled: "STOPPED" }[ph]; + if (route === "GET /api/v1/status") { + return json({ + printer: { state, temp_nozzle: ph === "printing" ? 215 : 25, target_nozzle: ph === "printing" ? 215 : 0, temp_bed: 60, target_bed: 0 }, + ...(ph === "printing" ? { job: { id: 1, progress: progress() * 100, time_remaining: Math.round((1 - progress()) * RUN_S) } } : {}), + }); + } + if (route === "GET /api/v1/job") return json({ id: 1, file: { display_name: file } }); + if (req.method === "PUT" && url.pathname.startsWith("/api/v1/files/local/")) { + if (req.headers.get("print-after-upload") !== "?1") return json({ error: "print flag missing" }, 400); + startFrom(body, decodeURIComponent(url.pathname.split("/").pop() ?? "")); + return new Response(null, { status: 201 }); + } + if (route === "DELETE /api/v1/job/1") { cancelled = true; return new Response(null, { status: 204 }); } + } + } + return json({ error: `no route ${route}` }, 404); + }, +}); +console.log(`fake ${kind} listening on :${portArg}`); diff --git a/src/db/schema/printers.ts b/src/db/schema/printers.ts index 3e6bb3c..fc423ad 100644 --- a/src/db/schema/printers.ts +++ b/src/db/schema/printers.ts @@ -1,7 +1,7 @@ import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, boolean, uniqueIndex } from "drizzle-orm/pg-core"; import { farms } from "./farms"; -export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]); +export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper", "octoprint"]); export const printerStatusEnum = pgEnum("printer_status", [ "offline", "idle", diff --git a/src/provision/plan.ts b/src/provision/plan.ts index 287b9c1..e8eb367 100644 --- a/src/provision/plan.ts +++ b/src/provision/plan.ts @@ -3,8 +3,37 @@ * they test without a database - same reasoning as `dispatch/job_status.ts`. */ -/** Vendors a gateway can actually drive today. Everything else is found by discovery but not yet controllable. */ -export const PROVISIONABLE_VENDORS = ["bambu"] as const; +/** + * Vendors a gateway can drive, by the name the gateway knows them as, and + * what each needs to be told. `prusa_link` is stored as `prusa` and + * `octoprint` as itself - the database enum predates the gateway's names. + * + * Bambu is verified against real printers. The other three are written from + * their public APIs and tested against mock servers only. + */ +export const PROVISIONABLE_VENDORS = ["bambu", "klipper", "octoprint", "prusa_link"] as const; +export type ProvisionableVendor = (typeof PROVISIONABLE_VENDORS)[number]; + +const NEEDS: Record = { + bambu: { accessCode: true, serialNumber: true, dbVendor: "bambu" }, + klipper: { dbVendor: "klipper" }, + octoprint: { apiKey: true, dbVendor: "octoprint" }, + prusa_link: { apiKey: true, dbVendor: "prusa" }, +}; + +export const dbVendorFor = (vendor: ProvisionableVendor) => NEEDS[vendor].dbVendor; + +/** The first credential this vendor needs that was not supplied, named for the operator. */ +export function missingCredential( + vendor: ProvisionableVendor, + given: { accessCode?: string; serialNumber?: string; apiKey?: string }, +): string | undefined { + const needs = NEEDS[vendor]; + if (needs.accessCode && !given.accessCode?.trim()) return "an access code"; + if (needs.serialNumber && !given.serialNumber?.trim()) return "a serial number"; + if (needs.apiKey && !given.apiKey?.trim()) return "an API key"; + return undefined; +} /** * The id the gateway will know this printer by, chosen here so telemetry diff --git a/src/routes/farms.ts b/src/routes/farms.ts index 33ed515..e90eb1f 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -4,7 +4,14 @@ import { ConflictError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms, printers } from "../db/schema"; import { farmProjection, printerProjection } from "../db/projections"; -import { PROVISIONABLE_VENDORS, gatewayIdFor, safeGatewayError } from "../provision/plan"; +import { + PROVISIONABLE_VENDORS, + dbVendorFor, + gatewayIdFor, + missingCredential, + safeGatewayError, + type ProvisionableVendor, +} from "../provision/plan"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge"; @@ -198,7 +205,7 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) * Adds a printer to a farm: tells the farm's gateway to connect to it, and * creates the printer row once it has. * - * **The access code passes through this process and is kept nowhere.** It + * **The access code (or API key) passes through this process and is kept nowhere.** It * is read from the body, placed in one frame to the gateway, and dropped: * not stored, not logged, not echoed in a response or an error. The * gateway keeps it in its own database. That is the platform's rule for @@ -228,14 +235,20 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) }, }; } + const vendor = body.vendor as ProvisionableVendor; + const missing = missingCredential(vendor, body); + if (missing) { + set.status = 422; + return { error: { code: "CREDENTIAL_REQUIRED", message: `a ${vendor} printer needs ${missing}` } }; + } if (!isGatewayOnline(params.id)) { throw new ConflictError( - "this farm's gateway is not connected, so it cannot be given the printer's access code - start it and try again", + "this farm's gateway is not connected, so it cannot be given the printer's credentials - start it and try again", ); } const gatewayPrinterId = gatewayIdFor(body.name); - const secrets = [body.accessCode]; + const secrets = [body.accessCode ?? "", body.apiKey ?? ""]; const answer = await requestGateway(params.id, (requestId) => ({ type: "provision_printer", request_id: requestId, @@ -246,6 +259,7 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) host: body.host, access_code: body.accessCode, sn: body.serialNumber, + api_key: body.apiKey, }, })).catch((err: unknown) => ({ ok: false, @@ -268,8 +282,7 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) .values({ farmId: params.id, name: body.name, - // Narrowed by the PROVISIONABLE_VENDORS check above. - vendor: body.vendor as (typeof PROVISIONABLE_VENDORS)[number], + vendor: dbVendorFor(vendor), lanAddress: body.host, serialNumber: body.serialNumber, gatewayPrinterId, @@ -295,9 +308,11 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) vendor: t.String({ minLength: 1, maxLength: 32 }), // An address or hostname: letters, digits, dots, dashes, colons (IPv6). host: t.String({ minLength: 1, maxLength: 255, pattern: "^[A-Za-z0-9.:_-]+$" }), - accessCode: t.String({ minLength: 1, maxLength: 128 }), + // Which of these a vendor needs is checked above (`missingCredential`). + accessCode: t.Optional(t.String({ minLength: 1, maxLength: 128 })), // Bambu's MQTT topics are addressed by serial number. - serialNumber: t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" }), + serialNumber: t.Optional(t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" })), + apiKey: t.Optional(t.String({ minLength: 1, maxLength: 256 })), }), detail: { tags: ["farms"], diff --git a/tests/provision.test.ts b/tests/provision.test.ts index e77d638..8c30b6a 100644 --- a/tests/provision.test.ts +++ b/tests/provision.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { gatewayIdFor, safeGatewayError } from "../src/provision/plan"; +import { dbVendorFor, gatewayIdFor, missingCredential, safeGatewayError } from "../src/provision/plan"; describe("gatewayIdFor", () => { const accepted = /^[A-Za-z0-9_-]{1,64}$/; @@ -38,3 +38,21 @@ describe("safeGatewayError", () => { expect(safeGatewayError(undefined, [])).toBe("the gateway did not say why"); }); }); + +describe("missingCredential", () => { + test("a Bambu needs its access code and serial number", () => { + expect(missingCredential("bambu", {})).toBe("an access code"); + expect(missingCredential("bambu", { accessCode: "12345678" })).toBe("a serial number"); + expect(missingCredential("bambu", { accessCode: "12345678", serialNumber: "01P00A123" })).toBeUndefined(); + }); + test("OctoPrint and PrusaLink need an API key; Klipper needs nothing", () => { + expect(missingCredential("octoprint", { accessCode: "x" })).toBe("an API key"); + expect(missingCredential("prusa_link", { apiKey: " " })).toBe("an API key"); + expect(missingCredential("prusa_link", { apiKey: "k" })).toBeUndefined(); + expect(missingCredential("klipper", {})).toBeUndefined(); + }); + test("the gateway's PrusaLink is stored under the database's older name", () => { + expect(dbVendorFor("prusa_link")).toBe("prusa"); + expect(dbVendorFor("octoprint")).toBe("octoprint"); + }); +});