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