feat(cloud): add edge host enrollment
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user