diff --git a/README.md b/README.md index 2947b01..9f005c1 100644 --- a/README.md +++ b/README.md @@ -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,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) | | 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) diff --git a/app/api/v1.py b/app/api/v1.py index 6333c49..36207cf 100644 --- a/app/api/v1.py +++ b/app/api/v1.py @@ -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): """Групповая операция над списком устройств.""" diff --git a/app/ros/operations.py b/app/ros/operations.py index c8bdf29..a6f9649 100644 --- a/app/ros/operations.py +++ b/app/ros/operations.py @@ -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) diff --git a/app/services/devices.py b/app/services/devices.py index d1404cd..38723d8 100644 --- a/app/services/devices.py +++ b/app/services/devices.py @@ -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 = "", diff --git a/app/services/jobs.py b/app/services/jobs.py index ba4dda6..7d89bc4 100644 --- a/app/services/jobs.py +++ b/app/services/jobs.py @@ -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() diff --git a/app/services/ops.py b/app/services/ops.py index ca58de4..4fd9927 100644 --- a/app/services/ops.py +++ b/app/services/ops.py @@ -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}" diff --git a/app/ui/routes.py b/app/ui/routes.py index b77459e..e8b9ce1 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -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) # --- группы --- diff --git a/app/ui/templates/_devices.html b/app/ui/templates/_devices.html index 4130df4..8281f24 100644 --- a/app/ui/templates/_devices.html +++ b/app/ui/templates/_devices.html @@ -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) %}