diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..03a6f86 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: help docker-prod testing-up testing-down + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +docker-prod: ## Build production Docker image (dataplane:latest) from docker/Dockerfile + docker build -t dataplane:latest -f docker/Dockerfile . + +testing-up: ## Launch testing stack: nginx, control plane, redis, postgres, pgbouncer, dataplane, fast_time_server + @docker image inspect dataplane:latest >/dev/null 2>&1 || { \ + echo "Image dataplane:latest not found. Run 'make docker-prod' first."; \ + exit 1; \ + } + docker compose -f docker/docker-compose.yml up -d nginx control-plane redis postgres pgbouncer data-plane fast_time_server register_fast_time + +testing-down: ## Tear down the testing stack + docker compose -f docker/docker-compose.yml stop nginx control-plane redis postgres pgbouncer data-plane fast_time_server register_fast_time diff --git a/docker/Dockerfile b/docker/Dockerfile index 94c1e36..db21aa6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,20 +1,18 @@ FROM rust:1.96.1 AS builder -WORKDIR /tmp/ +WORKDIR /app RUN </dev/null) + + python3 -c " + import json + import os + import time + import urllib.error + import urllib.request + + token = os.environ.get('MCPGATEWAY_BEARER_TOKEN', '') + + def api_request(method, path, data=None): + url = f'http://control-plane:4444{path}' + req = urllib.request.Request(url, method=method) + req.add_header('Authorization', f'Bearer {token}') + req.add_header('Content-Type', 'application/json') + if data: + req.data = json.dumps(data).encode('utf-8') + with urllib.request.urlopen(req, timeout=60) as response: + return json.loads(response.read().decode('utf-8')) + + def api_request_with_retry(method, path, data=None, retries=30, delay=2, retry_statuses=(401, 403, 502, 503)): + for attempt in range(1, retries + 1): + try: + return api_request(method, path, data) + except urllib.error.HTTPError as exc: + if exc.code in retry_statuses and attempt < retries: + print(f'Retrying {method} {path} after HTTP {exc.code} ({attempt}/{retries})') + time.sleep(delay) + continue + raise + except Exception: + if attempt < retries: + print(f'Retrying {method} {path} after transient error ({attempt}/{retries})') + time.sleep(delay) + continue + raise + + print('Waiting for authenticated control-plane readiness...') + for i in range(1, 61): + try: + gateways = api_request('GET', '/gateways') + print(f'Authenticated control-plane readiness confirmed ({len(gateways)} gateways visible)') + break + except urllib.error.HTTPError as exc: + print(f'Authenticated readiness not ready yet ({i}/60): HTTP {exc.code}') + except Exception as exc: + print(f'Authenticated readiness not ready yet ({i}/60): {exc}') + time.sleep(2) + else: + print('Control-plane authenticated readiness check failed') + exit(1) + + try: + gateways = api_request_with_retry('GET', '/gateways') + for gw in gateways: + if gw.get('name') == 'fast_time': + print(f'Deleting existing gateway {gw[\"id\"]}...') + api_request_with_retry('DELETE', f'/gateways/{gw[\"id\"]}') + except Exception as exc: + print(f'Note: {exc}') + + VIRTUAL_SERVER_ID = 'b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8' + + try: + api_request_with_retry('DELETE', f'/servers/{VIRTUAL_SERVER_ID}') + print(f'Deleted existing virtual server {VIRTUAL_SERVER_ID}') + except Exception as exc: + print(f'Note: No existing virtual server to delete (or error: {exc})') + + try: + result = api_request_with_retry('POST', '/gateways', { + 'name': 'fast_time', + 'url': 'http://fast_time_server:8880/mcp', + 'transport': 'STREAMABLEHTTP' + }) + gateway_id = result.get('id', '') + print(f'Registered fast_time_server (gateway_id: {gateway_id})') + except Exception as exc: + print(f'Registration failed: {exc}') + exit(1) + + print('Waiting for tools to sync...') + for i in range(30): + time.sleep(1) + try: + tools = api_request_with_retry('GET', '/tools') + fast_time_tools = [t for t in tools if t.get('gatewayId') == gateway_id] + if fast_time_tools: + print(f'Found {len(fast_time_tools)} tools from fast_time gateway') + break + except Exception: + pass + print(f'Waiting for sync... ({i + 1}/30)') + else: + print('Warning: No tools synced, continuing anyway...') + + tool_ids = [] + try: + tools = api_request_with_retry('GET', '/tools') + tool_ids = [t['id'] for t in tools if t.get('gatewayId') == gateway_id] + print(f'Tools: {[t[\"name\"] for t in tools if t.get(\"gatewayId\") == gateway_id]}') + except Exception as exc: + print(f'Failed to fetch tools: {exc}') + + print('Creating virtual server...') + try: + server_payload = { + 'server': { + 'id': VIRTUAL_SERVER_ID, + 'name': 'Fast Time Server', + 'description': 'Virtual server exposing Fast Time MCP tools', + 'associated_tools': tool_ids, + 'associated_resources': [], + 'associated_prompts': [] + } + } + api_request_with_retry('POST', '/servers', server_payload) + print(f'Virtual server created: {VIRTUAL_SERVER_ID} with {len(tool_ids)} tools') + except Exception as exc: + print(f'Failed to create virtual server: {exc}') + exit(1) + " + echo "Fast Time Server registration complete!" + profiles: ["testing", "monitoring", "sso"] diff --git a/docker/nginx.conf b/docker/nginx.conf index ba09282..205956c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -26,14 +26,6 @@ http { add_header 'Access-Control-Allow-Headers' '*' 'always'; add_header 'Access-Control-Allow-Credentials' 'true' 'always'; - upstream contextforge-gateway-rs { - hash $remote_addr$remote_port consistent; - server contextforge-gateway-rs-contextforge-gateway-rs-1; - server contextforge-gateway-rs-contextforge-gateway-rs-2; - server contextforge-gateway-rs-contextforge-gateway-rs-3; - } - - include /etc/nginx/mime.types; default_type application/octet-stream; @@ -167,7 +159,11 @@ http { # across gateway container churn. map "" $contextforge_gateway_backend_url { - default "http://contextforge-gateway-rs:4445"; + default "http://data-plane:4445"; + } + + map "" $contextforge_control_backend_url { + default "http://control-plane:4444"; } @@ -288,11 +284,41 @@ http { } + # ============================================================ + # control-plane (ContextForge Python) - config, policy, identity, UI + # Catch-all: anything not matching /contextforge-rs, /health, + # /nginx_status goes to control-plane. + # ============================================================ + location / { + limit_req zone=api_limit burst=20000 nodelay; + limit_conn conn_limit 20000; + + proxy_pass $contextforge_control_backend_url; + + proxy_cache off; + + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 10s; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header Connection ""; + proxy_http_version 1.1; + + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + # ============================================================ # Health Check - No rate limiting (for monitoring during load tests) # ============================================================ location = /health { - proxy_pass $contextforge_gateway_backend_url; + proxy_pass $contextforge_gateway_backend_url/contextforge-rs/health; proxy_set_header Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 5b42dca..a382a3f 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -23,6 +23,7 @@ - [Telemetry And Diagnostics](telemetry-and-diagnostics.md) - [Failure Modes](failure-modes.md) - [Testing](testing.md) + - [Local Docker Stack](local-docker-stack.md) - [Performance](performance.md) - [Deployment Notes](deployment-notes.md) - [🛠️ Project](project.md) diff --git a/docs/book/src/local-docker-stack.md b/docs/book/src/local-docker-stack.md new file mode 100644 index 0000000..06f9de1 --- /dev/null +++ b/docs/book/src/local-docker-stack.md @@ -0,0 +1,113 @@ +# Local Docker Stack + +`docker/docker-compose.yml` brings up the full control-plane + dataplane +stack locally and drives an MCP session through it against the bundled +`fast_time_server` test backend. This is separate from the `cf-integration` +harness (see [Testing](testing.md)) — use it for a quick local smoke test of +the built dataplane image. + +## Quick Start + +```bash +make docker-prod +make testing-up +``` + +`make docker-prod` builds `dataplane:latest` from `docker/Dockerfile`; +`testing-up` refuses to start if this image doesn't exist yet. `testing-up` +starts: `nginx`, `control-plane`, `redis`, `postgres`, `pgbouncer`, +`data-plane`, `fast_time_server`, `register_fast_time` (see `Makefile`). + +Resource budget for this stack (`docker/docker-compose.yml` `deploy.resources`): + +| Service | CPU Limit | Mem Limit | +| ---------------- | --------- | --------- | +| nginx | 0.5 | 256 M | +| control-plane | 2 | 2 G | +| redis | 1 | 1 G | +| postgres | 2 | 2 G | +| pgbouncer | 0.5 | 256 M | +| data-plane | 2 | 2 G | +| fast_time_server | 1 | 512 M | +| **Total** | **9 cores** | **8 G** | + +`register_fast_time` is a one-shot init container (no steady-state resources): +it logs into control-plane, registers `fast_time_server` as an upstream +gateway, and creates a virtual server with a fixed id +(`b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8`) exposing all of its tools. + +Watch it finish: + +```bash +docker compose -f docker/docker-compose.yml logs -f register_fast_time +``` + +Look for `Fast Time Server registration complete!`. + +## Config Propagation + +The control plane doesn't talk to the dataplane directly — it publishes +`UserConfig` snapshots into Redis (`DATAPLANE_PUBLISHER=true`), and the +dataplane reads/caches from Redis. Worst-case propagation delay: + +```text +publisher interval + dataplane user-config cache expiry +``` + +Both default to ~60s in this stack (see +[Control-Plane Integration](control-plane-integration.md)). After +`register_fast_time` finishes, give it up to a minute before the virtual host +resolves on the dataplane side. + +## Get A Bearer Token + +Data-plane can self-issue a test JWT (RS256, signed with the same +`assets/jwt.key` the control plane also signs with) — no need to shell into +any container: + +```bash +TOKEN=$(curl --silent --show-error --request GET \ + --url http://localhost:8080/contextforge-rs/admin/tokens/admin@example.com \ + --header 'accept: application/json') +``` + +Use `admin@example.com` — that's `PLATFORM_ADMIN_EMAIL`, the identity +`register_fast_time` used, so it's the subject the publisher's `UserConfig` +is keyed under. Token is valid for 1 hour. + +## Point mcp-inspector At It + +```bash +npx @modelcontextprotocol/inspector +``` + +| Field | Value | +| ----------- | ----- | +| URL | `http://localhost:8080/contextforge-rs/servers/b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8/mcp` | +| Transport | Streamable HTTP | +| Auth token | `$TOKEN` from the previous step | + +Note the `/contextforge-rs` prefix — that's what routes to the dataplane via +nginx (`docker/nginx.conf`, `location ^~ /contextforge-rs`). Without it, +the request goes to control-plane instead and you'll get a +`{"detail": "..."}`-shaped error from mcpgateway, not the dataplane. + +Run `tools/list` — you should get back the `fast_time_server` tools +(prefixed with the gateway name, e.g. `fast_time-`). + +## Tear Down + +```bash +make testing-down +``` + +Stops the stack (containers/volumes kept; rerun `testing-up` to resume). + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `403 {"detail":"Token is invalid: User is no longer a member of the associated team"}` | Hit control-plane instead of dataplane — URL missing `/contextforge-rs` prefix. | Use `/contextforge-rs/servers//mcp`. | +| `400 "Problem occurred retrieving the configuration"` | Dataplane has no `UserConfig` yet for the token's subject (`register_fast_time`/publisher hasn't run or synced). | Re-check config propagation above; confirm `register_fast_time` logs completed successfully and wait out the publisher interval. | +| `tools/list` returns `{"tools": [], "resultType": "complete"}` | Dataplane resolved the `UserConfig` fine, but couldn't connect to a declared backend. | `docker compose logs data-plane \| grep -i "worker quit\|BadScheme\|backend"`. A `BadScheme` error means `CONTEXTFORGE_GATEWAY_RS_UPSTREAM_CONNECTION_MODE` isn't set to `plain-text-or-tls` for a plain-`http://` backend. | +| `401` on every request | Bad/expired token, or control-plane and dataplane are signing/verifying with different keys or algorithms. | Confirm both sides have matching `JWT_ALGORITHM`/key config — see [Control-Plane Integration](control-plane-integration.md). |