Состояния «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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-29 00:04:51 +03:00
1 parent 2dda12e06c
commit 5f7bd6588a
16 files changed
+393 -52

No files matched your search

+15
View File
@@ -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):
"""Групповая операция над списком устройств."""
+53 -11
View File
@@ -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)
+15 -1
View File
@@ -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 = "",
+1
View File
@@ -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()
+13
View File
@@ -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}"
+67 -17
View File
@@ -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)
# --- группы ---
+7 -3
View File
@@ -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) %}
<tr>
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
<td>
@@ -39,8 +39,9 @@
</td>
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
<td>
{% if down or not (st.fw_current and st.fw_upgrade) %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
{% elif fw_new %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
{% if down or fst == "unknown" %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
{% elif fst == "update" %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
{% elif fst == "downgrade" %}<span class="badge" title="Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}. Работе обычно не мешает; откат — «Откатить прошивку…»">в ROS: {{ st.fw_upgrade }}</span>
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
</td>
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
@@ -59,6 +60,9 @@
{% endif %}
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/fw_update" hx-target="#jobs"
hx-confirm="Обновить прошивку на {{ d.name }}? Устройство перезагрузится сразу после обновления.">Обновить FW<small>Перезагрузка сразу после обновления</small></button>
{% if fst == "downgrade" %}
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw&device_ids={{ d.id }}" hx-target="#modal-body">Откатить прошивку…<small>Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}</small></button>
{% endif %}
<hr>
<details class="sub">
<summary class="mi">Сменить канал…</summary>
+8 -9
View File
@@ -1,19 +1,21 @@
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
{# Окно осознанного отката ROS/FW: одно устройство или группа (devs — исходный выбор), параметризовано видом
отката kind (ros|fw, изменение 027; по умолчанию ros — прежнее поведение и адреса без изменений). #}
{% import "_ui.html" as ui %}
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
<header class="modal-head">
<h2 id="modal-title">Откатить ROS до версии канала</h2>
<h2 id="modal-title">{{ k.title }}</h2>
<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>
</header>
<div class="modal-body">
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
{% for d in devs %}<input type="hidden" name="device_ids" value="{{ d.id }}">{% endfor %}
<input type="hidden" name="kind" value="{{ kind }}">
{% if affected %}
<table class="grid" style="width:100%">
<thead><tr><th>Устройство</th><th>Установлено → канал</th><th>Канал</th></tr></thead>
<thead><tr><th>Устройство</th><th>{{ k.col_label }}</th>{% if k.extra_label %}<th>{{ k.extra_label }}</th>{% endif %}</tr></thead>
<tbody>
{% for d in affected %}
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status.ros_installed }} → {{ d.status.ros_latest }}</td><td>{{ d.status.channel }}</td></tr>
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status[k.installed_key] }} → {{ d.status[k.target_key] }}</td>{% if k.extra_label %}<td>{{ d.status[k.extra_key] }}</td>{% endif %}</tr>
{% endfor %}
</tbody>
</table>
@@ -23,11 +25,8 @@
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
</div>
{% endif %}
<div class="note">Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки
устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. Если версия
канала у выбранных устройств различается, откатятся только те, чья версия канала совпадает с введённой ниже —
остальные останутся без изменений.</div>
<label class="lbl"><span>Введите целевую версию <small>должна совпадать с версией канала выбранных устройств</small></span>
<div class="note">{{ k.warn }}</div>
<label class="lbl"><span>Введите целевую версию <small>{{ k.hint }}</small></span>
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
</div>
<footer class="modal-foot">
+1 -1
View File
@@ -18,7 +18,7 @@
<tr>
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
<td style="font-weight:500">{{ j.device_name }}</td>
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS"}.get(j.type, j.type) }}</td>
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS", "fw_downgrade": "Откат FW"}.get(j.type, j.type) }}</td>
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
<td class="msg">{{ j.message }}</td>
<td class="c-small">{{ j.created_at|dt }}</td>
+1
View File
@@ -59,6 +59,7 @@
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить ROS до версии канала…<small>Обязателен бэкап; устройство перезагрузится</small></button>
<button type="button" class="mi warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
hx-confirm="Обновить прошивку на выбранных устройствах? Они перезагрузятся сразу после обновления.">Обновить прошивку (FW)<small>Устройство перезагрузится сразу после обновления</small></button>
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить прошивку до версии ROS…<small>Обязателен бэкап; устройство перезагрузится</small></button>
</div>
</details>
<details class="menu">