From ff9362e710395334c45a60b601348aea0070ef2b Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Sun, 10 May 2026 22:00:57 -0400 Subject: [PATCH 1/2] Disable public worker controls --- .env.example | 1 + gateway/app/dashboard.py | 24 ++++++++++++++++++------ gateway/app/main.py | 8 +++++++- gateway/app/settings.py | 2 ++ render.yaml | 2 ++ tests/test_dashboard.py | 17 +++++++++++++++++ tests/test_gateway_controls.py | 20 ++++++++++++++++++++ tests/test_gateway_settings.py | 2 ++ 8 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 tests/test_gateway_controls.py diff --git a/.env.example b/.env.example index 6eb2537..2845ff9 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,6 @@ REDIS_URL=redis://redis:6379/0 REQUEST_TIMEOUT_SECONDS=1.5 ROUTER_STRATEGY=load_aware CACHE_ENABLED=true +GATEWAY_CONTROLS_ENABLED=true SIMULATE_DELAY_MS=0 FAIL_RATE=0 diff --git a/gateway/app/dashboard.py b/gateway/app/dashboard.py index 27f862d..91f178f 100644 --- a/gateway/app/dashboard.py +++ b/gateway/app/dashboard.py @@ -113,6 +113,9 @@ .bad { color: #fca5a5; } + .muted { + color: #64748b; + } input { width: 76px; border: 1px solid #334155; @@ -215,6 +218,7 @@ errors: document.getElementById("errors"), workers: document.getElementById("workers"), }; + const controlsEnabled = __CONTROLS_ENABLED__; function pct(value) { return `${Math.round(value * 100)}%`; @@ -234,6 +238,16 @@ window.controlWorker = controlWorker; + function controlCell(worker) { + if (!controlsEnabled) { + return `disabled`; + } + return ` + + + `; + } + async function refresh() { try { const [health, metrics, workers] = await Promise.all([ @@ -247,6 +261,7 @@ ids.cache.textContent = pct(metrics.cache_hit_rate); ids.p95.textContent = `${metrics.p95_latency_ms} ms`; ids.errors.textContent = metrics.worker_errors; + const inputState = controlsEnabled ? "" : "disabled"; ids.workers.innerHTML = workers.map((worker) => ` ${worker.url} @@ -255,12 +270,9 @@ ${worker.requests} ${worker.failures} ${worker.latency_ema_ms} ms - - - - - - + + + ${controlCell(worker)} `).join(""); } catch (error) { diff --git a/gateway/app/main.py b/gateway/app/main.py index aa1225d..fc51139 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -128,7 +128,10 @@ async def health() -> dict[str, str | int]: @app.get("/dashboard", response_class=HTMLResponse) async def dashboard() -> str: - return DASHBOARD_HTML + return DASHBOARD_HTML.replace( + "__CONTROLS_ENABLED__", + str(settings.controls_enabled).lower(), + ) @app.get("/workers") @@ -145,6 +148,9 @@ async def control_worker( worker_index: int, update: WorkerControlRequest, ) -> dict[str, object]: + if not settings.controls_enabled: + raise HTTPException(status_code=403, detail="worker controls are disabled") + worker = worker_for_index(worker_index) config = await control_remote_worker(worker, update) return {"worker_index": worker_index, "worker_url": worker.url, "config": config} diff --git a/gateway/app/settings.py b/gateway/app/settings.py index 73f22db..11a35f0 100644 --- a/gateway/app/settings.py +++ b/gateway/app/settings.py @@ -60,6 +60,7 @@ class GatewaySettings: redis_url: str | None = None cache_enabled: bool = True request_timeout_seconds: float = 1.0 + controls_enabled: bool = True @classmethod def from_env(cls, env: Mapping[str, str] | None = None) -> GatewaySettings: @@ -77,4 +78,5 @@ def from_env(cls, env: Mapping[str, str] | None = None) -> GatewaySettings: values.get("REQUEST_TIMEOUT_SECONDS"), default=1.0, ), + controls_enabled=parse_bool(values.get("GATEWAY_CONTROLS_ENABLED"), default=True), ) diff --git a/render.yaml b/render.yaml index b6ee836..f27bdc7 100644 --- a/render.yaml +++ b/render.yaml @@ -53,3 +53,5 @@ services: value: load_aware - key: CACHE_ENABLED value: "true" + - key: GATEWAY_CONTROLS_ENABLED + value: "false" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 446f138..d018957 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,5 +1,8 @@ +from dataclasses import replace + from fastapi.testclient import TestClient +from gateway.app import main as gateway_main from gateway.app.main import app @@ -10,3 +13,17 @@ def test_dashboard_serves_html() -> None: assert response.status_code == 200 assert "ModelMesh" in response.text assert "Gateway metrics" in response.text + + +def test_dashboard_marks_controls_disabled(monkeypatch) -> None: + monkeypatch.setattr( + gateway_main, + "settings", + replace(gateway_main.settings, controls_enabled=False), + ) + + with TestClient(app) as client: + response = client.get("/dashboard") + + assert response.status_code == 200 + assert "const controlsEnabled = false;" in response.text diff --git a/tests/test_gateway_controls.py b/tests/test_gateway_controls.py new file mode 100644 index 0000000..1becdd2 --- /dev/null +++ b/tests/test_gateway_controls.py @@ -0,0 +1,20 @@ +from dataclasses import replace + +from fastapi.testclient import TestClient + +from gateway.app import main as gateway_main +from gateway.app.main import app + + +def test_gateway_controls_can_be_disabled(monkeypatch) -> None: + monkeypatch.setattr( + gateway_main, + "settings", + replace(gateway_main.settings, controls_enabled=False), + ) + + with TestClient(app) as client: + response = client.post("/workers/0/control", json={"delay_ms": 0}) + + assert response.status_code == 403 + assert response.json()["detail"] == "worker controls are disabled" diff --git a/tests/test_gateway_settings.py b/tests/test_gateway_settings.py index 500d2c8..d9462de 100644 --- a/tests/test_gateway_settings.py +++ b/tests/test_gateway_settings.py @@ -10,6 +10,7 @@ def test_gateway_settings_reads_explicit_worker_urls() -> None: "CACHE_ENABLED": "false", "REQUEST_TIMEOUT_SECONDS": "2.5", "REDIS_URL": "redis://cache:6379/0", + "GATEWAY_CONTROLS_ENABLED": "false", }) assert settings.worker_urls == ["http://a:9000", "http://b:9000"] @@ -17,6 +18,7 @@ def test_gateway_settings_reads_explicit_worker_urls() -> None: assert settings.cache_enabled is False assert settings.request_timeout_seconds == 2.5 assert settings.redis_url == "redis://cache:6379/0" + assert settings.controls_enabled is False def test_gateway_settings_reads_render_hostports() -> None: From 544d79ffdae5d8030903e48f3aebb792b4c5dd70 Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Sun, 10 May 2026 22:01:01 -0400 Subject: [PATCH 2/2] Document hosted demo validation --- README.md | 7 +++++++ docs/deployment.md | 8 ++++++-- docs/hosted-demo.md | 44 ++++++++++++++++++++++++++++++++++++++++++ docs/report.md | 3 ++- scripts/smoke_check.py | 13 ++++++++++++- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 docs/hosted-demo.md diff --git a/README.md b/README.md index fc63f6f..59d7ed3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ Deployment notes are in `docs/deployment.md`. There is also a Render Blueprint in `render.yaml` for a hosted demo with one public gateway, two private workers, and a managed cache. +## Hosted demo + +The hosted demo is Render-ready but does not have a checked-in public URL yet. The Blueprint +keeps workers private and disables public worker failure controls. + +Deployment and validation steps are in `docs/hosted-demo.md`. + ## Benchmarks The scenario runner starts local gateway and worker processes, sends load, then writes JSON and markdown results. diff --git a/docs/deployment.md b/docs/deployment.md index 57abe8e..5831469 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -22,7 +22,7 @@ For a hosted demo, deploy the services as a private internal mesh: | Service | Public | Notes | | --- | --- | --- | -| gateway | yes | Exposes `/predict`, `/dashboard`, `/metrics`, and worker control proxy | +| gateway | yes | Exposes `/predict`, `/dashboard`, `/metrics`, and `/workers` | | worker-a | no | Internal service on port 9000 | | worker-b | no | Internal service on port 9000 | | redis | no | Internal cache only | @@ -35,13 +35,15 @@ REDIS_URL=redis://redis:6379/0 REQUEST_TIMEOUT_SECONDS=1.5 ROUTER_STRATEGY=load_aware CACHE_ENABLED=true +GATEWAY_CONTROLS_ENABLED=false ``` ## What not to overclaim This is not a production ML platform yet. It is a compact inference mesh that demonstrates training, serving, caching, routing, live worker controls, failure recovery, and benchmark evidence. -The next real hosting step is choosing a provider and wiring its private service networking. The Docker setup here is intentionally provider-neutral so the app can go to Fly.io, Render, Railway, a VM, or a Kubernetes cluster without changing the Python code. +The next real hosting step is applying the Render Blueprint and validating the gateway URL with +`scripts/smoke_check.py`. ## Render Blueprint @@ -53,4 +55,6 @@ The next real hosting step is choosing a provider and wiring its private service The gateway reads `WORKER_A_HOSTPORT` and `WORKER_B_HOSTPORT`, which Render fills from each private worker service. That avoids hardcoding internal hostnames. +The Blueprint also sets `GATEWAY_CONTROLS_ENABLED=false`, so the public dashboard can show worker state without exposing delay or failure controls. + Important: the private worker services use Render's `starter` plan in the Blueprint because private services do not run on the free plan. Review the cost in Render before applying the Blueprint. diff --git a/docs/hosted-demo.md b/docs/hosted-demo.md new file mode 100644 index 0000000..28ab1ee --- /dev/null +++ b/docs/hosted-demo.md @@ -0,0 +1,44 @@ +# Hosted demo + +ModelMesh uses the Render Blueprint in `render.yaml` for the public demo shape: + +- one public gateway +- two private workers +- one managed cache + +The hosted gateway exposes `/health`, `/predict`, `/metrics`, `/workers`, and `/dashboard`. +Worker delay and failure controls are disabled in the Blueprint with +`GATEWAY_CONTROLS_ENABLED=false`. + +## Deploy from Render + +1. Open Render and choose **New** > **Blueprint**. +2. Connect this repository. +3. Select `render.yaml`. +4. Review the service list before applying: + - `modelmesh-gateway` uses the `starter` plan. + - `modelmesh-worker-a` and `modelmesh-worker-b` use private services. + - `modelmesh-cache` uses Render Key Value. +5. Apply the Blueprint. +6. Wait until the gateway and both workers finish their first deploy. + +Private worker services may cost money on Render. Review the pricing screen before applying +the Blueprint. + +## Validate the live gateway + +After Render gives the gateway a public URL, run: + +```bash +python scripts/smoke_check.py --base-url https://your-render-gateway-url +``` + +The smoke check verifies: + +- `/health` +- `/predict` +- `/metrics` +- `/workers` +- `/dashboard` + +Only add the live URL to the README or GitHub repo homepage after this smoke check passes. diff --git a/docs/report.md b/docs/report.md index c4dbcc5..85d5129 100644 --- a/docs/report.md +++ b/docs/report.md @@ -119,8 +119,9 @@ The repo includes: - local Docker Compose - production-style Docker Compose - Docker image builds in CI +- Render Blueprint for a hosted demo -A separate Render Blueprint PR prepares the hosted demo. It still needs a provider account action and cost review before there is a public URL. +The hosted demo still needs a Render account action and cost review before there is a public URL. The Blueprint keeps workers private and disables public worker failure controls. ## Limitations diff --git a/scripts/smoke_check.py b/scripts/smoke_check.py index 3bb143d..ce2528d 100644 --- a/scripts/smoke_check.py +++ b/scripts/smoke_check.py @@ -18,6 +18,11 @@ def get_json(url: str, timeout: float = 2.0) -> dict: return json.loads(response.read().decode("utf-8")) +def get_text(url: str, timeout: float = 2.0) -> str: + with request.urlopen(url, timeout=timeout) as response: + return response.read().decode("utf-8") + + def post_json(url: str, payload: dict, timeout: float = 2.0) -> dict: data = json.dumps(payload).encode("utf-8") req = request.Request( @@ -54,11 +59,16 @@ def main() -> None: ) metrics = get_json(f"{base_url}/metrics") workers = get_json(f"{base_url}/workers") + dashboard = get_text(f"{base_url}/dashboard") if prediction["prediction"]["label"] != "healthy": raise RuntimeError(f"unexpected prediction: {prediction}") + if "requests" not in metrics or "p95_latency_ms" not in metrics: + raise RuntimeError(f"unexpected metrics payload: {metrics}") if not workers: raise RuntimeError("gateway returned no workers") + if "ModelMesh" not in dashboard or "Gateway metrics" not in dashboard: + raise RuntimeError("dashboard did not return the expected HTML") print( json.dumps( @@ -66,7 +76,8 @@ def main() -> None: "health": health, "prediction": prediction, "metrics": metrics, - "workers": workers, + "worker_count": len(workers), + "dashboard": "ok", }, indent=2, )