add server api handlers. Fix bug with cmd output
This commit is contained in:
@@ -387,7 +387,7 @@ def test_main_logs_success_without_command(monkeypatch, caplog, tmp_path) -> Non
|
||||
assert "heartbeat accepted; no command" in caplog.text
|
||||
|
||||
|
||||
def test_main_logs_command_result(monkeypatch, caplog, tmp_path) -> None:
|
||||
def test_main_logs_and_reports_command_result(monkeypatch, caplog, tmp_path) -> None:
|
||||
caplog.set_level("INFO")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "nexus.yml").write_text(
|
||||
@@ -400,21 +400,27 @@ def test_main_logs_command_result(monkeypatch, caplog, tmp_path) -> None:
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nexus_sync.client.runtime.run_once",
|
||||
lambda _config: CommandResult(
|
||||
command_id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
status=CommandResultStatus.SUCCEEDED,
|
||||
return_code=0,
|
||||
stdout="host\n",
|
||||
stderr="",
|
||||
),
|
||||
command_result = CommandResult(
|
||||
command_id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
status=CommandResultStatus.SUCCEEDED,
|
||||
return_code=0,
|
||||
stdout="host\n",
|
||||
stderr="",
|
||||
)
|
||||
reported_results = []
|
||||
|
||||
def fake_run_once(_config: ClientConfig, *, last_command_result=None):
|
||||
reported_results.append(last_command_result)
|
||||
return command_result if last_command_result is None else None
|
||||
|
||||
monkeypatch.setattr("nexus_sync.client.runtime.run_once", fake_run_once)
|
||||
|
||||
exit_code = main([])
|
||||
|
||||
assert exit_code == 0
|
||||
assert reported_results == [None, command_result]
|
||||
assert "command result:" in caplog.text
|
||||
assert "command result reported to server" in caplog.text
|
||||
assert '"command_id":"cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"' in caplog.text
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from nexus_sync.server import InMemoryStore, Store, 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 _send_heartbeat(client: TestClient, payload: dict | None = None):
|
||||
return client.post(
|
||||
"/api/v1/client/heartbeat",
|
||||
json=payload or _heartbeat_payload(),
|
||||
headers={"Authorization": "Bearer client-token"},
|
||||
)
|
||||
|
||||
|
||||
def test_server_lists_clients_after_heartbeat() -> None:
|
||||
store = InMemoryStore()
|
||||
client = _client(store)
|
||||
payload = _heartbeat_payload()
|
||||
payload["available_commands"] = [
|
||||
{"name": "hostname", "description": "Get hostname"},
|
||||
]
|
||||
_send_heartbeat(client, payload)
|
||||
|
||||
response = client.get("/api/v1/server/clients")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["clients"][0]["id"] == "macbook-pro-01"
|
||||
assert response.json()["clients"][0]["available_commands"] == [
|
||||
{"name": "hostname", "description": "Get hostname"},
|
||||
]
|
||||
|
||||
|
||||
def test_server_queues_command_for_client_and_heartbeat_delivers_it() -> None:
|
||||
store = InMemoryStore()
|
||||
client = _client(store)
|
||||
_send_heartbeat(client)
|
||||
|
||||
created = client.post(
|
||||
"/api/v1/server/clients/macbook-pro-01/commands",
|
||||
json={"name": "hostname", "args": {}, "timeout_seconds": 30},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
command_id = created.json()["id"]
|
||||
assert created.json()["client_id"] == "macbook-pro-01"
|
||||
assert created.json()["status"] == "pending"
|
||||
|
||||
heartbeat = _send_heartbeat(client)
|
||||
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["command"]["id"] == command_id
|
||||
assert heartbeat.json()["command"]["name"] == "hostname"
|
||||
|
||||
|
||||
def test_server_rejects_command_for_unknown_client() -> None:
|
||||
client = _client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/server/clients/missing-client/commands",
|
||||
json={"name": "hostname", "args": {}, "timeout_seconds": 30},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_server_returns_command_with_result_after_client_reports_result() -> None:
|
||||
store = InMemoryStore()
|
||||
client = _client(store)
|
||||
_send_heartbeat(client)
|
||||
created = client.post(
|
||||
"/api/v1/server/clients/macbook-pro-01/commands",
|
||||
json={"name": "hostname", "args": {}, "timeout_seconds": 30},
|
||||
)
|
||||
command_id = created.json()["id"]
|
||||
_send_heartbeat(client)
|
||||
|
||||
reported = _send_heartbeat(
|
||||
client,
|
||||
_heartbeat_payload(
|
||||
result={
|
||||
"command_id": command_id,
|
||||
"status": "succeeded",
|
||||
"return_code": 0,
|
||||
"stdout": "host\n",
|
||||
"stderr": "",
|
||||
}
|
||||
),
|
||||
)
|
||||
fetched = client.get(f"/api/v1/server/commands/{command_id}")
|
||||
|
||||
assert reported.status_code == 200
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["status"] == "succeeded"
|
||||
assert fetched.json()["result"]["stdout"] == "host\n"
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from nexus_sync.common import Command, CommandKind, CommandStatus
|
||||
from nexus_sync.server.sqlalchemy_store import SQLAlchemyStore
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
"available_commands": [
|
||||
{"name": "hostname", "description": "Get hostname"},
|
||||
],
|
||||
"last_command_result": result,
|
||||
}
|
||||
|
||||
|
||||
def test_sqlalchemy_store_persists_clients_commands_and_results(tmp_path) -> None:
|
||||
db_url = f"sqlite:///{tmp_path / 'nexus-sync.db'}"
|
||||
store = SQLAlchemyStore(db_url)
|
||||
now = datetime(2026, 5, 24, 13, 20, tzinfo=UTC)
|
||||
|
||||
store.upsert_client_from_payload(_heartbeat_payload(), now)
|
||||
command = Command(
|
||||
id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B",
|
||||
kind=CommandKind.EXEC,
|
||||
name="hostname",
|
||||
args={},
|
||||
timeout_seconds=30,
|
||||
)
|
||||
store.enqueue_command(command, "macbook-pro-01", now)
|
||||
|
||||
reloaded = SQLAlchemyStore(db_url)
|
||||
assert reloaded.get_client("macbook-pro-01") is not None
|
||||
assert reloaded.get_client("macbook-pro-01").available_commands[0].name == "hostname"
|
||||
|
||||
delivered = reloaded.take_next_command("macbook-pro-01", now)
|
||||
assert delivered is not None
|
||||
assert delivered.id == command.id
|
||||
assert reloaded.get_command(command.id).status == CommandStatus.DELIVERED
|
||||
|
||||
result_payload = _heartbeat_payload(
|
||||
result={
|
||||
"command_id": command.id,
|
||||
"status": "succeeded",
|
||||
"return_code": 0,
|
||||
"stdout": "host\n",
|
||||
"stderr": "",
|
||||
}
|
||||
)
|
||||
reloaded.record_command_result_from_payload(result_payload, now)
|
||||
|
||||
final = SQLAlchemyStore(db_url)
|
||||
assert final.get_command(command.id).status == CommandStatus.SUCCEEDED
|
||||
assert final.get_command_result(command.id).stdout == "host\n"
|
||||
Reference in New Issue
Block a user