Групповое удаление бэкапов (docs/changes/013): - страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N / Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления; - backups.delete_many: все ключи проверяются до удаления, удаление параллельное; UI POST /backups/delete-many, API POST /api/v1/backups/delete; - выбор строк в app.js обобщён (data-select) для устройств и бэкапов. Поддержка CHR (docs/changes/014): - у CHR нет /system/routerboard (HTTP 400): устройство больше не считается недоступным, версия FW не показывается, обновление FW пропускается; - «есть обновление ROS» определяется строгим сравнением версий (на канале long-term последняя версия может быть старше установленной); - в таблице указана причина недоступности: авторизация / ошибка ответа / нет соединения. Тесты: 11 из 11. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
11 KiB
Python
211 lines
11 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 install_ros_update(c: RosClient) -> str:
|
|
"""Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания."""
|
|
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})"
|
|
try:
|
|
await c.post("system/package/update/install", timeout=15)
|
|
except RosError as e:
|
|
# после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно
|
|
if e.status is not None:
|
|
raise
|
|
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 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')})"
|
|
|
|
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 f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
|
|
await asyncio.sleep(poll)
|
|
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
|
|
|
|
|
|
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
|