"""Central config endpoints — the hive tells this instance that config changed.
The hive does not push the document itself. It posts ``{"version", "hash"}``
to every registered instance; each instance fetches, validates and applies the
document on its own, which keeps the instance in control of what it runs and
lets a machine that was offline catch up on its next start (or on its next
announce reply).
``POST /config/notify`` accepts a request from anyone holding the swarm key.
That key is the fleet's read credential and it is enough here: a notification
causes a *fetch* against the hive, so a forged one can at most make this
instance re-read the config it would have read anyway.
"""
from __future__ import annotations
import asyncio
import structlog
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from navi.overlay import refresh, status_dict
from navi.overlay.state import get_state
router = APIRouter(prefix="/config", tags=["config"])
log = structlog.get_logger()
def _require_swarm_key(x_swarm_key: str | None) -> None:
from navi.swarm import verify_swarm_key
if not x_swarm_key:
raise HTTPException(status_code=401, detail="X-Swarm-Key header required")
if not verify_swarm_key(x_swarm_key):
raise HTTPException(status_code=403, detail="invalid swarm key")
class NotifyPayload(BaseModel):
version: int | None = None
hash: str | None = None
@router.post("/notify", status_code=202)
async def config_notify(
payload: NotifyPayload | None = None,
x_swarm_key: str | None = Header(None),
) -> dict:
"""Schedule a refresh; answer at once so the hive's fan-out stays fast."""
_require_swarm_key(x_swarm_key)
state = get_state()
if not state.enabled or state.base_settings is None:
return {"status": state.status, "detail": "central config disabled on this instance"}
notified = payload or NotifyPayload()
log.info(
"hive.config.notified",
version=notified.version,
hash=notified.hash,
current_hash=state.hash,
)
# Blocking HTTP inside the refresh — run it off the event loop.
asyncio.create_task(asyncio.to_thread(refresh, "notify"))
return {
"status": "scheduled",
"version": notified.version,
"hash": notified.hash,
"current": status_dict()["status"],
}
@router.get("/status")
async def config_status(x_swarm_key: str | None = Header(None)) -> dict:
"""What this instance currently runs — the same block /health shows."""
_require_swarm_key(x_swarm_key)
return status_dict()