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]]
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user