add docstrings to public methods
This commit is contained in:
@@ -18,22 +18,28 @@ DEFAULT_PRESET_DESCRIPTIONS = {
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CommandAccessPolicy:
|
class CommandAccessPolicy:
|
||||||
|
"""Whitelist controlling which command presets a client may run."""
|
||||||
|
|
||||||
allowed_commands: frozenset[str] = field(default_factory=frozenset)
|
allowed_commands: frozenset[str] = field(default_factory=frozenset)
|
||||||
full_access: bool = False
|
full_access: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def allow(cls, command_names: Iterable[str]) -> "CommandAccessPolicy":
|
def allow(cls, command_names: Iterable[str]) -> "CommandAccessPolicy":
|
||||||
|
"""Allow exactly the named command presets."""
|
||||||
return cls(allowed_commands=frozenset(command_names))
|
return cls(allowed_commands=frozenset(command_names))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def allow_all(cls) -> "CommandAccessPolicy":
|
def allow_all(cls) -> "CommandAccessPolicy":
|
||||||
|
"""Allow every command preset (use with care)."""
|
||||||
return cls(full_access=True)
|
return cls(full_access=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deny_all(cls) -> "CommandAccessPolicy":
|
def deny_all(cls) -> "CommandAccessPolicy":
|
||||||
|
"""Deny every command preset."""
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
def allows(self, command_name: str) -> bool:
|
def allows(self, command_name: str) -> bool:
|
||||||
|
"""Return whether ``command_name`` is permitted by this policy."""
|
||||||
return self.full_access or command_name in self.allowed_commands
|
return self.full_access or command_name in self.allowed_commands
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +89,11 @@ def execute_command(
|
|||||||
access_policy: CommandAccessPolicy = DEFAULT_COMMAND_ACCESS_POLICY,
|
access_policy: CommandAccessPolicy = DEFAULT_COMMAND_ACCESS_POLICY,
|
||||||
output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
|
"""Run a command preset safely and return its :class:`CommandResult`.
|
||||||
|
|
||||||
|
Only known, allowed presets are executed (never arbitrary shell); output is
|
||||||
|
size-limited and each run is bound by ``command.timeout_seconds``.
|
||||||
|
"""
|
||||||
if command.kind != CommandKind.EXEC:
|
if command.kind != CommandKind.EXEC:
|
||||||
return _reject(command, f"unsupported command kind: {command.kind}")
|
return _reject(command, f"unsupported command kind: {command.kind}")
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ def find_client_config_path(
|
|||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
home: Path | None = None,
|
home: Path | None = None,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
|
"""Find the client config (nexus.yml/yaml) in cwd, then XDG, then ~/.config."""
|
||||||
cwd = cwd or Path.cwd()
|
cwd = cwd or Path.cwd()
|
||||||
home = home or Path.home()
|
home = home or Path.home()
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -110,6 +111,7 @@ def load_client_config(
|
|||||||
*,
|
*,
|
||||||
config_path: Path | str | None = None,
|
config_path: Path | str | None = None,
|
||||||
) -> ClientConfig:
|
) -> ClientConfig:
|
||||||
|
"""Load and validate the client config, returning a :class:`ClientConfig`."""
|
||||||
path = Path(config_path) if config_path is not None else find_client_config_path(env=env)
|
path = Path(config_path) if config_path is not None else find_client_config_path(env=env)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise ClientConfigError("client config file not found")
|
raise ClientConfigError("client config file not found")
|
||||||
@@ -145,6 +147,7 @@ def build_heartbeat_request(
|
|||||||
*,
|
*,
|
||||||
last_command_result: CommandResult | None = None,
|
last_command_result: CommandResult | None = None,
|
||||||
) -> HeartbeatRequest:
|
) -> HeartbeatRequest:
|
||||||
|
"""Build a heartbeat payload from config and an optional last command result."""
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
return HeartbeatRequest(
|
return HeartbeatRequest(
|
||||||
client_id=config.client_id,
|
client_id=config.client_id,
|
||||||
@@ -173,6 +176,7 @@ def list_available_commands(
|
|||||||
presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS,
|
presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS,
|
||||||
descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS,
|
descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS,
|
||||||
) -> list[ClientCommandCapability]:
|
) -> list[ClientCommandCapability]:
|
||||||
|
"""Return the capabilities the access policy allows, as advertised to the server."""
|
||||||
return [
|
return [
|
||||||
ClientCommandCapability(
|
ClientCommandCapability(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -189,6 +193,7 @@ def send_heartbeat(
|
|||||||
*,
|
*,
|
||||||
opener: HeartbeatOpener = urllib.request.urlopen,
|
opener: HeartbeatOpener = urllib.request.urlopen,
|
||||||
) -> HeartbeatResponse:
|
) -> HeartbeatResponse:
|
||||||
|
"""POST a heartbeat and return the parsed response; raises :class:`HeartbeatError`."""
|
||||||
payload = heartbeat.model_dump_json().encode()
|
payload = heartbeat.model_dump_json().encode()
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{config.server_url}{HEARTBEAT_PATH}",
|
f"{config.server_url}{HEARTBEAT_PATH}",
|
||||||
@@ -227,6 +232,7 @@ def run_once(
|
|||||||
] = send_heartbeat,
|
] = send_heartbeat,
|
||||||
executor: Callable[..., CommandResult] = execute_command,
|
executor: Callable[..., CommandResult] = execute_command,
|
||||||
) -> CommandResult | None:
|
) -> CommandResult | None:
|
||||||
|
"""Send one heartbeat and execute the command the server returns, if any."""
|
||||||
response = heartbeat_sender(
|
response = heartbeat_sender(
|
||||||
config,
|
config,
|
||||||
build_heartbeat_request(config, last_command_result=last_command_result),
|
build_heartbeat_request(config, last_command_result=last_command_result),
|
||||||
@@ -242,6 +248,7 @@ def run_once(
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run the client once: load config, heartbeat, and drain any queued commands."""
|
||||||
_ = argv
|
_ = argv
|
||||||
try:
|
try:
|
||||||
config = load_client_config()
|
config = load_client_config()
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class StrictBaseModel(BaseModel):
|
class StrictBaseModel(BaseModel):
|
||||||
|
"""Base model that rejects unknown fields (``extra='forbid'``)."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class ClientPlatform(StrEnum):
|
class ClientPlatform(StrEnum):
|
||||||
|
"""Operating-system family reported by a client."""
|
||||||
|
|
||||||
LINUX = "linux"
|
LINUX = "linux"
|
||||||
DARWIN = "darwin"
|
DARWIN = "darwin"
|
||||||
WINDOWS = "windows"
|
WINDOWS = "windows"
|
||||||
@@ -17,10 +21,14 @@ class ClientPlatform(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class CommandKind(StrEnum):
|
class CommandKind(StrEnum):
|
||||||
|
"""Type of executor a command targets (only ``exec`` is defined so far)."""
|
||||||
|
|
||||||
EXEC = "exec"
|
EXEC = "exec"
|
||||||
|
|
||||||
|
|
||||||
class CommandResultStatus(StrEnum):
|
class CommandResultStatus(StrEnum):
|
||||||
|
"""Terminal outcome a client reports for an executed command."""
|
||||||
|
|
||||||
SUCCEEDED = "succeeded"
|
SUCCEEDED = "succeeded"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
TIMED_OUT = "timed_out"
|
TIMED_OUT = "timed_out"
|
||||||
@@ -28,6 +36,8 @@ class CommandResultStatus(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class CommandStatus(StrEnum):
|
class CommandStatus(StrEnum):
|
||||||
|
"""Lifecycle state of a command on the server (pending → delivered → terminal)."""
|
||||||
|
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
DELIVERED = "delivered"
|
DELIVERED = "delivered"
|
||||||
SUCCEEDED = "succeeded"
|
SUCCEEDED = "succeeded"
|
||||||
@@ -37,22 +47,30 @@ class CommandStatus(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class ClientInfo(StrictBaseModel):
|
class ClientInfo(StrictBaseModel):
|
||||||
|
"""Identifying details a client reports about itself in a heartbeat."""
|
||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
platform: ClientPlatform
|
platform: ClientPlatform
|
||||||
version: str
|
version: str
|
||||||
|
|
||||||
|
|
||||||
class ClientState(StrictBaseModel):
|
class ClientState(StrictBaseModel):
|
||||||
|
"""Lightweight runtime state a client reports (local time, uptime)."""
|
||||||
|
|
||||||
local_time: datetime
|
local_time: datetime
|
||||||
uptime_seconds: int | None = Field(default=None, ge=0)
|
uptime_seconds: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ClientCommandCapability(StrictBaseModel):
|
class ClientCommandCapability(StrictBaseModel):
|
||||||
|
"""A command preset a client advertises as runnable (name and description)."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class CommandResult(StrictBaseModel):
|
class CommandResult(StrictBaseModel):
|
||||||
|
"""Outcome of one executed command, sent by the client to the server."""
|
||||||
|
|
||||||
command_id: str
|
command_id: str
|
||||||
status: CommandResultStatus
|
status: CommandResultStatus
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
@@ -63,6 +81,8 @@ class CommandResult(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class HeartbeatRequest(StrictBaseModel):
|
class HeartbeatRequest(StrictBaseModel):
|
||||||
|
"""Payload a client POSTs each poll: identity, state, capabilities, last result."""
|
||||||
|
|
||||||
client_id: str
|
client_id: str
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
client: ClientInfo
|
client: ClientInfo
|
||||||
@@ -72,6 +92,8 @@ class HeartbeatRequest(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Command(StrictBaseModel):
|
class Command(StrictBaseModel):
|
||||||
|
"""A command the server hands to a client for execution."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
kind: CommandKind
|
kind: CommandKind
|
||||||
name: str
|
name: str
|
||||||
@@ -80,6 +102,8 @@ class Command(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class HeartbeatResponse(StrictBaseModel):
|
class HeartbeatResponse(StrictBaseModel):
|
||||||
|
"""Server reply to a heartbeat: poll interval and an optional command to run."""
|
||||||
|
|
||||||
status: Literal["ok"] = "ok"
|
status: Literal["ok"] = "ok"
|
||||||
server_time: datetime
|
server_time: datetime
|
||||||
next_poll_after_seconds: int = Field(ge=0)
|
next_poll_after_seconds: int = Field(ge=0)
|
||||||
@@ -87,6 +111,8 @@ class HeartbeatResponse(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ClientRecord(StrictBaseModel):
|
class ClientRecord(StrictBaseModel):
|
||||||
|
"""Server-side stored view of a client and its last heartbeat."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
hostname: str
|
hostname: str
|
||||||
platform: ClientPlatform
|
platform: ClientPlatform
|
||||||
@@ -99,6 +125,8 @@ class ClientRecord(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CommandRecord(StrictBaseModel):
|
class CommandRecord(StrictBaseModel):
|
||||||
|
"""Server-side stored command with delivery and lifecycle bookkeeping."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
client_id: str
|
client_id: str
|
||||||
kind: CommandKind
|
kind: CommandKind
|
||||||
@@ -114,6 +142,8 @@ class CommandRecord(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CommandResultRecord(StrictBaseModel):
|
class CommandResultRecord(StrictBaseModel):
|
||||||
|
"""Server-side stored result reported by a client for a command."""
|
||||||
|
|
||||||
command_id: str
|
command_id: str
|
||||||
client_id: str
|
client_id: str
|
||||||
status: CommandResultStatus
|
status: CommandResultStatus
|
||||||
@@ -126,6 +156,8 @@ class CommandResultRecord(StrictBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AuditLogRecord(StrictBaseModel):
|
class AuditLogRecord(StrictBaseModel):
|
||||||
|
"""Audit-trail entry recording who did what to which subject."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
actor: str
|
actor: str
|
||||||
action: str
|
action: str
|
||||||
|
|||||||
@@ -15,29 +15,31 @@ from nexus_sync.common import (
|
|||||||
|
|
||||||
|
|
||||||
class Store(Protocol):
|
class Store(Protocol):
|
||||||
|
"""Storage interface for clients, commands, and command results."""
|
||||||
|
|
||||||
def list_clients(self) -> list[ClientRecord]:
|
def list_clients(self) -> list[ClientRecord]:
|
||||||
pass
|
"""Return all known clients."""
|
||||||
|
|
||||||
def get_client(self, client_id: str) -> ClientRecord | None:
|
def get_client(self, client_id: str) -> ClientRecord | None:
|
||||||
pass
|
"""Return the client with ``client_id``, or ``None`` if unknown."""
|
||||||
|
|
||||||
def upsert_client(self, heartbeat: HeartbeatRequest, now: datetime) -> ClientRecord:
|
def upsert_client(self, heartbeat: HeartbeatRequest, now: datetime) -> ClientRecord:
|
||||||
pass
|
"""Create or update a client from a heartbeat and return the stored record."""
|
||||||
|
|
||||||
def enqueue_command(self, command: Command, client_id: str, now: datetime) -> CommandRecord:
|
def enqueue_command(self, command: Command, client_id: str, now: datetime) -> CommandRecord:
|
||||||
pass
|
"""Queue a pending command for a client and return the stored record."""
|
||||||
|
|
||||||
def record_command_result(self, heartbeat: HeartbeatRequest, now: datetime) -> None:
|
def record_command_result(self, heartbeat: HeartbeatRequest, now: datetime) -> None:
|
||||||
pass
|
"""Persist the command result carried by a heartbeat, if one is present."""
|
||||||
|
|
||||||
def take_next_command(self, client_id: str, now: datetime) -> Command | None:
|
def take_next_command(self, client_id: str, now: datetime) -> Command | None:
|
||||||
pass
|
"""Pop the oldest pending command for a client, marking it delivered."""
|
||||||
|
|
||||||
def get_command(self, command_id: str) -> CommandRecord | None:
|
def get_command(self, command_id: str) -> CommandRecord | None:
|
||||||
pass
|
"""Return the command with ``command_id``, or ``None`` if unknown."""
|
||||||
|
|
||||||
def get_command_result(self, command_id: str) -> CommandResultRecord | None:
|
def get_command_result(self, command_id: str) -> CommandResultRecord | None:
|
||||||
pass
|
"""Return the latest result for ``command_id``, or ``None`` if none reported."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
Reference in New Issue
Block a user