Tests / Test passed: 581
Host Agent: - One-time local operator account bootstrap (PBKDF2-HMAC-SHA256, atomic 0600-permission write) gating the daemon's first unattended start via a new `setup` CLI subcommand. - Default control-plane URL now https://amcp.home.jerryyan.top (env var override unchanged). - Enrollment no longer requires a pre-issued token; falls back to zero-token self-service enrollment when none is configured. Cloud control plane: - CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED (default false) opt-in flag. - SelfServiceEnrollmentAuthProvider + ChainedEnrollmentAuthProvider: configured tokens still take priority; self-service only applies when no token matches, preserving edge-host-enrollment's token-bound path. - Fixed a latent bug in sql_repository.py::enroll_host: the token-conflict lookup used `== enrollment_token_digest`, which SQLAlchemy compiles to `IS NULL` when the value is None, so every self-service enrollment after the first would have falsely collided with an existing NULL-digest host. Skipped that lookup entirely when the digest is None. Docs/deploy: .env.example, compose.yaml, compose.deploy.yaml, CLOUD_DEPLOYMENT.md, MACOS_IPHONE_SETUP.md updated for the new flag, URL default, and required `device-host-agent setup` step. Verification: 494 non-integration tests pass; openspec validate --strict passes. PostgreSQL-backed contract tests and full manual end-to-end verification were not run (no Postgres/Docker or reachable cloud-api in this environment); noted as unchecked in tasks.md 7.2/7.4.
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.
|
|
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.
|
|
|
|
## Self-Service Edge Enrollment (Zero-Token)
|
|
|
|
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 `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
|
|
the Host Agent enrolls with no bearer credential at all. The Cloud API only
|
|
accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
|
|
(default `false`); a configured enrollment token, if presented, always takes
|
|
priority over self-service. This trades away any approval step: **any caller
|
|
that can reach the control-plane URL can register itself as a new Host.**
|
|
There is no rate limiting or throttling on this path by design — the intended
|
|
mitigation is network-perimeter control (firewall/reverse-proxy access to the
|
|
URL), not an in-process limiter. Only enable the flag on a deployment where
|
|
that network boundary is enforced.
|
|
|
|
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
|
|
```
|
|
|
|
## 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.
|
|
|
|
`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
|
|
Host enrollment requests with no bearer token at all — see
|
|
[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
|
|
|
|
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 task history, devices, hosts, plugins, and the user directory. 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. Existing bearer tokens remain available through the
|
|
Console's explicit **Use API token** action and for SDK, Host Agent, and
|
|
automation compatibility.
|
|
|
|
### 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.
|
|
|
|
### 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 and manages users.
|
|
Administrators create users, reset passwords, change roles, disable accounts,
|
|
and revoke sessions from the **Users** Console view. 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 `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). Put that origin behind an HTTPS reverse proxy, then open
|
|
for example `https://cloud.example.com/` directly — no separate dev server, no
|
|
static host, and no CORS allow-list are 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.
|
|
|
|
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.
|