Files

10 KiB

Context

apps/cloud-api/cloud_api/app.py composes exactly two routers today: create_cloud_router (the versioned /v1 platform SDK, packages/cloud-platform/cloud/sdk/api.py) and create_internal_router (Host Agent-only heartbeat/claim/renew/result). Neither mounts any static assets or HTML — confirmed by reading app.py in full, it has no StaticFiles/template mount. There is also no CORS middleware configured on the Cloud API app, unlike the local Runtime's api/rest.py, which enables permissive CORS specifically so the existing console/ SPA can call it cross-origin during npm run dev.

Auth on /v1/... is ConfiguredBearerAuthProvider (cloud/auth.py): a flat list of pre-shared opaque tokens, each mapped to a Principal(id, scopes, host_id). There is no login flow, no session, no user directory — every integrator (Host Agent, CloudClient, and now this console) authenticates the same way, by presenting Authorization: Bearer <token> on each request.

The repository (cloud/repository.py Protocol, implemented in sql_repository.py for both SQLite and PostgreSQL via the same SQLAlchemy code path) exposes list_queued_tasks() (queued-only, used internally by TaskScheduler.assign()) and get_task(task_id) (single lookup, backs GET /v1/tasks/{id}), but nothing that lists tasks across all statuses, and nothing paginated. list_task_attempts(task_id) is fully implemented (sql_repository.py:531) but has no route anywhere — it exists only for internal/audit use today.

Goals / Non-Goals

Goals:

  • Give an operator read visibility into tasks (all statuses, including history and per-attempt outcomes), the device pool, the host registry, and the plugin registry, without scripting REST calls.
  • Let an operator perform the two write actions that are already safe, scope-gated, and exposed today — submit an ad-hoc task and register a plugin manifest — from the same UI, instead of adding new write capabilities.
  • Reuse the Cloud Control Plane's existing bearer-token auth model exactly as CloudClient does — zero new auth surface.

Non-Goals:

  • Task cancellation or forced retry — no scheduler/repository operation for this exists (assign/claim/renew/record_result only); adding one is a scheduling-capability change out of this proposal's scope.
  • Host Agent remote start/stop — the Host Agent only initiates outbound calls (heartbeat/claim/renew/result); the control plane has no channel to push commands to it.
  • Plugin de-registration — CloudRepository has no delete/deregister method for plugins.
  • A new login/session/RBAC/user-management system — the console is just another bearer-token holder, provisioned the same way as any CLOUD_PUBLIC_CREDENTIALS_JSON entry.
  • Real-time push (WebSocket/SSE) — polling only.
  • A shared component library or JS monorepo tooling spanning console/ and the new cloud-console/ — they stay two fully independent SPA projects, matching console/README.md's own "Independent Vue 3 + Vite SPA" precedent.
  • Multi-cluster/multi-control-plane aggregation — docs/CLOUD_DEPLOYMENT.md already limits deployment to one scheduler-enabled Cloud API process; the console targets one Cloud API base URL at a time.

Decisions

  • Extend platform-sdk rather than invent a parallel console-only API. Add GET /v1/tasks and GET /v1/tasks/{task_id}/attempts to the existing cloud/sdk/api.py router, under the same /v1 prefix, same tasks:read scope, same AuthProvider hook. Rejected alternative: a separate console-api router mirroring the local Runtime's console-status-api/console-config-api split — rejected because the Cloud Control Plane's /v1 surface is already the one stable, versioned, scope-gated contract every integrator uses; a second parallel surface would duplicate auth wiring for no benefit and would fork platform-sdk's existing "client mirrors REST API" requirement into two inconsistent contracts.
  • New bounded, filterable task listing at the repository layer. Add list_tasks(*, status: ScheduledTaskStatus | None, limit: int, offset: int) -> list[ScheduledTask] and count_tasks(status: ScheduledTaskStatus | None) -> int to the CloudRepository Protocol, implemented via the same SQLAlchemy query builder sql_repository.py already uses for both SQLite and PostgreSQL (no dialect-specific SQL branch). GET /v1/tasks caps limit server-side (Field(..., le=100), default 50), ordered most-recent-first, mirroring the bounded-Field pattern internal_api/models.py already uses for ClaimRequest.timeout_seconds. Rejected alternative: reusing/widening list_queued_tasks() — rejected because it is scheduler-internal (assign-loop candidate selection), has no status filter or pagination, and callers outside the scheduler have no business depending on its exact contract.
  • GET /v1/tasks/{task_id}/attempts is a thin pass-through to the existing list_task_attempts(), no new repository work — it just needed a route and a response model.
  • Extend CloudClient in the same change. platform-sdk's existing requirement states the Python client mirrors every /v1/... route; adding routes without adding client methods would silently break that invariant. Add list_tasks(...) and get_task_attempts(...) to cloud/sdk/client.py with matching tests.
  • Bearer token pasted by the operator, held in sessionStorage, never in localStorage. On load, the console shows a token-entry screen if no token is present; every subsequent request attaches Authorization: Bearer <token>, exactly like CloudClient. Rejected alternative: building any login/username-password flow — there is no user directory to authenticate against; the Cloud Control Plane's entire identity model is pre-issued scoped tokens, and inventing a session layer on top would be new auth infrastructure this proposal has no reason to add. Operators are expected to hold a token scoped at least to tasks:read+pool:read+plugins:read (add tasks:submit/plugins:admin only if the console's write actions are needed), provisioned the same way as any other CLOUD_PUBLIC_CREDENTIALS_JSON entry.
  • Add configurable, closed-by-default CORS to the Cloud API. apps/cloud-api/cloud_api/app.py currently has zero CORS middleware. Add an env-driven allow-list (e.g. CLOUD_CONSOLE_CORS_ORIGINS, comma-separated, empty by default) wired through CloudControlConfig/load_control_config(), applied via FastAPI's CORSMiddleware only when non-empty. Rejected alternative: permissive CORS like the local Runtime's dev-only setup — rejected because the Cloud API, unlike the single-developer local Runtime, is meant for real deployment (PostgreSQL, production CLOUD_ENVIRONMENT) where the entire auth model is a bearer token; blanket allow_origins=["*"] would let any origin that tricks an operator's browser into sending that token succeed. Default-empty keeps every existing deployment's behavior unchanged until an operator opts in with their console's actual origin.
  • New top-level cloud-console/ project, sibling to console/, not nested under packages/cloud-platform or apps/cloud-api. Mirrors the existing console/ precedent (independent Node/Vue toolchain kept out of the Python uv workspace) and keeps the two SPAs — and the two backends and auth models they talk to — visibly separate rather than implying a shared deployment unit.

Risks / Trade-offs

  • [No CORS today on the Cloud API] → Mitigation: ship the allow-list closed by default; existing deployments see no behavior change until they configure their console's origin.
  • [Bearer token lives in browser storage] → Mitigation: sessionStorage only (cleared on tab close), never logged, sent only to the configured Cloud API base URL; document least-privilege scoping and the existing token-rotation guidance from docs/CLOUD_DEPLOYMENT.md.
  • [Task history is unbounded over time, unlike the depth-capped queue] → Mitigation: server-side hard cap on page size (le=100), default ordering most-recent-first; client paginates rather than fetching everything.
  • [New repository method must behave identically on SQLite and PostgreSQL] → Mitigation: implement through the same SQLAlchemy query builder every other sql_repository.py method already uses; cover both backends in the existing dual-backend repository test suite.
  • [Two independent frontends (console/, cloud-console/) can drift in look-and-feel] → Mitigation: accepted for this change's scope, matching console/'s own "independent" precedent; revisit a shared design system only if a third console appears.

Migration Plan

  1. Add list_tasks/count_tasks to the CloudRepository Protocol and sql_repository.py, with unit/integration tests against both SQLite and PostgreSQL.
  2. Add GET /v1/tasks and GET /v1/tasks/{task_id}/attempts to cloud/sdk/api.py, with response models in cloud/sdk/models.py.
  3. Add matching CloudClient methods and tests in cloud/sdk/client.py.
  4. Add the closed-by-default CORS allow-list to apps/cloud-api/cloud_api/app.py / cloud/control_config.py.
  5. Scaffold cloud-console/ (Vue 3 + Vite): token-entry screen, an API client wrapper that attaches the bearer token, and routing shell.
  6. Build the dashboard views: task list/detail/attempts, device pool, host registry, plugin registry + registration form.
  7. Document console usage and operator token provisioning in docs/CLOUD_DEPLOYMENT.md.
  8. Rollback: everything is additive — two new GET routes, two new repository methods, opt-in CORS middleware, a new frontend project. No schema migration and no changes to existing task/attempt table columns, so rollback is simply removing the new routes/middleware and not deploying the frontend.

Open Questions

  • Should live task status use SSE/WebSocket push instead of polling? Deferred — polling matches this change's scale; revisit only if operators report polling latency is actually a problem.
  • Should credential provisioning offer a bundled "console" scope alias instead of operators requesting tasks:read+pool:read+plugins:read separately? Deferred — documenting the combination in docs/CLOUD_DEPLOYMENT.md is enough for now; scope aliasing is an auth-model change beyond this proposal's impact.