Implements the cloud-console OpenSpec change: adds GET /v1/tasks (filterable,
bounded pagination, tasks:read) and GET /v1/tasks/{id}/attempts (404 on unknown
task) to the platform SDK, with matching CloudClient methods and a closed-by-
default CLOUD_CONSOLE_CORS_ORIGINS allow-list wired through CloudControlConfig.
Ships an independent Vue 3 + Vite SPA at cloud-console/ that authenticates with
an operator-supplied bearer token held in sessionStorage, renders tasks with
attempt history, device pool, host registry, and the plugin registry with a
registration form.
Backend test suite: 438 passed (-m "not integration"); cloud-console typecheck
and production build both succeed. PostgreSQL-backed repository tests and
manual end-to-end verification remain pending external infrastructure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
331 lines
14 KiB
Markdown
331 lines
14 KiB
Markdown
# Cloud Control Plane Deployment
|
|
|
|
This guide covers the deployable Cloud API and outbound Device Host Agent.
|
|
Run commands from the repository root after synchronizing the uv workspace:
|
|
|
|
```bash
|
|
uv sync --locked --all-packages
|
|
```
|
|
|
|
## Local SQLite
|
|
|
|
SQLite is intended for local development and tests with one Cloud API process.
|
|
Configure public and host credentials even in local mode because anonymous
|
|
development access cannot authorize a host identity.
|
|
|
|
```powershell
|
|
$env:CLOUD_ENVIRONMENT = "local"
|
|
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
|
|
$env:CLOUD_PUBLIC_CREDENTIALS_JSON = '[{"principal_id":"local-sdk","token":"replace-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]'
|
|
$env:CLOUD_HOST_CREDENTIALS_JSON = '[{"principal_id":"local-host","token":"replace-host-token","scopes":[],"host_id":"host-local"}]'
|
|
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
|
|
```
|
|
|
|
In a second terminal, start the Host Agent with the matching host identity and
|
|
token:
|
|
|
|
```powershell
|
|
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
|
|
$env:HOST_AGENT_HOST_ID = "host-local"
|
|
$env:HOST_AGENT_TOKEN = "replace-host-token"
|
|
uv run --package device-host-agent device-host-agent
|
|
```
|
|
|
|
At startup, the Host Agent loads device registrations from
|
|
`tasks/device_config.sqlite3`, the same `DeviceConfigStore` used by the local
|
|
Runtime console API. Register or update devices before starting the Host Agent,
|
|
then restart it to reload changes. In Compose,
|
|
`HOST_AGENT_TASKS_PATH` selects the host directory mounted at `/app/tasks`; it
|
|
defaults to `./tasks`.
|
|
|
|
The Host Agent only initiates outbound HTTP requests. It does not expose an
|
|
inbound port.
|
|
|
|
## Managed Edge Enrollment
|
|
|
|
New edge installations do not need a pre-coordinated Host or device ID. The
|
|
Cloud API accepts configured one-time enrollment credentials:
|
|
|
|
```powershell
|
|
$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
|
|
```
|
|
|
|
On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
|
|
the enrollment token only for the first successful enrollment:
|
|
|
|
```bash
|
|
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
|
|
export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
|
|
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
|
|
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
|
|
uv run --package device-host-agent device-host-agent
|
|
```
|
|
|
|
Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
|
|
an instance identifier and long-lived random Host secret. The cloud consumes
|
|
the enrollment token, assigns `host_id`, stores only credential digests, and
|
|
returns the assigned ID. The Host Agent then enrolls each record from
|
|
`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
|
|
database, connects the resulting devices, and starts heartbeat/claim loops.
|
|
|
|
Keep the identity file and device configuration database on persistent edge
|
|
storage with permissions limited to the service account. The identity file is
|
|
a bearer secret: do not put it in an image, repository, log, or general backup.
|
|
After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
|
|
environment. An intact identity file is sufficient for restart; if only a
|
|
device mapping is lost, device enrollment reconstructs the same cloud ID.
|
|
|
|
Enrollment tokens are one-time even when they remain in Cloud API environment
|
|
configuration: their consumed digest is stored in the database. Reusing a token
|
|
for another edge instance returns a conflict. Create a distinct token for every
|
|
edge installation.
|
|
|
|
Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
|
|
the previous legacy behavior, including locally selected device IDs. This is
|
|
the rollback and staged-migration path for existing deployments.
|
|
|
|
## PostgreSQL Deployment
|
|
|
|
Start from `.env.example`, replace every `change-me-*` value, and keep the
|
|
resulting `.env` file outside version control. The Compose stack contains
|
|
PostgreSQL, the Cloud API, and one Host Agent:
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
docker compose ps
|
|
```
|
|
|
|
The Cloud API container waits for PostgreSQL, runs the committed Alembic
|
|
migrations, then starts the API on port `8001`. Readiness is available at:
|
|
|
|
```text
|
|
GET http://127.0.0.1:8001/health/ready
|
|
```
|
|
|
|
## Deploying A Jenkins-Built Image
|
|
|
|
`docker compose up --build` above builds the image from source on the target
|
|
host. For environments that pull a pre-built image instead, `Jenkinsfile` runs
|
|
the pytest suite (`-m "not integration"`), builds the image from the same
|
|
`Dockerfile`, smoke-tests both console scripts (`device-cloud-api --help`,
|
|
`device-host-agent --help`), and pushes `<REGISTRY>/<IMAGE_NAME>:<BUILD_NUMBER>-<git short sha>`
|
|
plus `:latest` to the configured registry.
|
|
|
|
`compose.deploy.yaml` is the same three-service stack as `compose.yaml`
|
|
except `cloud-api` and `host-agent` reference `image:` instead of `build:`.
|
|
Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag
|
|
Jenkins published, then deploy without a local build step:
|
|
|
|
```bash
|
|
docker compose -f compose.deploy.yaml pull
|
|
docker compose -f compose.deploy.yaml up -d
|
|
docker compose -f compose.deploy.yaml ps
|
|
```
|
|
|
|
For a deployment that manages processes outside Compose, apply migrations
|
|
before starting the new Cloud API version:
|
|
|
|
```powershell
|
|
$env:CLOUD_DATABASE_URL = "postgresql+psycopg://USER:PASSWORD@HOST:5432/device_cloud"
|
|
uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini upgrade head
|
|
uv run --package device-cloud-api device-cloud-api --host 0.0.0.0 --port 8001
|
|
```
|
|
|
|
Production startup requires `CLOUD_ENVIRONMENT=production`, a current schema,
|
|
and at least one configured bearer credential.
|
|
|
|
## Credentials And Scopes
|
|
|
|
`CLOUD_PUBLIC_CREDENTIALS_JSON` is a JSON array of public API principals. Grant
|
|
only the scopes required by each integration:
|
|
|
|
- `tasks:submit`: submit tasks.
|
|
- `tasks:read`: read task status and failure metadata.
|
|
- `pool:read`: list hosts and devices.
|
|
- `plugins:read`: list installed plugin registrations.
|
|
- `plugins:admin`: register installed plugin entry points.
|
|
|
|
`CLOUD_HOST_CREDENTIALS_JSON` contains Host Agent principals. Every entry must
|
|
include exactly one `host_id`; its token is valid only for heartbeat, claim,
|
|
renewal, and result operations for that host.
|
|
|
|
`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
|
|
`principal_id` and `token`. These credentials cannot submit tasks, read the
|
|
pool, or operate as a Host; they can only create one durable Host binding.
|
|
Use high-entropy values generated by the deployment secret manager.
|
|
|
|
Do not place bearer tokens in command history, image layers, Compose files, or
|
|
logs. Use environment injection or the deployment platform's secret manager.
|
|
Rotate a token by deploying the updated Cloud API credential set and Host Agent
|
|
configuration together.
|
|
|
|
Dynamically enrolled Host credentials are stored as digests in the cloud
|
|
database. This release exposes repository-level revocation rather than a public
|
|
administration endpoint. An operator with database deployment access can revoke
|
|
a Host without deleting its task history:
|
|
|
|
```bash
|
|
export HOST_ID="host-..."
|
|
uv run --package device-cloud-platform python - <<'PY'
|
|
import os
|
|
from cloud.database import CloudDatabase
|
|
from core.models import utc_now
|
|
|
|
database = CloudDatabase(os.environ["CLOUD_DATABASE_URL"], create_schema=False)
|
|
try:
|
|
changed = database.repository.revoke_enrolled_host(
|
|
os.environ["HOST_ID"],
|
|
revoked_at=utc_now(),
|
|
)
|
|
print("revoked" if changed else "not an enrolled host")
|
|
finally:
|
|
database.close()
|
|
PY
|
|
```
|
|
|
|
## Cloud Console (Web UI)
|
|
|
|
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
|
|
renders the task queue/history, device pool, host registry, and plugin
|
|
registry, and exposes the existing plugin-registration action. It authenticates
|
|
the same way `CloudClient` does: by attaching a pre-issued bearer token to
|
|
every request. There is no login or session system.
|
|
|
|
### Provision an operator bearer token
|
|
|
|
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
|
|
console operators need to do. The least-privilege set for read-only dashboards
|
|
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
|
|
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
|
|
only if operators should register plugins:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"principal_id": "console-operator",
|
|
"token": "replace-with-a-long-random-opaque-token",
|
|
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
|
|
}
|
|
]
|
|
```
|
|
|
|
Rotate the token the same way as any other credential entry: deploy the
|
|
updated Cloud API credential set and instruct operators to paste the new token
|
|
into the console. The console keeps the token only in browser `sessionStorage`
|
|
for that tab; closing the tab discards it.
|
|
|
|
### Configure the CORS allow-list
|
|
|
|
The Cloud API has no CORS middleware by default. Before a browser can call it
|
|
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
|
|
that includes the exact origin (scheme + host + port, no trailing slash) the
|
|
operator's browser will load the console from:
|
|
|
|
```bash
|
|
# Allow a local Vite dev server
|
|
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
|
|
# Or a deployed origin
|
|
export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
|
|
```
|
|
|
|
Restart the Cloud API after changing this env. The middleware is added only
|
|
when the allow-list is non-empty — existing deployments see no behavior change
|
|
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
|
|
supported because every console request carries a bearer token.
|
|
|
|
### Run the console
|
|
|
|
```bash
|
|
cd cloud-console
|
|
cp .env.example .env.local
|
|
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
|
|
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
|
|
paste the operator token, and the dashboards become available.
|
|
|
|
For a production build, run `npm run build` and serve the resulting `dist/`
|
|
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
|
|
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
|
|
|
|
## Runtime AI Planner
|
|
|
|
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
|
default. Configure it in the Host Agent environment when goal assignments must
|
|
use a model:
|
|
|
|
```text
|
|
AI_PLANNER_ENABLED=true
|
|
AI_PLANNER_PROVIDER=anthropic
|
|
AI_PLANNER_MODEL=claude-sonnet-5
|
|
AI_PLANNER_TIMEOUT_SECONDS=30
|
|
ANTHROPIC_API_KEY=<secret manager reference>
|
|
```
|
|
|
|
For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through
|
|
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. Provider credentials belong
|
|
only on the Host Agent; the Cloud API does not need them.
|
|
|
|
## Operational Limitations
|
|
|
|
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
|
|
documented single-control-plane development mode. PostgreSQL row locking makes
|
|
assignment and claim transactions safe if requests overlap, but this release
|
|
does not implement scheduler leader election or claim active-active scheduler
|
|
operation. Starting multiple Cloud API replicas would start one scheduler loop
|
|
per replica and is outside the supported deployment topology.
|
|
|
|
Device execution provides at-least-once side-effect semantics, not exactly-once
|
|
semantics. A device action can succeed immediately before the Host Agent loses
|
|
its lease or its result response, after which the control plane may retry the
|
|
task. Lease renewal and cooperative stop checks prevent later interruptible
|
|
actions where possible, but they cannot roll back an action already sent to a
|
|
device or safely terminate an in-progress synchronous driver call.
|
|
|
|
Use bounded attempts, inspect task attempt and failure metadata, and design
|
|
device workflows to tolerate repeated actions when the target operation allows
|
|
it. Do not use this release for operations that require a transactional
|
|
exactly-once guarantee across the cloud database and an external device.
|
|
|
|
## Shutdown And Rollback
|
|
|
|
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
|
|
later cooperative actions, finish terminal reporting where the lease remains
|
|
valid, and attempt a final heartbeat. Stop the Cloud API after Host Agents have
|
|
exited, then stop PostgreSQL only if the database itself is being maintained:
|
|
|
|
```bash
|
|
docker compose stop host-agent
|
|
docker compose stop cloud-api
|
|
docker compose stop postgres
|
|
```
|
|
|
|
For rollback:
|
|
|
|
1. Stop all Host Agents and the Cloud API.
|
|
2. Back up PostgreSQL or the SQLite database file.
|
|
3. Before rolling back to a release without enrollment support, provision
|
|
temporary static Host credentials for every managed edge that must continue
|
|
operating. Stop those Host Agents and set their explicit Host ID/token.
|
|
4. If the previous application version cannot use the current schema, run the
|
|
tested downgrade while no application process is connected:
|
|
|
|
```bash
|
|
uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini downgrade -1
|
|
```
|
|
|
|
5. Restore the previous application image or checkout and start the Cloud API.
|
|
6. Verify `/health/ready`, then restart Host Agents with credentials compatible
|
|
with the restored Cloud API.
|
|
|
|
Downgrading revision 0002 removes dynamic credential bindings and durable
|
|
device enrollment mappings. It retains the revision-0001 Host heartbeat rows,
|
|
pooled devices, queued tasks, attempts, and plugins.
|
|
|
|
Do not remove the PostgreSQL volume during an application rollback. Queued and
|
|
attempt history are durable database state and should remain available to the
|
|
restored or forward-deployed control plane.
|