Implements all 19 tasks of the cloud-planner-proxy OpenSpec change:
- Cloud API: cloud.planner_config (CloudPlannerConfig, load/build helpers)
reusing runtime.tool_calling_client provider clients (no new dependency
needed -- device-cloud-platform already depends on device-agent-runtime).
- Cloud API: new host-scoped POST /internal/v1/hosts/{host_id}/planner/decide
internal endpoint, reusing existing bearer auth; logs only metadata
(host id, tool name, latency, error class), never prompt/screenshot
content.
- Host Agent: new AI_PLANNER_TRANSPORT config (direct default | cloud) and
host_agent/cloud_planner_client.py::CloudProxyToolCallingClient, a
synchronous ToolCallingClient implementation (structural, not importing
runtime) that calls the new endpoint via its own httpx.Client -- avoids
bridging the async HostAgentClient across the worker-thread boundary
that AIPlanner.plan() runs in (asyncio.to_thread in lease.py).
- Host Agent wiring: create_execution_factories()/_host_agent_planner()
select the cloud-proxy client only when AI_PLANNER_TRANSPORT=cloud;
direct/unset transport is unchanged (still the default).
- Tests: 22 new tests across Cloud API config, the new endpoint, the new
client, and transport-selection wiring; full non-integration suite
(492 tests) passes with no regressions.
- Docs: docs/CLOUD_DEPLOYMENT.md documents the cloud transport, its
trade-offs, and the credential split between Host Agent and Cloud API.
proposal.md/design.md were corrected during implementation to reflect two
findings: no new anthropic/openai dependency is actually needed, and
CloudProxyToolCallingClient uses its own sync httpx.Client rather than a
new HostAgentClient method, per the thread-boundary reasoning above.
458 lines
19 KiB
Markdown
458 lines
19 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`. This is the **`direct`
|
|
transport** (the default): the Host Agent holds provider credentials and
|
|
calls Anthropic/OpenAI itself.
|
|
|
|
### Cloud-proxy transport (`AI_PLANNER_TRANSPORT=cloud`)
|
|
|
|
Set `AI_PLANNER_TRANSPORT=cloud` on the Host Agent to instead route every
|
|
planning decision through the Cloud API's
|
|
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
|
|
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
|
|
mode:
|
|
|
|
- **Credentials move to the Cloud API.** Configure `AI_PLANNER_PROVIDER`,
|
|
`AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, and
|
|
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` on the Cloud API process instead of the
|
|
Host Agent -- edge hosts no longer need provider keys at all.
|
|
- **Trade-offs to accept before enabling:**
|
|
- *Latency*: every planning step now makes a round trip to the Cloud API in
|
|
addition to the LLM provider call.
|
|
- *Availability coupling*: unlike heartbeat/claim (which tolerate transient
|
|
Cloud API outages via retry/backoff), a planning step fails immediately if
|
|
the Cloud API or its configured provider is unreachable -- there is no
|
|
fallback to the stub planner or to a local direct call.
|
|
- *Expanded data path*: goal/scene prompts and screenshots now transit the
|
|
Cloud API. The endpoint logs only metadata (host id, resolved tool name,
|
|
latency, error class) and never prompt text or screenshot bytes, but the
|
|
request bodies themselves do cross the network to the control plane.
|
|
|
|
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing
|
|
direct-to-provider behavior with no change.
|
|
|
|
## 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.
|