feat(cloud): add edge host enrollment

This commit is contained in:
2026-07-13 13:54:16 +08:00
parent cd56facbbf
commit e61dcca801
40 changed files with 2302 additions and 48 deletions
+4
View File
@@ -12,6 +12,7 @@ POSTGRES_PASSWORD=change-me-database-password
CLOUD_API_PORT=8001
CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]
CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}]
CLOUD_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}]
CLOUD_SCHEDULER_INTERVAL_SECONDS=1
CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5
CLOUD_LEASE_DURATION_SECONDS=60
@@ -19,6 +20,9 @@ CLOUD_MAX_TASK_ATTEMPTS=3
HOST_AGENT_HOST_ID=host-local
HOST_AGENT_TOKEN=change-me-host-token
HOST_AGENT_ENROLLMENT_TOKEN=
HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json
HOST_AGENT_DISPLAY_NAME=
HOST_AGENT_TASKS_PATH=./tasks
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30
HOST_AGENT_POLL_TIMEOUT_SECONDS=20
+17 -2
View File
@@ -11,7 +11,12 @@ from fastapi import FastAPI, status
from fastapi import Request
from fastapi.responses import JSONResponse
from cloud.auth import create_auth_provider
from cloud.auth import (
ChainedAuthProvider,
ConfiguredEnrollmentTokenProvider,
RepositoryHostAuthProvider,
create_auth_provider,
)
from cloud.config import CloudConfig
from cloud.control_config import (
CloudControlConfig,
@@ -73,11 +78,20 @@ def create_app(
control_config = config or load_control_config()
validate_control_config(control_config)
build_database = database_factory or _default_database_factory
auth_provider = create_auth_provider(
configured_auth_provider = create_auth_provider(
control_config.credentials,
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
)
repository = _RepositoryProxy()
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
)
)
enrollment_auth_provider = ConfiguredEnrollmentTokenProvider(
control_config.enrollment_credentials
)
domain_config = CloudConfig(
lease_duration_seconds=control_config.lease_duration_seconds,
)
@@ -208,6 +222,7 @@ def create_app(
create_internal_router(
pool=pool,
auth_provider=auth_provider,
enrollment_auth_provider=enrollment_auth_provider,
lease_duration_seconds=control_config.lease_duration_seconds,
)
)
+151
View File
@@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
import cloud_api.app as app_module
from cloud_api.app import create_app
from cloud.auth import BearerCredential, EnrollmentCredential, digest_token
from cloud.control_config import CloudConfigurationError, CloudControlConfig
from cloud.database import CloudDatabase
from cloud.pool import PooledDevice
@@ -23,6 +24,156 @@ def test_create_app_returns_independent_cloud_application() -> None:
paths = set(app.openapi()["paths"])
assert "/v1/tasks" in paths
assert "/internal/v1/hosts/{host_id}/heartbeat" in paths
assert "/internal/v1/enrollments" in paths
assert "/internal/v1/hosts/{host_id}/devices/enroll" in paths
def test_managed_host_enrollment_device_mapping_and_restart_authentication(
tmp_path,
) -> None:
database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}"
enrollment_token = "one-time-enrollment-token"
host_token = "host-token-" + ("x" * 40)
config = CloudControlConfig(
database_url=database_url,
credentials=(
BearerCredential(
principal_id="operator",
token="operator-token",
scopes=frozenset({"pool:read"}),
),
),
enrollment_credentials=(
EnrollmentCredential(
principal_id="installer-a",
token=enrollment_token,
),
),
)
enrollment_payload = {
"agent_instance_id": "agent-instance-a",
"host_token": host_token,
"display_name": "Edge Mac",
}
app = create_app(config=config)
with TestClient(app) as client:
enrolled = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert enrolled.status_code == 201
host_id = enrolled.json()["host_id"]
assert host_id.startswith("host-")
retried = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert retried.status_code == 201
assert retried.json()["host_id"] == host_id
reused = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json={
**enrollment_payload,
"agent_instance_id": "agent-instance-b",
},
)
assert reused.status_code == 409
device = client.post(
f"/internal/v1/hosts/{host_id}/devices/enroll",
headers={"Authorization": f"Bearer {host_token}"},
json={
"local_device_id": "local-device-a",
"driver_type": "wda",
"name": "iPhone",
"capability_tags": ["ios"],
},
)
assert device.status_code == 201
device_id = device.json()["device_id"]
assert device_id.startswith("device-")
device_retry = client.post(
f"/internal/v1/hosts/{host_id}/devices/enroll",
headers={"Authorization": f"Bearer {host_token}"},
json={
"local_device_id": "local-device-a",
"driver_type": "wda",
"name": "Renamed iPhone",
"capability_tags": ["ios", "physical"],
},
)
assert device_retry.json()["device_id"] == device_id
rejected_snapshot = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={
"host_id": host_id,
"devices": [
{
"device_id": "caller-selected-device",
"driver_type": "wda",
"status": "idle",
}
],
},
)
assert rejected_snapshot.status_code == 409
heartbeat = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={
"host_id": host_id,
"devices": [
{
"device_id": device_id,
"driver_type": "wda",
"status": "idle",
}
],
},
)
assert heartbeat.status_code == 200
public_attempt = client.get(
"/v1/devices",
headers={"Authorization": f"Bearer {host_token}"},
)
assert public_attempt.status_code == 403
restarted = create_app(config=config)
with TestClient(restarted) as client:
heartbeat = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={"host_id": host_id, "devices": []},
)
assert heartbeat.status_code == 200
assert (
restarted.state.cloud_services.repository.authenticate_enrolled_host(
digest_token(host_token)
)
== host_id
)
assert restarted.state.cloud_services.repository.revoke_enrolled_host(
host_id,
revoked_at=utc_now(),
)
rejected = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={"host_id": host_id, "devices": []},
)
assert rejected.status_code == 401
def test_cloud_application_owns_database_lifecycle() -> None:
+41 -7
View File
@@ -8,10 +8,12 @@ from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.enrollment import resolve_host_identity
from host_agent.execution import create_execution_factories
from host_agent.heartbeat import HeartbeatSynchronizer
from host_agent.identity import HostIdentityStore
from host_agent.lease import ActiveAssignmentRunner
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from storage.device_config import DeviceConfigStore
@@ -97,9 +99,29 @@ def create_application(
config: HostAgentConfig | None = None,
manager: DeviceManager | None = None,
device_config_store: DeviceConfigStore | None = None,
identity_store: HostIdentityStore | None = None,
enrollment_client: HostAgentEnrollmentClient | None = None,
) -> HostAgentApplication:
resolved_config = config or load_host_agent_config()
resolved_manager = manager or _configured_device_manager(device_config_store)
startup_config = config or load_host_agent_config()
config_store = device_config_store or DeviceConfigStore()
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
try:
resolved_config = resolve_host_identity(
startup_config,
identity_store=identity_store
or HostIdentityStore(startup_config.identity_path),
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
finally:
if owned_enrollment_client:
bootstrap_client.close()
client = HostAgentClient(resolved_config)
heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config)
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
@@ -112,13 +134,25 @@ def create_application(
def _configured_device_manager(
config_store: DeviceConfigStore | None,
config_store: DeviceConfigStore,
*,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient,
) -> DeviceManager:
manager = DeviceManager()
store = config_store or DeviceConfigStore()
for device in store.list():
for device in config_store.list():
runtime_device_id = device["device_id"]
if config.enrollment_managed:
enrollment = enrollment_client.enroll_device(
local_device_id=device["device_id"],
driver_type=device["driver_type"],
name=device["name"],
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device["device_id"], runtime_device_id)
manager.register_device(
device["device_id"],
runtime_device_id,
build_driver_factory(
device["driver_type"],
device["connection_info"],
+111 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
@@ -9,8 +10,10 @@ import httpx
from cloud.internal_api.models import (
AssignmentModel,
ClaimResponse,
DeviceEnrollmentResponse,
DeviceSnapshotModel,
HeartbeatResponse,
HostEnrollmentResponse,
LeaseRenewalResponse,
TerminalResultResponse,
)
@@ -30,6 +33,99 @@ class StaleLeaseError(HostAgentAPIError):
pass
class HostAgentEnrollmentClient:
def __init__(
self,
config: HostAgentConfig,
*,
http_client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.config = config
self._sleep = sleep
self._owns_client = http_client is None
self._client = http_client or httpx.Client(base_url=config.control_plane_url)
def enroll_host(
self,
*,
agent_instance_id: str,
host_token: str,
display_name: str | None,
) -> HostEnrollmentResponse:
if not self.config.enrollment_token:
raise HostAgentAPIError(0, "Host enrollment token is unavailable")
response = self._request(
"POST",
"/internal/v1/enrollments",
token=self.config.enrollment_token,
json={
"agent_instance_id": agent_instance_id,
"host_token": host_token,
"display_name": display_name,
},
)
return HostEnrollmentResponse.model_validate(response.json())
def enroll_device(
self,
*,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
) -> DeviceEnrollmentResponse:
if not self.config.host_id or not self.config.token:
raise HostAgentAPIError(0, "Host identity is unresolved")
response = self._request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/devices/enroll",
token=self.config.token,
json={
"local_device_id": local_device_id,
"driver_type": driver_type,
"name": name,
"capability_tags": list(capability_tags),
},
)
return DeviceEnrollmentResponse.model_validate(response.json())
def close(self) -> None:
if self._owns_client:
self._client.close()
def _request(
self,
method: str,
path: str,
*,
token: str,
json: dict[str, Any],
) -> httpx.Response:
backoff = self.config.retry_backoff_seconds
for attempt in range(1, self.config.max_retry_attempts + 1):
try:
response = self._client.request(
method,
path,
json=json,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.TransportError:
if attempt == self.config.max_retry_attempts:
raise
else:
if response.status_code < 500:
if response.is_success:
return response
_raise_api_error(response)
if attempt == self.config.max_retry_attempts:
_raise_api_error(response)
self._sleep(backoff)
backoff = min(backoff * 2, self.config.max_retry_backoff_seconds)
raise AssertionError("retry loop exited unexpectedly")
class HostAgentClient:
def __init__(
self,
@@ -149,22 +245,24 @@ class HostAgentClient:
if response.status_code < 500:
if response.is_success:
return response
self._raise_api_error(response)
_raise_api_error(response, stale_lease=True)
if attempt == self.config.max_retry_attempts:
self._raise_api_error(response)
_raise_api_error(response, stale_lease=True)
await self._sleep(backoff)
backoff = min(backoff * 2, self.config.max_retry_backoff_seconds)
raise AssertionError("retry loop exited unexpectedly")
@staticmethod
def _raise_api_error(response: httpx.Response) -> None:
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail") or payload.get("code") or "request rejected"
error_type = (
StaleLeaseError if response.status_code == 409 else HostAgentAPIError
)
raise error_type(response.status_code, str(detail))
def _raise_api_error(response: httpx.Response, *, stale_lease: bool = False) -> None:
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail") or payload.get("code") or "request rejected"
error_type = (
StaleLeaseError
if stale_lease and response.status_code == 409
else HostAgentAPIError
)
raise error_type(response.status_code, str(detail))
+25 -7
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse
@@ -13,8 +14,12 @@ class HostAgentConfigurationError(ValueError):
@dataclass(frozen=True)
class HostAgentConfig:
control_plane_url: str
host_id: str
token: str
host_id: str = ""
token: str = field(default="", repr=False)
enrollment_token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json")
enrollment_managed: bool = False
display_name: str | None = None
heartbeat_interval_seconds: float = 30.0
poll_timeout_seconds: float = 20.0
retry_backoff_seconds: float = 1.0
@@ -41,16 +46,29 @@ def load_host_agent_config(
)
host_id = values.get("HOST_AGENT_HOST_ID", "").strip()
if not host_id:
raise HostAgentConfigurationError("HOST_AGENT_HOST_ID is required")
token = values.get("HOST_AGENT_TOKEN", "").strip()
if not token:
raise HostAgentConfigurationError("HOST_AGENT_TOKEN is required")
if bool(host_id) != bool(token):
raise HostAgentConfigurationError(
"HOST_AGENT_HOST_ID and HOST_AGENT_TOKEN must be configured together"
)
enrollment_token = values.get("HOST_AGENT_ENROLLMENT_TOKEN", "").strip()
identity_path = Path(
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip()
)
if not host_id and not enrollment_token and not identity_path.is_file():
raise HostAgentConfigurationError(
"explicit Host credentials, an enrollment token, or existing identity state "
"is required"
)
config = HostAgentConfig(
control_plane_url=control_plane_url,
host_id=host_id,
token=token,
enrollment_token=enrollment_token,
identity_path=identity_path,
enrollment_managed=not bool(host_id),
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float(
values,
"HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS",
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import replace
from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, HostAgentConfigurationError
from host_agent.identity import HostIdentityStore
def resolve_host_identity(
config: HostAgentConfig,
*,
identity_store: HostIdentityStore,
client: HostAgentEnrollmentClient,
) -> HostAgentConfig:
if config.host_id and config.token:
return config
state = identity_store.load_or_create()
if state.host_id is None:
if not config.enrollment_token:
raise HostAgentConfigurationError(
"HOST_AGENT_ENROLLMENT_TOKEN is required to complete enrollment"
)
response = client.enroll_host(
agent_instance_id=state.agent_instance_id,
host_token=state.token,
display_name=config.display_name,
)
state = identity_store.complete(state, response.host_id)
return replace(
config,
host_id=state.host_id or "",
token=state.token,
enrollment_managed=True,
)
@@ -0,0 +1,101 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, replace
from pathlib import Path
from secrets import token_urlsafe
from uuid import uuid4
class HostIdentityStateError(RuntimeError):
"""Raised when persisted Host enrollment identity is missing or invalid."""
@dataclass(frozen=True)
class HostIdentityState:
agent_instance_id: str
token: str = field(repr=False)
host_id: str | None = None
class HostIdentityStore:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
def load(self) -> HostIdentityState | None:
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise HostIdentityStateError("Host identity state is unreadable") from exc
if not isinstance(payload, dict):
raise HostIdentityStateError("Host identity state must be an object")
agent_instance_id = payload.get("agent_instance_id")
token = payload.get("token")
host_id = payload.get("host_id")
if (
not isinstance(agent_instance_id, str)
or not agent_instance_id
or not isinstance(token, str)
or len(token) < 32
or (host_id is not None and (not isinstance(host_id, str) or not host_id))
):
raise HostIdentityStateError("Host identity state is invalid")
return HostIdentityState(
agent_instance_id=agent_instance_id,
token=token,
host_id=host_id,
)
def load_or_create(self) -> HostIdentityState:
existing = self.load()
if existing is not None:
return existing
state = HostIdentityState(
agent_instance_id=f"agent-{uuid4().hex}",
token=token_urlsafe(48),
)
self._write(state)
return state
def complete(self, state: HostIdentityState, host_id: str) -> HostIdentityState:
if not host_id:
raise ValueError("host_id must not be empty")
current = self.load()
if current is not None and (
current.agent_instance_id != state.agent_instance_id
or current.token != state.token
):
raise HostIdentityStateError("Host identity changed during enrollment")
completed = replace(state, host_id=host_id)
self._write(completed)
return completed
def _write(self, state: HostIdentityState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
payload = {
"agent_instance_id": state.agent_instance_id,
"token": state.token,
"host_id": state.host_id,
}
try:
temporary.write_text(
json.dumps(payload, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
_restrict_permissions(temporary)
os.replace(temporary, self.path)
_restrict_permissions(self.path)
finally:
if temporary.exists():
temporary.unlink()
def _restrict_permissions(path: Path) -> None:
try:
path.chmod(0o600)
except OSError:
return
+106 -1
View File
@@ -4,10 +4,15 @@ import asyncio
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from cloud.internal_api.models import AssignmentModel
from cloud.internal_api.models import (
AssignmentModel,
DeviceEnrollmentResponse,
HostEnrollmentResponse,
)
from device.manager import DeviceManager
from host_agent.app import HostAgentApplication, create_application
from host_agent.config import HostAgentConfig
from host_agent.identity import HostIdentityStore
from storage.device_config import DeviceConfigStore
@@ -65,6 +70,106 @@ def test_create_application_loads_persisted_device_configuration(
asyncio.run(application.client.aclose())
def test_create_application_enrolls_host_and_devices_before_managed_startup(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
)
events: list[str] = []
class EnrollmentClient:
def __init__(self) -> None:
self.config = HostAgentConfig(
control_plane_url="https://control.example",
enrollment_token="one-time-token",
enrollment_managed=True,
)
def enroll_host(self, **payload):
events.append(f"host:{payload['agent_instance_id']}")
return HostEnrollmentResponse(host_id="host-cloud-a")
def enroll_device(self, **payload):
events.append(f"device:{payload['local_device_id']}")
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
raise AssertionError("injected client must not be closed")
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events[0].startswith("host:agent-")
assert events[1] == "device:local-device-a"
assert application.client.config.host_id == "host-cloud-a"
assert application.client.config.enrollment_managed is True
assert [device.id for device in application.heartbeat.manager.list_devices()] == [
"device-cloud-a"
]
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
assert identity_store.load().host_id == "host-cloud-a"
asyncio.run(application.client.aclose())
def test_managed_restart_reuses_identity_and_recovers_device_mapping(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
driver_type="wda",
connection_info={},
)
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
identity_store.complete(identity_store.load_or_create(), "host-cloud-a")
events: list[str] = []
class EnrollmentClient:
config = HostAgentConfig(
control_plane_url="https://control.example",
identity_path=tmp_path / "host_identity.json",
enrollment_managed=True,
)
def enroll_host(self, **payload):
raise AssertionError("completed identity must skip Host enrollment")
def enroll_device(self, **payload):
events.append(payload["local_device_id"])
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
return None
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events == ["local-device-a"]
assert application.client.config.host_id == "host-cloud-a"
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
asyncio.run(application.client.aclose())
def test_shutdown_cancels_long_poll_and_sends_final_heartbeat() -> None:
async def scenario() -> None:
claim_started = asyncio.Event()
+60 -1
View File
@@ -8,7 +8,11 @@ import httpx
import pytest
from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
from host_agent.client import HostAgentClient, StaleLeaseError
from host_agent.client import (
HostAgentClient,
HostAgentEnrollmentClient,
StaleLeaseError,
)
from host_agent.config import HostAgentConfig
@@ -166,3 +170,58 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None:
assert len(payloads) == 2
assert payloads[0] == payloads[1]
assert payloads[0]["failure_reason"] == "planner unavailable"
def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> None:
requests: list[httpx.Request] = []
host_attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal host_attempts
requests.append(request)
if request.url.path == "/internal/v1/enrollments":
host_attempts += 1
if host_attempts == 1:
raise httpx.ReadError("response lost", request=request)
return httpx.Response(201, json={"host_id": "host-cloud-a"})
return httpx.Response(201, json={"device_id": "device-cloud-a"})
config = _config(
host_id="",
token="",
enrollment_token="one-time-token",
enrollment_managed=True,
)
with httpx.Client(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentEnrollmentClient(
config,
http_client=http_client,
sleep=lambda _delay: None,
)
host = client.enroll_host(
agent_instance_id="agent-instance-a",
host_token="host-token-" + ("x" * 40),
display_name="Edge Mac",
)
client.config = _config(
host_id=host.host_id,
token="host-token-" + ("x" * 40),
enrollment_token="one-time-token",
enrollment_managed=True,
)
device = client.enroll_device(
local_device_id="local-device-a",
driver_type="wda",
name="iPhone",
capability_tags=["ios"],
)
assert host.host_id == "host-cloud-a"
assert device.device_id == "device-cloud-a"
assert len(requests) == 3
assert requests[0].content == requests[1].content
assert requests[0].headers["authorization"] == "Bearer one-time-token"
assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40))
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from host_agent.config import (
@@ -40,11 +42,44 @@ def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
assert config.max_retry_backoff_seconds == 20
def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None:
identity_path = tmp_path / "host_identity.json"
config = load_host_agent_config(
{
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_ENROLLMENT_TOKEN": "one-time-token",
"HOST_AGENT_IDENTITY_PATH": str(identity_path),
"HOST_AGENT_DISPLAY_NAME": "Edge Mac",
}
)
assert config.host_id == ""
assert config.token == ""
assert config.enrollment_token == "one-time-token"
assert config.identity_path == identity_path
assert config.enrollment_managed is True
assert config.display_name == "Edge Mac"
assert "one-time-token" not in repr(config)
def test_existing_identity_state_allows_restart_without_enrollment_token(
tmp_path,
) -> None:
identity_path = tmp_path / "host_identity.json"
identity_path.write_text("{}", encoding="utf-8")
config = load_host_agent_config({"HOST_AGENT_IDENTITY_PATH": str(identity_path)})
assert config.identity_path == Path(identity_path)
assert config.enrollment_managed is True
@pytest.mark.parametrize(
"overrides",
[
{"HOST_AGENT_HOST_ID": ""},
{"HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_HOST_ID": "host-a", "HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_CONTROL_PLANE_URL": "ftp://cloud.example"},
{"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"},
{
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
import pytest
from host_agent.identity import (
HostIdentityStateError,
HostIdentityStore,
)
def test_identity_store_persists_pending_and_completed_state(tmp_path) -> None:
path = tmp_path / "state" / "host_identity.json"
store = HostIdentityStore(path)
pending = store.load_or_create()
assert pending.host_id is None
assert len(pending.token) >= 32
assert "token=" not in repr(pending)
assert store.load() == pending
completed = store.complete(pending, "host-cloud-a")
assert completed.host_id == "host-cloud-a"
assert store.load() == completed
assert "host-cloud-a" in path.read_text(encoding="utf-8")
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
def test_identity_store_rejects_invalid_or_changed_state(tmp_path) -> None:
path = tmp_path / "host_identity.json"
path.write_text('{"agent_instance_id":"a"}', encoding="utf-8")
store = HostIdentityStore(path)
with pytest.raises(HostIdentityStateError):
store.load()
path.unlink()
pending = store.load_or_create()
path.write_text(
'{"agent_instance_id":"other","token":"' + ("x" * 40) + '"}',
encoding="utf-8",
)
with pytest.raises(HostIdentityStateError, match="changed"):
store.complete(pending, "host-a")
+4
View File
@@ -27,6 +27,7 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
@@ -50,6 +51,9 @@ services:
HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001
HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID}
HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1}
+4
View File
@@ -28,6 +28,7 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
@@ -52,6 +53,9 @@ services:
HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001
HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID}
HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1}
+82 -3
View File
@@ -41,6 +41,49 @@ 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
@@ -106,11 +149,40 @@ only the scopes required by each integration:
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
```
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. AI planning is disabled by
@@ -167,17 +239,24 @@ For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
3. If the previous application version cannot use the current schema, run the
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
```
4. Restore the previous application image or checkout and start the Cloud API.
5. Verify `/health/ready`, then restart Host Agents with credentials compatible
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.
+58 -4
View File
@@ -338,7 +338,60 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
操作只写入配置,不会自动 connect。
## 9. 多设备与端口
## 9. 启动云端受管 Host Agent
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
先把设备连接参数写入本地配置;这里的 `device_id` 只是 Mac 内部引用,不需要与
云端协调,云端会在首次启动时下发正式 ID:
```bash
uv run --package device-agent-runtime python - <<'PY'
import os
from storage.device_config import DeviceConfigStore
DeviceConfigStore("tasks/device_config.sqlite3").add(
device_id="local-iphone-1",
name="Edge iPhone",
driver_type="wda",
connection_info={
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"wda_local_port": 8100,
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
},
)
PY
```
从云端管理员获取一枚未使用的一次性 enrollment token,然后启动:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="<one-time enrollment token>"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent
```
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 不监听入站端口。成功后可以从边缘环境移除 enrollment token,但必须
保留并保护 `tasks/host_identity.json``tasks/device_config.sqlite3`;前者等同于
Host bearer credential。
需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量
同时存在时优先于 enrollment state。
## 10. 多设备与端口
同时连接多台 iPhone 时,每台设备至少需要:
@@ -364,7 +417,7 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
`webDriverAgentUrl`。常规单机使用优先让 Appium 管理 WDA,不要一开始就引入
`iproxy` 或手工 WDA 生命周期。
## 10. 常见故障
## 11. 常见故障
### Appium 返回 `device offline`
@@ -420,7 +473,7 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
wheel 与 Apple Silicon 兼容性。
## 11. 完成检查表
## 12. 完成检查表
- [ ] Xcode 能看到已解锁的 iPhone。
- [ ] iPhone 已信任 Mac,并启用 Developer Mode。
@@ -433,8 +486,9 @@ wheel 与 Apple Silicon 兼容性。
- [ ] 实机 integration test 通过。
- [ ] Runtime API 返回 `iphone-1`,并能执行 screenshot/tap/launch。
- [ ] 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。
- [ ] 云端受管部署已保存 Host identity,并能在 `/v1/hosts``/v1/devices` 中看到。
## 12. 后续代码改进建议
## 13. 后续代码改进建议
为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现:
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,111 @@
## Context
The current Cloud Control Plane authenticates Host Agents from a static environment-provided credential list. A Host Agent must start with a pre-agreed `host_id`, bearer token, and locally chosen device IDs; its first heartbeat implicitly creates the host and pooled-device rows. This works for controlled development but creates manual coordination, identity collisions, and unsafe retry behavior for repeatable edge deployment.
The change crosses Cloud API authentication, durable repository state, migrations, internal protocol models, Host Agent startup, and local device configuration. It must preserve the established outbound-only Host Agent protocol, PostgreSQL/SQLite parity, the existing static-credential deployment path, and the Runtime dependency direction: enrollment remains an outer cloud/application concern and does not enter `core`, `driver`, `device`, or `tools`.
## Goals / Non-Goals
**Goals:**
- Allow a new edge installation to start with a control-plane URL and one-time enrollment token instead of pre-coordinated Host and device IDs.
- Make the Cloud Control Plane authoritative for generated `host_id` and `device_id` values.
- Keep enrollment retries idempotent across response loss and process restart without persisting plaintext long-lived Host secrets in cloud storage.
- Persist Host credentials and device enrollment mappings through the existing repository abstraction for both PostgreSQL and SQLite.
- Let dynamically enrolled Host Agents authenticate normal heartbeat, claim, renew, and result requests through the existing Host-bound authorization contract.
- Preserve explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` configuration as a compatible legacy mode.
- Protect existing local Runtime device IDs by storing the cloud mapping separately and translating managed assignments at Host Agent composition time.
**Non-Goals:**
- Automatic discovery of iPhones from USB/Appium; operators still create local device configuration records.
- A public enrollment-management UI, tenant model, certificate authority, mTLS, or remote Host installation service.
- Silent transfer of an enrolled device between Hosts. Moving a phone creates a new Host-scoped device enrollment unless a future explicit transfer capability is added.
- Distribution of Appium/WDA signing configuration, LLM credentials, or workflow definitions from the cloud.
- Removal of static Host credentials in this change.
## Decisions
### D1. Separate bootstrap enrollment from the operational Host protocol
Add `POST /internal/v1/enrollments` authenticated by a configured enrollment token. The endpoint is the only internal route that does not require an existing Host-bound principal. Heartbeat, claim, renewal, result reporting, and device enrollment continue to require a Host-bound bearer credential.
Alternative considered: allow an unknown Host to create itself through heartbeat. Rejected because heartbeat would mix bootstrap authentication, identity creation, and state replacement, and an interrupted first heartbeat would be difficult to distinguish from credential misuse.
### D2. The edge generates the long-lived Host secret; the cloud generates the Host ID
Before its first enrollment request, the Host Agent generates and durably stores an `agent_instance_id` and a high-entropy bearer token. The enrollment request sends the candidate Host token over TLS while authenticating with the one-time enrollment token. The cloud generates `host_id`, stores only the Host token digest, and binds it to the instance.
This makes response-loss retries safe: the same instance can resend the same candidate secret, and the server can verify its digest and return the same `host_id`. The cloud never needs to retain or re-return plaintext Host credentials.
Alternative considered: cloud-generated Host secret returned once. Rejected because a lost response would require either unrecoverable enrollment or plaintext/encrypted secret recovery state on the server.
### D3. Enrollment tokens are configured but consumption is durable
`CLOUD_ENROLLMENT_TOKENS_JSON` supplies high-entropy bootstrap tokens to the Cloud API. Authentication compares token digests without logging token values. The Host enrollment transaction records the enrollment-token digest used by an instance, with a uniqueness constraint so a token cannot enroll a second instance after restart.
An identical retry for the same `agent_instance_id` and Host credential digest returns the existing Host identity. Reuse for another instance, or retry with a different candidate Host token, returns a conflict.
### D4. Dynamic Host credentials compose with existing configured credentials
Add a repository-backed Host `AuthProvider` that hashes the presented bearer token and resolves a non-revoked enrolled Host. Compose it after the existing configured bearer provider. Public SDK scopes remain configuration-driven; dynamically enrolled credentials receive only a Host-bound principal and therefore cannot call public operator APIs.
Static Host credentials retain current behavior and local device IDs. This limits migration risk and permits staged deployment.
### D5. Durable device enrollment is separate from transient pool state
Add a `device_enrollments` table with cloud-generated `device_id`, owning `host_id`, opaque `local_device_id`, driver metadata, enrollment timestamps, and revocation state. Enforce uniqueness for both `device_id` and `(host_id, local_device_id)`.
`POST /internal/v1/hosts/{host_id}/devices/enroll` is Host-authenticated. Repeating the same Host/local-device pair returns the same cloud ID and may refresh non-identity metadata. A different Host receives a different ID even if it reports the same physical phone.
Pooled-device rows remain replaceable heartbeat projections. For enrollment-managed Hosts, a heartbeat may report only non-revoked device IDs enrolled to that Host, with matching driver type. Legacy statically authenticated Hosts retain the existing snapshot behavior.
### D6. Local Runtime identity and cloud identity remain distinct
Extend `DeviceConfigStore` records with nullable `cloud_device_id`. Existing `device_id` remains the local Runtime/configuration key and is used as the opaque enrollment reference; no raw iPhone UDID must be sent solely for enrollment.
In managed mode the Host Agent enrolls every configured device before constructing its `DeviceManager`, persists returned mappings, and registers drivers under the cloud `device_id`. Assignment execution therefore continues to use the existing `DeviceManager` and tool contracts without adding translation logic to Runtime layers. Legacy mode registers the existing local IDs unchanged.
### D7. Host identity state is written before and after network enrollment
Use an edge-local identity file under the mounted tasks/state path. Before the first request, atomically persist the generated instance ID and Host token; after a successful response, atomically add the assigned Host ID. Restrict file permissions to the current user where the operating system supports it. Environment-provided explicit Host credentials take precedence and do not overwrite managed identity state.
This state is a secret and must be backed up or deliberately revoked before replacement. Losing it causes a new enrollment rather than unsafe guessing of a prior identity.
### D8. Schema revision 0002 carries enrollment state
Add a forward/downgrade Alembic revision after `0001_cloud_repository`. Extend `host_registrations` with nullable instance, credential, enrollment-token, display-name, enrolled-at, and revoked-at fields, plus required uniqueness/indexes. Add `device_enrollments`. Fresh local/test databases continue to use SQLAlchemy metadata creation; production readiness requires revision 0002.
The repository owns atomic Host enrollment, credential lookup/revocation, device enrollment/lookup, and managed-snapshot validation queries. HTTP handlers do not assemble multi-step uniqueness checks outside the transaction.
## Risks / Trade-offs
- [Identity file theft permits Host impersonation] -> Store only on the edge host, set restrictive permissions, keep it outside images/source control, use HTTPS, and support repository-level revocation.
- [Configured enrollment tokens remain present after consumption] -> Persist token-digest consumption with a unique constraint so application restart or unchanged environment configuration cannot reuse them.
- [Static and managed modes increase transitional complexity] -> Make the mode explicit in resolved Host Agent configuration and cover both paths with contract tests; do not silently convert a static deployment.
- [Device mapping is lost locally] -> Re-enrollment is idempotent by `(host_id, local_device_id)` and reconstructs the same cloud ID when Host identity state remains available.
- [Phone movement creates multiple historical device IDs] -> Treat enrollment as a Host attachment for this release; require future explicit transfer semantics before preserving identity across Hosts.
- [Database-backed auth adds a query to Host requests] -> Query by an indexed SHA-256 digest. Optimize with bounded caching only after measurement; revocation correctness takes priority.
- [Rollback cannot authenticate newly enrolled Hosts] -> Keep static credential support and require operators to provision temporary static credentials before rolling back application/schema.
## Migration Plan
1. Deploy the schema migration while existing Cloud API and Host Agent versions are stopped or compatible with the additive schema.
2. Deploy the Cloud API with `CLOUD_ENROLLMENT_TOKENS_JSON`; retain existing `CLOUD_HOST_CREDENTIALS_JSON` during migration.
3. Verify readiness and enrollment API tests, then deploy new Host Agents.
4. Existing explicitly configured Host Agents continue in legacy mode. New edge installations use enrollment mode and persist identity/device mappings under their tasks/state volume.
5. After all managed Hosts are verified, rotate or remove no-longer-required static Host credentials independently; public SDK credentials remain configured.
Rollback requires stopping managed Host Agents, provisioning static Host credentials and IDs for any Host that must continue operating on the previous release, then downgrading the schema to revision 0001. The downgrade removes dynamic credentials and durable device enrollments but leaves legacy hosts, pooled devices, tasks, attempts, and plugins intact.
## Open Questions
No blocking questions remain for the first implementation. Certificate-based Host identity, enrollment-token administration APIs, and cross-Host device transfer are intentionally deferred.
## Verification Notes
- Repository, migration, authentication, Cloud API, Host Agent, deployment-contract, and existing end-to-end tests pass in the local SQLite/non-integration environment.
- PostgreSQL behavior is covered by the shared parameterized repository contract but was not executed without `TEST_POSTGRES_URL`.
- No real macOS/iPhone/Appium environment was available to validate first enrollment against physical hardware.
- Host revocation is implemented at the repository/operations layer; a public administrative revocation API and UI remain out of scope.
- Device discovery remains explicit local configuration, and device identity transfer between Hosts remains intentionally unsupported.
@@ -0,0 +1,31 @@
## Why
Deploying a Device Host Agent currently requires an operator to pre-coordinate both `host_id` and every `device_id` with the Cloud Control Plane. This makes edge installation brittle and prevents a cloud-authoritative onboarding flow where a trusted control plane assigns stable identities after a host proves possession of a bootstrap credential.
## What Changes
- Add an authenticated Host enrollment operation that consumes a configured one-time enrollment token and returns a cloud-generated `host_id`.
- Let the enrolling Host Agent generate its long-lived bearer secret locally, so retries remain idempotent without the control plane storing or returning plaintext credentials.
- Persist dynamically enrolled host credential digests in the cloud repository while retaining existing statically configured Host credentials for compatibility.
- Add an authenticated device enrollment operation that maps a host-scoped opaque local device reference to a cloud-generated `device_id`.
- Persist the cloud device mapping separately from transient heartbeat state and require heartbeat snapshots to use device IDs assigned to the authenticated host.
- Add Host Agent bootstrap and identity persistence so an edge installation can start with only the control-plane URL and an enrollment token, recover safely after response loss, and reuse assigned IDs after restart.
- Extend local device configuration with an optional cloud device mapping while keeping existing local Runtime device identifiers compatible.
- Update deployment configuration and operator documentation for enrollment-token provisioning, identity-state protection, migration, revocation, and static-credential fallback.
## Capabilities
### New Capabilities
- `edge-host-enrollment`: Secure, idempotent Host and device onboarding with cloud-assigned identities and edge-persisted credential/mapping state.
### Modified Capabilities
- `host-agent-protocol`: Permit bootstrap enrollment before the normal authenticated heartbeat/claim protocol and require enrolled cloud device IDs in later Host Agent traffic.
- `device-pool`: Separate durable device enrollment identity from transient heartbeat state and accept only device identities assigned to the reporting host.
- `cloud-control-plane`: Persist dynamic Host credentials and enrollment records through the shared PostgreSQL/SQLite repository and schema migration contract.
## Impact
- Cloud authentication, internal Host Agent API models/routes, repository protocol, SQLAlchemy models, migrations, and application composition.
- Device Host Agent configuration, startup/bootstrap client, local identity persistence, device configuration mapping, heartbeat construction, and assignment execution lookup.
- Environment variables, Compose wiring, deployment documentation, repository/HTTP/Host Agent tests, migration tests, and OpenSpec main capability contracts after archive.
- Existing deployments using `CLOUD_HOST_CREDENTIALS_JSON` with explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` remain supported during migration.
@@ -0,0 +1,44 @@
## MODIFIED Requirements
### Requirement: Deployment and local persistence modes share one contract
The cloud repository SHALL support PostgreSQL for deployed operation and SQLite for local development and tests through the same behavioral contract, including hosts, dynamic Host credential bindings, device enrollments, pooled devices, tasks, leases, attempts, and plugins.
#### Scenario: Start with PostgreSQL
- **WHEN** the configured database URL selects PostgreSQL and the schema is current
- **THEN** the control plane uses PostgreSQL for cloud state, enrollment idempotency, authentication lookup, and transactional assignment operations
#### Scenario: Start in local SQLite mode
- **WHEN** the configured database URL selects SQLite in a local or test environment
- **THEN** the same repository contract, including Host and device enrollment, is available with the documented single-control-plane concurrency limitation
### Requirement: Cloud schema is versioned with migrations
The system SHALL provide versioned forward and downgrade database migrations and SHALL refuse readiness when the database schema is incompatible with the running application.
#### Scenario: Upgrade an existing cloud database
- **WHEN** an operator applies the enrollment release migration to a database at revision 0001
- **THEN** existing hosts, pooled devices, tasks, attempts, and plugins are retained while nullable Host enrollment fields and durable device enrollment storage are added
#### Scenario: Downgrade the enrollment schema
- **WHEN** an operator downgrades revision 0002 while no enrollment-capable application process is connected
- **THEN** dynamic Host credential and device enrollment storage is removed while legacy cloud state from revision 0001 remains available
#### Scenario: Schema is behind at startup
- **WHEN** the application connects to a database whose schema version is not accepted by the running release
- **THEN** readiness fails with a diagnostic that does not expose credentials
## ADDED Requirements
### Requirement: Dynamic Host authentication uses durable credential digests
The Cloud Control Plane SHALL authenticate dynamically enrolled Host bearer credentials through indexed repository lookup of a cryptographic token digest and SHALL compose that lookup with existing configured credentials.
#### Scenario: Enrolled Host authenticates after Cloud API restart
- **WHEN** a non-revoked enrolled Host presents its bearer credential after the Cloud API restarts
- **THEN** the repository-backed authentication provider resolves the stored Host binding and authorizes only Host-scoped internal operations
#### Scenario: Dynamic Host credential calls a public route
- **WHEN** a dynamically enrolled Host credential is presented to a public SDK operation requiring a scope
- **THEN** the request is rejected for missing scope rather than inheriting public operator privileges
#### Scenario: Static credential deployment remains active
- **WHEN** an operator continues to configure a Host-bound credential through the existing environment configuration
- **THEN** that Host can use the existing operational protocol without performing bootstrap enrollment
@@ -0,0 +1,40 @@
## MODIFIED Requirements
### Requirement: Authenticated network synchronization feeds the device pool
The system SHALL expose an authenticated Host Agent operation that validates a host device snapshot against its authentication mode and durable device enrollments before delegating it to the existing device-pool synchronization behavior.
#### Scenario: Valid managed remote snapshot
- **WHEN** an authenticated enrollment-managed Host submits a complete snapshot containing only non-revoked device IDs enrolled to that Host with matching driver types
- **THEN** the device pool refreshes that Host and its devices with the same replacement and staleness semantics as an in-process synchronization call
#### Scenario: Valid legacy remote snapshot
- **WHEN** an authenticated statically configured Host submits a valid complete snapshot
- **THEN** the device pool preserves the existing compatible synchronization and ownership-conflict behavior
#### Scenario: Invalid snapshot is rejected atomically
- **WHEN** a Host Agent snapshot contains invalid identifiers, unowned cloud device IDs, conflicting driver metadata, statuses, or capability tags
- **THEN** the control plane rejects the snapshot without partially replacing the Host's previous pooled devices or heartbeat timestamp
## ADDED Requirements
### Requirement: Durable device enrollment identity is independent of pool presence
The cloud repository SHALL retain a Host-scoped device enrollment and its assigned `device_id` independently of whether the device appears in the Host's latest heartbeat snapshot.
#### Scenario: Enrolled device disconnects
- **WHEN** a Host submits a heartbeat that no longer includes a previously enrolled device
- **THEN** the pooled-device projection removes that device while its durable enrollment remains available for later idempotent re-enrollment
#### Scenario: Enrolled device reconnects
- **WHEN** the Host later enrolls or reports the same local device reference again
- **THEN** the control plane reuses the existing cloud `device_id`
### Requirement: Managed device identity cannot be claimed by another Host
The device pool SHALL derive managed device ownership from durable enrollment rather than accepting a caller-selected cloud device ID.
#### Scenario: Host reports another Host's managed device
- **WHEN** Host B includes a cloud device ID enrolled to Host A in its heartbeat
- **THEN** the control plane rejects Host B's snapshot and Host A retains ownership
#### Scenario: Prior Host becomes stale
- **WHEN** Host A becomes stale and Host B presents Host A's cloud device ID
- **THEN** the control plane still rejects implicit takeover because managed device transfer requires a future explicit operation
@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Host bootstrap uses a one-time enrollment credential
The Cloud Control Plane SHALL provide a Host enrollment operation that authenticates a configured bootstrap credential, generates the authoritative `host_id`, and binds it to an edge-generated long-lived Host credential without storing the plaintext Host credential.
#### Scenario: New edge instance enrolls
- **WHEN** an edge instance presents a valid unused enrollment token, a new instance identifier, and a high-entropy candidate Host credential
- **THEN** the control plane returns a generated `host_id` and durably stores only the credential digest and enrollment binding
#### Scenario: Enrollment token is invalid
- **WHEN** an edge instance presents an unknown enrollment token
- **THEN** the control plane rejects enrollment without creating a Host identity or consuming any configured token
#### Scenario: Enrollment token is reused by another instance
- **WHEN** a consumed enrollment token is presented with a different instance identifier
- **THEN** the control plane returns a conflict and preserves the original Host binding
### Requirement: Host enrollment is idempotent across response loss
The Host enrollment operation SHALL return the existing cloud Host identity when the same instance repeats enrollment with the same bootstrap-token binding and Host credential digest.
#### Scenario: Identical enrollment is retried
- **WHEN** an edge instance repeats a successful enrollment request after losing the response
- **THEN** the control plane returns the original `host_id` without creating another Host or rotating the submitted Host credential
#### Scenario: Instance retries with a different Host credential
- **WHEN** an enrolled instance repeats enrollment with a different candidate Host credential
- **THEN** the control plane rejects the request and leaves the original Host credential binding unchanged
### Requirement: Cloud assigns Host-scoped device identities
An authenticated Host SHALL enroll each local device through an opaque Host-scoped local reference, and the control plane SHALL generate and durably return the `device_id` used by scheduling, heartbeat, leases, and assignment execution.
#### Scenario: Host enrolls a local device
- **WHEN** an authenticated Host submits a previously unknown local device reference and valid driver metadata
- **THEN** the control plane creates a device enrollment owned by that Host and returns a generated `device_id`
#### Scenario: Device enrollment is repeated
- **WHEN** the same Host repeats enrollment for the same local device reference
- **THEN** the control plane returns the existing `device_id` and does not create a duplicate enrollment
#### Scenario: Same local reference appears on another Host
- **WHEN** a different Host enrolls an identical local device reference
- **THEN** the control plane creates a distinct Host-scoped device enrollment rather than silently transferring ownership
### Requirement: Edge identity and device mappings survive restart
The Host Agent SHALL persist its generated instance identifier, long-lived Host credential, assigned Host ID, and local-to-cloud device mappings outside process memory and SHALL reuse them on later starts.
#### Scenario: Host Agent restarts after enrollment
- **WHEN** a managed Host Agent restarts with intact identity state
- **THEN** it authenticates with the previously assigned `host_id` and credential without consuming another enrollment token
#### Scenario: Device mapping is missing but Host identity remains
- **WHEN** a managed Host Agent has its Host identity but lacks a cached mapping for a configured local device
- **THEN** it repeats idempotent device enrollment and restores the original cloud `device_id`
#### Scenario: Enrollment response is lost before Host ID persistence
- **WHEN** the Host Agent persisted its candidate credential but did not persist the successful response
- **THEN** its next start retries the identical enrollment request and recovers the original `host_id`
### Requirement: Enrolled Host credentials are revocable
The cloud repository SHALL support revoking a dynamically enrolled Host credential, and authentication SHALL reject revoked credentials without deleting task or attempt history.
#### Scenario: Revoked Host sends heartbeat
- **WHEN** a Host presents a credential whose enrollment has been revoked
- **THEN** the internal API rejects the request and preserves existing cloud history for that Host and its devices
@@ -0,0 +1,48 @@
## MODIFIED Requirements
### Requirement: Host identity is authenticated and bound to one host id
Except for the bootstrap enrollment operation authenticated by a configured one-time enrollment credential, the internal Host Agent API SHALL require a host-scoped bearer principal and SHALL reject any request that attempts to act for a `host_id` different from the authenticated principal's bound host.
#### Scenario: Host authenticates as itself
- **WHEN** a Host Agent presents valid static or dynamically enrolled credentials bound to its requested `host_id`
- **THEN** the internal API authorizes permitted device enrollment, heartbeat, claim, renewal, and result operations
#### Scenario: Host attempts to impersonate another host
- **WHEN** valid credentials bound to host A are used on a request for host B
- **THEN** the internal API rejects the request without reading or modifying host B's state
#### Scenario: Unknown Host credential is presented
- **WHEN** a caller presents a bearer credential that is neither statically configured nor bound to a non-revoked enrolled Host
- **THEN** the internal API rejects the request without exposing whether a Host ID exists
### Requirement: Host Agent synchronizes heartbeat and complete device snapshots
The Host Agent SHALL periodically submit its complete local device snapshot to the control plane, and the control plane SHALL atomically refresh the host heartbeat and replace only that host's pooled-device records. Enrollment-managed Hosts SHALL report the cloud device IDs assigned through device enrollment.
#### Scenario: Managed Host reports enrolled devices
- **WHEN** an enrollment-managed Host Agent submits a valid heartbeat containing cloud device IDs enrolled to that Host
- **THEN** the control plane updates the host's last-seen time and exposes the submitted devices through the aggregated pool
#### Scenario: Managed Host reports an unknown device ID
- **WHEN** an enrollment-managed Host reports a device ID not enrolled to that Host or a driver type that conflicts with its enrollment
- **THEN** the control plane rejects the entire snapshot without changing the previous heartbeat or pooled-device state
#### Scenario: Legacy Host reports devices
- **WHEN** a statically configured legacy Host submits a valid device snapshot
- **THEN** the control plane retains the existing compatible snapshot and ownership-conflict behavior
#### Scenario: Host reports no devices
- **WHEN** a previously populated Host submits an empty device snapshot
- **THEN** only that Host's prior pooled-device records are removed while durable enrollment records and devices owned by other Hosts remain unchanged
## ADDED Requirements
### Requirement: Managed Host startup enrolls before normal protocol loops
The Host Agent SHALL resolve its Host identity and cloud device mappings before starting heartbeat synchronization or assignment long-polling.
#### Scenario: New managed Host starts
- **WHEN** the Host Agent has an enrollment token but no completed local Host identity
- **THEN** it performs idempotent Host enrollment, enrolls configured local devices, constructs its `DeviceManager` with cloud device IDs, and only then starts heartbeat and claim loops
#### Scenario: Explicit legacy credentials are configured
- **WHEN** both `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` are explicitly supplied
- **THEN** the Host Agent skips bootstrap enrollment and preserves existing local device ID behavior
@@ -0,0 +1,40 @@
## 1. Durable Enrollment Repository
- [x] 1.1 Add Host and device enrollment records plus repository protocol operations for atomic enrollment, credential lookup/revocation, device lookup, and managed-Host detection.
- [x] 1.2 Extend SQLAlchemy models and add Alembic revision 0002 with forward/downgrade support, indexes, uniqueness constraints, and the updated schema head.
- [x] 1.3 Implement atomic Host enrollment idempotency and one-time enrollment-token consumption in the SQL repository for SQLite and PostgreSQL.
- [x] 1.4 Implement dynamic credential lookup/revocation and idempotent Host-scoped device enrollment in the SQL repository.
- [x] 1.5 Add repository contract and migration tests covering success, retries, token reuse conflicts, revocation, per-Host device identity, and schema preservation.
## 2. Cloud Authentication And Internal API
- [x] 2.1 Parse and validate `CLOUD_ENROLLMENT_TOKENS_JSON` without exposing token values, while retaining existing configured bearer credentials.
- [x] 2.2 Add enrollment-token verification, repository-backed dynamic Host authentication, and composed authentication providers.
- [x] 2.3 Add Host/device enrollment request-response models and authenticated internal API routes with generated cloud IDs and conflict handling.
- [x] 2.4 Require enrollment-managed Host heartbeat snapshots to contain only enrolled, non-revoked device IDs with matching driver types while preserving legacy snapshot behavior.
- [x] 2.5 Compose enrollment services into the deployable Cloud API and add HTTP tests for bootstrap, idempotency, authorization, device enrollment, heartbeat validation, restart authentication, and public-scope isolation.
## 3. Edge Identity And Device Mapping Storage
- [x] 3.1 Add an atomic, restrictive-permission Host identity store that persists pending and completed enrollment state across response loss and restart.
- [x] 3.2 Extend Host Agent configuration to support explicit legacy credentials or managed enrollment with configurable token and identity-state path.
- [x] 3.3 Extend `DeviceConfigStore` with nullable cloud device mappings and backward-compatible schema upgrade/read/write operations.
- [x] 3.4 Add focused tests for identity-state recovery, configuration mode validation, file secrecy behavior, and device mapping persistence.
## 4. Host Agent Enrollment Startup
- [x] 4.1 Extend the Host Agent client with synchronous bootstrap/device-enrollment operations that preserve existing retry and typed-error behavior.
- [x] 4.2 Resolve managed Host identity before application construction, persisting the candidate secret before the request and assigned Host ID after success.
- [x] 4.3 Enroll configured local devices before building the managed `DeviceManager`, persist mappings, and register drivers under cloud device IDs while leaving legacy mode unchanged.
- [x] 4.4 Update Host Agent application and heartbeat/assignment tests for first enrollment, response-loss retry, restart reuse, device mapping recovery, legacy compatibility, and startup ordering.
## 5. Deployment And Operations
- [x] 5.1 Add enrollment-token and identity-path variables to example/Compose deployment configuration without overwriting existing registry/image customizations.
- [x] 5.2 Update cloud and macOS edge deployment documentation for managed enrollment, secret/state handling, static fallback, revocation, migration, rollback, and outbound-only networking.
## 6. Verification
- [x] 6.1 Run formatting/static checks and focused repository, migration, Cloud API, Host Agent, deployment-contract, and end-to-end tests.
- [x] 6.2 Run the complete non-integration workspace test suite and resolve regressions without modifying unrelated cloud-console work.
- [x] 6.3 Run strict OpenSpec validation, review the final diff for credential leakage and architecture-boundary violations, and record verified limitations.
+82 -1
View File
@@ -4,7 +4,10 @@ from dataclasses import dataclass, field
from hashlib import sha256
from hmac import compare_digest
from collections.abc import Iterable
from typing import Protocol, runtime_checkable
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from cloud.repository import CloudRepository
TASKS_SUBMIT_SCOPE = "tasks:submit"
@@ -78,6 +81,24 @@ class _StoredBearerCredential:
token_digest: bytes = field(repr=False)
@dataclass(frozen=True)
class EnrollmentCredential:
principal_id: str
token: str = field(repr=False)
def __post_init__(self) -> None:
if not self.principal_id.strip():
raise ValueError("enrollment credential principal_id must not be empty")
if not self.token:
raise ValueError("enrollment credential token must not be empty")
@dataclass(frozen=True)
class EnrollmentPrincipal:
id: str
token_digest: str = field(repr=False)
class ConfiguredBearerAuthProvider:
"""Authenticate configured bearer tokens without exposing credential text."""
@@ -107,6 +128,57 @@ class ConfiguredBearerAuthProvider:
return matched_principal
class ConfiguredEnrollmentTokenProvider:
def __init__(self, credentials: Iterable[EnrollmentCredential]) -> None:
self._credentials = tuple(
EnrollmentPrincipal(
id=credential.principal_id,
token_digest=digest_token(credential.token),
)
for credential in credentials
)
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
candidate = bearer_token_digest(request)
if candidate is None:
return None
candidate_bytes = bytes.fromhex(candidate)
matched: EnrollmentPrincipal | None = None
for credential in self._credentials:
if compare_digest(
candidate_bytes,
bytes.fromhex(credential.token_digest),
):
matched = credential
return matched
class RepositoryHostAuthProvider:
def __init__(self, repository: CloudRepository) -> None:
self.repository = repository
def authenticate(self, request: object) -> Principal | None:
credential_digest = bearer_token_digest(request)
if credential_digest is None:
return None
host_id = self.repository.authenticate_enrolled_host(credential_digest)
if host_id is None:
return None
return Principal(id=f"enrolled-host:{host_id}", host_id=host_id)
class ChainedAuthProvider:
def __init__(self, providers: Iterable[AuthProvider]) -> None:
self.providers = tuple(providers)
def authenticate(self, request: object) -> Principal | None:
for provider in self.providers:
principal = provider.authenticate(request)
if principal is not None:
return principal
return None
def create_auth_provider(
credentials: Iterable[BearerCredential],
*,
@@ -133,5 +205,14 @@ def _extract_bearer_token(request: object) -> str | None:
return token
def bearer_token_digest(request: object) -> str | None:
token = _extract_bearer_token(request)
return digest_token(token) if token is not None else None
def digest_token(token: str) -> str:
return _token_digest(token).hex()
def _token_digest(token: str) -> bytes:
return sha256(token.encode("utf-8")).digest()
@@ -6,7 +6,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal
from cloud.auth import BearerCredential
from cloud.auth import BearerCredential, EnrollmentCredential
EnvironmentName = Literal["local", "test", "production"]
@@ -31,6 +31,7 @@ class CloudControlConfig:
max_task_attempts: int = 3
allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
def load_control_config(
@@ -86,6 +87,9 @@ def load_control_config(
require_host_id=True,
),
),
enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
),
)
validate_control_config(config)
return config
@@ -145,6 +149,33 @@ def _parse_credentials(
) from exc
def _parse_enrollment_credentials(
raw_value: str | None,
) -> tuple[EnrollmentCredential, ...]:
if raw_value is None or not raw_value.strip():
return ()
try:
payload = json.loads(raw_value)
if not isinstance(payload, list):
raise TypeError
credentials: list[EnrollmentCredential] = []
for item in payload:
if not isinstance(item, dict):
raise TypeError
principal_id = item.get("principal_id")
token = item.get("token")
if not isinstance(principal_id, str) or not isinstance(token, str):
raise TypeError
credentials.append(
EnrollmentCredential(principal_id=principal_id, token=token)
)
return tuple(credentials)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise CloudConfigurationError(
"configured enrollment credentials are invalid"
) from exc
def _positive_float(
values: Mapping[str, str],
name: str,
+51 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from sqlalchemy import Integer, String, Text, text
from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -10,10 +10,60 @@ class Base(DeclarativeBase):
class HostRow(Base):
__tablename__ = "host_registrations"
__table_args__ = (
UniqueConstraint(
"agent_instance_id",
name="uq_host_registrations_agent_instance_id",
),
UniqueConstraint(
"credential_digest",
name="uq_host_registrations_credential_digest",
),
UniqueConstraint(
"enrollment_token_digest",
name="uq_host_registrations_enrollment_token_digest",
),
)
host_id: Mapped[str] = mapped_column(String, primary_key=True)
address: Mapped[str | None] = mapped_column(String, nullable=True)
last_seen_at: Mapped[str] = mapped_column(String, nullable=False)
agent_instance_id: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
credential_digest: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
enrollment_token_digest: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
display_name: Mapped[str | None] = mapped_column(String, nullable=True)
enrolled_at: Mapped[str | None] = mapped_column(String, nullable=True)
revoked_at: Mapped[str | None] = mapped_column(String, nullable=True)
class DeviceEnrollmentRow(Base):
__tablename__ = "device_enrollments"
__table_args__ = (
UniqueConstraint(
"host_id",
"local_device_id",
name="uq_device_enrollments_host_local",
),
Index("ix_device_enrollments_host_id", "host_id"),
)
device_id: Mapped[str] = mapped_column(String, primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False)
local_device_id: Mapped[str] = mapped_column(String, nullable=False)
driver_type: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str | None] = mapped_column(String, nullable=True)
capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False)
enrolled_at: Mapped[str] = mapped_column(String, nullable=False)
revoked_at: Mapped[str | None] = mapped_column(String, nullable=True)
class PooledDeviceRow(Base):
@@ -5,26 +5,38 @@ from collections.abc import Awaitable, Callable
from datetime import timedelta
from time import monotonic
from typing import TYPE_CHECKING
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from cloud.auth import (
AuthProvider,
ConfiguredEnrollmentTokenProvider,
HostAuthorizationError,
digest_token,
)
from cloud.internal_api.models import (
AssignmentModel,
ClaimRequest,
ClaimResponse,
DeviceEnrollmentRequest,
DeviceEnrollmentResponse,
HeartbeatRequest,
HeartbeatResponse,
HostEnrollmentRequest,
HostEnrollmentResponse,
LeaseRenewalRequest,
LeaseRenewalResponse,
StaleLeaseConflict,
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.repository import (
DeviceEnrollmentConflictError,
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
)
from core.models import Device, utc_now
if TYPE_CHECKING:
@@ -35,6 +47,7 @@ def create_internal_router(
*,
pool: DevicePool,
auth_provider: AuthProvider,
enrollment_auth_provider: ConfiguredEnrollmentTokenProvider | None = None,
version_prefix: str = "/internal/v1",
claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0,
@@ -45,6 +58,7 @@ def create_internal_router(
if lease_duration_seconds <= 0:
raise ValueError("lease_duration_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
enrollment_auth = enrollment_auth_provider or ConfiguredEnrollmentTokenProvider(())
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
@@ -62,6 +76,66 @@ def create_internal_router(
detail=str(exc),
) from exc
@router.post(
"/enrollments",
response_model=HostEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_host(
payload: HostEnrollmentRequest,
request: Request,
) -> HostEnrollmentResponse:
enrollment_principal = enrollment_auth.authenticate(request)
if enrollment_principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
try:
enrollment = pool.store.enroll_host(
host_id=f"host-{uuid4().hex}",
agent_instance_id=payload.agent_instance_id,
credential_digest=digest_token(payload.host_token),
enrollment_token_digest=enrollment_principal.token_digest,
display_name=payload.display_name,
enrolled_at=utc_now(),
)
except (EnrollmentTokenConflictError, HostEnrollmentConflictError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return HostEnrollmentResponse(host_id=enrollment.host_id)
@router.post(
"/hosts/{host_id}/devices/enroll",
response_model=DeviceEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_device(
host_id: str,
payload: DeviceEnrollmentRequest,
request: Request,
) -> DeviceEnrollmentResponse:
authorize_host(request, host_id)
try:
enrollment = pool.store.enroll_device(
device_id=f"device-{uuid4().hex}",
host_id=host_id,
local_device_id=payload.local_device_id,
driver_type=payload.driver_type,
name=payload.name,
capability_tags=list(payload.capability_tags),
enrolled_at=utc_now(),
)
except DeviceEnrollmentConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return DeviceEnrollmentResponse(device_id=enrollment.device_id)
@router.put(
"/hosts/{host_id}/heartbeat",
response_model=HeartbeatResponse,
@@ -250,6 +324,25 @@ def _validate_snapshot(
detail="heartbeat snapshot contains duplicate device ids",
)
if pool.store.is_enrollment_managed_host(host_id):
enrollments = {
enrollment.device_id: enrollment
for enrollment in pool.store.list_device_enrollments(host_id)
if enrollment.revoked_at is None
}
invalid = [
device.device_id
for device in payload.devices
if device.device_id not in enrollments
or enrollments[device.device_id].driver_type != device.driver_type
]
if invalid:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"device enrollment conflict: {sorted(set(invalid))}",
)
return False
now = utc_now()
hosts = {host.host_id: host for host in pool.store.list_hosts()}
conflicts: list[str] = []
@@ -6,6 +6,27 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class HostEnrollmentRequest(BaseModel):
agent_instance_id: str = Field(min_length=1, max_length=256)
host_token: str = Field(min_length=32, max_length=512)
display_name: str | None = Field(default=None, max_length=256)
class HostEnrollmentResponse(BaseModel):
host_id: str
class DeviceEnrollmentRequest(BaseModel):
local_device_id: str = Field(min_length=1, max_length=256)
driver_type: str = Field(min_length=1, max_length=128)
name: str | None = Field(default=None, max_length=256)
capability_tags: list[str] = Field(default_factory=list)
class DeviceEnrollmentResponse(BaseModel):
device_id: str
class DeviceSnapshotModel(BaseModel):
device_id: str = Field(min_length=1)
driver_type: str = Field(min_length=1)
@@ -0,0 +1,96 @@
"""Add durable Host and device enrollment identity."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0002_edge_host_enrollment"
down_revision = "0001_cloud_repository"
branch_labels = None
depends_on = None
HOST_ENROLLMENT_COLUMNS = (
sa.Column("agent_instance_id", sa.String(), nullable=True),
sa.Column("credential_digest", sa.String(), nullable=True),
sa.Column("enrollment_token_digest", sa.String(), nullable=True),
sa.Column("display_name", sa.String(), nullable=True),
sa.Column("enrolled_at", sa.String(), nullable=True),
sa.Column("revoked_at", sa.String(), nullable=True),
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
existing_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
with op.batch_alter_table("host_registrations") as batch:
for column in HOST_ENROLLMENT_COLUMNS:
if column.name not in existing_columns:
batch.add_column(column)
inspector = sa.inspect(op.get_bind())
indexes = {index["name"] for index in inspector.get_indexes("host_registrations")}
with op.batch_alter_table("host_registrations") as batch:
for name, column in (
("uq_host_registrations_agent_instance_id", "agent_instance_id"),
("uq_host_registrations_credential_digest", "credential_digest"),
(
"uq_host_registrations_enrollment_token_digest",
"enrollment_token_digest",
),
):
if name not in indexes:
batch.create_unique_constraint(name, [column])
inspector = sa.inspect(op.get_bind())
if "device_enrollments" not in inspector.get_table_names():
op.create_table(
"device_enrollments",
sa.Column("device_id", sa.String(), primary_key=True),
sa.Column("host_id", sa.String(), nullable=False),
sa.Column("local_device_id", sa.String(), nullable=False),
sa.Column("driver_type", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("capability_tags_json", sa.Text(), nullable=False),
sa.Column("enrolled_at", sa.String(), nullable=False),
sa.Column("revoked_at", sa.String(), nullable=True),
sa.UniqueConstraint(
"host_id",
"local_device_id",
name="uq_device_enrollments_host_local",
),
)
op.create_index(
"ix_device_enrollments_host_id",
"device_enrollments",
["host_id"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "device_enrollments" in inspector.get_table_names():
op.drop_index(
"ix_device_enrollments_host_id",
table_name="device_enrollments",
)
op.drop_table("device_enrollments")
inspector = sa.inspect(op.get_bind())
existing_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
with op.batch_alter_table("host_registrations") as batch:
for name in (
"uq_host_registrations_enrollment_token_digest",
"uq_host_registrations_credential_digest",
"uq_host_registrations_agent_instance_id",
):
batch.drop_constraint(name, type_="unique")
for column in reversed(HOST_ENROLLMENT_COLUMNS):
if column.name in existing_columns:
batch.drop_column(column.name)
@@ -16,6 +16,39 @@ ResultRecordStatus = Literal["recorded", "already_recorded", "conflict"]
LeaseRenewalStatus = Literal["renewed", "not_found", "conflict", "expired"]
class HostEnrollmentConflictError(RuntimeError):
"""Raised when a Host enrollment cannot preserve its existing binding."""
class EnrollmentTokenConflictError(HostEnrollmentConflictError):
"""Raised when a one-time enrollment token is already bound elsewhere."""
class DeviceEnrollmentConflictError(RuntimeError):
"""Raised when a local device enrollment conflicts with stored identity."""
@dataclass(frozen=True)
class HostEnrollment:
host_id: str
agent_instance_id: str
display_name: str | None
enrolled_at: datetime
revoked_at: datetime | None = None
@dataclass(frozen=True)
class DeviceEnrollment:
device_id: str
host_id: str
local_device_id: str
driver_type: str
name: str | None
capability_tags: list[str]
enrolled_at: datetime
revoked_at: datetime | None = None
@dataclass(frozen=True)
class TaskAttemptRecord:
task_id: str
@@ -46,6 +79,53 @@ class LeasedAssignment:
class CloudRepository(Protocol):
"""Persistence port for cloud state and atomic scheduling operations."""
def enroll_host(
self,
*,
host_id: str,
agent_instance_id: str,
credential_digest: str,
enrollment_token_digest: str,
display_name: str | None,
enrolled_at: datetime,
) -> HostEnrollment: ...
def authenticate_enrolled_host(
self,
credential_digest: str,
) -> str | None: ...
def revoke_enrolled_host(
self,
host_id: str,
*,
revoked_at: datetime,
) -> bool: ...
def is_enrollment_managed_host(self, host_id: str) -> bool: ...
def enroll_device(
self,
*,
device_id: str,
host_id: str,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
enrolled_at: datetime,
) -> DeviceEnrollment: ...
def get_device_enrollment(
self,
device_id: str,
) -> DeviceEnrollment | None: ...
def list_device_enrollments(
self,
host_id: str,
) -> list[DeviceEnrollment]: ...
def upsert_host(
self,
host_id: str,
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0001_cloud_repository"
HEAD_REVISION = "0002_edge_host_enrollment"
class SchemaVersionError(RuntimeError):
@@ -7,10 +7,12 @@ from datetime import datetime
from typing import Any
from sqlalchemy import Engine, delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from cloud.db_models import (
Base,
DeviceEnrollmentRow,
HostRow,
PluginRow,
PooledDeviceRow,
@@ -33,6 +35,211 @@ class SQLAlchemyCloudRepository:
if create_schema:
Base.metadata.create_all(engine)
def enroll_host(
self,
*,
host_id: str,
agent_instance_id: str,
credential_digest: str,
enrollment_token_digest: str,
display_name: str | None,
enrolled_at: datetime,
) -> Any:
from cloud.repository import (
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
)
try:
with self._sessions.begin() as session:
statement = select(HostRow).where(
HostRow.agent_instance_id == agent_instance_id
)
if self.engine.dialect.name == "postgresql":
statement = statement.with_for_update()
existing = session.scalars(statement).first()
if existing is not None:
if (
existing.credential_digest != credential_digest
or existing.enrollment_token_digest != enrollment_token_digest
):
raise HostEnrollmentConflictError(
"Host enrollment identity does not match existing binding"
)
return _host_enrollment_from_row(existing)
token_owner = session.scalars(
select(HostRow).where(
HostRow.enrollment_token_digest == enrollment_token_digest
)
).first()
if token_owner is not None:
raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host"
)
row = HostRow(
host_id=host_id,
address=None,
last_seen_at=_iso(enrolled_at),
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_token_digest,
display_name=display_name,
enrolled_at=_iso(enrolled_at),
revoked_at=None,
)
session.add(row)
session.flush()
return _host_enrollment_from_row(row)
except IntegrityError as exc:
with self._sessions() as session:
existing = session.scalars(
select(HostRow).where(
HostRow.agent_instance_id == agent_instance_id
)
).first()
if (
existing is not None
and existing.credential_digest == credential_digest
and existing.enrollment_token_digest == enrollment_token_digest
):
return _host_enrollment_from_row(existing)
token_owner = session.scalars(
select(HostRow).where(
HostRow.enrollment_token_digest == enrollment_token_digest
)
).first()
if token_owner is not None:
raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host"
) from exc
raise HostEnrollmentConflictError(
"Host enrollment conflicts with an existing identity"
) from exc
def authenticate_enrolled_host(self, credential_digest: str) -> str | None:
with self._sessions() as session:
host_id = session.scalar(
select(HostRow.host_id)
.where(
HostRow.credential_digest == credential_digest,
HostRow.revoked_at.is_(None),
)
.limit(1)
)
return str(host_id) if host_id is not None else None
def revoke_enrolled_host(
self,
host_id: str,
*,
revoked_at: datetime,
) -> bool:
with self._sessions.begin() as session:
row = session.get(
HostRow,
host_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None or row.credential_digest is None:
return False
row.revoked_at = _iso(revoked_at)
return True
def is_enrollment_managed_host(self, host_id: str) -> bool:
with self._sessions() as session:
credential_digest = session.scalar(
select(HostRow.credential_digest).where(HostRow.host_id == host_id)
)
return credential_digest is not None
def enroll_device(
self,
*,
device_id: str,
host_id: str,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
enrolled_at: datetime,
) -> Any:
from cloud.repository import DeviceEnrollmentConflictError
tags_json = json.dumps(list(capability_tags), ensure_ascii=False)
try:
with self._sessions.begin() as session:
statement = select(DeviceEnrollmentRow).where(
DeviceEnrollmentRow.host_id == host_id,
DeviceEnrollmentRow.local_device_id == local_device_id,
)
if self.engine.dialect.name == "postgresql":
statement = statement.with_for_update()
existing = session.scalars(statement).first()
if existing is not None:
if existing.driver_type != driver_type:
raise DeviceEnrollmentConflictError(
"device driver type does not match existing enrollment"
)
if existing.revoked_at is not None:
raise DeviceEnrollmentConflictError(
"device enrollment has been revoked"
)
existing.name = name
existing.capability_tags_json = tags_json
return _device_enrollment_from_row(existing)
host = session.get(HostRow, host_id)
if host is None:
session.add(
HostRow(
host_id=host_id,
address=None,
last_seen_at=_iso(enrolled_at),
)
)
row = DeviceEnrollmentRow(
device_id=device_id,
host_id=host_id,
local_device_id=local_device_id,
driver_type=driver_type,
name=name,
capability_tags_json=tags_json,
enrolled_at=_iso(enrolled_at),
revoked_at=None,
)
session.add(row)
session.flush()
return _device_enrollment_from_row(row)
except IntegrityError as exc:
with self._sessions() as session:
existing = session.scalars(
select(DeviceEnrollmentRow).where(
DeviceEnrollmentRow.host_id == host_id,
DeviceEnrollmentRow.local_device_id == local_device_id,
)
).first()
if existing is not None and existing.driver_type == driver_type:
return _device_enrollment_from_row(existing)
raise DeviceEnrollmentConflictError(
"device enrollment conflicts with an existing identity"
) from exc
def get_device_enrollment(self, device_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(DeviceEnrollmentRow, device_id)
return _device_enrollment_from_row(row) if row else None
def list_device_enrollments(self, host_id: str) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(DeviceEnrollmentRow)
.where(DeviceEnrollmentRow.host_id == host_id)
.order_by(DeviceEnrollmentRow.device_id)
).all()
return [_device_enrollment_from_row(row) for row in rows]
def upsert_host(
self,
host_id: str,
@@ -568,6 +775,40 @@ def _host_from_row(row: HostRow) -> Any:
)
def _host_enrollment_from_row(row: HostRow) -> Any:
from cloud.repository import HostEnrollment
enrolled_at = _parse_dt(row.enrolled_at) or _parse_dt(row.last_seen_at) or utc_now()
if row.agent_instance_id is None:
raise ValueError(f"Host {row.host_id!r} is not enrollment-managed")
return HostEnrollment(
host_id=row.host_id,
agent_instance_id=row.agent_instance_id,
display_name=row.display_name,
enrolled_at=enrolled_at,
revoked_at=_parse_dt(row.revoked_at),
)
def _device_enrollment_from_row(row: DeviceEnrollmentRow) -> Any:
from cloud.repository import DeviceEnrollment
try:
tags = list(json.loads(row.capability_tags_json))
except TypeError, ValueError:
tags = []
return DeviceEnrollment(
device_id=row.device_id,
host_id=row.host_id,
local_device_id=row.local_device_id,
driver_type=row.driver_type,
name=row.name,
capability_tags=tags,
enrolled_at=_parse_dt(row.enrolled_at) or utc_now(),
revoked_at=_parse_dt(row.revoked_at),
)
def _device_from_row(row: PooledDeviceRow) -> Any:
from cloud.pool import PooledDevice
+34 -4
View File
@@ -28,22 +28,42 @@ class DeviceConfigStore:
name: str | None = None,
driver_type: str,
connection_info: dict[str, Any],
cloud_device_id: str | None = None,
) -> None:
with self._connect() as connection:
connection.execute(
"""
insert or replace into device_configs (
device_id, name, driver_type, connection_info
) values (?, ?, ?, ?)
insert into device_configs (
device_id, name, driver_type, connection_info, cloud_device_id
) values (?, ?, ?, ?, ?)
on conflict(device_id) do update set
name = excluded.name,
driver_type = excluded.driver_type,
connection_info = excluded.connection_info,
cloud_device_id = coalesce(
excluded.cloud_device_id,
device_configs.cloud_device_id
)
""",
(
device_id,
name,
driver_type,
json.dumps(connection_info, ensure_ascii=False),
cloud_device_id,
),
)
def set_cloud_device_id(self, device_id: str, cloud_device_id: str) -> bool:
if not cloud_device_id:
raise ValueError("cloud_device_id must not be empty")
with self._connect() as connection:
cursor = connection.execute(
"update device_configs set cloud_device_id = ? where device_id = ?",
(cloud_device_id, device_id),
)
return cursor.rowcount > 0
def remove(self, device_id: str) -> None:
with self._connect() as connection:
connection.execute(
@@ -93,10 +113,19 @@ class DeviceConfigStore:
device_id text primary key,
name text,
driver_type text not null,
connection_info text not null
connection_info text not null,
cloud_device_id text
)
"""
)
columns = {
row["name"]
for row in connection.execute("pragma table_info(device_configs)")
}
if "cloud_device_id" not in columns:
connection.execute(
"alter table device_configs add column cloud_device_id text"
)
connection.execute(
"""
create table if not exists settings (
@@ -121,4 +150,5 @@ class DeviceConfigStore:
"name": row["name"],
"driver_type": row["driver_type"],
"connection_info": json.loads(row["connection_info"]),
"cloud_device_id": row["cloud_device_id"],
}
+51
View File
@@ -6,10 +6,15 @@ import cloud.auth as auth_module
import pytest
from cloud.auth import (
BearerCredential,
ChainedAuthProvider,
ConfiguredBearerAuthProvider,
ConfiguredEnrollmentTokenProvider,
EnrollmentCredential,
HostIdentityMismatchError,
HostPrincipalRequiredError,
NullAuthProvider,
RepositoryHostAuthProvider,
digest_token,
)
@@ -162,3 +167,49 @@ def test_authentication_failure_does_not_log_bearer_secret(caplog) -> None:
)
assert "invalid-secret" not in caplog.text
assert "valid-secret" not in caplog.text
def test_enrollment_token_provider_returns_digest_without_exposing_secret() -> None:
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ConfiguredEnrollmentTokenProvider([credential])
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
assert "one-time-enrollment-secret" not in repr(credential)
assert "one-time-enrollment-secret" not in repr(provider.__dict__)
def test_repository_host_auth_and_chain_preserve_host_scope() -> None:
class Repository:
def authenticate_enrolled_host(self, credential_digest: str) -> str | None:
if credential_digest == digest_token("dynamic-host-secret"):
return "host-managed"
return None
configured = ConfiguredBearerAuthProvider(
[BearerCredential(principal_id="sdk", token="sdk-secret")]
)
provider = ChainedAuthProvider(
[configured, RepositoryHostAuthProvider(Repository())] # type: ignore[arg-type]
)
public_principal = provider.authenticate(
_Request(headers={"authorization": "Bearer sdk-secret"})
)
host_principal = provider.authenticate(
_Request(headers={"authorization": "Bearer dynamic-host-secret"})
)
assert public_principal is not None
assert public_principal.id == "sdk"
assert host_principal is not None
assert host_principal.host_id == "host-managed"
assert host_principal.scopes == frozenset()
+19 -1
View File
@@ -47,6 +47,20 @@ def test_load_control_config_parses_deployment_values() -> None:
assert "sdk-secret" not in repr(config)
def test_load_control_config_parses_enrollment_credentials() -> None:
config = load_control_config(
{
"CLOUD_ENROLLMENT_TOKENS_JSON": (
'[{"principal_id":"installer-a","token":"one-time-enrollment-secret"}]'
)
}
)
assert len(config.enrollment_credentials) == 1
assert config.enrollment_credentials[0].principal_id == "installer-a"
assert "one-time-enrollment-secret" not in repr(config)
@pytest.mark.parametrize(
"environment,database_url",
[("invalid", "sqlite:///test.db"), ("local", "mysql://db/cloud")],
@@ -97,13 +111,17 @@ def test_load_control_config_rejects_missing_production_credentials() -> None:
"CLOUD_HOST_CREDENTIALS_JSON",
'[{"principal_id":"agent","token":"secret","scopes":[]}]',
),
(
"CLOUD_ENROLLMENT_TOKENS_JSON",
'[{"principal_id":"installer","token":123}]',
),
],
)
def test_load_control_config_rejects_invalid_credentials(
name: str,
value: str,
) -> None:
with pytest.raises(CloudConfigurationError, match="credentials") as error:
with pytest.raises(CloudConfigurationError, match="credential") as error:
load_control_config({name: value})
assert "secret" not in str(error.value)
+59
View File
@@ -29,12 +29,23 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
table_names = set(inspect(engine).get_table_names())
assert {
"host_registrations",
"device_enrollments",
"pooled_devices",
"scheduled_tasks",
"plugins",
"task_attempts",
} <= table_names
assert current_revision(database_url) == HEAD_REVISION
host_columns = {
column["name"]
for column in inspect(engine).get_columns("host_registrations")
}
assert {
"agent_instance_id",
"credential_digest",
"enrollment_token_digest",
"revoked_at",
} <= host_columns
finally:
engine.dispose()
@@ -43,6 +54,7 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "device_enrollments" not in inspector.get_table_names()
assert "task_attempts" not in inspector.get_table_names()
task_columns = {
column["name"] for column in inspector.get_columns("scheduled_tasks")
@@ -95,6 +107,14 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None:
column["name"] for column in inspect(engine).get_columns("scheduled_tasks")
}
assert {"attempt_count", "lease_id", "result_json"} <= task_columns
host_columns = {
column["name"]
for column in inspect(engine).get_columns("host_registrations")
}
assert {"agent_instance_id", "credential_digest", "revoked_at"} <= (
host_columns
)
assert "device_enrollments" in inspect(engine).get_table_names()
finally:
engine.dispose()
@@ -114,6 +134,40 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None:
engine.dispose()
def test_enrollment_downgrade_to_revision_0001_preserves_legacy_state(
tmp_path,
) -> None:
database_url = _database_url(tmp_path)
upgrade_database(database_url)
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"insert into host_registrations "
"(host_id, address, last_seen_at) values "
"('legacy-host', null, '2026-01-01T00:00:00+00:00')"
)
)
finally:
engine.dispose()
downgrade_database(database_url, "0001_cloud_repository")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "device_enrollments" not in inspector.get_table_names()
assert connection_scalar(engine, "select count(*) from host_registrations") == 1
host_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
assert "credential_digest" not in host_columns
assert current_revision(database_url) == "0001_cloud_repository"
finally:
engine.dispose()
def test_schema_readiness_requires_head_revision(tmp_path) -> None:
database_url = _database_url(tmp_path)
@@ -146,3 +200,8 @@ def _create_legacy_schema(connection) -> None:
"name text primary key, version text not null, entry_point_kind text not null, "
"target text not null, wired integer not null)"
)
def connection_scalar(engine, statement: str):
with engine.connect() as connection:
return connection.scalar(text(statement))
+143 -1
View File
@@ -14,7 +14,14 @@ from cloud.database import CloudDatabase
from cloud.db_models import TaskAttemptRow
from cloud.plugins import PluginManifest
from cloud.pool import PooledDevice
from cloud.repository import CloudRepository, LeasedAssignment, TaskAttemptRecord
from cloud.repository import (
CloudRepository,
DeviceEnrollmentConflictError,
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
LeasedAssignment,
TaskAttemptRecord,
)
from cloud.scheduler import ScheduledTask, TaskConstraints
@@ -53,6 +60,13 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
members = get_protocol_members(CloudRepository)
assert {
"enroll_host",
"authenticate_enrolled_host",
"revoke_enrolled_host",
"is_enrollment_managed_host",
"enroll_device",
"get_device_enrollment",
"list_device_enrollments",
"upsert_host",
"replace_host_devices",
"list_hosts",
@@ -76,6 +90,134 @@ def test_repository_transfer_records_are_immutable() -> None:
assert LeasedAssignment.__dataclass_params__.frozen is True
def test_host_enrollment_is_idempotent_and_token_is_one_time(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 4, 0, tzinfo=UTC)
host_id = _unique_id("managed-host")
agent_instance_id = _unique_id("agent-instance")
credential_digest = _unique_id("credential-digest")
enrollment_digest = _unique_id("enrollment-digest")
try:
created = repository.enroll_host(
host_id=host_id,
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_digest,
display_name="Edge Mac",
enrolled_at=enrolled_at,
)
assert created.host_id == host_id
assert created.agent_instance_id == agent_instance_id
assert created.display_name == "Edge Mac"
assert created.enrolled_at == enrolled_at
assert repository.is_enrollment_managed_host(host_id) is True
assert repository.authenticate_enrolled_host(credential_digest) == host_id
retried = repository.enroll_host(
host_id=_unique_id("ignored-host"),
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_digest,
display_name="Renamed Edge Mac",
enrolled_at=enrolled_at + timedelta(minutes=1),
)
assert retried == created
with pytest.raises(HostEnrollmentConflictError):
repository.enroll_host(
host_id=_unique_id("host"),
agent_instance_id=agent_instance_id,
credential_digest=_unique_id("different-credential"),
enrollment_token_digest=enrollment_digest,
display_name=None,
enrolled_at=enrolled_at,
)
with pytest.raises(EnrollmentTokenConflictError):
repository.enroll_host(
host_id=_unique_id("host"),
agent_instance_id=_unique_id("different-instance"),
credential_digest=_unique_id("credential"),
enrollment_token_digest=enrollment_digest,
display_name=None,
enrolled_at=enrolled_at,
)
assert repository.revoke_enrolled_host(
host_id,
revoked_at=enrolled_at + timedelta(hours=1),
)
assert repository.authenticate_enrolled_host(credential_digest) is None
assert repository.get_host(host_id) is not None
finally:
database.close()
def test_device_enrollment_is_host_scoped_and_idempotent(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 5, 0, tzinfo=UTC)
host_a = _unique_id("host-a")
host_b = _unique_id("host-b")
local_device_id = _unique_id("local-device")
try:
device_a = repository.enroll_device(
device_id=_unique_id("cloud-device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="wda",
name="iPhone",
capability_tags=["ios"],
enrolled_at=enrolled_at,
)
retried = repository.enroll_device(
device_id=_unique_id("ignored-device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="wda",
name="Renamed iPhone",
capability_tags=["ios", "physical"],
enrolled_at=enrolled_at + timedelta(minutes=1),
)
assert retried.device_id == device_a.device_id
assert retried.name == "Renamed iPhone"
assert retried.capability_tags == ["ios", "physical"]
assert repository.get_device_enrollment(device_a.device_id) == retried
assert repository.list_device_enrollments(host_a) == [retried]
device_b = repository.enroll_device(
device_id=_unique_id("cloud-device"),
host_id=host_b,
local_device_id=local_device_id,
driver_type="wda",
name="Moved iPhone",
capability_tags=[],
enrolled_at=enrolled_at,
)
assert device_b.device_id != device_a.device_id
assert device_b.host_id == host_b
with pytest.raises(DeviceEnrollmentConflictError):
repository.enroll_device(
device_id=_unique_id("device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="android",
name=None,
capability_tags=[],
enrolled_at=enrolled_at,
)
finally:
database.close()
def test_repository_crud_contract(database_url: str) -> None:
database = CloudDatabase(database_url)
repository = database.repository
+12
View File
@@ -32,6 +32,15 @@ def test_compose_defines_database_control_plane_and_outbound_host_agent() -> Non
assert services["host-agent"]["environment"]["AI_PLANNER_ENABLED"] == (
"${AI_PLANNER_ENABLED:-false}"
)
assert (
services["cloud-api"]["environment"]["CLOUD_ENROLLMENT_TOKENS_JSON"]
== "${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}"
)
assert (
services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"]
== "${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}"
)
assert "ports" not in services["host-agent"]
def test_container_uses_locked_workspace_install_and_migrations() -> None:
@@ -54,8 +63,11 @@ def test_example_environment_contains_only_placeholder_credentials() -> None:
public_credentials = json.loads(values["CLOUD_PUBLIC_CREDENTIALS_JSON"])
host_credentials = json.loads(values["CLOUD_HOST_CREDENTIALS_JSON"])
enrollment_credentials = json.loads(values["CLOUD_ENROLLMENT_TOKENS_JSON"])
assert public_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["token"] == values["HOST_AGENT_TOKEN"]
assert host_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["host_id"] == values["HOST_AGENT_HOST_ID"]
assert enrollment_credentials[0]["token"].startswith("change-me-")
assert values["HOST_AGENT_IDENTITY_PATH"] == "/app/tasks/host_identity.json"
+27
View File
@@ -27,6 +27,7 @@ def test_device_config_store_add_remove_list_and_get(tmp_path) -> None:
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
},
"cloud_device_id": None,
}
assert [config["device_id"] for config in store.list()] == ["iphone-1", "iphone-2"]
@@ -56,3 +57,29 @@ def test_device_config_store_unknown_device_remove_is_noop(tmp_path) -> None:
store.remove("missing")
assert store.get("missing") is None
def test_device_config_store_persists_cloud_mapping_without_losing_it_on_update(
tmp_path,
) -> None:
store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
store.add(
device_id="iphone-local",
name="iPhone",
driver_type="wda",
connection_info={"udid": "abc"},
)
assert store.set_cloud_device_id("iphone-local", "device-cloud-a") is True
store.add(
device_id="iphone-local",
name="Renamed iPhone",
driver_type="wda",
connection_info={"udid": "abc", "wda_local_port": 8101},
)
config = store.get("iphone-local")
assert config is not None
assert config["cloud_device_id"] == "device-cloud-a"
assert config["name"] == "Renamed iPhone"
assert store.set_cloud_device_id("missing", "device-cloud-b") is False