Состояния «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

+18 -9
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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 = "",
+1
View File
@@ -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()
+13
View File
@@ -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
View File
@@ -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)
# --- группы --- # --- группы ---
+7 -3
View File
@@ -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>
+8 -9
View File
@@ -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">
+1 -1
View File
@@ -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>
+1
View File
@@ -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">
+85
View File
@@ -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.
+36
View File
@@ -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)
+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.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
+60
View File
@@ -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 — отдельный типизированный