feat: read pending updates from the cached firmware status, and run the check on request
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 28s
CI / test (3.11) (pull_request) Successful in 27s
CI / test (3.12) (pull_request) Successful in 29s

get_available_updates triggered firmware/check and slept up to 15 s, too
long for a poll, and returned [] when the check had not finished. For netOrk
MVP 5 it now reads the cached GET /api/core/firmware/status:

- [] only when the last check found nothing; it raises when the firewall
  never checked (no last_check) or its connection/repository is not "ok".
- refresh_available_updates(): POST firmware/check, then waits up to 60 s
  for last_check to change.
- get_host_status(): reboot_required from the status' needs_reboot.
This commit is contained in:
Christian Manivong
2026-10-06 00:20:10 +02:00
parent 0f172f02c0
commit fdda745388
2 changed files with 183 additions and 36 deletions
+58 -36
View File
@@ -39,11 +39,16 @@ import difflib
import json
import logging
import socket
import time
from ipaddress import ip_address, ip_network
from typing import Any
logger = logging.getLogger(__name__)
#: How long refresh_available_updates waits for the firewall's update check.
_REFRESH_POLLS = 20
_REFRESH_INTERVAL = 3
import requests
from requests.exceptions import RequestException
@@ -1998,45 +2003,62 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
return tunnels
def get_available_updates(self) -> list[dict[str, Any]]:
"""Return available firmware and package updates.
"""Return the pending firmware and package updates the firewall last found.
Triggers an async update-check on OPNsense via
``POST /api/core/firmware/check``, then polls
``GET /api/core/firmware/status`` for up to 15 seconds.
Returns a list of ``{name, current_version, new_version}`` dicts,
or an empty list when everything is up to date or the check has
not yet finished.
Reads the cached ``GET /api/core/firmware/status``; it triggers no check
(that is :meth:`refresh_available_updates`). An empty list means the last
check found nothing.
:raises RuntimeError: when the firewall never checked or cannot reach its
mirror -- never an empty list for "don't know".
"""
import time
try:
self._post("/api/core/firmware/check")
except Exception as exc:
logger.debug("Firmware update check trigger failed: %s", exc)
status = self._checked_firmware_status()
if status.get("status") not in ("update", "upgrade"):
return []
return sorted(
(
{
"name": u.get("name", ""),
"current_version": u.get("current_version", u.get("version", "")),
"new_version": u.get("new_version", u.get("version", "")),
}
for u in status.get("upgrade_packages") or status.get("updates") or []
),
key=lambda u: u["name"],
)
for _ in range(5):
time.sleep(3)
try:
status = self._get("/api/core/firmware/status")
state = status.get("status", "none")
if state in ("update", "upgrade"):
updates = (
status.get("upgrade_packages")
or status.get("updates")
or []
)
return [
{
"name": u.get("name", ""),
"current_version": u.get("current_version", u.get("version", "")),
"new_version": u.get("new_version", u.get("version", "")),
}
for u in updates
]
if state == "latest":
return []
except Exception as exc:
logger.debug("Firmware status poll failed: %s", exc)
return []
def _checked_firmware_status(self) -> dict[str, Any]:
status = self._get("/api/core/firmware/status")
if not status.get("last_check"):
raise RuntimeError("The firewall has not checked for updates yet")
for field in ("connection", "repository"):
if status.get(field, "ok") != "ok":
raise RuntimeError(f"The firmware {field} is {status.get(field)!r}")
return status
def refresh_available_updates(self) -> dict[str, Any]:
"""Run the firewall's update check (``firmware/check``) and wait for it."""
before = self._get("/api/core/firmware/status").get("last_check")
self._post("/api/core/firmware/check")
for _ in range(_REFRESH_POLLS):
time.sleep(_REFRESH_INTERVAL)
status = self._get("/api/core/firmware/status")
if status.get("last_check") and status.get("last_check") != before:
return {"success": True, "output": status.get("status_msg", "")}
return {
"success": False,
"output": f"The update check did not finish within {_REFRESH_POLLS * _REFRESH_INTERVAL} s",
}
def get_host_status(self) -> dict[str, Any]:
"""Whether the firewall needs a reboot to finish an update; it does not
patch itself as far as netOrk can tell."""
pending = self._get("/api/core/firmware/status").get("needs_reboot") == "1"
return {
"reboot_required": pending,
"reboot_reason": "the firmware status reports a pending reboot" if pending else None,
"auto_updates": None,
}
def get_device_warnings(self) -> list[dict[str, Any]]:
"""Return a list of warning dicts for issues detected on this device.