Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static. Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass that falls back to index.html for deep-link refreshes) when the new CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static files bypass bearer auth (the SPA shell is public; tokens are still required for /v1/*). Compose enables the mount by default; local dev still uses npm run dev + CLOUD_CONSOLE_CORS_ORIGINS. Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE, APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors (registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top, mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to official upstreams so `docker build .` still works anywhere. Backend suite: 443 passed (-m "not integration"); cloud-console typecheck and production build succeed with the new base path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
15 KiB
Markdown
355 lines
15 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`.
|
|
|
|
### Same-origin deployment (baked into the Cloud API image)
|
|
|
|
The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
|
|
and `compose.yaml` / `compose.deploy.yaml` set
|
|
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
|
|
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
|
|
307-redirects there), so operators can open `https://<cloud-api-host>:8001/`
|
|
directly — no separate dev server, no static host, no CORS allow-list needed
|
|
(the SPA and the API share one origin).
|
|
|
|
The SPA shell (`index.html`, JS, CSS) is served without a bearer token by
|
|
design — `_authorize(...)` is called inside the `/v1/*` route handlers, not in
|
|
middleware, so the SPA can boot before the operator pastes a token. All
|
|
`/v1/*` API calls still require `tasks:read`/`pool:read`/`plugins:read` scopes
|
|
as before.
|
|
|
|
To opt out (e.g. for local development where you run `npm run dev`), leave
|
|
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
|
|
|
|
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
|
|
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
|
|
Docker Hub / ghcr.io / npmjs.org / deb.debian.org. Blank any of them to fall
|
|
back to the upstream.
|
|
|
|
## 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.
|