Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD

Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
  фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
  перезагрузка; API POST /devices/{id}/firmware/downgrade и
  /batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.

Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-29 00:04:51 +03:00
1 parent 2dda12e06c
commit 5f7bd6588a
16 files changed
+393 -52

No files matched your search

+12
View File
@@ -165,3 +165,15 @@ def test_ros_state_values():
assert devices.ros_state({"ros_installed": "7.15", "ros_latest": "7.16"}) == "update"
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.23.7"}) == "downgrade" # long-term старше установленной
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
def test_fw_state_values():
"""Состояния колонки «Upgrade FW» (изменение 027): unknown / update / downgrade / current;
has_fw_update ложно для downgrade — откат прошивки не считается обновлением."""
assert devices.fw_state({}) == "unknown" # CHR или ещё не опрошено
assert devices.fw_state({"fw_current": "7.14"}) == "unknown" # нет upgrade-firmware
assert devices.fw_state({"fw_current": "7.14", "fw_upgrade": "7.15"}) == "update"
assert devices.fw_state({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) == "downgrade" # после отката ROS
assert devices.fw_state({"fw_current": "7.15", "fw_upgrade": "7.15"}) == "current"
assert devices.has_fw_update({"fw_current": "7.14", "fw_upgrade": "7.15"}) is True
assert devices.has_fw_update({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) is False
+60
View File
@@ -126,6 +126,66 @@ async def test_run_ros_downgrade_order_and_failures(monkeypatch):
assert j.status == "failed" and calls == ["backup"]
@pytest.mark.asyncio
async def test_fw_downgrade_order_and_failures(monkeypatch):
"""Изменение 027, по образцу test_run_ros_downgrade_order_and_failures: плата новее встроенной в ROS —
upgrade_firmware не пишет прошивку; fw_downgrade с несовпавшей версией — бэкап и запись не вызываются;
с совпавшей — порядок backup → upgrade → reboot. Заглушка после POST .../routerboard/upgrade сразу
отдаёт в /rest/log запись с FW_DONE_MSG, чтобы ожидание завершалось без опроса."""
calls = []
written = {"on": False}
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/routerboard/upgrade":
calls.append("upgrade")
written["on"] = True
return httpx.Response(200, json=[])
if r.url.path == "/rest/system/reboot":
calls.append("reboot")
return httpx.Response(200, json=[])
data = {
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.24.4", "upgrade-firmware": "7.23.7"},
"/rest/system/clock": {"date": "2026-09-28", "time": "12:00:00"},
"/rest/system/resource": {"uptime": "1h"},
"/rest/log": [{".id": "*9", "time": "2026-09-28 12:00:00", "message": ros.FW_DONE_MSG}] if written["on"] else [],
}
return httpx.Response(200, json=data.get(r.url.path, []))
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
# 1) плата (7.24.4) новее встроенной в ROS (7.23.7) — это откат, не обновление: запись не выполняется
async with ros_client(handler) as c:
msg = await ros.upgrade_firmware(c)
assert "upgrade" not in calls and "новее встроенной" in msg and "Откатить прошивку" in msg
async def fake_backup_ok(device_id):
calls.append("backup")
return "Бэкап загружен в S3: k.backup, k.rsc"
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
# 2) версия в ROS не совпала с подтверждённой -> задача failed, бэкапа и записи нет
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.6"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "failed" and "не совпадает" in j.message and calls == []
# 3) версия совпала -> порядок backup -> upgrade -> reboot
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.7"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "done" and calls == ["backup", "upgrade", "reboot"]
def test_batch_fw_downgrade_requires_target_version():
"""Изменение 027: /batch/fw_downgrade — отдельный типизированный эндпоинт, target_version обязателен."""
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": f"Bearer {API_TOKEN}"}
assert c.post("/api/v1/batch/fw_downgrade", headers=h, json={"device_ids": [d.id]}).status_code == 422
@pytest.mark.asyncio
async def test_downgrade_api_endpoints(monkeypatch):
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный