add info about aviable commands in heartbeat

This commit is contained in:
2026-06-24 04:11:31 +03:00
parent 9eb2a06757
commit 25ae509d52
8 changed files with 86 additions and 1 deletions
+4
View File
@@ -10,6 +10,10 @@ from nexus_sync.common import Command, CommandKind, CommandResult, CommandResult
PresetBuilder = Callable[[Mapping[str, Any]], Sequence[str]] PresetBuilder = Callable[[Mapping[str, Any]], Sequence[str]]
DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024 DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024
DEFAULT_PRESET_DESCRIPTIONS = {
"hostname": "Return system hostname",
"network_interfaces": "Return network interface information",
}
@dataclass(frozen=True) @dataclass(frozen=True)
+21 -1
View File
@@ -13,11 +13,17 @@ from typing import Callable, Mapping, Protocol, Self
from pydantic import ValidationError from pydantic import ValidationError
from nexus_sync.client.config import load_command_access_policy from nexus_sync.client.config import load_command_access_policy
from nexus_sync.client.execute import CommandAccessPolicy, execute_command from nexus_sync.client.execute import (
DEFAULT_PRESET_DESCRIPTIONS,
DEFAULT_PRESETS,
CommandAccessPolicy,
execute_command,
)
from nexus_sync.common import ( from nexus_sync.common import (
ClientInfo, ClientInfo,
ClientPlatform, ClientPlatform,
ClientState, ClientState,
ClientCommandCapability,
CommandResult, CommandResult,
HeartbeatRequest, HeartbeatRequest,
HeartbeatResponse, HeartbeatResponse,
@@ -93,10 +99,24 @@ def build_heartbeat_request(
local_time=datetime.now().astimezone(), local_time=datetime.now().astimezone(),
uptime_seconds=None, uptime_seconds=None,
), ),
available_commands=list_available_commands(config.command_access_policy),
last_command_result=last_command_result, last_command_result=last_command_result,
) )
def list_available_commands(
access_policy: CommandAccessPolicy,
) -> list[ClientCommandCapability]:
return [
ClientCommandCapability(
name=name,
description=DEFAULT_PRESET_DESCRIPTIONS.get(name, ""),
)
for name in sorted(DEFAULT_PRESETS)
if access_policy.allows(name)
]
def send_heartbeat( def send_heartbeat(
config: ClientConfig, config: ClientConfig,
heartbeat: HeartbeatRequest, heartbeat: HeartbeatRequest,
+2
View File
@@ -4,6 +4,7 @@ from nexus_sync.common.models import (
ClientPlatform, ClientPlatform,
ClientRecord, ClientRecord,
ClientState, ClientState,
ClientCommandCapability,
Command, Command,
CommandKind, CommandKind,
CommandRecord, CommandRecord,
@@ -21,6 +22,7 @@ __all__ = [
"ClientPlatform", "ClientPlatform",
"ClientRecord", "ClientRecord",
"ClientState", "ClientState",
"ClientCommandCapability",
"Command", "Command",
"CommandKind", "CommandKind",
"CommandRecord", "CommandRecord",
+7
View File
@@ -47,6 +47,11 @@ class ClientState(StrictBaseModel):
uptime_seconds: int | None = Field(default=None, ge=0) uptime_seconds: int | None = Field(default=None, ge=0)
class ClientCommandCapability(StrictBaseModel):
name: str
description: str
class CommandResult(StrictBaseModel): class CommandResult(StrictBaseModel):
command_id: str command_id: str
status: CommandResultStatus status: CommandResultStatus
@@ -62,6 +67,7 @@ class HeartbeatRequest(StrictBaseModel):
observed_at: datetime observed_at: datetime
client: ClientInfo client: ClientInfo
state: ClientState state: ClientState
available_commands: list[ClientCommandCapability] = Field(default_factory=list)
last_command_result: CommandResult | None = None last_command_result: CommandResult | None = None
@@ -89,6 +95,7 @@ class ClientRecord(StrictBaseModel):
last_seen_at: datetime last_seen_at: datetime
is_active: bool = True is_active: bool = True
token_hash: str | None = None token_hash: str | None = None
available_commands: list[ClientCommandCapability] = Field(default_factory=list)
class CommandRecord(StrictBaseModel): class CommandRecord(StrictBaseModel):
+1
View File
@@ -45,6 +45,7 @@ class InMemoryStore:
last_seen_at=now, last_seen_at=now,
is_active=True, is_active=True,
token_hash=token_hash, token_hash=token_hash,
available_commands=heartbeat.available_commands,
) )
self.clients[heartbeat.client_id] = record self.clients[heartbeat.client_id] = record
return record return record
+19
View File
@@ -14,6 +14,7 @@ from nexus_sync.client.runtime import (
ClientConfigError, ClientConfigError,
HeartbeatError, HeartbeatError,
build_heartbeat_request, build_heartbeat_request,
list_available_commands,
load_client_config, load_client_config,
main, main,
run_once, run_once,
@@ -84,6 +85,24 @@ def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None:
assert serialized["state"]["uptime_seconds"] is None assert serialized["state"]["uptime_seconds"] is None
def test_list_available_commands_returns_allowed_command_names_and_descriptions() -> None:
commands = list_available_commands(CommandAccessPolicy.allow(["hostname"]))
assert [command.model_dump() for command in commands] == [
{"name": "hostname", "description": "Return system hostname"}
]
def test_build_heartbeat_request_includes_available_commands(monkeypatch) -> None:
monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local")
monkeypatch.setattr("platform.system", lambda: "Darwin")
heartbeat = build_heartbeat_request(_config(CommandAccessPolicy.allow(["hostname"])))
assert [command.name for command in heartbeat.available_commands] == ["hostname"]
assert heartbeat.available_commands[0].description == "Return system hostname"
def test_build_heartbeat_request_includes_last_command_result(monkeypatch) -> None: def test_build_heartbeat_request_includes_last_command_result(monkeypatch) -> None:
monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local") monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local")
monkeypatch.setattr("platform.system", lambda: "Darwin") monkeypatch.setattr("platform.system", lambda: "Darwin")
+10
View File
@@ -6,6 +6,7 @@ from nexus_sync.common import (
ClientInfo, ClientInfo,
ClientPlatform, ClientPlatform,
ClientState, ClientState,
ClientCommandCapability,
Command, Command,
CommandKind, CommandKind,
CommandResult, CommandResult,
@@ -28,6 +29,12 @@ def test_heartbeat_request_accepts_payload() -> None:
local_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), local_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
uptime_seconds=1200, uptime_seconds=1200,
), ),
available_commands=[
ClientCommandCapability(
name="hostname",
description="Return system hostname",
)
],
last_command_result=None, last_command_result=None,
) )
@@ -35,6 +42,9 @@ def test_heartbeat_request_accepts_payload() -> None:
assert serialized["client_id"] == "macbook-pro-01" assert serialized["client_id"] == "macbook-pro-01"
assert serialized["client"]["platform"] == "darwin" assert serialized["client"]["platform"] == "darwin"
assert serialized["available_commands"] == [
{"name": "hostname", "description": "Return system hostname"}
]
assert serialized["last_command_result"] is None assert serialized["last_command_result"] is None
+22
View File
@@ -87,6 +87,28 @@ def test_heartbeat_accepts_state_without_command() -> None:
assert store.clients["macbook-pro-01"].hostname == "macbook-pro.local" assert store.clients["macbook-pro-01"].hostname == "macbook-pro.local"
def test_heartbeat_stores_available_commands() -> None:
store = InMemoryStore()
client = _client(store)
payload = _heartbeat_payload()
payload["available_commands"] = [
{"name": "hostname", "description": "Return system hostname"},
{"name": "network_interfaces", "description": "Return network interface information"},
]
response = client.post(
"/api/v1/client/heartbeat",
json=payload,
headers={"Authorization": "Bearer client-token"},
)
assert response.status_code == 200
assert [command.name for command in store.clients["macbook-pro-01"].available_commands] == [
"hostname",
"network_interfaces",
]
def test_heartbeat_delivers_pending_command_and_marks_it_delivered() -> None: def test_heartbeat_delivers_pending_command_and_marks_it_delivered() -> None:
store = InMemoryStore() store = InMemoryStore()
store.enqueue_command( store.enqueue_command(