implement heartbeat command
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import subprocess
|
||||
|
||||
from nexus_sync.client.execute import execute_command
|
||||
from nexus_sync.common import Command, CommandKind, CommandResultStatus
|
||||
|
||||
|
||||
def _command(name: str = "hostname", timeout_seconds: int = 30) -> Command:
|
||||
return Command(
|
||||
id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
kind=CommandKind.EXEC,
|
||||
name=name,
|
||||
args={},
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def test_execute_command_runs_known_preset_without_shell(monkeypatch) -> None:
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="host\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = execute_command(_command())
|
||||
|
||||
assert result.status == CommandResultStatus.SUCCEEDED
|
||||
assert result.return_code == 0
|
||||
assert result.stdout == "host\n"
|
||||
assert calls == [
|
||||
(
|
||||
["hostname"],
|
||||
{
|
||||
"input": None,
|
||||
"text": True,
|
||||
"capture_output": True,
|
||||
"timeout": 30,
|
||||
"shell": False,
|
||||
"check": False,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_execute_command_rejects_unknown_preset() -> None:
|
||||
result = execute_command(_command(name="rm_everything"))
|
||||
|
||||
assert result.status == CommandResultStatus.REJECTED
|
||||
assert result.return_code is None
|
||||
assert "unknown command preset" in result.stderr
|
||||
|
||||
|
||||
def test_execute_command_maps_non_zero_exit_to_failed(monkeypatch) -> None:
|
||||
def fake_run(argv, **kwargs):
|
||||
return subprocess.CompletedProcess(argv, 2, stdout="", stderr="failed\n")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = execute_command(_command())
|
||||
|
||||
assert result.status == CommandResultStatus.FAILED
|
||||
assert result.return_code == 2
|
||||
assert result.stderr == "failed\n"
|
||||
|
||||
|
||||
def test_execute_command_maps_timeout(monkeypatch) -> None:
|
||||
def fake_run(argv, **kwargs):
|
||||
raise subprocess.TimeoutExpired(argv, timeout=kwargs["timeout"], output="partial")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = execute_command(_command(timeout_seconds=1))
|
||||
|
||||
assert result.status == CommandResultStatus.TIMED_OUT
|
||||
assert result.return_code is None
|
||||
assert result.started_at is not None
|
||||
assert result.finished_at is not None
|
||||
|
||||
|
||||
def test_execute_command_truncates_output(monkeypatch) -> None:
|
||||
def fake_run(argv, **kwargs):
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="abcdefghijklmnopqrstuvwxyz", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
result = execute_command(_command(), output_limit_bytes=20)
|
||||
|
||||
assert result.stdout.endswith("\n[truncated]")
|
||||
assert len(result.stdout.encode()) <= 20
|
||||
|
||||
|
||||
def test_execute_command_rejects_preset_validation_error() -> None:
|
||||
command = _command()
|
||||
invalid = command.model_copy(update={"args": {"unexpected": True}})
|
||||
|
||||
result = execute_command(invalid)
|
||||
|
||||
assert result.status == CommandResultStatus.REJECTED
|
||||
assert "does not accept arguments" in result.stderr
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
foobuzz test
|
||||
"""
|
||||
|
||||
|
||||
def test_foo():
|
||||
pass
|
||||
@@ -15,7 +15,7 @@ from nexus_sync.common import (
|
||||
)
|
||||
|
||||
|
||||
def test_heartbeat_request_accepts_current_mvp_payload() -> None:
|
||||
def test_heartbeat_request_accepts_payload() -> None:
|
||||
payload = HeartbeatRequest(
|
||||
client_id="macbook-pro-01",
|
||||
observed_at=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from nexus_sync.common import Command, CommandKind, CommandResultStatus, CommandStatus
|
||||
from nexus_sync.server import (
|
||||
DEFAULT_COMMAND_POLL_SECONDS,
|
||||
DEFAULT_IDLE_POLL_SECONDS,
|
||||
Store,
|
||||
InMemoryStore,
|
||||
create_app,
|
||||
)
|
||||
|
||||
|
||||
def _heartbeat_payload(client_id: str = "macbook-pro-01", result: dict | None = None) -> dict:
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"observed_at": "2026-05-24T13:20:30Z",
|
||||
"client": {
|
||||
"hostname": "macbook-pro.local",
|
||||
"platform": "darwin",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"state": {
|
||||
"local_time": "2026-05-24T16:20:30+03:00",
|
||||
"uptime_seconds": 1200,
|
||||
},
|
||||
"last_command_result": result,
|
||||
}
|
||||
|
||||
|
||||
def _client(store: Store | None = None) -> TestClient:
|
||||
app = create_app(
|
||||
store=store or InMemoryStore(),
|
||||
client_tokens={"macbook-pro-01": "client-token", "other-client": "other-token"},
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_heartbeat_requires_bearer_token() -> None:
|
||||
client = _client()
|
||||
|
||||
response = client.post("/api/v1/client/heartbeat", json=_heartbeat_payload())
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_heartbeat_rejects_token_for_another_client() -> None:
|
||||
client = _client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=_heartbeat_payload(),
|
||||
headers={"Authorization": "Bearer other-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_heartbeat_returns_bad_request_for_invalid_payload() -> None:
|
||||
payload = _heartbeat_payload()
|
||||
payload["unexpected"] = True
|
||||
client = _client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=payload,
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_heartbeat_accepts_state_without_command() -> None:
|
||||
store = InMemoryStore()
|
||||
client = _client(store)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=_heartbeat_payload(),
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["command"] is None
|
||||
assert response.json()["next_poll_after_seconds"] == DEFAULT_IDLE_POLL_SECONDS
|
||||
assert store.clients["macbook-pro-01"].hostname == "macbook-pro.local"
|
||||
|
||||
|
||||
def test_heartbeat_delivers_pending_command_and_marks_it_delivered() -> None:
|
||||
store = InMemoryStore()
|
||||
store.enqueue_command(
|
||||
Command(
|
||||
id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
kind=CommandKind.EXEC,
|
||||
name="hostname",
|
||||
args={},
|
||||
timeout_seconds=30,
|
||||
),
|
||||
client_id="macbook-pro-01",
|
||||
now=datetime(2026, 5, 24, 13, 20, 0, tzinfo=UTC),
|
||||
)
|
||||
client = _client(store)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=_heartbeat_payload(),
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["next_poll_after_seconds"] == DEFAULT_COMMAND_POLL_SECONDS
|
||||
assert body["command"]["id"] == "cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"
|
||||
assert store.commands["cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"].status == CommandStatus.DELIVERED
|
||||
assert store.commands["cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"].attempts == 1
|
||||
|
||||
|
||||
def test_heartbeat_records_command_result() -> None:
|
||||
store = InMemoryStore()
|
||||
store.enqueue_command(
|
||||
Command(
|
||||
id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
kind=CommandKind.EXEC,
|
||||
name="hostname",
|
||||
args={},
|
||||
timeout_seconds=30,
|
||||
),
|
||||
client_id="macbook-pro-01",
|
||||
now=datetime(2026, 5, 24, 13, 20, 0, tzinfo=UTC),
|
||||
)
|
||||
store.take_next_command("macbook-pro-01", datetime(2026, 5, 24, 13, 20, 1, tzinfo=UTC))
|
||||
client = _client(store)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=_heartbeat_payload(
|
||||
result={
|
||||
"command_id": "cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
"status": "succeeded",
|
||||
"started_at": "2026-05-24T13:20:02Z",
|
||||
"finished_at": "2026-05-24T13:20:03Z",
|
||||
"return_code": 0,
|
||||
"stdout": "host\n",
|
||||
"stderr": "",
|
||||
}
|
||||
),
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert store.commands["cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"].status == CommandStatus.SUCCEEDED
|
||||
assert len(store.results) == 1
|
||||
assert store.results[0].status == CommandResultStatus.SUCCEEDED
|
||||
|
||||
|
||||
def test_heartbeat_rejects_unknown_command_result() -> None:
|
||||
client = _client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=_heartbeat_payload(
|
||||
result={
|
||||
"command_id": "cmd_unknown",
|
||||
"status": "succeeded",
|
||||
"return_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
}
|
||||
),
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
Reference in New Issue
Block a user