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
+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