implement heartbeat command

This commit is contained in:
ars
2026-05-24 18:36:55 +03:00
parent c92c5195ce
commit f9aed123fd
11 changed files with 663 additions and 25 deletions
+11
View File
@@ -0,0 +1,11 @@
from nexus_sync.server.app import create_app
from nexus_sync.server.config import DEFAULT_COMMAND_POLL_SECONDS, DEFAULT_IDLE_POLL_SECONDS
from nexus_sync.server.store import InMemoryStore, Store
__all__ = [
"DEFAULT_COMMAND_POLL_SECONDS",
"DEFAULT_IDLE_POLL_SECONDS",
"InMemoryStore",
"Store",
"create_app",
]
+6 -1
View File
@@ -1,5 +1,10 @@
import uvicorn
from nexus_sync.server.app import app
def main() -> None:
print("nexus-sync server")
uvicorn.run(app, host="0.0.0.0", port=8000)
if __name__ == "__main__":
+84
View File
@@ -0,0 +1,84 @@
from datetime import UTC, datetime
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from nexus_sync.common import HeartbeatRequest, HeartbeatResponse
from nexus_sync.server.config import (
DEFAULT_COMMAND_POLL_SECONDS,
DEFAULT_IDLE_POLL_SECONDS,
load_client_tokens,
)
from nexus_sync.server.store import InMemoryStore, Store
def create_app(
*,
store: Store | None = None,
client_tokens: dict[str, str] | None = None,
) -> FastAPI:
app = FastAPI(title="nexus-sync", version="0.1.0")
app.state.store = store or InMemoryStore()
app.state.client_tokens = client_tokens if client_tokens is not None else load_client_tokens()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
_request: Request,
error: RequestValidationError,
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, content={"detail": error.errors()}
)
def authorize_token(authorization: str | None = Header(default=None)) -> str:
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing bearer token",
)
token = authorization.removeprefix("Bearer ").strip()
owner_client_id = _client_id_for_token(app.state.client_tokens, token)
if owner_client_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid bearer token",
)
return owner_client_id
@app.post("/api/v1/client/heartbeat", response_model=HeartbeatResponse)
def heartbeat(
payload: HeartbeatRequest,
token_client_id: str = Depends(authorize_token),
) -> HeartbeatResponse:
if token_client_id != payload.client_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="token is not allowed for this client_id",
)
now = datetime.now(UTC)
app.state.store.upsert_client(payload, now)
app.state.store.record_command_result(payload, now)
command = app.state.store.take_next_command(payload.client_id, now)
return HeartbeatResponse(
server_time=now,
next_poll_after_seconds=(
DEFAULT_COMMAND_POLL_SECONDS if command else DEFAULT_IDLE_POLL_SECONDS
),
command=command,
)
return app
def _client_id_for_token(client_tokens: dict[str, str], token: str) -> str | None:
for client_id, expected_token in client_tokens.items():
if token == expected_token:
return client_id
return None
app = create_app()
+16
View File
@@ -0,0 +1,16 @@
import os
DEFAULT_IDLE_POLL_SECONDS = 60
DEFAULT_COMMAND_POLL_SECONDS = 10
def load_client_tokens() -> dict[str, str]:
raw_tokens = os.environ.get("NEXUS_SYNC_CLIENT_TOKENS", "dev-client:dev-token")
tokens: dict[str, str] = {}
for item in raw_tokens.split(","):
if not item.strip():
continue
client_id, separator, token = item.partition(":")
if separator and client_id and token:
tokens[client_id] = token
return tokens
+143
View File
@@ -0,0 +1,143 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol
from fastapi import HTTPException, status
from nexus_sync.common import (
ClientRecord,
Command,
CommandRecord,
CommandResultRecord,
CommandStatus,
HeartbeatRequest,
)
class Store(Protocol):
def upsert_client(self, heartbeat: HeartbeatRequest, now: datetime) -> ClientRecord:
pass
def record_command_result(self, heartbeat: HeartbeatRequest, now: datetime) -> None:
pass
def take_next_command(self, client_id: str, now: datetime) -> Command | None:
pass
@dataclass
class InMemoryStore:
clients: dict[str, ClientRecord] = field(default_factory=dict)
commands: dict[str, CommandRecord] = field(default_factory=dict)
results: list[CommandResultRecord] = field(default_factory=list)
def upsert_client(self, heartbeat: HeartbeatRequest, now: datetime) -> ClientRecord:
existing = self.clients.get(heartbeat.client_id)
created_at = existing.created_at if existing else now
token_hash = existing.token_hash if existing else None
record = ClientRecord(
id=heartbeat.client_id,
hostname=heartbeat.client.hostname,
platform=heartbeat.client.platform,
version=heartbeat.client.version,
created_at=created_at,
last_seen_at=now,
is_active=True,
token_hash=token_hash,
)
self.clients[heartbeat.client_id] = record
return record
def enqueue_command(self, command: Command, client_id: str, now: datetime) -> CommandRecord:
record = CommandRecord(
id=command.id,
client_id=client_id,
kind=command.kind,
name=command.name,
args=command.args,
status=CommandStatus.PENDING,
timeout_seconds=command.timeout_seconds,
created_at=now,
)
self.commands[record.id] = record
return record
def record_command_result(self, heartbeat: HeartbeatRequest, now: datetime) -> None:
result = heartbeat.last_command_result
if result is None:
return
command = self.commands.get(result.command_id)
if command is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="command result references unknown command",
)
if command.client_id != heartbeat.client_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="command result references command for another client",
)
if command.status in TERMINAL_COMMAND_STATUSES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="command result references terminal command",
)
finished_at = result.finished_at or now
self.commands[command.id] = command.model_copy(
update={
"status": CommandStatus(result.status.value),
"finished_at": finished_at,
}
)
self.results.append(
CommandResultRecord(
command_id=result.command_id,
client_id=heartbeat.client_id,
status=result.status,
started_at=result.started_at,
finished_at=finished_at,
return_code=result.return_code,
stdout=result.stdout,
stderr=result.stderr,
received_at=now,
)
)
def take_next_command(self, client_id: str, now: datetime) -> Command | None:
pending = sorted(
(
command
for command in self.commands.values()
if command.client_id == client_id and command.status == CommandStatus.PENDING
),
key=lambda command: command.created_at,
)
if not pending:
return None
record = pending[0]
self.commands[record.id] = record.model_copy(
update={
"status": CommandStatus.DELIVERED,
"attempts": record.attempts + 1,
"delivered_at": now,
}
)
return Command(
id=record.id,
kind=record.kind,
name=record.name,
args=record.args,
timeout_seconds=record.timeout_seconds,
)
TERMINAL_COMMAND_STATUSES = {
CommandStatus.SUCCEEDED,
CommandStatus.FAILED,
CommandStatus.TIMED_OUT,
CommandStatus.REJECTED,
}