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
CloudClientdoes — zero new auth surface.
Non-Goals:
- Task cancellation or forced retry — no scheduler/repository operation for this exists (
assign/claim/renew/record_resultonly); 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 —
CloudRepositoryhas 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_JSONentry. - Real-time push (WebSocket/SSE) — polling only.
- A shared component library or JS monorepo tooling spanning
console/and the newcloud-console/— they stay two fully independent SPA projects, matchingconsole/README.md's own "Independent Vue 3 + Vite SPA" precedent. - Multi-cluster/multi-control-plane aggregation —
docs/CLOUD_DEPLOYMENT.mdalready limits deployment to one scheduler-enabled Cloud API process; the console targets one Cloud API base URL at a time.
Decisions
- Extend
platform-sdkrather than invent a parallel console-only API. AddGET /v1/tasksandGET /v1/tasks/{task_id}/attemptsto the existingcloud/sdk/api.pyrouter, under the same/v1prefix, sametasks:readscope, sameAuthProviderhook. Rejected alternative: a separateconsole-apirouter mirroring the local Runtime'sconsole-status-api/console-config-apisplit — rejected because the Cloud Control Plane's/v1surface 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 forkplatform-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]andcount_tasks(status: ScheduledTaskStatus | None) -> intto theCloudRepositoryProtocol, implemented via the same SQLAlchemy query buildersql_repository.pyalready uses for both SQLite and PostgreSQL (no dialect-specific SQL branch).GET /v1/taskscapslimitserver-side (Field(..., le=100), default 50), ordered most-recent-first, mirroring the bounded-Fieldpatterninternal_api/models.pyalready uses forClaimRequest.timeout_seconds. Rejected alternative: reusing/wideninglist_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}/attemptsis a thin pass-through to the existinglist_task_attempts(), no new repository work — it just needed a route and a response model.- Extend
CloudClientin 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. Addlist_tasks(...)andget_task_attempts(...)tocloud/sdk/client.pywith matching tests. - Bearer token pasted by the operator, held in
sessionStorage, never inlocalStorage. On load, the console shows a token-entry screen if no token is present; every subsequent request attachesAuthorization: Bearer <token>, exactly likeCloudClient. 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 totasks:read+pool:read+plugins:read(addtasks:submit/plugins:adminonly if the console's write actions are needed), provisioned the same way as any otherCLOUD_PUBLIC_CREDENTIALS_JSONentry. - Add configurable, closed-by-default CORS to the Cloud API.
apps/cloud-api/cloud_api/app.pycurrently has zero CORS middleware. Add an env-driven allow-list (e.g.CLOUD_CONSOLE_CORS_ORIGINS, comma-separated, empty by default) wired throughCloudControlConfig/load_control_config(), applied via FastAPI'sCORSMiddlewareonly 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, productionCLOUD_ENVIRONMENT) where the entire auth model is a bearer token; blanketallow_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 toconsole/, not nested underpackages/cloud-platformorapps/cloud-api. Mirrors the existingconsole/precedent (independent Node/Vue toolchain kept out of the Pythonuvworkspace) 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:
sessionStorageonly (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 fromdocs/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.pymethod 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, matchingconsole/'s own "independent" precedent; revisit a shared design system only if a third console appears.
Migration Plan
- Add
list_tasks/count_tasksto theCloudRepositoryProtocol andsql_repository.py, with unit/integration tests against both SQLite and PostgreSQL. - Add
GET /v1/tasksandGET /v1/tasks/{task_id}/attemptstocloud/sdk/api.py, with response models incloud/sdk/models.py. - Add matching
CloudClientmethods and tests incloud/sdk/client.py. - Add the closed-by-default CORS allow-list to
apps/cloud-api/cloud_api/app.py/cloud/control_config.py. - Scaffold
cloud-console/(Vue 3 + Vite): token-entry screen, an API client wrapper that attaches the bearer token, and routing shell. - Build the dashboard views: task list/detail/attempts, device pool, host registry, plugin registry + registration form.
- Document console usage and operator token provisioning in
docs/CLOUD_DEPLOYMENT.md. - 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:readseparately? Deferred — documenting the combination indocs/CLOUD_DEPLOYMENT.mdis enough for now; scope aliasing is an auth-model change beyond this proposal's impact.