From ae826fc56228e9290cfd58da3fe0d7876b561e1c Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Mon, 28 Sep 2026 17:28:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=C2=ABUpgrade=20ROS=C2=BB=20=D0=B8=20=D0=BE=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=20ROS=20=D0=B4=D0=BE=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После смены канала на long-term колонка показывала «актуально», хотя версия канала (7.23.7) старше установленной (7.24.4) — docs/changes/023: - состояния колонки: «↑ X», «актуально», «канал: X» (версия канала старше установленной), «проверка не удалась» (ошибка check-for-updates больше не маскируется под «актуально»), «—»; - откат ROS до версии канала как отдельная задача ros_downgrade: проверка версии на устройстве → обязательный бэкап (сбой прерывает) → install; - UI: «Откатить ROS…» в меню устройства и групповой пункт в «Обновление», окно с вводом целевой версии, список не затрагиваемых устройств; - API: POST /devices/{id}/update/downgrade и /batch/ros_downgrade с обязательным target_version. Штатное обновление откат не выполняет. Тесты: 33 из 33. Стенд: 422 без версии, задачи отката на недоступном устройстве завершаются на проверке без бэкапа. Реальный откат и ручная проверка UI пользователем на момент коммита не выполнены. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- app/api/v1.py | 23 +++++ app/ros/operations.py | 42 ++++++-- app/services/devices.py | 15 +++ app/services/jobs.py | 1 + app/services/ops.py | 13 +++ app/ui/routes.py | 50 +++++++++- app/ui/templates/_devices.html | 11 ++- app/ui/templates/_dialog_downgrade.html | 38 +++++++ app/ui/templates/_jobs.html | 2 +- app/ui/templates/dashboard.html | 1 + docs/changes/023-ros-downgrade/plan.md | 95 ++++++++++++++++++ docs/changes/023-ros-downgrade/summary.md | 35 +++++++ tests/test_app.py | 115 ++++++++++++++++++++++ 14 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 app/ui/templates/_dialog_downgrade.html create mode 100644 docs/changes/023-ros-downgrade/plan.md create mode 100644 docs/changes/023-ros-downgrade/summary.md diff --git a/README.md b/README.md index ed8d2ca..708fe15 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр - Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения; **само имя задаётся только при создании и не меняется** (оно входит в ключи бэкапов). - К устройству можно добавить **примечание** (до 500 символов): видно подсказкой при наведении на имя. - Статус: online/offline, модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа. Колонки **Upgrade ROS** и **Upgrade FW** показывают версию, до которой можно обновиться (устройство проверяет обновления на серверах MikroTik; без интернета на устройстве — «—»). +- Колонка **Upgrade ROS** — пять состояний: обновление доступно («↑ версия»), актуально, **«канал: версия»** — версия канала старше установленной (например, после переключения на `long-term`: это не обновление, а осознанный откат), «проверка не удалась» — последняя проверка обновлений завершилась ошибкой, «—» — проверка ещё не выполнялась. - **Мониторинг доступности**: сервер опрашивает устройства каждые 30 с (`POLL_INTERVAL`), недоступность определяется за ~4 с на устройство (`ROS_CONNECT_TIMEOUT`); страница обновляет статусы сама, без перезагрузки. **Группы и фильтры** @@ -26,6 +27,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр - **Бэкап**: `.backup` (без шифрования) и `.rsc` (с `show-sensitive`, т.е. с паролями и ключами — из него можно восстановить все сущности, использующие пароль). Сервер создаёт файлы через REST, скачивает во временную папку, загружает в S3 и удаляет файлы с устройства. - Список бэкапов в бакете, скачивание (временная ссылка), удаление одного файла или **группы выбранных** (чекбоксы, кнопка «Удалить» с подтверждением). - Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»). +- **Откат ROS до версии канала** (состояние «канал: X»): окно с вводом целевой версии (кнопка активна только после ввода, сервер сверяет версию с кэшем статуса) — для одного устройства (пункт «Откатить ROS…» в меню «⋯», виден только в состоянии отката) и для группы (пункт «Откатить ROS до версии канала…» в меню «Обновление» с выбранными устройствами; устройства, которые откатывать нельзя, показаны отдельным списком «Не будут затронуты»). Перед откатом — обязательный бэкап: не удался — откат не запускается. Реальный откат на устройстве запускает пользователь в UI. - Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API). ## Идентификаторы и журнал событий @@ -40,7 +42,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр ## Интерфейс Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными. -Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного ``, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются. ## Требования к устройствам @@ -98,7 +100,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs python3 -m venv venv && ./venv/bin/pip install -r requirements.txt set -a; . ./.env; set +a ./venv/bin/uvicorn app.main:app --reload -./venv/bin/python -m pytest # 29 тестов, фоновый опрос в тестах выключен +./venv/bin/python -m pytest # 33 теста, фоновый опрос в тестах выключен ``` ## API v1 @@ -119,11 +121,13 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic | POST | `/api/v1/devices/{id}/backups` | бэкап (задача) | | PUT | `/api/v1/devices/{id}/update/channel` | `{"channel": "stable\|long-term\|testing\|development"}` | | POST | `/api/v1/devices/{id}/update/install` | обновление ROS (задача) | +| POST | `/api/v1/devices/{id}/update/downgrade` | `{"target_version": "7.23.7"}` (обязательно) — откат ROS до версии канала: бэкап, затем откат (задача `ros_downgrade`); версия сверяется на устройстве, не совпала — задача завершается ошибкой | | POST | `/api/v1/devices/{id}/firmware/upgrade` | обновление FW (задача) | | GET/POST | `/api/v1/groups` | группы (с числом устройств) / создать | | PATCH/DELETE | `/api/v1/groups/{id}` | переименовать / удалить (устройства остаются без группы) | | POST | `/api/v1/batch/{backup\|ros_update\|fw_update}` | `{"device_ids": [...]}` и/или `{"group_id": N}` — групповая операция (задача) | | PUT | `/api/v1/batch/channel` | `{"device_ids": [...] или "group_id": N, "channel": "..."}` — групповая смена канала (задача `set_channel`) → 202 `{"job_ids": [...]}` | +| POST | `/api/v1/batch/ros_downgrade` | `{"device_ids": [...] или "group_id": N, "target_version": "7.23.7"}` — групповой откат ROS (задача `ros_downgrade`); отдельный эндпоинт — `ros_downgrade` в `Literal` `/batch/{action}` не входит, версия там обязательна | | GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш) | | POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` | | GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач | @@ -167,3 +171,4 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic - `020-custom-select-menus` — выпадающие списки (фильтры, «Группа») в стиле меню действий «⋯»: прогрессивное улучшение в JS, нативный `select` остаётся в разметке и работает без JavaScript. - `021-security-hardening` — отказ старта при небезопасных секретах (`API_TOKEN`/`SESSION_SECRET`/`ADMIN_PASSWORD`/`SECRET_KEY`), блокировка входа в UI по IP клиента, `SESSION_COOKIE_SECURE`, безопасный `next` в редиректах (`/ui/move`, `/backups/delete-many`). - `022-async-db-remainder` — остаток п. 9 ревью: оставшиеся синхронные обращения к БД в async-обработчиках (API, UI, `ops`, `backups.search`) — через `asyncio.to_thread`; `jobs.start_jobs` разделён на синхронную `_create_jobs` (в потоке) и `async start_jobs` (создаёт задачи и планирует их в event loop); регрессионный тест-линтер (AST-обход `app/`) не даёт синхронным обращениям к БД вернуться в async-код. +- `023-ros-downgrade` — пять состояний колонки «Upgrade ROS» (`update`/`current`/`downgrade`/`check_error`/`unknown`; строгое сравнение версий больше не путает откат канала с «актуально»); осознанный откат ROS до версии канала с обязательным бэкапом — для одного устройства и группы, в UI (окно с подтверждением версии) и API (`/update/downgrade`, `/batch/ros_downgrade`). diff --git a/app/api/v1.py b/app/api/v1.py index 54a1ab1..6333c49 100644 --- a/app/api/v1.py +++ b/app/api/v1.py @@ -108,6 +108,14 @@ class BatchChannelIn(BatchIn): channel: Literal[CHANNELS] # type: ignore[valid-type] +class DowngradeIn(BaseModel): + target_version: str = Field(min_length=1) + + +class BatchDowngradeIn(BatchIn): + target_version: str = Field(min_length=1) + + # --- устройства --- @router.get("/devices", response_model=list[DeviceOut]) @@ -203,6 +211,21 @@ async def upgrade_firmware(device_id: str): return {"job_ids": await jobs.start_jobs("fw_update", [device_id])} +@router.post("/devices/{device_id}/update/downgrade", status_code=202) +async def downgrade_update(device_id: str, body: DowngradeIn): + """Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на + устройстве в задаче — если она не совпала с подтверждённой, задача завершается ошибкой.""" + return {"job_ids": await jobs.start_jobs("ros_downgrade", [device_id], {"target_version": body.target_version})} + + +@router.post("/batch/ros_downgrade", status_code=202) +async def batch_ros_downgrade(body: BatchDowngradeIn): + """Групповой откат ROS — отдельный эндпоинт с обязательной target_version (не входит в /batch/{action}); + регистрируется раньше /batch/{action}, иначе Starlette отдаст этот путь туда (action="ros_downgrade" не в Literal).""" + device_ids = await asyncio.to_thread(body.resolve) + return {"job_ids": await jobs.start_jobs("ros_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 c337d8b..c8bdf29 100644 --- a/app/ros/operations.py +++ b/app/ros/operations.py @@ -125,22 +125,52 @@ async def set_channel(c: RosClient, channel: str) -> None: await c.post("system/package/update/set", {"channel": channel}) -async def install_ros_update(c: RosClient) -> str: - """Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания.""" +async def _check_update(c: RosClient) -> tuple[str | None, str | None]: + """check-for-updates + чтение system/package/update. Возвращает (installed, latest).""" await c.post("system/package/update/check-for-updates", timeout=120) u = await c.get("system/package/update") - installed, latest = u.get("installed-version"), u.get("latest-version") - if not version_newer(latest, installed): - return f"Обновление не требуется (установлена {installed}, на канале {latest})" + return u.get("installed-version"), u.get("latest-version") + + +async def _install(c: RosClient) -> None: + """POST system/package/update/install: после скачивания устройство уходит в перезагрузку + и рвёт соединение — это штатно (RosError со status=None).""" try: await c.post("system/package/update/install", timeout=15) except RosError as e: - # после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно if e.status is not None: raise + + +async def install_ros_update(c: RosClient) -> str: + """Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания.""" + installed, latest = await _check_update(c) + if not version_newer(latest, installed): + return f"Обновление не требуется (установлена {installed}, на канале {latest})" + await _install(c) return f"Обновление {installed} → {latest} запущено, устройство перезагрузится" +async def check_downgrade(c: RosClient, target_version: str) -> tuple[str | None, str | None]: + """Проверяет, что версия канала совпадает с подтверждённой пользователем и действительно старше + установленной (иначе это не откат). Install не выполняет — чтобы не делать бэкап устройства, + которое откатывать нельзя. Возвращает (installed, latest).""" + installed, latest = await _check_update(c) + if latest != target_version: + raise RosError(f"Версия канала {latest} не совпадает с подтверждённой {target_version} — откат отменён") + if not version_newer(installed, latest): + raise RosError(f"Версия канала {latest} не старше установленной {installed} — это не откат") + return installed, latest + + +async def downgrade_ros(c: RosClient, target_version: str) -> str: + """Осознанный откат ROS до версии канала (long-term/testing старше установленной): RouterOS 7 при + /system/package/update/install скачивает версию канала и выполняет откат, если она старше установленной.""" + installed, latest = await check_downgrade(c, target_version) + await _install(c) + return f"Откат {installed} → {latest} запущен, устройство перезагрузится" + + FW_DONE_MSG = "Firmware upgraded successfully, please reboot for changes to take effect!" _UPTIME_RE = re.compile(r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") diff --git a/app/services/devices.py b/app/services/devices.py index caa5dfe..d1404cd 100644 --- a/app/services/devices.py +++ b/app/services/devices.py @@ -155,6 +155,21 @@ def has_ros_update(st: dict) -> bool: return version_newer(st.get("ros_latest"), st.get("ros_installed")) +def ros_state(st: dict) -> str: + """Состояние колонки «Upgrade ROS»: unknown — нет данных проверки; check_error — последняя проверка + не удалась (старая latest-version могла сохраниться); update — есть обновление; downgrade — версия + канала старше установленной (long-term/testing после переключения канала); иначе current.""" + if not (st.get("ros_latest") and st.get("ros_installed")): + return "unknown" + if st.get("ros_check_error"): + return "check_error" + if version_newer(st["ros_latest"], st["ros_installed"]): + return "update" + if version_newer(st["ros_installed"], st["ros_latest"]): + 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"]) diff --git a/app/services/jobs.py b/app/services/jobs.py index b208600..ba4dda6 100644 --- a/app/services/jobs.py +++ b/app/services/jobs.py @@ -12,6 +12,7 @@ JOB_TYPES = { "ros_update": ops.run_ros_update, "fw_update": ops.run_fw_update, "set_channel": ops.run_set_channel, + "ros_downgrade": ops.run_ros_downgrade, } _tasks: set[asyncio.Task] = set() diff --git a/app/services/ops.py b/app/services/ops.py index 6b593cf..ca58de4 100644 --- a/app/services/ops.py +++ b/app/services/ops.py @@ -154,3 +154,16 @@ async def run_ros_update(device_id: str) -> str: async def run_fw_update(device_id: str) -> str: async with devices.open_client(await _conn(device_id)) as c: return await ros.upgrade_firmware(c) + + +async def run_ros_downgrade(device_id: str, target_version: str) -> str: + """Осознанный откат ROS до версии канала: 1) проверка, что версия канала совпадает с подтверждённой + и действительно старше установленной; 2) обязательный бэкап — сбой прерывает задачу, откат не + запускается (бэкап привязывается к этой же задаче через job_id, ContextVar events.current_job); + 3) повторная проверка и запуск отката на новом подключении.""" + async with devices.open_client(await _conn(device_id)) as c: + await ros.check_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_ros(c, target_version) + return f"{install_msg}. {backup_msg}" diff --git a/app/ui/routes.py b/app/ui/routes.py index 6320a5e..13afd2b 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -5,7 +5,7 @@ from datetime import date from pathlib import Path from urllib.parse import urlencode, urlsplit -from fastapi import APIRouter, Depends, Form, Request, Response +from fastapi import APIRouter, Depends, Form, Query, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -47,6 +47,7 @@ templates.env.globals["ev_kind"] = lambda t: _EV_KIND_BY_TYPE.get(t, "plain") 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 class LoginRequired(Exception): @@ -234,6 +235,25 @@ async def device_action(request: Request, device_id: str, action: str, channel: return _render(request, "_devices.html", oob=True, **await asyncio.to_thread(_devices_ctx, flt)) +@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 и версия канала совпадает с введённой; иначе окно остаётся + открытым с ошибкой — задачи не создаются.""" + target_version = target_version.strip() + wanted = set(device_ids) + + def _load(): + 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] + 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 _done(request) + + def _safe_next(value: str, default: str = "/", prefix: str = "/") -> str: """Адрес для редиректа после формы, присланный пользователем (`next`/`back`): только локальный путь, иначе открытый редирект (в т.ч. `\\evil.com`, который браузеры трактуют как `//evil.com`).""" @@ -303,6 +323,34 @@ 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 до версии канала --- + +def _downgrade_split(devs: list) -> tuple[list, list]: + """Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной).""" + affected, skipped = [], [] + for d in devs: + rst = devices.ros_state(d.status) + if rst == "downgrade": + affected.append(d) + else: + reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной" + skipped.append((d, reason)) + return affected, skipped + + +def _downgrade_dialog(request: Request, devs: list, error: str | None = None): + affected, skipped = _downgrade_split(devs) + return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected, + skipped=skipped, error=error) + + +@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)]) +def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])): + wanted = set(device_ids) + devs = [d for d in devices.list_devices() if d.id in wanted] + return _downgrade_dialog(request, devs) + + # --- группы --- @router.get("/groups", response_class=HTMLResponse, dependencies=[Depends(require_login)]) diff --git a/app/ui/templates/_devices.html b/app/ui/templates/_devices.html index 72589f8..4130df4 100644 --- a/app/ui/templates/_devices.html +++ b/app/ui/templates/_devices.html @@ -12,7 +12,7 @@ {% for d in devices %} {% set st = d.status %}{% set down = d.online is false %} - {% set ros_new = ros_newer(st.ros_latest, st.ros_installed) %} + {% set rst = ros_state(st) %} {% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %} @@ -31,8 +31,10 @@ {% if down %}—{% else %}{{ st.channel or "—" }}{% endif %} {% if down %}—{% else %}{{ st.ros_installed or st.ros_version or "—" }}{% endif %} - {% if down or not (st.ros_latest and st.ros_installed) %}— - {% elif ros_new %}{{ ui.icon("up", 12, 2.2) }}{{ st.ros_latest }} + {% if down or rst == "unknown" %}— + {% elif rst == "check_error" %}проверка не удалась + {% elif rst == "update" %}{{ ui.icon("up", 12, 2.2) }}{{ st.ros_latest }} + {% elif rst == "downgrade" %}канал: {{ st.ros_latest }} {% else %}{{ ui.icon("check", 14, 2.2) }}актуально{% endif %} {% if down %}—{% else %}{{ st.fw_current or "—" }}{% endif %} @@ -52,6 +54,9 @@
+ {% if rst == "downgrade" %} + + {% endif %}
diff --git a/app/ui/templates/_dialog_downgrade.html b/app/ui/templates/_dialog_downgrade.html new file mode 100644 index 0000000..7040622 --- /dev/null +++ b/app/ui/templates/_dialog_downgrade.html @@ -0,0 +1,38 @@ +{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #} +{% import "_ui.html" as ui %} +
+ + +
+ + + +
+
diff --git a/app/ui/templates/_jobs.html b/app/ui/templates/_jobs.html index 67c176b..f5577dc 100644 --- a/app/ui/templates/_jobs.html +++ b/app/ui/templates/_jobs.html @@ -18,7 +18,7 @@ {{ j.id|short_id }} {{ j.device_name }} - {{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала"}.get(j.type, j.type) }} + {{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS"}.get(j.type, j.type) }} {{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }} {{ j.message }} {{ j.created_at|dt }} diff --git a/app/ui/templates/dashboard.html b/app/ui/templates/dashboard.html index 8a1c043..3163ea0 100644 --- a/app/ui/templates/dashboard.html +++ b/app/ui/templates/dashboard.html @@ -56,6 +56,7 @@ diff --git a/docs/changes/023-ros-downgrade/plan.md b/docs/changes/023-ros-downgrade/plan.md new file mode 100644 index 0000000..58c3c84 --- /dev/null +++ b/docs/changes/023-ros-downgrade/plan.md @@ -0,0 +1,95 @@ +# План: 023 — состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала + +## Context + +Пользователь переключил канал обновлений на `long-term` (и `testing`), а колонка «Upgrade ROS» продолжала показывать «актуально». +Разбор (оркестратор): +- Цепочка обновления статуса корректна: `ops.set_channel` → `refresh_status` → `check-for-updates` (в REST синхронный — ответ приходит + с итоговой `latest-version`, проверено на реальном устройстве) → статус сохраняется. +- Версии каналов MikroTik сейчас: stable 7.24.4, **testing 7.24.4**, **long-term 7.23.7**, development 7.25beta5; у устройств установлена 7.24.4. + На `testing` «актуально» верно. На `long-term` версия канала **старше** установленной: RouterOS пишет `New version is available` + (установка = откат), а приложение по строгому сравнению (`ros.version_newer`, коммит `0914209`) показывает «актуально» — вводит в заблуждение. +- Попутный дефект: если последний `check-for-updates` завершился ошибкой (`ros_check_error`), а старая `latest-version` сохранилась, + таблица тоже показывает «актуально»; ошибка видна только при пустой `latest-version`. + +Требование пользователя: переход stable → long-term должен быть возможен как **осознанная опция отката (downgrade)**. + +Решения пользователя: +- Подтверждение — **окно с вводом целевой версии**: кнопка активна только после ввода версии; сервер проверяет, что она совпадает с текущей версией канала. +- **Обязательный бэкап** перед откатом; бэкап не удался — откат не выполняется. +- Откат для **одного устройства и группой** (UI и API). +- Реальный откат на устройстве запускает **пользователь** в UI; оркестратор проверяет всё без реального отката (тесты с имитацией RouterOS). + +Техническая основа: в RouterOS 7 `/system/package/update/install` при версии канала старше установленной скачивает версию канала и выполняет откат +(отсюда `New version is available`). Штатное «Upgrade ROS» (`ros.install_ros_update`) откат по-прежнему **не** выполняет. + +## Изменения + +### Отображение (`app/ui/templates/_devices.html`, `app/ros/operations.py` или `app/services/devices.py`) +- Хелпер состояния (рядом с `devices.has_ros_update`): `ros_state(st) -> "unknown" | "check_error" | "update" | "current" | "downgrade"`: + `unknown` — нет `ros_latest`/`ros_installed`; `check_error` — есть `ros_check_error`; `update` — `version_newer(latest, installed)`; + `downgrade` — `version_newer(installed, latest)`; иначе `current`. Экспортировать в шаблоны (как `ros_newer`). +- Колонка «Upgrade ROS»: `update` — как сейчас («↑ X», warn); `current` — «актуально»; **`downgrade`** — нейтральная метка «канал: X» + с подсказкой «Версия канала X старше установленной Y. Обновление не требуется; откат — пункт «Откатить ROS…»»; + **`check_error`** — метка-предупреждение «проверка не удалась», текст ошибки в подсказке; `unknown` — «—» как сейчас. +- Стили меток — существующие классы `badge` и токены; новых цветов нет. +- Фильтр «Обновления» не меняется (`downgrade` не считается обновлением). + +### Операция отката (`app/ros/operations.py`, `app/services/ops.py`, `app/services/jobs.py`) +- `ros.downgrade_ros(c, target_version) -> str`: `check-for-updates` (timeout как в `install_ros_update`), чтение `system/package/update`; + если `latest-version != target_version` → `RosError("Версия канала X не совпадает с подтверждённой Y — откат отменён")`; + если не `version_newer(installed, latest)` → `RosError("Версия канала X не старше установленной Y — это не откат")`; + иначе `POST system/package/update/install` (обрыв соединения при перезагрузке — штатно, как в `install_ros_update`); + сообщение «Откат Y → X запущен, устройство перезагрузится». **Переиспользовать** разбор ответа и обработку обрыва из `install_ros_update` + (вынести общую часть, не копировать). +- Проверка до бэкапа: вынести первую половину (check + сверка версий) в `ros.check_downgrade(c, target) -> (installed, latest)`, + чтобы не делать бэкап устройства, которое откатывать нельзя. +- `ops.run_ros_downgrade(device_id, target_version) -> str`: 1) подключение, `check_downgrade`; 2) **`await run_backup(device_id)`** — ошибка + прерывает задачу, откат не запускается (бэкап привязывается к той же задаче через ContextVar `job_id`); 3) новое подключение, + повторная `check_downgrade` + `install` (`downgrade_ros`). Итоговое сообщение включает ключи бэкапа. +- `jobs.JOB_TYPES["ros_downgrade"] = ops.run_ros_downgrade`; параметр `target_version` — через `params` (как `channel` у `set_channel`). + Подпись типа в `_jobs.html`: «Откат ROS». + +### API (`app/api/v1.py`) +- `POST /api/v1/devices/{id}/update/downgrade` — тело `{"target_version": "7.23.7"}` (обязательное, непустое) → 202 `{"job_ids"}`. +- `POST /api/v1/batch/ros_downgrade` — `BatchIn` + `target_version` → 202 `{"job_ids"}`. Существующий `/batch/{action}` не меняется + (`ros_downgrade` в его `Literal` **не** добавлять — откат только через эндпоинт с обязательной версией). +- Предварительная проверка по кэшу статуса не делается в API (достоверна только проверка на устройстве в задаче); задача на устройстве + с несовпадающей версией завершается `failed` с понятным сообщением. + +### UI (`app/ui/routes.py`, шаблоны, `app/ui/static/app.js` при необходимости) +- Окно `GET /ui/dialog/downgrade?device_ids=…` (для одного устройства — из меню «⋯» строки, пункт «Откатить ROS…», виден только при + состоянии `downgrade`; для группы — пункт «Откатить ROS до версии канала…» в меню «Обновление» с выбранными устройствами): + - таблица выбранных устройств: имя, установлено → версия канала (из кэша статуса), канал; + - устройства не в состоянии `downgrade` — отдельным списком «Не будут затронуты» (причина: «версия канала не старше установленной» / «нет данных проверки»); + - если у устройств разные версии канала — откатываются только совпадающие с введённой, остальные показаны как «не будут затронуты»; + - предупреждение: перед откатом создаётся бэкап; устройство перезагрузится; конфигурация новой версии может быть частично несовместима; + - поле «Введите целевую версию» (`input[data-enables]` — существующий механизм `syncEnables`), кнопка «Откатить» (класс опасного действия, как «Удалить») неактивна, пока поле пусто. +- `POST /ui/downgrade`: `device_ids`, `target_version`; сервер оставляет устройства, у которых в кэше `ros_state == "downgrade"` и + `ros_latest == target_version`; если введённая версия не совпала ни с одним — окно остаётся открытым с ошибкой «Версия не совпадает + с версией канала выбранных устройств»; иначе задачи `ros_downgrade` и обновление панели «Задачи» (как у групповых действий; окно закрывается — `HX-Refresh` или закрытие окна и обновление `#jobs`). +- Разметка окна — по образцу существующих окон (`_events_clear.html` — окно подтверждения опасного действия, `_device_form.html`). + +## Тесты (минимально, `tests/test_app.py`, RouterOS — `httpx.MockTransport`, как в существующих тестах) +- `ros_state`: update / current / downgrade / check_error / unknown. +- `run_ros_downgrade`: версия не совпала → задача `failed`, **бэкап и install не вызывались**; совпала → бэкап вызван **до** install (порядок), + install вызван; бэкап упал → install не вызывался. +- API: `target_version` обязателен (422 без него); `/batch/ros_downgrade` → 202 и тип задачи `ros_downgrade`; `/batch/ros_downgrade` через `/batch/{action}` недоступен (422). +- UI: `POST /ui/downgrade` с несовпадающей версией → ошибка в окне, задач нет. + +## Документация +README: «Возможности»/«Интерфейс» (состояния колонки, откат), таблица API (два эндпоинта), типы задач, число тестов, строка 023 в истории изменений. +`summary.md` — оркестратор. + +## Исполнение +Исполнитель (Sonnet): код, тесты, README, пересборка стенда. Тесты не запускает, не коммитит, `.env` не читает. +**Реальный откат на устройствах не запускать** (ни исполнителю, ни оркестратору): его выполняет пользователь в UI. + +## Проверка +- `pytest` — все зелёные. +- Стенд (override 8001, `--force-recreate`): новый код; колонка на реальных данных — устройства на `stable` с 7.24.4 «актуально» + (при переключении пользователем на long-term — «канал: 7.23.7» и пункт «Откатить ROS…»). +- Безопасные проверки на стенде: `POST /api/v1/devices/{id}/update/downgrade` для временного устройства `192.0.2.1` → 202, задача `failed` + на проверке (устройство недоступно), бэкап не создан; `target_version` отсутствует → 422. Временное устройство удаляется. +- Ручная проверка — пользователь: отображение состояний; окно отката (неактивная кнопка, неверная версия, список «не будут затронуты»); + реальный откат на выбранном устройстве — с бэкапом в S3 до перезагрузки. diff --git a/docs/changes/023-ros-downgrade/summary.md b/docs/changes/023-ros-downgrade/summary.md new file mode 100644 index 0000000..a18fb2f --- /dev/null +++ b/docs/changes/023-ros-downgrade/summary.md @@ -0,0 +1,35 @@ +# Итоги: 023 — состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала + +## Разбор исходной жалобы +После смены канала колонка показывала «актуально». Цепочка обновления статуса корректна (`check-for-updates` в REST синхронный — проверено +на реальном устройстве). Причина — отображение: версии каналов MikroTik — stable 7.24.4, testing 7.24.4 (совпадает — «актуально» верно), +long-term 7.23.7 (старше установленной 7.24.4 — RouterOS пишет `New version is available`, а приложение по строгому сравнению показывало «актуально»). +Попутно: при ошибке `check-for-updates` и сохранённой старой `latest-version` тоже показывалось «актуально». + +## Сделано +- `devices.ros_state(st)`: `unknown` / `check_error` / `update` / `downgrade` / `current`. Колонка «Upgrade ROS»: «↑ X», «актуально», + нейтральная «канал: X» (версия канала старше установленной), предупреждение «проверка не удалась» (ошибка в подсказке), «—». + Фильтр «Обновления» и штатное обновление не изменились. +- `ros.check_downgrade` (версия канала = подтверждённой и старше установленной), `ros.downgrade_ros`; общая часть с `install_ros_update` + вынесена (`_check_update`, `_install` — обрыв соединения при перезагрузке штатен). +- Задача `ros_downgrade` (`ops.run_ros_downgrade`, параметр `target_version`): проверка → **обязательный бэкап** (сбой прерывает) → + повторная проверка и install; сообщение содержит ключи бэкапа, бэкап привязан к той же задаче. +- API: `POST /api/v1/devices/{id}/update/downgrade`, `POST /api/v1/batch/ros_downgrade` (обязательный `target_version`, 202); + маршрут зарегистрирован до `/batch/{action}`, в общий `Literal` откат не добавлен. +- UI: пункт «Откатить ROS…» в меню «⋯» (только в состоянии `downgrade`), «Откатить ROS до версии канала…» в меню «Обновление»; + окно `_dialog_downgrade.html`: что будет откатано, «Не будут затронуты» с причинами, предупреждения, ввод целевой версии + (кнопка неактивна до ввода), при несовпадении — ошибка в окне без задач. Подпись задачи «Откат ROS». + +## Проверено +- `pytest`: 33 из 33 (новые: состояния, порядок «бэкап → install» и отсутствие install при несовпадении/сбое бэкапа, API, окно UI). +- Стенд (8001), временное недоступное устройство: без `target_version` → 422; оба эндпоинта → 202; задачи `ros_downgrade` → `failed` + на проверке, **бэкап не создавался**. Временное устройство удалено. +- Реальные устройства (все на stable 7.24.4): «актуально», пунктов отката нет — верно. +- Боевые данные: группы, устройства, бэкапы совпадают по ID; добавлено 3 задачи и 12 событий проверки + (одна — лишний `ros_update` на временном недоступном устройстве, ошибочно запущенный оркестратором; на данные не повлиял). + +## Оговорки +- **Реальный откат не выполнялся**: поведение RouterOS 7 `update/install` при версии канала старше установленной (скачать и откатить) + подтверждено статусом устройства (`New version is available`), но не прогоном — проверяет пользователь. +- Прошивка RouterBOARD после отката ROS остаётся новее — отдельно не обрабатывается. +- Ручная проверка UI пользователем на момент коммита не подтверждена. diff --git a/tests/test_app.py b/tests/test_app.py index e9e5534..2f65790 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -455,6 +455,121 @@ async def test_batch_channel_runs_as_jobs(monkeypatch): assert c.put("/api/v1/batch/channel", headers=h, json={"device_ids": [d1.id], "channel": "bogus"}).status_code == 422 +def test_ros_state_values(): + """Состояния колонки «Upgrade ROS»: unknown / check_error / update / downgrade / current.""" + assert devices.ros_state({}) == "unknown" + assert devices.ros_state({"ros_installed": "7.24.4"}) == "unknown" # нет latest — проверка ещё не выполнялась + assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.16", "ros_check_error": "timeout"}) == "check_error" + 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" + + +@pytest.mark.asyncio +async def test_run_ros_downgrade_order_and_failures(monkeypatch): + """run_ros_downgrade: версия не совпала с подтверждённой — бэкап и install не вызываются; версия совпала — + бэкап выполняется до install (порядок); бэкап упал — install не вызывается.""" + calls = [] + + def handler(r: httpx.Request) -> httpx.Response: + if r.url.path == "/rest/system/package/update/install": + calls.append("install") + return httpx.Response(200, json=[]) + data = {"/rest/system/package/update": {"installed-version": "7.24.4", "latest-version": "7.23.7"}} + 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)) + + async def fake_backup_ok(device_id): + calls.append("backup") + return "Бэкап загружен в S3: k.backup, k.rsc" + + async def fake_backup_fail(device_id): + calls.append("backup") + raise RuntimeError("бэкап не удался") + + # 1) версия канала (7.23.7) не совпала с подтверждённой пользователем -> задача failed, ничего не вызывается + monkeypatch.setattr(ops, "run_backup", fake_backup_ok) + [jid] = await jobs.start_jobs("ros_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 == [] + + # 2) версия совпала -> бэкап выполняется до install, install вызван + [jid] = await jobs.start_jobs("ros_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", "install"] + + # 3) бэкап падает -> install не вызывается + calls.clear() + monkeypatch.setattr(ops, "run_backup", fake_backup_fail) + [jid] = await jobs.start_jobs("ros_downgrade", [d.id], {"target_version": "7.23.7"}) + await asyncio.sleep(0.2) + j = jobs.get_job(jid) + assert j.status == "failed" and calls == ["backup"] + + +@pytest.mark.asyncio +async def test_downgrade_api_endpoints(monkeypatch): + """target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный + эндпоинт (не через общий /batch/{action}, у которого в Literal ros_downgrade нет).""" + async def fake_downgrade(device_id, target_version): + return f"откат до {target_version}" + + monkeypatch.setitem(jobs.JOB_TYPES, "ros_downgrade", fake_downgrade) + d1 = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw") + d2 = devices.create_device("r2", "10.0.0.2", 443, "admin", "pw") + + with TestClient(create_app()) as c: + h = {"Authorization": f"Bearer {API_TOKEN}"} + assert c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={}).status_code == 422 + r = c.post(f"/api/v1/devices/{d1.id}/update/downgrade", headers=h, json={"target_version": "7.23.7"}) + assert r.status_code == 202 + job_ids = r.json()["job_ids"] + + # тело без target_version — если бы запрос ушёл в общий /batch/{action} (там target_version не нужен), + # он был бы принят; 422 подтверждает, что обрабатывает именно типизированный /batch/ros_downgrade + assert c.post("/api/v1/batch/ros_downgrade", headers=h, json={"device_ids": [d1.id]}).status_code == 422 + r = c.post("/api/v1/batch/ros_downgrade", headers=h, + json={"device_ids": [d1.id, d2.id], "target_version": "7.23.7"}) + assert r.status_code == 202 + job_ids += r.json()["job_ids"] + assert len(job_ids) == 3 + + await asyncio.sleep(0.3) + for jid in job_ids: + j = jobs.get_job(jid) + assert j.type == "ros_downgrade" and j.status == "done" and j.message == "откат до 7.23.7" + + +def test_ui_downgrade_dialog_and_mismatch(monkeypatch): + """Окно отката: неактивная кнопка, пока версия не введена (data-enables); несовпадающая версия — ошибка + в окне, задача не создаётся.""" + d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw") + + def _set_status(device, status): + with session_scope() as s: + s.get(Device, device.id).status_json = json.dumps(status) + + _set_status(d, {"channel": "long-term", "ros_installed": "7.24.4", "ros_latest": "7.23.7"}) + + with TestClient(create_app()) as c: + c.post("/login", data={"username": "admin", "password": ADMIN_PASSWORD}) + htmx = {"HX-Request": "true"} + page = c.get(f"/ui/dialog/downgrade?device_ids={d.id}", headers=htmx).text + assert "r1" in page and 'data-enables="#downgrade-confirm"' in page and 'id="downgrade-confirm" disabled' in page + + r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.16"}, headers=htmx) + assert "не совпадает" in r.text and 'id="modal-title"' in r.text + assert jobs.list_jobs() == [] + + r = c.post("/ui/downgrade", data={"device_ids": [d.id], "target_version": "7.23.7"}, headers=htmx) + assert r.headers.get("HX-Refresh") == "true" + assert len(jobs.list_jobs()) == 1 and jobs.list_jobs()[0].type == "ros_downgrade" + + @pytest.mark.asyncio async def test_events_link_entities(monkeypatch): """Журнал: у каждой записи свой ID, ссылка на ID сущности и актор; в журнал попадают только смены online/offline."""