Compare commits
2
Commits
e1f197ccff
...
5f7bd6588a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7bd6588a | ||
|
|
2dda12e06c |
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 — и над целой группой.
|
||||
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
|
||||
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS, откат FW (027) выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
|
||||
- Обновление ROS: устройство перезагружается после скачивания. Обновление FW: перезагрузка, как только в журнале устройства появилась запись «Firmware upgraded successfully…».
|
||||
- Штатное обновление не выполняет откат: версия канала старше установленной — «Обновление не требуется».
|
||||
- Штатное обновление не выполняет откат: версия канала (или встроенной прошивки) старше установленной/записанной — «Обновление не требуется», с подсказкой на соответствующий откат.
|
||||
|
||||
**Откат ROS до версии канала**
|
||||
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Для одного устройства — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X»), для группы — пункт в меню «Обновление»; устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
|
||||
- Задача: проверка на устройстве (версия канала совпадает с введённой и старше установленной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и установка.
|
||||
**Откат ROS до версии канала / откат FW до версии ROS**
|
||||
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Одно окно на оба вида отката (параметр `kind`): для ROS — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X») и пункт в меню «Обновление»; для FW (изменение 027) — «Откатить прошивку…» (виден в состоянии «в ROS: X») и «Откатить прошивку до версии ROS…» там же. Устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
|
||||
- Задача: проверка на устройстве (целевая версия совпадает с введённой и старше записанной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и запись. Для FW прошивка берётся из установленной ROS, а не с серверов MikroTik.
|
||||
|
||||
**Бэкапы**
|
||||
- Пара файлов: `.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` |
|
||||
| Операции | `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 /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade` (`target_version`) — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
|
||||
| Операции | `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`, `POST /batch/fw_downgrade` (027) — оба `target_version` — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
|
||||
| Группы | `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 /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`-коде.
|
||||
```bash
|
||||
python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
||||
@@ -209,8 +217,10 @@ 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) |
|
||||
| 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) |
|
||||
| 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-28 12:43](docs/reviews/2026-09-28-1243-codebase-review.md) (→ 022, 024)
|
||||
- [Третье ревью 2026-09-28 17:35](docs/reviews/2026-09-28-1735-codebase-review.md) (открыты п. 11, 12, 14–19)
|
||||
- [Третье ревью 2026-09-28 17:35](docs/reviews/2026-09-28-1735-codebase-review.md) (→ 025, 026)
|
||||
- [Четвёртое ревью 2026-09-28 21:32](docs/reviews/2026-09-28-2132-codebase-review.md) (открыты п. 11, 13, 15, 19; п. 18 отложен)
|
||||
@@ -211,6 +211,13 @@ async def upgrade_firmware(device_id: str):
|
||||
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)
|
||||
async def downgrade_update(device_id: str, body: DowngradeIn):
|
||||
"""Осознанный откат 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})}
|
||||
|
||||
|
||||
@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)
|
||||
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
|
||||
|
||||
|
||||
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) — пропущено"
|
||||
if board.get("current-firmware") == board.get("upgrade-firmware"):
|
||||
return f"Прошивка актуальна ({board.get('current-firmware')})"
|
||||
|
||||
async def _flash_firmware(c: RosClient, wait_seconds: int, poll: float) -> str | None:
|
||||
"""Записывает встроенную в ROS прошивку (POST system/routerboard/upgrade) и ждёт появления FW_DONE_MSG
|
||||
в журнале, либо обнаруживает, что запись уже была сделана после последней загрузки (изменение 027: общая
|
||||
часть upgrade_firmware и downgrade_firmware — команда и признак завершения одни и те же, отличается только
|
||||
итоговое сообщение). Возвращает готовое сообщение для уже записанной прошивки, иначе None — сообщение о
|
||||
свежей записи формирует вызывающий код (текст различается для обновления и отката)."""
|
||||
before = await _fw_done_entries(c)
|
||||
booted = await _booted_at(c)
|
||||
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):
|
||||
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
|
||||
await _reboot(c)
|
||||
return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
|
||||
return None
|
||||
await asyncio.sleep(poll)
|
||||
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:
|
||||
try:
|
||||
await c.post("system/reboot", timeout=10)
|
||||
|
||||
+15
-1
@@ -170,8 +170,22 @@ def ros_state(st: dict) -> str:
|
||||
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:
|
||||
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 = "",
|
||||
|
||||
@@ -13,6 +13,7 @@ JOB_TYPES = {
|
||||
"fw_update": ops.run_fw_update,
|
||||
"set_channel": ops.run_set_channel,
|
||||
"ros_downgrade": ops.run_ros_downgrade,
|
||||
"fw_downgrade": ops.run_fw_downgrade, # изменение 027
|
||||
}
|
||||
|
||||
_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:
|
||||
install_msg = await ros.downgrade_ros(c, target_version)
|
||||
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["ros_newer"] = version_newer
|
||||
templates.env.globals["ros_state"] = devices.ros_state
|
||||
templates.env.globals["fw_state"] = devices.fw_state # изменение 027
|
||||
|
||||
|
||||
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)])
|
||||
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default="")):
|
||||
"""Откат ROS для выбранных устройств (окно «Откатить ROS…»): из выбранных берутся только те, у кого
|
||||
в кэше статуса ros_state == downgrade и версия канала совпадает с введённой; иначе окно остаётся
|
||||
открытым с ошибкой — задачи не создаются."""
|
||||
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default=""),
|
||||
kind: str = Form(default="ros")):
|
||||
"""Откат 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()
|
||||
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]
|
||||
|
||||
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:
|
||||
return _downgrade_dialog(request, devs, "Версия не совпадает с версией канала выбранных устройств")
|
||||
await jobs.start_jobs("ros_downgrade", [d.id for d in matched], {"target_version": target_version})
|
||||
return _downgrade_dialog(request, devs, k["mismatch_error"], kind)
|
||||
await jobs.start_jobs(k["job_type"], [d.id for d in matched], {"target_version": target_version})
|
||||
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)
|
||||
|
||||
|
||||
# --- откат 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 («будут откатены») и остальные («не будут затронуты», с причиной)."""
|
||||
k = DOWNGRADE_KINDS[kind]
|
||||
affected, skipped = [], []
|
||||
for d in devs:
|
||||
rst = devices.ros_state(d.status)
|
||||
if rst == "downgrade":
|
||||
st = k["state"](d.status)
|
||||
if st == "downgrade":
|
||||
affected.append(d)
|
||||
else:
|
||||
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
|
||||
reason = "нет данных проверки" if st in ("unknown", "check_error") else k["mismatch_reason"]
|
||||
skipped.append((d, reason))
|
||||
return affected, skipped
|
||||
|
||||
|
||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
|
||||
affected, skipped = _downgrade_split(devs)
|
||||
def _downgrade_dialog(request: Request, devs: list, error: str | None = None, kind: str = "ros"):
|
||||
affected, skipped = _downgrade_split(devs, kind)
|
||||
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)])
|
||||
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)
|
||||
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 %}
|
||||
{% set st = d.status %}{% set down = d.online is false %}
|
||||
{% 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>
|
||||
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
|
||||
<td>
|
||||
@@ -39,8 +39,9 @@
|
||||
</td>
|
||||
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
|
||||
<td>
|
||||
{% if down or not (st.fw_current and st.fw_upgrade) %}<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>
|
||||
{% if down or fst == "unknown" %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</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 %}
|
||||
</td>
|
||||
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
|
||||
@@ -59,6 +60,9 @@
|
||||
{% endif %}
|
||||
<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>
|
||||
{% 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>
|
||||
<details class="sub">
|
||||
<summary class="mi">Сменить канал…</summary>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
|
||||
{# Окно осознанного отката ROS/FW: одно устройство или группа (devs — исходный выбор), параметризовано видом
|
||||
отката kind (ros|fw, изменение 027; по умолчанию ros — прежнее поведение и адреса без изменений). #}
|
||||
{% import "_ui.html" as ui %}
|
||||
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
|
||||
<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>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
{% 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 %}
|
||||
<input type="hidden" name="kind" value="{{ kind }}">
|
||||
{% if affected %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -23,11 +25,8 @@
|
||||
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="note">Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки
|
||||
устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. Если версия
|
||||
канала у выбранных устройств различается, откатятся только те, чья версия канала совпадает с введённой ниже —
|
||||
остальные останутся без изменений.</div>
|
||||
<label class="lbl"><span>Введите целевую версию <small>должна совпадать с версией канала выбранных устройств</small></span>
|
||||
<div class="note">{{ k.warn }}</div>
|
||||
<label class="lbl"><span>Введите целевую версию <small>{{ k.hint }}</small></span>
|
||||
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
|
||||
</div>
|
||||
<footer class="modal-foot">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<tr>
|
||||
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</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 class="msg">{{ j.message }}</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 warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
|
||||
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>
|
||||
</details>
|
||||
<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 может не возникнуть.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Ревью кодовой базы ros_control — 2026-09-28 21:32 MSK
|
||||
|
||||
Четвёртое ревью. Состояние на коммит `e1f197c`. Тесты: 36/36 проходят.
|
||||
Предыдущие ревью: [`2026-09-27`](2026-09-27-codebase-review.md), [`2026-09-28 12:43`](2026-09-28-1243-codebase-review.md),
|
||||
[`2026-09-28 17:35`](2026-09-28-1735-codebase-review.md) (коммит `6ff7f1b`).
|
||||
|
||||
## Итог
|
||||
|
||||
Из 19 замечаний закрыто 15, частично — 1 (п. 13), открыто 3 (п. 11, 15, 19) и 1 отложено (п. 18).
|
||||
С предыдущего ревью закрыты п. 12, 14, 16 и подтверждён на практике п. 17.
|
||||
|
||||
| Изменение | Коммит | Пункты |
|
||||
|---|---|---|
|
||||
| `018-correctness-consistency` | `123b5ab` | 5, 6, 7 |
|
||||
| `019-performance-scaling` | `ba8ac7b` | 8, 9 (частично), 10 |
|
||||
| `020-custom-select-menus` | `6b5c521` | — (доработка UI по запросу) |
|
||||
| `021-security-hardening` | `c407972` | 1, 2, 3, 4 |
|
||||
| `022-async-db-remainder` | `0bda003` | 9 (остаток) |
|
||||
| `023-ros-downgrade` | `ae826fc` | — (отображение и откат ROS по запросу) |
|
||||
| `024-readme-restructure` | `7c830be` | 13 (документация, с неточностью) |
|
||||
| `025-trusted-proxies-port` | `5367cb5` | 14, 16 |
|
||||
| `026-split-tests` | `e1f197c` | 12 |
|
||||
|
||||
## Статус замечаний
|
||||
|
||||
### Закрытые ранее (п. 1–10)
|
||||
Без изменений: безопасность (1–4, изменение 021), корректность (5–7, 018), производительность (8–10, 019 и 022). Регресс п. 9 контролирует
|
||||
тест-линтер `tests/test_architecture.py::test_no_sync_db_calls_in_async_functions`.
|
||||
|
||||
### П. 11–19
|
||||
|
||||
| № | Замечание | Приоритет | Статус | Подтверждение |
|
||||
|---|---|---|---|---|
|
||||
| 11 | Нет healthcheck, логирование не настроено | Низкий | ❌ Открыт | Нет `HEALTHCHECK` в `Dockerfile`/`docker-compose.yml`, нет `/healthz`; `logging` не настраивается |
|
||||
| 12 | Тесты в одном файле | Низкий | ✅ 026 | 7 модулей по 68–185 строк (всего 1035), `tests/helpers.py`; тела тестов не менялись (AST совпадает), каждый модуль проходит отдельно |
|
||||
| 13 | Копирование БД в режиме WAL | Средний | 🟡 Неточность | `README.md:161` советует `sqlite3 <БД> ".backup …"`; в контейнере (`python:3.12-slim`) утилиты `sqlite3` нет, том на хост не смонтирован. Рабочий способ — `sqlite3.Connection.backup` через `docker exec … python -c …` |
|
||||
| 14 | За reverse-proxy блокировка видит IP прокси | Низкий | ✅ 025 | `app/security.py::client_ip` + `TRUSTED_PROXIES` (по умолчанию пусто — XFF игнорируется); на стенде поддельный `X-Forwarded-For` не повлиял на блокировку и журнал |
|
||||
| 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-файл не нужен |
|
||||
| 17 | Откат ROS не проверен на устройстве | Средний | ✅ Подтверждён | См. «Проверка отката на устройстве» |
|
||||
| 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` |
|
||||
|
||||
## Проверка отката на устройстве (п. 17)
|
||||
|
||||
Пользователь выполнил реальный откат на `5G-AC-BED` через UI (актор `ui:tstark`). Журнал событий (UTC):
|
||||
|
||||
| Время | Событие |
|
||||
|---|---|
|
||||
| 14:26:06 | `job.created` / `job.started` — задача `ros_downgrade` |
|
||||
| 14:26:07 → 14:26:10 | `backup.created` → `backup.done` — бэкап `backups/5G-AC-BED/20260928-142607.{backup,rsc}` **до** установки |
|
||||
| 14:26:26 | `job.done` — «Откат 7.24.4 → 7.23.7 запущен, устройство перезагрузится» |
|
||||
| 14:26:31 → 14:29:54 | `device.offline` → `device.online` — перезагрузка |
|
||||
| 14:34:41 | задача `ros_update`: «**Обновление 7.23.7 → 7.24.4** запущено» — подтверждает, что откат установил 7.23.7 |
|
||||
| 14:35:04 → 14:37:53 | перезагрузка, устройство снова на 7.24.4 (stable) |
|
||||
|
||||
Вывод: `/system/package/update/install` в RouterOS 7 при версии канала старше установленной выполняет откат; порядок
|
||||
«проверка → бэкап → установка» соблюдён; возврат штатным обновлением работает.
|
||||
|
||||
## Проверка UI
|
||||
|
||||
Ручная проверка интерфейса по 019–026 явно не подтверждена, но фактическая работа пользователя через UI (откат, обновление,
|
||||
бэкапы, смена каналов — события `ui:tstark`) проходит без ошибок.
|
||||
|
||||
## Рекомендуемый порядок
|
||||
|
||||
1. **Изменение 027 — эксплуатация и хвосты:** п. 11 (`/healthz`, `HEALTHCHECK`, формат логов), п. 15 (редирект без `refresh=1`),
|
||||
п. 13 (исправить совет о копировании БД в README), п. 19 (транзитивное замыкание в тест-линтере).
|
||||
2. П. 18 — при необходимости долгой работы на long-term: показывать и выполнять откат прошивки к версии ROS.
|
||||
@@ -165,3 +165,15 @@ def test_ros_state_values():
|
||||
assert devices.ros_state({"ros_installed": "7.15", "ros_latest": "7.16"}) == "update"
|
||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.23.7"}) == "downgrade" # long-term старше установленной
|
||||
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
|
||||
|
||||
|
||||
def test_fw_state_values():
|
||||
"""Состояния колонки «Upgrade FW» (изменение 027): unknown / update / downgrade / current;
|
||||
has_fw_update ложно для downgrade — откат прошивки не считается обновлением."""
|
||||
assert devices.fw_state({}) == "unknown" # CHR или ещё не опрошено
|
||||
assert devices.fw_state({"fw_current": "7.14"}) == "unknown" # нет upgrade-firmware
|
||||
assert devices.fw_state({"fw_current": "7.14", "fw_upgrade": "7.15"}) == "update"
|
||||
assert devices.fw_state({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) == "downgrade" # после отката ROS
|
||||
assert devices.fw_state({"fw_current": "7.15", "fw_upgrade": "7.15"}) == "current"
|
||||
assert devices.has_fw_update({"fw_current": "7.14", "fw_upgrade": "7.15"}) is True
|
||||
assert devices.has_fw_update({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) is False
|
||||
@@ -126,6 +126,66 @@ async def test_run_ros_downgrade_order_and_failures(monkeypatch):
|
||||
assert j.status == "failed" and calls == ["backup"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fw_downgrade_order_and_failures(monkeypatch):
|
||||
"""Изменение 027, по образцу test_run_ros_downgrade_order_and_failures: плата новее встроенной в ROS —
|
||||
upgrade_firmware не пишет прошивку; fw_downgrade с несовпавшей версией — бэкап и запись не вызываются;
|
||||
с совпавшей — порядок backup → upgrade → reboot. Заглушка после POST .../routerboard/upgrade сразу
|
||||
отдаёт в /rest/log запись с FW_DONE_MSG, чтобы ожидание завершалось без опроса."""
|
||||
calls = []
|
||||
written = {"on": False}
|
||||
|
||||
def handler(r: httpx.Request) -> httpx.Response:
|
||||
if r.url.path == "/rest/system/routerboard/upgrade":
|
||||
calls.append("upgrade")
|
||||
written["on"] = True
|
||||
return httpx.Response(200, json=[])
|
||||
if r.url.path == "/rest/system/reboot":
|
||||
calls.append("reboot")
|
||||
return httpx.Response(200, json=[])
|
||||
data = {
|
||||
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.24.4", "upgrade-firmware": "7.23.7"},
|
||||
"/rest/system/clock": {"date": "2026-09-28", "time": "12:00:00"},
|
||||
"/rest/system/resource": {"uptime": "1h"},
|
||||
"/rest/log": [{".id": "*9", "time": "2026-09-28 12:00:00", "message": ros.FW_DONE_MSG}] if written["on"] else [],
|
||||
}
|
||||
return httpx.Response(200, json=data.get(r.url.path, []))
|
||||
|
||||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||||
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
|
||||
|
||||
# 1) плата (7.24.4) новее встроенной в ROS (7.23.7) — это откат, не обновление: запись не выполняется
|
||||
async with ros_client(handler) as c:
|
||||
msg = await ros.upgrade_firmware(c)
|
||||
assert "upgrade" not in calls and "новее встроенной" in msg and "Откатить прошивку" in msg
|
||||
|
||||
async def fake_backup_ok(device_id):
|
||||
calls.append("backup")
|
||||
return "Бэкап загружен в S3: k.backup, k.rsc"
|
||||
|
||||
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
|
||||
|
||||
# 2) версия в ROS не совпала с подтверждённой -> задача failed, бэкапа и записи нет
|
||||
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.6"})
|
||||
await asyncio.sleep(0.2)
|
||||
j = jobs.get_job(jid)
|
||||
assert j.status == "failed" and "не совпадает" in j.message and calls == []
|
||||
|
||||
# 3) версия совпала -> порядок backup -> upgrade -> reboot
|
||||
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.7"})
|
||||
await asyncio.sleep(0.2)
|
||||
j = jobs.get_job(jid)
|
||||
assert j.status == "done" and calls == ["backup", "upgrade", "reboot"]
|
||||
|
||||
|
||||
def test_batch_fw_downgrade_requires_target_version():
|
||||
"""Изменение 027: /batch/fw_downgrade — отдельный типизированный эндпоинт, target_version обязателен."""
|
||||
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
|
||||
with TestClient(create_app()) as c:
|
||||
h = {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
assert c.post("/api/v1/batch/fw_downgrade", headers=h, json={"device_ids": [d.id]}).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_api_endpoints(monkeypatch):
|
||||
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный
|
||||
|
||||
Reference in new issue
Block a user