add info about aviable commands in heartbeat
This commit is contained in:
@@ -10,6 +10,10 @@ from nexus_sync.common import Command, CommandKind, CommandResult, CommandResult
|
||||
PresetBuilder = Callable[[Mapping[str, Any]], Sequence[str]]
|
||||
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024
|
||||
DEFAULT_PRESET_DESCRIPTIONS = {
|
||||
"hostname": "Return system hostname",
|
||||
"network_interfaces": "Return network interface information",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -13,11 +13,17 @@ from typing import Callable, Mapping, Protocol, Self
|
||||
from pydantic import ValidationError
|
||||
|
||||
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 (
|
||||
ClientInfo,
|
||||
ClientPlatform,
|
||||
ClientState,
|
||||
ClientCommandCapability,
|
||||
CommandResult,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
@@ -93,10 +99,24 @@ def build_heartbeat_request(
|
||||
local_time=datetime.now().astimezone(),
|
||||
uptime_seconds=None,
|
||||
),
|
||||
available_commands=list_available_commands(config.command_access_policy),
|
||||
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(
|
||||
config: ClientConfig,
|
||||
heartbeat: HeartbeatRequest,
|
||||
|
||||
@@ -4,6 +4,7 @@ from nexus_sync.common.models import (
|
||||
ClientPlatform,
|
||||
ClientRecord,
|
||||
ClientState,
|
||||
ClientCommandCapability,
|
||||
Command,
|
||||
CommandKind,
|
||||
CommandRecord,
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"ClientPlatform",
|
||||
"ClientRecord",
|
||||
"ClientState",
|
||||
"ClientCommandCapability",
|
||||
"Command",
|
||||
"CommandKind",
|
||||
"CommandRecord",
|
||||
|
||||
@@ -47,6 +47,11 @@ class ClientState(StrictBaseModel):
|
||||
uptime_seconds: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ClientCommandCapability(StrictBaseModel):
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class CommandResult(StrictBaseModel):
|
||||
command_id: str
|
||||
status: CommandResultStatus
|
||||
@@ -62,6 +67,7 @@ class HeartbeatRequest(StrictBaseModel):
|
||||
observed_at: datetime
|
||||
client: ClientInfo
|
||||
state: ClientState
|
||||
available_commands: list[ClientCommandCapability] = Field(default_factory=list)
|
||||
last_command_result: CommandResult | None = None
|
||||
|
||||
|
||||
@@ -89,6 +95,7 @@ class ClientRecord(StrictBaseModel):
|
||||
last_seen_at: datetime
|
||||
is_active: bool = True
|
||||
token_hash: str | None = None
|
||||
available_commands: list[ClientCommandCapability] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CommandRecord(StrictBaseModel):
|
||||
|
||||
@@ -45,6 +45,7 @@ class InMemoryStore:
|
||||
last_seen_at=now,
|
||||
is_active=True,
|
||||
token_hash=token_hash,
|
||||
available_commands=heartbeat.available_commands,
|
||||
)
|
||||
self.clients[heartbeat.client_id] = record
|
||||
return record
|
||||
|
||||
@@ -14,6 +14,7 @@ from nexus_sync.client.runtime import (
|
||||
ClientConfigError,
|
||||
HeartbeatError,
|
||||
build_heartbeat_request,
|
||||
list_available_commands,
|
||||
load_client_config,
|
||||
main,
|
||||
run_once,
|
||||
@@ -84,6 +85,24 @@ def test_build_heartbeat_request_contains_client_state(monkeypatch) -> 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:
|
||||
monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local")
|
||||
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
||||
|
||||
@@ -6,6 +6,7 @@ from nexus_sync.common import (
|
||||
ClientInfo,
|
||||
ClientPlatform,
|
||||
ClientState,
|
||||
ClientCommandCapability,
|
||||
Command,
|
||||
CommandKind,
|
||||
CommandResult,
|
||||
@@ -28,6 +29,12 @@ def test_heartbeat_request_accepts_payload() -> None:
|
||||
local_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
|
||||
uptime_seconds=1200,
|
||||
),
|
||||
available_commands=[
|
||||
ClientCommandCapability(
|
||||
name="hostname",
|
||||
description="Return system hostname",
|
||||
)
|
||||
],
|
||||
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"]["platform"] == "darwin"
|
||||
assert serialized["available_commands"] == [
|
||||
{"name": "hostname", "description": "Return system hostname"}
|
||||
]
|
||||
assert serialized["last_command_result"] is None
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,28 @@ def test_heartbeat_accepts_state_without_command() -> None:
|
||||
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:
|
||||
store = InMemoryStore()
|
||||
store.enqueue_command(
|
||||
|
||||
Reference in New Issue
Block a user