Nodes: Update All Nodes — fleet-wide update request for every approved node
POST /nodes/request-update-all (admin) does what Update Node does for each approved node: flags update_requested_at for the HTTPS updater and nudges it over MQTT. Pending and rejected nodes are skipped. Admin-only button on the Nodes page reports which nodes weren't reachable over MQTT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
16a609e35b
commit
f18f7a49f6
@@ -97,10 +97,40 @@ async def request_node_update(node_id: str, _: dict = Depends(require_admin_toke
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
requested_at = datetime.now(timezone.utc).isoformat()
|
||||
mqtt_sent = await _flag_update(node_id, requested_at)
|
||||
return {"ok": True, "update_requested_at": requested_at, "mqtt_sent": mqtt_sent}
|
||||
|
||||
|
||||
async def _flag_update(node_id: str, requested_at: str) -> bool:
|
||||
await fstore.doc_update("nodes", node_id, {"update_requested_at": requested_at})
|
||||
mqtt_sent = mqtt_handler.send_command(node_id, {"action": "node_update"})
|
||||
logger.info(f"Update requested for {node_id!r} at {requested_at} (mqtt_sent={mqtt_sent})")
|
||||
return {"ok": True, "update_requested_at": requested_at, "mqtt_sent": mqtt_sent}
|
||||
return mqtt_sent
|
||||
|
||||
|
||||
@router.post("/request-update-all")
|
||||
async def request_update_all_nodes(_: dict = Depends(require_admin_token)):
|
||||
"""Fleet-wide "Update Node": the same two-channel request as
|
||||
request_node_update, for every approved node. Pending/rejected nodes are
|
||||
skipped — a rejected node must not be nudged (server-26#195), and a pending
|
||||
one has no key to check in with."""
|
||||
requested_at = datetime.now(timezone.utc).isoformat()
|
||||
nodes = await fstore.collection_list("nodes")
|
||||
requested, mqtt_sent, skipped = [], [], []
|
||||
for n in nodes:
|
||||
nid = n.get("node_id")
|
||||
if not nid:
|
||||
continue
|
||||
if n.get("approval_status") != "approved":
|
||||
skipped.append(nid)
|
||||
continue
|
||||
requested.append(nid)
|
||||
if await _flag_update(nid, requested_at):
|
||||
mqtt_sent.append(nid)
|
||||
logger.info(f"Fleet update requested at {requested_at}: {len(requested)} nodes, "
|
||||
f"{len(mqtt_sent)} via MQTT, {len(skipped)} skipped")
|
||||
return {"ok": True, "update_requested_at": requested_at, "requested": requested,
|
||||
"mqtt_sent": mqtt_sent, "skipped": skipped}
|
||||
|
||||
|
||||
@router.delete("/{node_id}", status_code=204)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
POST /nodes/request-update-all — the fleet-wide "Update Node": flags every
|
||||
approved node (HTTPS) and nudges it over MQTT; pending/rejected nodes are skipped.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||
from app.routers import nodes
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def setup_function():
|
||||
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
|
||||
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||
|
||||
|
||||
def teardown_function():
|
||||
app.dependency_overrides.pop(require_admin_token, None)
|
||||
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||
|
||||
|
||||
def test_update_all_flags_only_approved_nodes():
|
||||
fleet = [
|
||||
{"node_id": "a", "approval_status": "approved"},
|
||||
{"node_id": "b", "approval_status": "approved"},
|
||||
{"node_id": "p", "approval_status": "pending"},
|
||||
{"node_id": "r", "approval_status": "rejected"},
|
||||
]
|
||||
doc_update = AsyncMock()
|
||||
send = MagicMock(side_effect=lambda nid, cmd: nid == "a")
|
||||
with patch.object(nodes.fstore, "collection_list", AsyncMock(return_value=fleet)), \
|
||||
patch.object(nodes.fstore, "doc_update", doc_update), \
|
||||
patch.object(nodes.mqtt_handler, "send_command", send):
|
||||
r = client.post("/nodes/request-update-all")
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["requested"] == ["a", "b"]
|
||||
assert body["mqtt_sent"] == ["a"]
|
||||
assert sorted(body["skipped"]) == ["p", "r"]
|
||||
flagged = {c.args[1] for c in doc_update.await_args_list}
|
||||
assert flagged == {"a", "b"}
|
||||
for c in doc_update.await_args_list:
|
||||
assert c.args[2] == {"update_requested_at": body["update_requested_at"]}
|
||||
assert {c.args[0] for c in send.call_args_list} == {"a", "b"}
|
||||
assert all(c.args[1] == {"action": "node_update"} for c in send.call_args_list)
|
||||
|
||||
|
||||
def test_update_all_requires_admin():
|
||||
app.dependency_overrides.pop(require_admin_token, None)
|
||||
r = client.post("/nodes/request-update-all")
|
||||
assert r.status_code in (401, 403)
|
||||
Reference in New Issue
Block a user