Files
ros_control/app/ros/operations.py
T
ayurishchevandClaude Sonnet 5 4c1841b61b ros_control: централизованное управление парком MikroTik RouterOS
Control Server (FastAPI) с WEB UI (Jinja2 + HTMX) и JSON API для группового
администрирования устройств RouterOS 7 через REST.

- Устройства: список, статус (модель, канал, версии ROS/FW, uptime, доступные
  обновления), примечания, пароли шифруются (Fernet); фоновый опрос каждые 30 с
  и быстрое обнаружение недоступности (таймаут соединения 4 с).
- Группы устройств и фильтры (устройства, резервные копии).
- Резервные копии: .backup и .rsc (show-sensitive) создаются через REST,
  скачиваются сервером и загружаются в S3 (Yandex Object Storage).
- Обновление ROS/FW и выбор канала, групповые операции задачами.
- Интерфейс по утверждённому макету: светлая/тёмная темы, окна, шрифты IBM Plex
  локально. Docker Compose, SQLite в томе, минимальный набор тестов.
- Планы и итоги каждого изменения — в docs/changes/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 13:13:29 +03:00

186 lines
9.4 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")
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"),
c.get("system/routerboard"),
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 latest or installed == latest:
return f"Обновление не требуется (версия {installed})"
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 c.get("system/routerboard")
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