Files
ayurishchevandClaude Opus 5.5 5f7bd6588a Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD
Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
  фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
  перезагрузка; API POST /devices/{id}/firmware/downgrade и
  /batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.

Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:04:51 +03:00

283 lines
17 KiB
Python

"""Операции над устройством через RouterOS REST. Не знают про БД и S3."""
import asyncio
import base64
import re
from datetime import datetime, timedelta
from pathlib import Path
from app.ros.client import RosClient, RosError
CHANNELS = ("stable", "long-term", "testing", "development")
_PRERELEASE_RE = re.compile(r"(alpha|beta|rc)", re.I)
def _ver_key(v: str | None) -> tuple:
head = re.match(r"\s*([\d.]+)", v or "") # числовая часть до суффикса: '7.25rc1' -> '7.25'
nums = [int(x) for x in re.findall(r"\d+", head.group(1) if head else "")][:4]
return tuple(nums + [0] * (4 - len(nums))) + (0 if _PRERELEASE_RE.search(v or "") else 1,)
def version_newer(latest: str | None, installed: str | None) -> bool:
"""True, если latest действительно новее installed. Строгое сравнение: на канале long-term «последняя»
версия может быть старше установленной — это не обновление. '7.25rc1' < '7.25'."""
return bool(latest and installed) and _ver_key(latest) > _ver_key(installed)
async def get_routerboard(c: RosClient) -> dict:
"""system/routerboard; на CHR и x86 раздела нет (HTTP 400 no such command) — это не сбой устройства."""
try:
return await c.get("system/routerboard") or {}
except RosError as e:
if e.status == 400 and "no such command" in str(e):
return {"routerboard": "false"}
raise
async def get_status(c: RosClient, check_updates: bool = True) -> dict:
"""Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления.
latest-version в system/package/update появляется только после check-for-updates
(устройство обращается к серверам MikroTik). Сбой проверки не считается сбоем опроса.
"""
resource, board, update = await asyncio.gather(
c.get("system/resource"),
get_routerboard(c),
c.get("system/package/update"),
)
check_error = None
if check_updates:
try:
await c.post("system/package/update/check-for-updates", timeout=30)
update = await c.get("system/package/update")
except RosError as e:
check_error = str(e)
is_board = str(board.get("routerboard", "false")) == "true"
return {
"model": (board.get("model") if is_board else None) or resource.get("board-name"),
"channel": update.get("channel"),
"ros_version": resource.get("version"),
"ros_installed": update.get("installed-version"), # без суффикса «(stable)» — для сравнения
"ros_latest": update.get("latest-version"),
"ros_update_status": update.get("status"),
"ros_check_error": check_error,
"fw_current": board.get("current-firmware") if is_board else None,
"fw_upgrade": board.get("upgrade-firmware") if is_board else None,
"uptime": resource.get("uptime"),
}
async def wait_file(c: RosClient, name: str, attempts: int = 15) -> str:
"""Ждёт появления файла на устройстве и возвращает его .id."""
for _ in range(attempts):
files = await c.get("file", name=name)
if files:
return files[0][".id"]
await asyncio.sleep(1)
raise RosError(f"Файл {name} не появился на устройстве")
async def create_backup_files(c: RosClient, base: str) -> tuple[str, str]:
"""Создаёт бинарный бэкап (без шифрования) и .rsc-экспорт (с секретами). Возвращает имена файлов."""
binary, rsc = f"{base}.backup", f"{base}.rsc"
await c.post("system/backup/save", {"name": base, "dont-encrypt": "yes"})
# show-sensitive: в .rsc попадают пароли, ключи и секреты — из файла можно восстановить всё, что их использует
await c.post("export", {"file": base, "show-sensitive": ""})
await wait_file(c, binary)
await wait_file(c, rsc)
return binary, rsc
async def download_file(c: RosClient, name: str, dest: Path, chunk_size: int = 32768) -> int:
"""Скачивает файл блоками через REST (/file/read). REST не отдаёт содержимое больших файлов
через GET /file, а бинарные данные в JSON ненадёжны, поэтому блок читается скриптом и
передаётся в base64. Возвращает размер файла; сверяет его с размером на устройстве."""
files = await c.get("file", name=name)
if not files:
raise RosError(f"Файл {name} не найден на устройстве")
size = int(files[0]["size"])
received = 0
with dest.open("wb") as f:
while received < size:
script = (f':put [:convert to=base64 ([/file/read file="{name}" offset={received} '
f'chunk-size={chunk_size} as-value]->"data")]')
res = await c.post("execute", {"script": script, "as-string": ""})
chunk = base64.b64decode((res or {}).get("ret", ""))
if not chunk:
raise RosError(f"Пустой блок при чтении {name} со смещения {received}")
f.write(chunk)
received += len(chunk)
if received != size:
raise RosError(f"Размер {name}: получено {received} из {size} байт")
return size
async def remove_file(c: RosClient, filename: str) -> None:
files = await c.get("file", name=filename)
for f in files or []:
await c.delete(f"file/{f['.id']}")
async def set_channel(c: RosClient, channel: str) -> None:
if channel not in CHANNELS:
raise ValueError(f"Неизвестный канал: {channel}")
await c.post("system/package/update/set", {"channel": channel})
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")
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)?$")
def parse_uptime(value: str | None) -> int | None:
"""'1w2d3h4m5s' -> секунды; None, если формат не распознан."""
m = _UPTIME_RE.match(value or "")
if not m or not any(m.groups()):
return None
w, d, h, mi, sec = (int(x or 0) for x in m.groups())
return (((w * 7 + d) * 24 + h) * 60 + mi) * 60 + sec
def _ts(value: str | None) -> datetime | None:
try:
return datetime.strptime(value or "", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
async def _fw_done_entries(c: RosClient) -> list[dict]:
# фильтр REST ?message=… по точному тексту ничего не возвращает — фильтруем сами
log = await c.get("log", **{".proplist": ".id,time,message"})
return [e for e in log or [] if e.get("message") == FW_DONE_MSG]
async def _booted_at(c: RosClient) -> datetime | None:
"""Время последней загрузки устройства по его часам (в той же зоне, что и время журнала)."""
clock, res = await asyncio.gather(c.get("system/clock"), c.get("system/resource"))
now = _ts(f"{clock.get('date')} {clock.get('time')}")
up = parse_uptime(res.get("uptime"))
return now - timedelta(seconds=up) if now and up is not None else None
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):
# прошивка уже записана после последней загрузки, ждём только перезагрузки
await _reboot(c)
return "Прошивка уже записана (запись в журнале), перезагрузка отправлена"
known = {e[".id"] for e in before}
await c.post("system/routerboard/upgrade")
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 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)
except RosError as e:
if e.status is not None: # обрыв соединения при перезагрузке — штатно
raise