Состояния «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:
1 parent
2dda12e06c
commit
5f7bd6588a
16 files changed
+393
-52
No files matched your search
@@ -79,7 +79,15 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
| Последняя проверка обновлений завершилась ошибкой | «проверка не удалась», текст ошибки в подсказке |
|
| Последняя проверка обновлений завершилась ошибкой | «проверка не удалась», текст ошибки в подсказке |
|
||||||
| Проверка ещё не выполнялась (или у устройства нет интернета) | «—» |
|
| Проверка ещё не выполнялась (или у устройства нет интернета) | «—» |
|
||||||
|
|
||||||
«Upgrade FW» — версия прошивки для обновления или «актуально». Устройство проверяет обновления на серверах MikroTik.
|
**Колонка «Upgrade FW»** (изменение 027)
|
||||||
|
| Состояние | Отображение |
|
||||||
|
|---|---|
|
||||||
|
| В установленную ROS встроена более новая прошивка платы | «↑ X» |
|
||||||
|
| Записана версия, встроенная в ROS | «актуально» |
|
||||||
|
| Прошивка платы новее встроенной в ROS (например, плата не откатилась вместе с ROS — 023) | «в ROS: X» — не обновление; откат — отдельной операцией |
|
||||||
|
| Нет RouterBOARD firmware (CHR) или устройство не опрошено | «—» |
|
||||||
|
|
||||||
|
Прошивка сравнивается с версией, встроенной в установленную ROS (`upgrade-firmware`), а не с серверами MikroTik: `/system/routerboard/upgrade` только записывает то, что уже скачано вместе с ROS.
|
||||||
|
|
||||||
**Группы и фильтры**
|
**Группы и фильтры**
|
||||||
- Устройство — в одной группе или «Без группы». Вкладки групп со счётчиками, страница «Группы»; группу можно создать при добавлении устройства.
|
- Устройство — в одной группе или «Без группы». Вкладки групп со счётчиками, страница «Группы»; группу можно создать при добавлении устройства.
|
||||||
@@ -87,13 +95,13 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
|
|
||||||
**Операции и задачи**
|
**Операции и задачи**
|
||||||
- Действия по устройству — меню «⋯» строки; над выбранными — полоса инструментов; через API — и над целой группой.
|
- Действия по устройству — меню «⋯» строки; над выбранными — полоса инструментов; через API — и над целой группой.
|
||||||
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
|
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS, откат FW (027) выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
|
||||||
- Обновление ROS: устройство перезагружается после скачивания. Обновление FW: перезагрузка, как только в журнале устройства появилась запись «Firmware upgraded successfully…».
|
- Обновление ROS: устройство перезагружается после скачивания. Обновление FW: перезагрузка, как только в журнале устройства появилась запись «Firmware upgraded successfully…».
|
||||||
- Штатное обновление не выполняет откат: версия канала старше установленной — «Обновление не требуется».
|
- Штатное обновление не выполняет откат: версия канала (или встроенной прошивки) старше установленной/записанной — «Обновление не требуется», с подсказкой на соответствующий откат.
|
||||||
|
|
||||||
**Откат ROS до версии канала**
|
**Откат ROS до версии канала / откат FW до версии ROS**
|
||||||
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Для одного устройства — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X»), для группы — пункт в меню «Обновление»; устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
|
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Одно окно на оба вида отката (параметр `kind`): для ROS — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X») и пункт в меню «Обновление»; для FW (изменение 027) — «Откатить прошивку…» (виден в состоянии «в ROS: X») и «Откатить прошивку до версии ROS…» там же. Устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
|
||||||
- Задача: проверка на устройстве (версия канала совпадает с введённой и старше установленной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и установка.
|
- Задача: проверка на устройстве (целевая версия совпадает с введённой и старше записанной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и запись. Для FW прошивка берётся из установленной ROS, а не с серверов MikroTik.
|
||||||
|
|
||||||
**Бэкапы**
|
**Бэкапы**
|
||||||
- Пара файлов: `.backup` (без шифрования) и `.rsc` (с `show-sensitive` — пароли и ключи). Сервер создаёт их через REST, скачивает, загружает в S3 и удаляет с устройства.
|
- Пара файлов: `.backup` (без шифрования) и `.rsc` (с `show-sensitive` — пароли и ключи). Сервер создаёт их через REST, скачивает, загружает в S3 и удаляет с устройства.
|
||||||
@@ -119,8 +127,8 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
|||||||
| Область | Эндпоинты |
|
| Область | Эндпоинты |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Устройства | `GET\|POST /devices` (фильтры `group`, `q`, `status`, `updates`, `channel`), `GET\|PATCH\|DELETE /devices/{id}` (смена имени — 400), `POST /devices/refresh`, `POST /devices/{id}/refresh` |
|
| Устройства | `GET\|POST /devices` (фильтры `group`, `q`, `status`, `updates`, `channel`), `GET\|PATCH\|DELETE /devices/{id}` (смена имени — 400), `POST /devices/refresh`, `POST /devices/{id}/refresh` |
|
||||||
| Операции | `POST /devices/{id}/backups`, `PUT /devices/{id}/update/channel`, `POST /devices/{id}/update/install`, `POST /devices/{id}/firmware/upgrade`, `POST /devices/{id}/update/downgrade` (`{"target_version"}` обязателен) |
|
| Операции | `POST /devices/{id}/backups`, `PUT /devices/{id}/update/channel`, `POST /devices/{id}/update/install`, `POST /devices/{id}/firmware/upgrade`, `POST /devices/{id}/update/downgrade`, `POST /devices/{id}/firmware/downgrade` (027) — оба отката `{"target_version"}` обязателен |
|
||||||
| Групповые | `POST /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade` (`target_version`) — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
|
| Групповые | `POST /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade`, `POST /batch/fw_downgrade` (027) — оба `target_version` — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
|
||||||
| Группы | `GET\|POST /groups`, `PATCH\|DELETE /groups/{id}` (устройства остаются без группы) |
|
| Группы | `GET\|POST /groups`, `PATCH\|DELETE /groups/{id}` (устройства остаются без группы) |
|
||||||
| Бэкапы | `GET /backups` (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш), `GET /backups/download?key=`, `DELETE /backups?key=`, `POST /backups/delete` (`{"keys": [...]}` → `{"deleted", "failed"}`) |
|
| Бэкапы | `GET /backups` (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш), `GET /backups/download?key=`, `DELETE /backups?key=`, `POST /backups/delete` (`{"keys": [...]}` → `{"deleted", "failed"}`) |
|
||||||
| Задачи | `GET /jobs`, `GET /jobs/{id}` |
|
| Задачи | `GET /jobs`, `GET /jobs/{id}` |
|
||||||
@@ -169,7 +177,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
|||||||
- Светлая и тёмная темы — по настройке системы.
|
- Светлая и тёмная темы — по настройке системы.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
36 тестов по модулям предметных областей (`tests/test_*.py`), фоновый опрос выключен; стенд не нужен (временная SQLite,
|
39 тестов по модулям предметных областей (`tests/test_*.py`), фоновый опрос выключен; стенд не нужен (временная SQLite,
|
||||||
RouterOS и S3 — заглушки). Тест-линтер (`test_architecture.py`) не допускает синхронных обращений к БД в `async`-коде.
|
RouterOS и S3 — заглушки). Тест-линтер (`test_architecture.py`) не допускает синхронных обращений к БД в `async`-коде.
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
||||||
@@ -209,6 +217,7 @@ venv/bin/python -m pytest -q tests/test_backups.py # один модуль
|
|||||||
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
|
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
|
||||||
| 025 | Доверенные прокси (реальный IP клиента), параметризация порта | [план](docs/changes/025-trusted-proxies-port/plan.md) · [итог](docs/changes/025-trusted-proxies-port/summary.md) |
|
| 025 | Доверенные прокси (реальный IP клиента), параметризация порта | [план](docs/changes/025-trusted-proxies-port/plan.md) · [итог](docs/changes/025-trusted-proxies-port/summary.md) |
|
||||||
| 026 | Тесты по модулям предметных областей | [план](docs/changes/026-split-tests/plan.md) · [итог](docs/changes/026-split-tests/summary.md) |
|
| 026 | Тесты по модулям предметных областей | [план](docs/changes/026-split-tests/plan.md) · [итог](docs/changes/026-split-tests/summary.md) |
|
||||||
|
| 027 | Состояния «Upgrade FW», откат прошивки RouterBOARD до версии ROS | [план](docs/changes/027-fw-downgrade/plan.md) · [итог](docs/changes/027-fw-downgrade/summary.md) |
|
||||||
|
|
||||||
## Отчёты ревью
|
## Отчёты ревью
|
||||||
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
|
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ async def upgrade_firmware(device_id: str):
|
|||||||
return {"job_ids": await jobs.start_jobs("fw_update", [device_id])}
|
return {"job_ids": await jobs.start_jobs("fw_update", [device_id])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/firmware/downgrade", status_code=202)
|
||||||
|
async def downgrade_firmware(device_id: str, body: DowngradeIn):
|
||||||
|
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (устройство
|
||||||
|
перезагрузится); версия проверяется на устройстве в задаче — как и /update/downgrade."""
|
||||||
|
return {"job_ids": await jobs.start_jobs("fw_downgrade", [device_id], {"target_version": body.target_version})}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/update/downgrade", status_code=202)
|
@router.post("/devices/{device_id}/update/downgrade", status_code=202)
|
||||||
async def downgrade_update(device_id: str, body: DowngradeIn):
|
async def downgrade_update(device_id: str, body: DowngradeIn):
|
||||||
"""Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на
|
"""Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на
|
||||||
@@ -226,6 +233,14 @@ async def batch_ros_downgrade(body: BatchDowngradeIn):
|
|||||||
return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})}
|
return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch/fw_downgrade", status_code=202)
|
||||||
|
async def batch_fw_downgrade(body: BatchDowngradeIn):
|
||||||
|
"""Изменение 027: групповой откат прошивки — отдельный эндпоинт с обязательной target_version, по образцу
|
||||||
|
/batch/ros_downgrade; регистрируется раньше /batch/{action}."""
|
||||||
|
device_ids = await asyncio.to_thread(body.resolve)
|
||||||
|
return {"job_ids": await jobs.start_jobs("fw_downgrade", device_ids, {"target_version": body.target_version})}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch/{action}", status_code=202)
|
@router.post("/batch/{action}", status_code=202)
|
||||||
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
|
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
|
||||||
"""Групповая операция над списком устройств."""
|
"""Групповая операция над списком устройств."""
|
||||||
|
|||||||
+53
-11
@@ -205,16 +205,12 @@ async def _booted_at(c: RosClient) -> datetime | None:
|
|||||||
return now - timedelta(seconds=up) if now and up is not None else None
|
return now - timedelta(seconds=up) if now and up is not None else None
|
||||||
|
|
||||||
|
|
||||||
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
async def _flash_firmware(c: RosClient, wait_seconds: int, poll: float) -> str | None:
|
||||||
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
"""Записывает встроенную в ROS прошивку (POST system/routerboard/upgrade) и ждёт появления FW_DONE_MSG
|
||||||
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
в журнале, либо обнаруживает, что запись уже была сделана после последней загрузки (изменение 027: общая
|
||||||
поэтому по нему завершение определить нельзя."""
|
часть upgrade_firmware и downgrade_firmware — команда и признак завершения одни и те же, отличается только
|
||||||
board = await get_routerboard(c)
|
итоговое сообщение). Возвращает готовое сообщение для уже записанной прошивки, иначе None — сообщение о
|
||||||
if str(board.get("routerboard", "false")) != "true":
|
свежей записи формирует вызывающий код (текст различается для обновления и отката)."""
|
||||||
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
|
||||||
if board.get("current-firmware") == board.get("upgrade-firmware"):
|
|
||||||
return f"Прошивка актуальна ({board.get('current-firmware')})"
|
|
||||||
|
|
||||||
before = await _fw_done_entries(c)
|
before = await _fw_done_entries(c)
|
||||||
booted = await _booted_at(c)
|
booted = await _booted_at(c)
|
||||||
if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before):
|
if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before):
|
||||||
@@ -227,11 +223,57 @@ async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float =
|
|||||||
for _ in range(int(wait_seconds / poll) if poll else 1000):
|
for _ in range(int(wait_seconds / poll) if poll else 1000):
|
||||||
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
|
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
|
||||||
await _reboot(c)
|
await _reboot(c)
|
||||||
return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
|
return None
|
||||||
await asyncio.sleep(poll)
|
await asyncio.sleep(poll)
|
||||||
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
|
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
||||||
|
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
|
||||||
|
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
|
||||||
|
поэтому по нему завершение определить нельзя."""
|
||||||
|
board = await get_routerboard(c)
|
||||||
|
if str(board.get("routerboard", "false")) != "true":
|
||||||
|
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
|
||||||
|
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
|
||||||
|
if current == upgrade:
|
||||||
|
return f"Прошивка актуальна ({current})"
|
||||||
|
if version_newer(current, upgrade):
|
||||||
|
# изменение 027: плата записана прошивкой новее встроенной в установленную ROS (например, после отката
|
||||||
|
# ROS) — это не обновление, а откат; штатное обновление его не выполняет
|
||||||
|
return (f"Прошивка платы {current} новее встроенной в ROS {upgrade} — обновление не требуется; "
|
||||||
|
f"откат — «Откатить прошивку…»")
|
||||||
|
|
||||||
|
already = await _flash_firmware(c, wait_seconds, poll)
|
||||||
|
return already or f"Прошивка обновлена до {upgrade}, перезагрузка отправлена"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_fw_downgrade(c: RosClient, target_version: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Изменение 027: проверяет осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS —
|
||||||
|
по образцу check_downgrade. Не RouterBOARD — откат недоступен; встроенная версия должна совпадать с
|
||||||
|
подтверждённой пользователем и быть действительно старше записанной на плате (иначе это не откат). Запись не
|
||||||
|
выполняет — чтобы не делать бэкап устройства, которое откатывать нельзя. Возвращает (current, upgrade)."""
|
||||||
|
board = await get_routerboard(c)
|
||||||
|
if str(board.get("routerboard", "false")) != "true":
|
||||||
|
raise RosError("Устройство без RouterBOARD firmware (например, CHR) — откат прошивки недоступен")
|
||||||
|
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
|
||||||
|
if upgrade != target_version:
|
||||||
|
raise RosError(f"Прошивка в ROS {upgrade} не совпадает с подтверждённой {target_version} — откат отменён")
|
||||||
|
if not version_newer(current, upgrade):
|
||||||
|
raise RosError(f"Прошивка в ROS {upgrade} не старше записанной {current} — это не откат")
|
||||||
|
return current, upgrade
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade_firmware(c: RosClient, target_version: str, wait_seconds: int = 180, poll: float = 2.0) -> str:
|
||||||
|
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (после
|
||||||
|
отката ROS, например изменение 023, плата остаётся на прежней прошивке). Технически — та же команда
|
||||||
|
/system/routerboard/upgrade, что и обычное обновление: она записывает версию, встроенную в установленную
|
||||||
|
ROS, в том числе более старую."""
|
||||||
|
current, upgrade = await check_fw_downgrade(c, target_version)
|
||||||
|
already = await _flash_firmware(c, wait_seconds, poll)
|
||||||
|
return already or f"Откат прошивки {current} → {upgrade} записан, перезагрузка отправлена"
|
||||||
|
|
||||||
|
|
||||||
async def _reboot(c: RosClient) -> None:
|
async def _reboot(c: RosClient) -> None:
|
||||||
try:
|
try:
|
||||||
await c.post("system/reboot", timeout=10)
|
await c.post("system/reboot", timeout=10)
|
||||||
|
|||||||
+15
-1
@@ -170,8 +170,22 @@ def ros_state(st: dict) -> str:
|
|||||||
return "current"
|
return "current"
|
||||||
|
|
||||||
|
|
||||||
|
def fw_state(st: dict) -> str:
|
||||||
|
"""Состояние колонки «Upgrade FW» (изменение 027): unknown — нет fw_current/fw_upgrade (CHR или не опрошено);
|
||||||
|
update — в установленную ROS встроена более новая прошивка; downgrade — записанная на плате прошивка новее
|
||||||
|
встроенной в ROS (например, после отката ROS, изменение 023 — плата не откатывается вместе с ROS); иначе
|
||||||
|
current. По образцу ros_state."""
|
||||||
|
if not (st.get("fw_current") and st.get("fw_upgrade")):
|
||||||
|
return "unknown"
|
||||||
|
if version_newer(st["fw_upgrade"], st["fw_current"]):
|
||||||
|
return "update"
|
||||||
|
if version_newer(st["fw_current"], st["fw_upgrade"]):
|
||||||
|
return "downgrade"
|
||||||
|
return "current"
|
||||||
|
|
||||||
|
|
||||||
def has_fw_update(st: dict) -> bool:
|
def has_fw_update(st: dict) -> bool:
|
||||||
return bool(st.get("fw_current") and st.get("fw_upgrade") and st["fw_upgrade"] != st["fw_current"])
|
return fw_state(st) == "update"
|
||||||
|
|
||||||
|
|
||||||
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
|
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ JOB_TYPES = {
|
|||||||
"fw_update": ops.run_fw_update,
|
"fw_update": ops.run_fw_update,
|
||||||
"set_channel": ops.run_set_channel,
|
"set_channel": ops.run_set_channel,
|
||||||
"ros_downgrade": ops.run_ros_downgrade,
|
"ros_downgrade": ops.run_ros_downgrade,
|
||||||
|
"fw_downgrade": ops.run_fw_downgrade, # изменение 027
|
||||||
}
|
}
|
||||||
|
|
||||||
_tasks: set[asyncio.Task] = set()
|
_tasks: set[asyncio.Task] = set()
|
||||||
|
|||||||
@@ -167,3 +167,16 @@ async def run_ros_downgrade(device_id: str, target_version: str) -> str:
|
|||||||
async with devices.open_client(await _conn(device_id)) as c:
|
async with devices.open_client(await _conn(device_id)) as c:
|
||||||
install_msg = await ros.downgrade_ros(c, target_version)
|
install_msg = await ros.downgrade_ros(c, target_version)
|
||||||
return f"{install_msg}. {backup_msg}"
|
return f"{install_msg}. {backup_msg}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run_fw_downgrade(device_id: str, target_version: str) -> str:
|
||||||
|
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS — та же
|
||||||
|
последовательность, что и run_ros_downgrade: 1) проверка совпадения с подтверждённой версией и того, что она
|
||||||
|
действительно старше записанной; 2) обязательный бэкап (сбой прерывает задачу, запись не выполняется);
|
||||||
|
3) повторная проверка и запись на новом подключении."""
|
||||||
|
async with devices.open_client(await _conn(device_id)) as c:
|
||||||
|
await ros.check_fw_downgrade(c, target_version)
|
||||||
|
backup_msg = await run_backup(device_id)
|
||||||
|
async with devices.open_client(await _conn(device_id)) as c:
|
||||||
|
install_msg = await ros.downgrade_firmware(c, target_version)
|
||||||
|
return f"{install_msg}. {backup_msg}"
|
||||||
+67
-17
@@ -48,6 +48,7 @@ templates.env.globals["ev_entity"] = lambda t: _EV_ENTITY.get(t, t)
|
|||||||
templates.env.globals["ICONS"] = ICONS
|
templates.env.globals["ICONS"] = ICONS
|
||||||
templates.env.globals["ros_newer"] = version_newer
|
templates.env.globals["ros_newer"] = version_newer
|
||||||
templates.env.globals["ros_state"] = devices.ros_state
|
templates.env.globals["ros_state"] = devices.ros_state
|
||||||
|
templates.env.globals["fw_state"] = devices.fw_state # изменение 027
|
||||||
|
|
||||||
|
|
||||||
class LoginRequired(Exception):
|
class LoginRequired(Exception):
|
||||||
@@ -236,10 +237,15 @@ async def device_action(request: Request, device_id: str, action: str, channel:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/ui/downgrade", dependencies=[Depends(require_login)])
|
@router.post("/ui/downgrade", dependencies=[Depends(require_login)])
|
||||||
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default="")):
|
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default=""),
|
||||||
"""Откат ROS для выбранных устройств (окно «Откатить ROS…»): из выбранных берутся только те, у кого
|
kind: str = Form(default="ros")):
|
||||||
в кэше статуса ros_state == downgrade и версия канала совпадает с введённой; иначе окно остаётся
|
"""Откат ROS/FW для выбранных устройств (окно «Откатить…», параметризовано kind — изменение 027: без kind
|
||||||
открытым с ошибкой — задачи не создаются."""
|
поведение и адреса как раньше): из выбранных берутся только те, у кого в кэше статуса соответствующее
|
||||||
|
состояние downgrade и целевая версия совпадает с введённой; иначе окно остаётся открытым с ошибкой —
|
||||||
|
задачи не создаются."""
|
||||||
|
if kind not in DOWNGRADE_KINDS:
|
||||||
|
raise ValueError(f"Неизвестный вид отката: {kind}")
|
||||||
|
k = DOWNGRADE_KINDS[kind]
|
||||||
target_version = target_version.strip()
|
target_version = target_version.strip()
|
||||||
wanted = set(device_ids)
|
wanted = set(device_ids)
|
||||||
|
|
||||||
@@ -247,10 +253,10 @@ async def downgrade(request: Request, device_ids: list[str] = Form(default=[]),
|
|||||||
return [d for d in devices.list_devices() if d.id in wanted]
|
return [d for d in devices.list_devices() if d.id in wanted]
|
||||||
|
|
||||||
devs = await asyncio.to_thread(_load)
|
devs = await asyncio.to_thread(_load)
|
||||||
matched = [d for d in devs if devices.ros_state(d.status) == "downgrade" and d.status.get("ros_latest") == target_version]
|
matched = [d for d in devs if k["state"](d.status) == "downgrade" and d.status.get(k["target_key"]) == target_version]
|
||||||
if not matched:
|
if not matched:
|
||||||
return _downgrade_dialog(request, devs, "Версия не совпадает с версией канала выбранных устройств")
|
return _downgrade_dialog(request, devs, k["mismatch_error"], kind)
|
||||||
await jobs.start_jobs("ros_downgrade", [d.id for d in matched], {"target_version": target_version})
|
await jobs.start_jobs(k["job_type"], [d.id for d in matched], {"target_version": target_version})
|
||||||
return _done(request)
|
return _done(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -323,32 +329,76 @@ def dialog_group_edit(request: Request, group_id: str):
|
|||||||
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
|
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
|
||||||
|
|
||||||
|
|
||||||
# --- откат ROS до версии канала ---
|
# --- откат ROS до версии канала / откат FW до версии, встроенной в ROS (изменение 027) ---
|
||||||
|
|
||||||
def _downgrade_split(devs: list) -> tuple[list, list]:
|
# Описания видов отката для общего окна _dialog_downgrade.html: функция состояния, ключи установленной/целевой
|
||||||
|
# версии в кэше статуса, тип фоновой задачи и тексты. Без kind (?kind= отсутствует, поле формы по умолчанию)
|
||||||
|
# используется "ros" — адреса и поведение отката ROS не меняются.
|
||||||
|
DOWNGRADE_KINDS: dict[str, dict] = {
|
||||||
|
"ros": dict(
|
||||||
|
title="Откатить ROS до версии канала",
|
||||||
|
state=devices.ros_state,
|
||||||
|
installed_key="ros_installed",
|
||||||
|
target_key="ros_latest",
|
||||||
|
col_label="Установлено → канал",
|
||||||
|
extra_label="Канал",
|
||||||
|
extra_key="channel",
|
||||||
|
job_type="ros_downgrade",
|
||||||
|
hint="должна совпадать с версией канала выбранных устройств",
|
||||||
|
warn=("Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки "
|
||||||
|
"устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. "
|
||||||
|
"Если версия канала у выбранных устройств различается, откатятся только те, чья версия канала "
|
||||||
|
"совпадает с введённой ниже — остальные останутся без изменений."),
|
||||||
|
mismatch_reason="версия канала не старше установленной",
|
||||||
|
mismatch_error="Версия не совпадает с версией канала выбранных устройств",
|
||||||
|
),
|
||||||
|
"fw": dict(
|
||||||
|
title="Откатить прошивку до версии ROS",
|
||||||
|
state=devices.fw_state,
|
||||||
|
installed_key="fw_current",
|
||||||
|
target_key="fw_upgrade",
|
||||||
|
col_label="Записано → в ROS",
|
||||||
|
extra_label=None,
|
||||||
|
extra_key=None,
|
||||||
|
job_type="fw_downgrade",
|
||||||
|
hint="должна совпадать с версией, встроенной в установленную ROS выбранных устройств",
|
||||||
|
warn=("Перед записью создаётся резервная копия устройства; без неё откат не выполняется. После записи "
|
||||||
|
"устройство перезагрузится. Прошивка берётся из установленной ROS — не с серверов MikroTik. Если "
|
||||||
|
"встроенная версия у выбранных устройств различается, откатятся только те, чья версия совпадает "
|
||||||
|
"с введённой ниже — остальные останутся без изменений."),
|
||||||
|
mismatch_reason="версия в ROS не старше записанной",
|
||||||
|
mismatch_error="Версия не совпадает с версией в ROS выбранных устройств",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _downgrade_split(devs: list, kind: str = "ros") -> tuple[list, list]:
|
||||||
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
||||||
|
k = DOWNGRADE_KINDS[kind]
|
||||||
affected, skipped = [], []
|
affected, skipped = [], []
|
||||||
for d in devs:
|
for d in devs:
|
||||||
rst = devices.ros_state(d.status)
|
st = k["state"](d.status)
|
||||||
if rst == "downgrade":
|
if st == "downgrade":
|
||||||
affected.append(d)
|
affected.append(d)
|
||||||
else:
|
else:
|
||||||
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
|
reason = "нет данных проверки" if st in ("unknown", "check_error") else k["mismatch_reason"]
|
||||||
skipped.append((d, reason))
|
skipped.append((d, reason))
|
||||||
return affected, skipped
|
return affected, skipped
|
||||||
|
|
||||||
|
|
||||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
|
def _downgrade_dialog(request: Request, devs: list, error: str | None = None, kind: str = "ros"):
|
||||||
affected, skipped = _downgrade_split(devs)
|
affected, skipped = _downgrade_split(devs, kind)
|
||||||
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
|
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
|
||||||
skipped=skipped, error=error)
|
skipped=skipped, error=error, kind=kind, k=DOWNGRADE_KINDS[kind])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
|
||||||
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])):
|
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[]), kind: str = "ros"):
|
||||||
|
if kind not in DOWNGRADE_KINDS:
|
||||||
|
raise ValueError(f"Неизвестный вид отката: {kind}")
|
||||||
wanted = set(device_ids)
|
wanted = set(device_ids)
|
||||||
devs = [d for d in devices.list_devices() if d.id in wanted]
|
devs = [d for d in devices.list_devices() if d.id in wanted]
|
||||||
return _downgrade_dialog(request, devs)
|
return _downgrade_dialog(request, devs, kind=kind)
|
||||||
|
|
||||||
|
|
||||||
# --- группы ---
|
# --- группы ---
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
{% set st = d.status %}{% set down = d.online is false %}
|
{% set st = d.status %}{% set down = d.online is false %}
|
||||||
{% set rst = ros_state(st) %}
|
{% set rst = ros_state(st) %}
|
||||||
{% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %}
|
{% set fst = fw_state(st) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
|
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -39,8 +39,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
|
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if down or not (st.fw_current and st.fw_upgrade) %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
|
{% if down or fst == "unknown" %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
|
||||||
{% elif fw_new %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
|
{% elif fst == "update" %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
|
||||||
|
{% elif fst == "downgrade" %}<span class="badge" title="Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}. Работе обычно не мешает; откат — «Откатить прошивку…»">в ROS: {{ st.fw_upgrade }}</span>
|
||||||
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
|
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
|
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
|
||||||
@@ -59,6 +60,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/fw_update" hx-target="#jobs"
|
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/fw_update" hx-target="#jobs"
|
||||||
hx-confirm="Обновить прошивку на {{ d.name }}? Устройство перезагрузится сразу после обновления.">Обновить FW<small>Перезагрузка сразу после обновления</small></button>
|
hx-confirm="Обновить прошивку на {{ d.name }}? Устройство перезагрузится сразу после обновления.">Обновить FW<small>Перезагрузка сразу после обновления</small></button>
|
||||||
|
{% if fst == "downgrade" %}
|
||||||
|
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw&device_ids={{ d.id }}" hx-target="#modal-body">Откатить прошивку…<small>Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}</small></button>
|
||||||
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
<details class="sub">
|
<details class="sub">
|
||||||
<summary class="mi">Сменить канал…</summary>
|
<summary class="mi">Сменить канал…</summary>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
|
{# Окно осознанного отката ROS/FW: одно устройство или группа (devs — исходный выбор), параметризовано видом
|
||||||
|
отката kind (ros|fw, изменение 027; по умолчанию ros — прежнее поведение и адреса без изменений). #}
|
||||||
{% import "_ui.html" as ui %}
|
{% import "_ui.html" as ui %}
|
||||||
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
|
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
|
||||||
<header class="modal-head">
|
<header class="modal-head">
|
||||||
<h2 id="modal-title">Откатить ROS до версии канала</h2>
|
<h2 id="modal-title">{{ k.title }}</h2>
|
||||||
<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>
|
<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
|
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
|
||||||
{% for d in devs %}<input type="hidden" name="device_ids" value="{{ d.id }}">{% endfor %}
|
{% for d in devs %}<input type="hidden" name="device_ids" value="{{ d.id }}">{% endfor %}
|
||||||
|
<input type="hidden" name="kind" value="{{ kind }}">
|
||||||
{% if affected %}
|
{% if affected %}
|
||||||
<table class="grid" style="width:100%">
|
<table class="grid" style="width:100%">
|
||||||
<thead><tr><th>Устройство</th><th>Установлено → канал</th><th>Канал</th></tr></thead>
|
<thead><tr><th>Устройство</th><th>{{ k.col_label }}</th>{% if k.extra_label %}<th>{{ k.extra_label }}</th>{% endif %}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for d in affected %}
|
{% for d in affected %}
|
||||||
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status.ros_installed }} → {{ d.status.ros_latest }}</td><td>{{ d.status.channel }}</td></tr>
|
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status[k.installed_key] }} → {{ d.status[k.target_key] }}</td>{% if k.extra_label %}<td>{{ d.status[k.extra_key] }}</td>{% endif %}</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -23,11 +25,8 @@
|
|||||||
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
|
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="note">Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки
|
<div class="note">{{ k.warn }}</div>
|
||||||
устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. Если версия
|
<label class="lbl"><span>Введите целевую версию <small>{{ k.hint }}</small></span>
|
||||||
канала у выбранных устройств различается, откатятся только те, чья версия канала совпадает с введённой ниже —
|
|
||||||
остальные останутся без изменений.</div>
|
|
||||||
<label class="lbl"><span>Введите целевую версию <small>должна совпадать с версией канала выбранных устройств</small></span>
|
|
||||||
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
|
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
|
||||||
</div>
|
</div>
|
||||||
<footer class="modal-foot">
|
<footer class="modal-foot">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
|
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
|
||||||
<td style="font-weight:500">{{ j.device_name }}</td>
|
<td style="font-weight:500">{{ j.device_name }}</td>
|
||||||
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS"}.get(j.type, j.type) }}</td>
|
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS", "fw_downgrade": "Откат FW"}.get(j.type, j.type) }}</td>
|
||||||
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
|
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
|
||||||
<td class="msg">{{ j.message }}</td>
|
<td class="msg">{{ j.message }}</td>
|
||||||
<td class="c-small">{{ j.created_at|dt }}</td>
|
<td class="c-small">{{ j.created_at|dt }}</td>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить ROS до версии канала…<small>Обязателен бэкап; устройство перезагрузится</small></button>
|
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить ROS до версии канала…<small>Обязателен бэкап; устройство перезагрузится</small></button>
|
||||||
<button type="button" class="mi warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
|
<button type="button" class="mi warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
|
||||||
hx-confirm="Обновить прошивку на выбранных устройствах? Они перезагрузятся сразу после обновления.">Обновить прошивку (FW)<small>Устройство перезагрузится сразу после обновления</small></button>
|
hx-confirm="Обновить прошивку на выбранных устройствах? Они перезагрузятся сразу после обновления.">Обновить прошивку (FW)<small>Устройство перезагрузится сразу после обновления</small></button>
|
||||||
|
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить прошивку до версии ROS…<small>Обязателен бэкап; устройство перезагрузится</small></button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="menu">
|
<details class="menu">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# План: 027 — состояния колонки «Upgrade FW» и осознанный откат прошивки RouterBOARD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
П. 18 ревью [2026-09-28 21:32](../../reviews/2026-09-28-2132-codebase-review.md). Прошивка RouterBOARD записывается отдельно от ROS:
|
||||||
|
`current-firmware` — записанная на плате, `upgrade-firmware` — встроенная в установленную ROS. После отката ROS (изменение 023),
|
||||||
|
например 7.24.4 → 7.23.7, плата остаётся на 7.24.4, а `upgrade-firmware` становится 7.23.7.
|
||||||
|
|
||||||
|
Приложение сравнивает прошивки на неравенство (`devices.has_fw_update`, `fw_new` в `_devices.html`), поэтому:
|
||||||
|
- колонка «Upgrade FW» показывает «↑ 7.23.7» (жёлтая метка «обновление»), хотя это откат;
|
||||||
|
- фильтры `updates=fw|any|none` и массовое «Обновить прошивку» считают такое устройство обновляемым;
|
||||||
|
- `ros.upgrade_firmware` без предупреждения запишет более старую прошивку и перезагрузит устройство.
|
||||||
|
|
||||||
|
Решения пользователя:
|
||||||
|
- объём — отображение и запрет понижения в штатном обновлении **плюс** отдельное действие «Откатить прошивку…»;
|
||||||
|
- откат прошивки — по образцу отката ROS (023): одно устройство и группа (UI и API), окно с вводом целевой версии, бэкап перед записью.
|
||||||
|
|
||||||
|
Техническая основа: `/system/routerboard/upgrade` записывает прошивку, встроенную в установленную ROS, в том числе более старую
|
||||||
|
(так MikroTik описывает откат прошивки: сначала откатить ROS, затем routerboard upgrade). Признак завершения записи — тот же
|
||||||
|
`FW_DONE_MSG` в журнале; `current-firmware` меняется только после перезагрузки.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### Состояние (`app/services/devices.py`, `app/ui/routes.py`)
|
||||||
|
- `fw_state(st) -> "unknown" | "update" | "downgrade" | "current"`: `unknown` — нет `fw_current` или `fw_upgrade` (CHR, не опрошено);
|
||||||
|
`update` — `version_newer(fw_upgrade, fw_current)`; `downgrade` — `version_newer(fw_current, fw_upgrade)`; иначе `current`.
|
||||||
|
По образцу `ros_state`; экспорт в шаблоны — `templates.env.globals["fw_state"]`, как `ros_state`.
|
||||||
|
- `has_fw_update(st)` → `fw_state(st) == "update"`. Фильтры `filter_devices` не меняются: `downgrade` не считается обновлением (как у ROS).
|
||||||
|
|
||||||
|
### Операции (`app/ros/operations.py`)
|
||||||
|
- Вынести из `upgrade_firmware` запись и ожидание в `_flash_firmware(c, wait_seconds, poll) -> str | None`
|
||||||
|
(ветка «уже записана после последней загрузки», `POST system/routerboard/upgrade`, ожидание `FW_DONE_MSG`, `_reboot`), без копирования.
|
||||||
|
- `upgrade_firmware`: при `version_newer(current, upgrade)` **не записывает** и возвращает сообщение
|
||||||
|
«Прошивка платы X новее встроенной в ROS Y — обновление не требуется; откат — «Откатить прошивку…»» (задача `done`, как «Прошивка актуальна»).
|
||||||
|
Массовое обновление FW такие устройства пропускает с этим сообщением.
|
||||||
|
- `check_fw_downgrade(c, target_version) -> (current, upgrade)`: не RouterBOARD → `RosError("Устройство без RouterBOARD firmware…")`;
|
||||||
|
`upgrade-firmware != target_version` → `RosError("Прошивка в ROS X не совпадает с подтверждённой Y — откат отменён")`;
|
||||||
|
не `version_newer(current, upgrade)` → `RosError("Прошивка в ROS X не старше записанной Y — это не откат")`. По образцу `check_downgrade`.
|
||||||
|
- `downgrade_firmware(c, target_version, wait_seconds=180, poll=2.0) -> str`: `check_fw_downgrade` + `_flash_firmware`;
|
||||||
|
сообщение «Откат прошивки X → Y записан, перезагрузка отправлена».
|
||||||
|
|
||||||
|
### Задачи (`app/services/ops.py`, `app/services/jobs.py`, `_jobs.html`)
|
||||||
|
- `ops.run_fw_downgrade(device_id, target_version)` — по образцу `run_ros_downgrade`: проверка → `await run_backup(device_id)`
|
||||||
|
(сбой прерывает задачу) → новое подключение, `downgrade_firmware`. Итог включает сообщение бэкапа.
|
||||||
|
- `jobs.JOB_TYPES["fw_downgrade"]`; подпись в `_jobs.html` — «Откат FW».
|
||||||
|
|
||||||
|
### API (`app/api/v1.py`)
|
||||||
|
- `POST /api/v1/devices/{id}/firmware/downgrade` — тело `DowngradeIn` → 202 `{"job_ids"}`.
|
||||||
|
- `POST /api/v1/batch/fw_downgrade` — `BatchDowngradeIn` → 202; регистрировать **до** `/batch/{action}` (как `/batch/ros_downgrade`);
|
||||||
|
в `Literal` общего `/batch/{action}` не добавлять.
|
||||||
|
|
||||||
|
### UI (`app/ui/routes.py`, `_dialog_downgrade.html`, `_devices.html`, `dashboard.html`)
|
||||||
|
- Колонка «Upgrade FW» по `fw_state`: `update` — как сейчас; `current` — «актуально»; **`downgrade`** — нейтральная `badge`
|
||||||
|
«в ROS: X» с подсказкой «Прошивка платы Y новее встроенной в ROS X. Работе обычно не мешает; откат — «Откатить прошивку…»»; `unknown` — «—».
|
||||||
|
- Меню «⋯» строки: пункт «Откатить прошивку…» (класс `mi bad`) только при `fw_state == "downgrade"`, как «Откатить ROS…».
|
||||||
|
- Меню «Обновление» панели: «Откатить прошивку до версии ROS…» рядом с «Обновить прошивку (FW)».
|
||||||
|
- Окно — **то же** `_dialog_downgrade.html`, параметризованное видом отката `kind` (`ros` | `fw`, по умолчанию `ros` — прежние адреса
|
||||||
|
работают без изменений): заголовок, колонки «записано → в ROS», текст предупреждения, поле версии. Описание видов — один словарь
|
||||||
|
в `routes.py` (функция состояния, ключи установленной/целевой версии, тип задачи, тексты), им пользуются `_downgrade_split`,
|
||||||
|
`dialog_downgrade` (`?kind=`) и `POST /ui/downgrade` (скрытое поле `kind`; неизвестное значение → 400/422). Новых шаблонов и стилей нет.
|
||||||
|
- Текст предупреждения для FW: перед записью создаётся бэкап; после записи устройство перезагрузится; прошивка берётся из установленной ROS.
|
||||||
|
|
||||||
|
## Тесты (минимально)
|
||||||
|
- `tests/test_devices.py`: `fw_state` — unknown / update / downgrade / current; `has_fw_update` ложно для `downgrade`.
|
||||||
|
- `tests/test_operations.py`, один тест по образцу `test_run_ros_downgrade_order_and_failures`: `fw_update` на устройстве в состоянии
|
||||||
|
downgrade не вызывает `routerboard/upgrade`; `fw_downgrade` с несовпавшей версией — `failed`, бэкапа и записи нет;
|
||||||
|
с совпавшей — порядок `backup → upgrade → reboot`.
|
||||||
|
- Одна проверка API: `/batch/fw_downgrade` без `target_version` → 422.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
README: «Возможности»/«Интерфейс» (состояния «Upgrade FW», откат прошивки), таблица API (два эндпоинта), типы задач, число тестов,
|
||||||
|
строка 027 в истории изменений; отчёт ревью — п. 18 со ссылкой на 027. `summary.md` — оркестратор.
|
||||||
|
|
||||||
|
## Исполнение
|
||||||
|
Исполнитель (Sonnet): код, тесты, README, пересборка стенда. Тесты не запускает, не коммитит, `.env` не читает.
|
||||||
|
**Реальную запись прошивки на устройствах не запускать** — её выполняет пользователь в UI.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `venv/bin/python -m pytest -q` — все зелёные.
|
||||||
|
- Стенд (8001, `docker compose up -d --build --force-recreate`): контейнер отдаёт новый код (grep по файлу в контейнере).
|
||||||
|
- Безопасно на стенде: временное недоступное устройство `192.0.2.1` — `POST /api/v1/devices/{id}/firmware/downgrade` → 202, задача `failed`
|
||||||
|
на проверке, бэкапа нет; без `target_version` → 422; устройство удаляется.
|
||||||
|
- Ручная проверка — пользователь: колонка «Upgrade FW» на реальных устройствах («актуально»); окно отката FW (неактивная кнопка,
|
||||||
|
неверная версия, «не будут затронуты»); окно отката ROS не изменилось. Полный сценарий — откат ROS на `5G-AC-BED`,
|
||||||
|
метка «в ROS: 7.23.7», «Откатить прошивку…», возврат обычным обновлением ROS и FW.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Итог: состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD (изменение 027)
|
||||||
|
|
||||||
|
План: [`plan.md`](plan.md). Закрывает п. 18 ревью [2026-09-28 21:32](../../reviews/2026-09-28-2132-codebase-review.md): после отката ROS
|
||||||
|
прошивка платы больше не выдаётся за «обновление», штатное обновление FW её не понижает, откат прошивки — отдельное осознанное действие.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `app/services/devices.py` | `fw_state(st)`: `unknown` / `update` / `downgrade` / `current` (по `version_newer`); `has_fw_update` = `fw_state == "update"` — фильтры `updates=fw\|any\|none` не считают откат обновлением |
|
||||||
|
| `app/ros/operations.py` | запись и ожидание `FW_DONE_MSG` вынесены в `_flash_firmware`; `upgrade_firmware` при плате новее встроенной в ROS не пишет и возвращает «обновление не требуется»; `check_fw_downgrade`, `downgrade_firmware` — по образцу `check_downgrade`/`downgrade_ros` |
|
||||||
|
| `app/services/ops.py`, `jobs.py` | задача `fw_downgrade` (`run_fw_downgrade`): проверка → обязательный бэкап → повторная проверка и запись |
|
||||||
|
| `app/api/v1.py` | `POST /devices/{id}/firmware/downgrade`, `POST /batch/fw_downgrade` (обязательный `target_version`; до `/batch/{action}`) |
|
||||||
|
| `app/ui/routes.py`, `_dialog_downgrade.html` | одно окно отката на оба вида: словарь `DOWNGRADE_KINDS` (`ros`/`fw`), параметр `kind` (по умолчанию `ros` — прежние адреса), неизвестный `kind` → 400 |
|
||||||
|
| `_devices.html`, `dashboard.html`, `_jobs.html` | метка «в ROS: X», пункты «Откатить прошивку…» (строка, только в состоянии `downgrade`) и «Откатить прошивку до версии ROS…» (группа), подпись «Откат FW» |
|
||||||
|
| `tests/test_devices.py`, `tests/test_operations.py` | состояния `fw_state`; отказ `upgrade_firmware` понижать; порядок `backup → upgrade → reboot`, несовпадение версии — без бэкапа и записи; 422 без `target_version` |
|
||||||
|
| `README.md`, отчёт ревью | таблица состояний «Upgrade FW», раздел об откате, API, 39 тестов, строка 027; п. 18 — ✅ 027 |
|
||||||
|
|
||||||
|
## Найдено и исправлено в ходе цикла
|
||||||
|
Дефектов на ревью диффа не найдено, возвратов исполнителю не было.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
- `pytest`: 39 из 39 (новых 3).
|
||||||
|
- Стенд (8001, пересоздан): в контейнере новый код (`def fw_state`, `fw_downgrade`, `kind=fw` в шаблонах).
|
||||||
|
- Временное недоступное устройство `192.0.2.1`: без `target_version` — 422 на обоих эндпоинтах; с версией — 202; обе задачи `fw_downgrade` —
|
||||||
|
`failed` на проверке (ConnectTimeout), бэкапов нет. Устройство удалено (204).
|
||||||
|
- Реальные устройства: у всех RouterBOARD `fw_current = fw_upgrade` — «актуально»; CHR — «—».
|
||||||
|
|
||||||
|
## Не проверено
|
||||||
|
- **Реальная запись прошивки при откате** не выполнялась. Не подтверждено, что RouterOS при записи более старой прошивки пишет в журнал тот же
|
||||||
|
`FW_DONE_MSG`. Если текст другой — задача через 180 с завершится ошибкой без перезагрузки (прошивка, вероятно, записана и применится
|
||||||
|
при следующей перезагрузке).
|
||||||
|
- Ручная проверка UI пользователем на момент коммита не подтверждена.
|
||||||
|
|
||||||
|
## Эксплуатация
|
||||||
|
- Если на устройстве включён `/system/routerboard/settings auto-upgrade=yes`, RouterOS сама записывает встроенную прошивку при загрузке —
|
||||||
|
состояние «в ROS: X» после отката ROS может не возникнуть.
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
| 15 | `refresh=1` остаётся в адресе «Бэкапов» | Низкий | ❌ Открыт | `app/ui/routes.py:485` — `refresh_url` |
|
| 15 | `refresh=1` остаётся в адресе «Бэкапов» | Низкий | ❌ Открыт | `app/ui/routes.py:485` — `refresh_url` |
|
||||||
| 16 | Порт стенда не параметризован | Низкий | ✅ 025 | `"${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"`; стенд на `0.0.0.0:8001` через `APP_PORT` в `.env`, override-файл не нужен |
|
| 16 | Порт стенда не параметризован | Низкий | ✅ 025 | `"${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"`; стенд на `0.0.0.0:8001` через `APP_PORT` в `.env`, override-файл не нужен |
|
||||||
| 17 | Откат ROS не проверен на устройстве | Средний | ✅ Подтверждён | См. «Проверка отката на устройстве» |
|
| 17 | Откат ROS не проверен на устройстве | Средний | ✅ Подтверждён | См. «Проверка отката на устройстве» |
|
||||||
| 18 | Прошивка RouterBOARD после отката остаётся новее | Низкий | ⏸ Отложен | Не обрабатывается; на `5G-AC-BED` не проявилось — устройство возвращено на 7.24.4 (`fw_current` = `fw_upgrade` = 7.24.4). Актуально при долгой работе на long-term |
|
| 18 | Прошивка RouterBOARD после отката остаётся новее | Низкий | ✅ 027 | Состояния колонки «Upgrade FW» (`unknown`/`update`/`downgrade`/`current`) и осознанный откат FW до версии, встроенной в установленную ROS, по образцу отката ROS (023) — [план](../changes/027-fw-downgrade/plan.md) · [итог](../changes/027-fw-downgrade/summary.md) |
|
||||||
| 19 | Тест-линтер видит одну ступень транзитивности | Низкий | ❌ Открыт | `tests/test_architecture.py:54–56` — только `level0`/`level1` |
|
| 19 | Тест-линтер видит одну ступень транзитивности | Низкий | ❌ Открыт | `tests/test_architecture.py:54–56` — только `level0`/`level1` |
|
||||||
|
|
||||||
## Проверка отката на устройстве (п. 17)
|
## Проверка отката на устройстве (п. 17)
|
||||||
|
|||||||
@@ -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.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.23.7"}) == "downgrade" # long-term старше установленной
|
||||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
|
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
|
||||||
@@ -126,6 +126,66 @@ async def test_run_ros_downgrade_order_and_failures(monkeypatch):
|
|||||||
assert j.status == "failed" and calls == ["backup"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_downgrade_api_endpoints(monkeypatch):
|
async def test_downgrade_api_endpoints(monkeypatch):
|
||||||
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
|
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
|
||||||
|
|||||||
Reference in new issue
Block a user