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