Tests / Test passed: 626
- Host Agent now defaults AI_PLANNER_ENABLED=true (opt-out via env), scoped to apps/device-host-agent/host_agent/execution.py only; the shared runtime.planner_config default (disabled) is unchanged. - Add openspec proposal for cloud-planner-proxy: centralize LLM provider config/credentials on the Cloud Control Plane and let the Host Agent proxy AI Planner decisions through it instead of holding provider API keys locally. Proposal only, no implementation yet.
430 lines
18 KiB
Markdown
430 lines
18 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.
|
|
The Cloud API uses persistent user sessions for human access and a Host-generated
|
|
secret for later Host operations; no static bearer credential configuration is
|
|
required.
|
|
|
|
```powershell
|
|
$env:CLOUD_ENVIRONMENT = "local"
|
|
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
|
|
$env:CLOUD_SESSION_COOKIE_SECURE = "false"
|
|
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
|
|
```
|
|
|
|
Create the first administrator interactively, then open the Console and sign
|
|
in with that account:
|
|
|
|
```bash
|
|
uv run --package device-cloud-api device-cloud-admin users create --username admin --display-name "Local Administrator" --role admin
|
|
```
|
|
|
|
In a second terminal, configure the local Host Agent to reach this Cloud API:
|
|
|
|
```powershell
|
|
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
|
|
uv run --package device-host-agent device-host-agent setup
|
|
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.
|
|
|
|
## Direct Edge Enrollment
|
|
|
|
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
|
|
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
|
|
default; override the environment variable for local/dev/test runs pointed at
|
|
a different Cloud API.
|
|
|
|
When no cached identity exists, the Host Agent creates a random instance
|
|
identifier and Host secret, registers directly with the Cloud API, persists the
|
|
assigned `host_id`, and uses that secret for all later device enrollment,
|
|
heartbeat, claim, renewal, and result calls. The Cloud stores only the secret
|
|
digest. There is no configured enrollment-token or static Host-credential path.
|
|
|
|
This trades away any approval step: **any caller that can reach the control
|
|
plane can register itself as a new Host.** There is no rate limiting or
|
|
throttling on this path by design; restrict access at the firewall or reverse
|
|
proxy before exposing the endpoint.
|
|
|
|
Before the Host Agent's first unattended start, create the one-time local
|
|
operator account interactively:
|
|
|
|
```bash
|
|
uv run --package device-host-agent device-host-agent setup
|
|
```
|
|
|
|
This prompts for a username/password and writes a PBKDF2-hashed credential
|
|
file to `HOST_AGENT_LOCAL_ACCOUNT_PATH` (default
|
|
`tasks/host_local_account.json`), gating only this first-run bootstrap step —
|
|
it is not re-checked on subsequent unattended restarts. Running the daemon's
|
|
default command without a controlling terminal before this file exists fails
|
|
fast with a message naming the `setup` step, instead of hanging on a prompt
|
|
no one can answer.
|
|
|
|
Running under Compose, create the account once before `docker compose up`:
|
|
|
|
```bash
|
|
docker compose run --rm host-agent device-host-agent setup
|
|
docker compose up -d
|
|
```
|
|
|
|
Keep `HOST_AGENT_IDENTITY_PATH`, the local-account file, and
|
|
`tasks/device_config.sqlite3` on persistent 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.
|
|
|
|
### Local Web Console
|
|
|
|
The Host Agent can optionally serve a small local-only web console on the
|
|
edge machine: heartbeat/enrollment status, registered local devices, current
|
|
assignment progress, local device add/edit/remove, a local account password
|
|
change, and recent assignment/heartbeat history. It authenticates with the
|
|
same local account created by `device-host-agent setup` above — there is no
|
|
separate console credential.
|
|
|
|
```text
|
|
HOST_AGENT_CONSOLE_ENABLED=false
|
|
HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
|
|
HOST_AGENT_CONSOLE_PORT=8765
|
|
HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=false
|
|
HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS=43200
|
|
HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
|
|
```
|
|
|
|
- `HOST_AGENT_CONSOLE_ENABLED` — starts the console when `true`; disabled by
|
|
default, so existing deployments see no new listening port.
|
|
- `HOST_AGENT_CONSOLE_BIND_HOST` — the address the console binds to; defaults
|
|
to loopback-only.
|
|
- `HOST_AGENT_CONSOLE_PORT` — the TCP port the console listens on.
|
|
- `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` — required opt-in before
|
|
`HOST_AGENT_CONSOLE_BIND_HOST` may be a non-loopback address; the Host
|
|
Agent refuses to start otherwise.
|
|
- `HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS` — sliding idle timeout, in seconds,
|
|
for an authenticated console session.
|
|
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
|
|
entries the console retains before pruning older ones.
|
|
|
|
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
|
|
operator-accepted risk: the console has no built-in TLS and no rate
|
|
limiting, so a non-loopback bind exposes an unencrypted login form to
|
|
whatever network can reach that port. To reach the console from another
|
|
machine instead, keep it bound to loopback and open an SSH local
|
|
port-forward to the edge machine:
|
|
|
|
```bash
|
|
ssh -L 8765:127.0.0.1:8765 user@edge-host
|
|
# then open http://127.0.0.1:8765 from the local browser
|
|
```
|
|
|
|
## 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` contains only PostgreSQL and the Cloud API; Host Agents
|
|
run at their edge sites rather than beside the Cloud API. It references the
|
|
fixed Jenkins registry image and interpolates only `IMAGE_TAG` (see
|
|
`.env.example`). 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 HTTPS before browser sessions are exposed. It does not require credential
|
|
JSON. Create the first administrator interactively after startup, before
|
|
opening the Console to operators.
|
|
|
|
## Authentication And Host Identities
|
|
|
|
Cloud users are the human authorization boundary. Their `viewer`, `operator`,
|
|
and `admin` roles map to the existing API scopes, and the browser sends an
|
|
`HttpOnly` session cookie plus CSRF proof for unsafe operations. The deployment
|
|
does not accept `CLOUD_PUBLIC_CREDENTIALS_JSON`,
|
|
`CLOUD_HOST_CREDENTIALS_JSON`, or `CLOUD_ENROLLMENT_TOKENS_JSON`.
|
|
|
|
A fresh Host sends its generated candidate secret only during direct
|
|
registration. The Cloud stores its digest and returns a `host_id`; later Host
|
|
operations use that secret and are strictly bound to the returned `host_id`.
|
|
Protect the persisted Host identity file as a bearer secret.
|
|
|
|
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 task history, devices, hosts, and plugins. Human operators sign in with
|
|
a username and password; the Cloud API creates an expiring, revocable
|
|
`HttpOnly` session cookie and uses a separate CSRF cookie/header for writes.
|
|
The Console has no bearer-token fallback or user-directory view.
|
|
|
|
### HTTPS and session configuration
|
|
|
|
`CLOUD_ENVIRONMENT=production` requires `CLOUD_SESSION_COOKIE_SECURE=true`.
|
|
Terminate TLS at a reverse proxy and open the same-origin Console through HTTPS,
|
|
for example `https://cloud.example.com/console/`. Direct `http://host:8001`
|
|
access is for local/test mode only; it cannot retain production login cookies.
|
|
|
|
The defaults are an 8-hour idle session TTL, 7-day absolute TTL, and a temporary
|
|
block after five failed logins in a 15-minute username/client-address window:
|
|
|
|
```text
|
|
CLOUD_USER_SESSION_IDLE_SECONDS=28800
|
|
CLOUD_USER_SESSION_ABSOLUTE_SECONDS=604800
|
|
CLOUD_LOGIN_FAILURE_LIMIT=5
|
|
CLOUD_LOGIN_FAILURE_WINDOW_SECONDS=900
|
|
CLOUD_LOGIN_BLOCK_SECONDS=900
|
|
CLOUD_SESSION_COOKIE_SECURE=true
|
|
CLOUD_TRUST_PROXY_HEADERS=false
|
|
```
|
|
|
|
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
|
|
`X-Forwarded-For` before requests reach the Cloud API. This affects the
|
|
client-address bucket used for login throttling; the default is safe when the
|
|
application is reached directly.
|
|
|
|
### Create and recover administrator accounts
|
|
|
|
After migrations and Cloud API startup, create the first account interactively:
|
|
|
|
```bash
|
|
docker compose exec cloud-api \
|
|
device-cloud-admin users create \
|
|
--username admin --display-name "Cloud Administrator" --role admin
|
|
```
|
|
|
|
The command prompts twice for the password, so it does not enter shell history,
|
|
Compose configuration, process arguments, logs, or container inspection output.
|
|
Recovery commands are also interactive:
|
|
|
|
```bash
|
|
docker compose exec cloud-api device-cloud-admin users reset-password --username admin
|
|
docker compose exec cloud-api device-cloud-admin users enable --username admin
|
|
docker compose exec cloud-api device-cloud-admin users revoke-sessions --username admin
|
|
```
|
|
|
|
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
|
|
submits tasks; `admin` has unrestricted Cloud API access. The administration
|
|
CLI creates users, resets passwords, changes roles, enables accounts, and
|
|
revokes sessions. New and reset users must change their temporary password
|
|
before accessing other resources, and the API will not disable or demote the
|
|
last enabled administrator.
|
|
|
|
### 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 browser sessions are credentialed.
|
|
|
|
### 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. For local development
|
|
set `CLOUD_SESSION_COOKIE_SECURE=false`, then open the dev URL and sign in with
|
|
a user account. The Console sends credentialed requests and attaches CSRF proof
|
|
to writes.
|
|
|
|
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 sets `CLOUD_CONSOLE_STATIC_DIR` in the image itself. The Cloud API serves
|
|
the Console at `/console/` (visiting `/` 307-redirects there). Put that origin
|
|
behind an HTTPS reverse proxy, then open for example
|
|
`https://cloud.example.com/` directly — no separate dev server, static host, or
|
|
CORS allow-list is needed because the SPA and API share one origin.
|
|
|
|
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
|
|
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
|
|
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
|
|
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
|
|
|
|
Source-based local development leaves `CLOUD_CONSOLE_STATIC_DIR` unset; the
|
|
mount remains conditional on that image-provided setting.
|
|
|
|
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. Unlike the shared Runtime
|
|
library (whose own default is the deterministic stub planner), the **Host
|
|
Agent defaults `AI_PLANNER_ENABLED` to on** -- it is the actual device-control
|
|
path, so goal assignments use a model unless an operator explicitly opts out.
|
|
Provide provider credentials before deploying:
|
|
|
|
```text
|
|
AI_PLANNER_PROVIDER=anthropic
|
|
AI_PLANNER_MODEL=claude-sonnet-5
|
|
AI_PLANNER_TIMEOUT_SECONDS=30
|
|
ANTHROPIC_API_KEY=<secret manager reference>
|
|
```
|
|
|
|
Without a valid API key, every planning step raises immediately and the task
|
|
fails on its first step (no silent fallback to the stub planner). Set
|
|
`AI_PLANNER_ENABLED=false` to opt back out to the deterministic stub planner
|
|
(e.g. for offline/dev hosts with no provider credentials).
|
|
|
|
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 an image that still requires static credentials,
|
|
restore that image's matching deployment configuration and provision the
|
|
required legacy credentials outside this release's Compose contract.
|
|
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 identities 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.
|