From a1b2e522ab1a6cd4ef84f3c63a8da121c169d317 Mon Sep 17 00:00:00 2001 From: ars Date: Sun, 24 May 2026 17:18:44 +0300 Subject: [PATCH] add common models definations --- pyproject.toml | 7 +- src/nexus_sync/common/__init__.py | 33 ++++++++ src/nexus_sync/common/models.py | 128 ++++++++++++++++++++++++++++++ tests/test_models.py | 87 ++++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/nexus_sync/common/models.py create mode 100644 tests/test_models.py diff --git a/pyproject.toml b/pyproject.toml index bc40596..7fd83f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,12 @@ description = "A utility that allows you to manage your computers from a central readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.11" -dependencies = [] +dependencies = [ + "fastapi>=0.115.0", + "pydantic>=2.10.0", + "sqlalchemy>=2.0.0", + "uvicorn[standard]>=0.34.0", +] [project.optional-dependencies] dev = [ diff --git a/src/nexus_sync/common/__init__.py b/src/nexus_sync/common/__init__.py index e69de29..960b955 100644 --- a/src/nexus_sync/common/__init__.py +++ b/src/nexus_sync/common/__init__.py @@ -0,0 +1,33 @@ +from nexus_sync.common.models import ( + AuditLogRecord, + ClientInfo, + ClientPlatform, + ClientRecord, + ClientState, + Command, + CommandKind, + CommandRecord, + CommandResult, + CommandResultRecord, + CommandResultStatus, + CommandStatus, + HeartbeatRequest, + HeartbeatResponse, +) + +__all__ = [ + "AuditLogRecord", + "ClientInfo", + "ClientPlatform", + "ClientRecord", + "ClientState", + "Command", + "CommandKind", + "CommandRecord", + "CommandResult", + "CommandResultRecord", + "CommandResultStatus", + "CommandStatus", + "HeartbeatRequest", + "HeartbeatResponse", +] diff --git a/src/nexus_sync/common/models.py b/src/nexus_sync/common/models.py new file mode 100644 index 0000000..c76c5e0 --- /dev/null +++ b/src/nexus_sync/common/models.py @@ -0,0 +1,128 @@ +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class StrictBaseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ClientPlatform(StrEnum): + LINUX = "linux" + DARWIN = "darwin" + WINDOWS = "windows" + UNKNOWN = "unknown" + + +class CommandKind(StrEnum): + EXEC = "exec" + + +class CommandResultStatus(StrEnum): + SUCCEEDED = "succeeded" + FAILED = "failed" + TIMED_OUT = "timed_out" + REJECTED = "rejected" + + +class CommandStatus(StrEnum): + PENDING = "pending" + DELIVERED = "delivered" + SUCCEEDED = "succeeded" + FAILED = "failed" + TIMED_OUT = "timed_out" + REJECTED = "rejected" + + +class ClientInfo(StrictBaseModel): + hostname: str + platform: ClientPlatform + version: str + + +class ClientState(StrictBaseModel): + local_time: datetime + uptime_seconds: int | None = Field(default=None, ge=0) + + +class CommandResult(StrictBaseModel): + command_id: str + status: CommandResultStatus + started_at: datetime | None = None + finished_at: datetime | None = None + return_code: int | None = None + stdout: str = "" + stderr: str = "" + + +class HeartbeatRequest(StrictBaseModel): + client_id: str + observed_at: datetime + client: ClientInfo + state: ClientState + last_command_result: CommandResult | None = None + + +class Command(StrictBaseModel): + id: str + kind: CommandKind + name: str + args: dict[str, Any] = Field(default_factory=dict) + timeout_seconds: int = Field(gt=0) + + +class HeartbeatResponse(StrictBaseModel): + status: Literal["ok"] = "ok" + server_time: datetime + next_poll_after_seconds: int = Field(ge=0) + command: Command | None = None + + +class ClientRecord(StrictBaseModel): + id: str + hostname: str + platform: ClientPlatform + version: str + created_at: datetime + last_seen_at: datetime + is_active: bool = True + token_hash: str | None = None + + +class CommandRecord(StrictBaseModel): + id: str + client_id: str + kind: CommandKind + name: str + args: dict[str, Any] = Field(default_factory=dict) + status: CommandStatus = CommandStatus.PENDING + timeout_seconds: int = Field(gt=0) + attempts: int = Field(default=0, ge=0) + max_attempts: int = Field(default=1, ge=1) + created_at: datetime + delivered_at: datetime | None = None + finished_at: datetime | None = None + + +class CommandResultRecord(StrictBaseModel): + command_id: str + client_id: str + status: CommandResultStatus + started_at: datetime | None = None + finished_at: datetime | None = None + return_code: int | None = None + stdout: str = "" + stderr: str = "" + received_at: datetime + + +class AuditLogRecord(StrictBaseModel): + id: str + actor: str + action: str + subject_type: str + subject_id: str + created_at: datetime + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ff6633a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,87 @@ +from datetime import UTC, datetime + +from pydantic import ValidationError + +from nexus_sync.common import ( + ClientInfo, + ClientPlatform, + ClientState, + Command, + CommandKind, + CommandResult, + CommandResultStatus, + HeartbeatRequest, + HeartbeatResponse, +) + + +def test_heartbeat_request_accepts_current_mvp_payload() -> None: + payload = HeartbeatRequest( + client_id="macbook-pro-01", + observed_at=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + client=ClientInfo( + hostname="macbook-pro.local", + platform=ClientPlatform.DARWIN, + version="0.1.0", + ), + state=ClientState( + local_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + uptime_seconds=1200, + ), + last_command_result=None, + ) + + serialized = payload.model_dump(mode="json") + + assert serialized["client_id"] == "macbook-pro-01" + assert serialized["client"]["platform"] == "darwin" + assert serialized["last_command_result"] is None + + +def test_heartbeat_response_accepts_one_structured_command() -> None: + response = HeartbeatResponse( + server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + next_poll_after_seconds=10, + command=Command( + id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + kind=CommandKind.EXEC, + name="network_interfaces", + args={}, + timeout_seconds=30, + ), + ) + + serialized = response.model_dump(mode="json") + + assert serialized["status"] == "ok" + assert serialized["command"]["kind"] == "exec" + assert serialized["command"]["name"] == "network_interfaces" + + +def test_command_result_supports_rejected_commands() -> None: + result = CommandResult( + command_id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + status=CommandResultStatus.REJECTED, + stderr="unknown command preset", + ) + + serialized = result.model_dump(mode="json") + + assert serialized["status"] == "rejected" + assert serialized["return_code"] is None + + +def test_models_reject_unknown_fields() -> None: + try: + Command( + id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + kind=CommandKind.EXEC, + name="network_interfaces", + args={}, + timeout_seconds=30, + shell="ip a", + ) + except ValidationError as error: + assert "shell" in str(error) + else: + raise AssertionError("Command model accepted an unknown field")