backups v0.2.4 [base]
Бэкапы дерева бота: создание/восстановление/удаление, расписание, ротация, вынос на NAS через outbound, проверка целостности.
| version | date | commit | файлов |
|---|---|---|---|
| 0.2.4 | 2026-09-17 | 46803aa3a927 | 9 |
README
# backups
Бэкапы дерева бота: создание, восстановление, удаление, расписание, ротация, вынос на сетевое хранилище, проверка целостности.
Тип: Модуль. Категория: `base`. Зависит от: outbound.
## Описание
По расписанию модуль собирает архив всего дерева бота. Старые архивы ротируются: хранится заданное число свежих, свежий архив проверяется на целостность.
Свежий архив увозится на сетевое хранилище через общий шлюз подключений. Хранилище работает по принципу «как получится»: недоступно — локальный архив всё равно остаётся, а в лог уходит предупреждение. Ротация действует и на удалённой стороне: там тоже остаётся заданное число свежих архивов.
Если задан пароль, архив шифруется — сессии и секреты на хранилище едут шифром и помечаются замочком в кабинете. Шифрованные архивы участвуют в списках, ротации и восстановлении наравне с открытыми. Без пароля архив открытый, а в статусе висит предупреждение.
Восстановление выполняется из кабинета: архив распаковывается поверх текущего дерева, дальше нужен рестарт. Каждая меняющая операция пишется в аудит. Вне расписания архив собирается кнопкой, с флагом «сразу на хранилище».
## Возможности
- `backups.list {}` → архивы `[{name, size_mb, mtime, remote}]` + флаг «есть на NAS» (`.enc` включены)
- `backups.create {upload?}` → собрать архив (сразу на NAS — по флагу)
- `backups.delete {name, remote?}` → удалить (локально и/или на NAS)
- `backups.restore {name, from_remote?}` → восстановить (нужен рестарт)
- `backups.remote_sync {}` → догнать NAS недостающими архивами
- `backups.remote_list {}` → список архивов на NAS
- `backups.status {}` → расписание и здоровье `{last, next, keep, remote_ok}`
## Права
- `backups_manage` — Бэкапы (создание/восстановление/удаление, расписание)
## Web
- `GET /api/backups/list` — архивы и статус
- `POST /api/backups/create`, `POST /api/backups/delete`, `POST /api/backups/restore`, `POST /api/backups/sync`
- Карточка «Бэкапы»
## Конфигурация (`config.schema.json`)
- Обязательных нет.
- Опциональные:
- `BACKUP_DIR` = `` (дефолт — в `data/`)
- `BACKUP_TIME` = `04:00`
- `BACKUP_KEEP` = `10` — сколько архивов держать (локально и на NAS)
- `REMOTE_DIR` = `bot-backups` — каталог на NAS
- `BACKUP_PASSPHRASE` = `` (пусто — без шифрования)
Манифест
{
"name": "backups",
"version": "0.2.4",
"category": "base",
"description": "Бэкапы дерева бота: создание/восстановление/удаление, расписание, ротация, вынос на NAS через outbound, проверка целостности.",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "46803aa3a927a3a188c2249f494e5ea5ecf92dc6",
"updated": "2026-09-17 12:54:21 +0300",
"integrity": "sha256:b0e84eea85e9c1d9f8d70bd75ada729cfcb0613bec92f8cc4240c9813bcba55a",
"files": "[9 файлов — см. вкладки ниже]",
"note": "",
"wdesc": "",
"wbody": "",
"_extra": {
"api": "",
"events_emitted": "[]",
"events_subscribed": "[]",
"audit_allow": "",
"shared_routes": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 3.5 КБ
- 0.2 КБ
- 7.2 КБ
- 2.0 КБ
- 12.1 КБ
- 0.4 КБ
- 2.9 КБ
- web/
- 1.0 КБ
- 5.7 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
engine.py 7.2 КБ
"""backups.engine — создание/проверка/восстановление tar.gz + ротация.
Порт admin.backup: корень — дерево bot_modular целиком (включая .env
модулей), в архив не едут __pycache__, data/cache, data/logs и сам
каталог бэкапов. Имена bot_backup_*.tar.gz(.enc). restore — поверх корня.
Шифрование: если задана BACKUP_PASSPHRASE — архив шифруется
openssl aes-256-cbc/pbkdf2 (пароль только через env, в командной строке
его нет), сессии и секреты на NAS едут шифром. Без пароля — открытый
архив + предупреждение в статусе.
"""
import logging
import os
import re
import subprocess
import time
logger = logging.getLogger(__name__)
_NAME_RX = re.compile(r"^bot_backup_[A-Za-z0-9_.-]*[A-Za-z0-9_-]\.tar\.gz(\.enc)?$")
_PASSPHRASE = ""
def configure(passphrase=""):
"""Пароль шифрования (из Module, в памяти процесса)."""
global _PASSPHRASE
_PASSPHRASE = (passphrase or "").strip()
def encrypted():
return bool(_PASSPHRASE)
def _root():
from . import state as _st
return _st.ROOT_DIR
def _backup_dir():
from . import state as _st
return _st.BACKUP_DIR
def backup_path(stamp=None):
stamp = stamp or time.strftime("%Y%m%d_%H%M%S")
return os.path.join(_backup_dir(), f"bot_backup_{stamp}.tar.gz")
def make_backup():
"""Создать архив. Вернуть путь или None (ошибка — в лог)."""
try:
os.makedirs(_backup_dir(), exist_ok=True)
except Exception as e: # noqa: BLE001
logger.error("backup mkdir: %s", e)
return None
root = _root()
dest = backup_path()
cmd = ["tar", "-czf", dest, "--exclude=__pycache__",
"--exclude=data/cache", "--exclude=data/logs",
"--exclude=data/backups",
"-C", os.path.dirname(root), os.path.basename(root)]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
except Exception as e: # noqa: BLE001
logger.error("backup tar: %s", e)
return None
if r.returncode != 0 or not os.path.isfile(dest):
logger.error("backup failed: %s", (r.stderr or "").strip() or r.returncode)
return None
try:
r = subprocess.run(["tar", "-tzf", dest], capture_output=True,
text=True, timeout=120)
except Exception as e: # noqa: BLE001
logger.error("backup verify: %s", e)
return None
if r.returncode != 0:
logger.error("backup повреждён: %s", (r.stderr or "").strip())
try:
os.unlink(dest)
except OSError:
pass
return None
if _PASSPHRASE:
enc = dest + ".enc"
env = dict(os.environ)
env["BKP_PP"] = _PASSPHRASE
try:
r = subprocess.run(
["openssl", "enc", "-aes-256-cbc", "-pbkdf2",
"-pass", "env:BKP_PP", "-in", dest, "-out", enc],
capture_output=True, text=True, timeout=600, env=env)
except Exception as e: # noqa: BLE001
logger.error("backup encrypt: %s", e)
return None
finally:
env.pop("BKP_PP", None)
if r.returncode != 0 or not os.path.isfile(enc):
logger.error("backup encrypt failed: %s",
(r.stderr or "").strip() or r.returncode)
return None
try:
os.unlink(dest)
except OSError:
pass
dest = enc
return dest
def list_backups():
"""Список архивов [{name, size_mb, mtime, path}]."""
items = []
try:
names = sorted(os.listdir(_backup_dir()))
except Exception:
return items
for name in names:
if not _NAME_RX.match(name or ""):
continue
path = os.path.join(_backup_dir(), name)
try:
items.append({
"name": name,
"size_mb": round(os.path.getsize(path) / (1024 * 1024), 1),
"mtime": time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(os.path.getmtime(path))),
"path": path,
"encrypted": name.endswith(".enc"),
})
except Exception: # noqa: BLE001
continue
return items
def check_name(name):
if not _NAME_RX.match(name or ""):
raise ValueError("Некорректное имя архива")
path = os.path.join(_backup_dir(), name)
if os.path.commonpath((os.path.realpath(path),
os.path.realpath(_backup_dir()))) != \
os.path.realpath(_backup_dir()):
raise ValueError("Выход за пределы BACKUP_DIR")
return path
def delete_backup(name):
"""Удалить архив. Вернуть (ok, err)."""
try:
path = check_name(name)
except ValueError as e:
return False, str(e)
try:
os.unlink(path)
return True, ""
except Exception as e: # noqa: BLE001
return False, str(e)
def restore_backup(name):
"""Распаковать архив поверх корня. Вернуть (ok, err). Нужен перезапуск."""
try:
path = check_name(name)
except ValueError as e:
return False, str(e)
if not os.path.isfile(path):
return False, "Архив не найден"
tmp = None
try:
if path.endswith(".enc"):
if not _PASSPHRASE:
return False, "Архив шифрован: задайте BACKUP_PASSPHRASE"
tmp = path + ".dec.tmp"
env = dict(os.environ)
env["BKP_PP"] = _PASSPHRASE
try:
r = subprocess.run(
["openssl", "enc", "-d", "-aes-256-cbc", "-pbkdf2",
"-pass", "env:BKP_PP", "-in", path, "-out", tmp],
capture_output=True, text=True, timeout=600, env=env)
finally:
env.pop("BKP_PP", None)
if r.returncode != 0 or not os.path.isfile(tmp):
return False, "Расшифровка не удалась (пароль?)"
path = tmp
r = subprocess.run(
["tar", "-xzf", path, "-C", os.path.dirname(_root())],
capture_output=True, text=True, timeout=600)
except Exception as e: # noqa: BLE001
return False, str(e)
finally:
if tmp:
try:
os.unlink(tmp)
except OSError:
pass
if r.returncode != 0:
return False, (r.stderr or "").strip() or f"exit {r.returncode}"
return True, ""
def rotate(keep):
"""Оставить keep свежих архивов. Вернуть число удалённых."""
try:
keep = max(1, int(keep or 10))
except (TypeError, ValueError):
keep = 10
items = sorted(list_backups(), key=lambda x: x["name"])
doomed = items[:-keep] if len(items) > keep else []
n = 0
for it in doomed:
ok, _err = delete_backup(it["name"])
if ok:
n += 1
return n
module.py 12.1 КБ
"""backups.Module — бэкапы дерева бота.
Создание/восстановление/удаление (движок — порт admin.backup),
ежедневный автобэкап в BACKUP_TIME (HH:MM), ротация BACKUP_KEEP,
вынос на NAS через outbound (потребитель backups: smb.put/get/delete,
remote-путь REMOTE_DIR). После мутаций — audit.record.
"""
import logging
import os
import threading
import time
from core.base_module import BaseModule
logger = logging.getLogger(__name__)
CONSUMER = "backups"
class Module(BaseModule):
def setup(self, ctx) -> None:
mod_dir = os.path.dirname(os.path.abspath(__file__))
self.config = ctx.module_config("backups", mod_dir)
self.ctx = ctx
from . import state as _st
from . import engine as _eng
self._eng = _eng
def _f(key, default):
return (self.config.get(key, "") or "").strip() or default
def _fp(key, default):
from core.paths import abs_data
return abs_data(ctx.data_dir,
(self.config.get(key, "") or "").strip(),
default)
root = os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__))))
_st.configure(root, _fp(
"BACKUP_DIR", os.path.join(ctx.data_dir, "backups")))
try:
self._time = _f("BACKUP_TIME", "04:00")
hh, mm = self._time.split(":")
self._hhmm = "%02d:%02d" % (int(hh) % 24, int(mm) % 60)
except (ValueError, AttributeError):
self._hhmm = "04:00"
try:
self._keep = max(1, int(self.config.get("BACKUP_KEEP", "10")
or 10))
except (TypeError, ValueError):
self._keep = 10
self._remote_dir = _f("REMOTE_DIR", "bot-backups")
_eng.configure((self.config.get("BACKUP_PASSPHRASE", "") or "").strip())
if not _eng.encrypted():
logger.warning("backups: нет BACKUP_PASSPHRASE — архивы открытые!")
self._stop = threading.Event()
self._thread = None
self._last_auto = ""
try:
ctx.api.call("outbound.consumer_ensure", CONSUMER, "smb.file",
"вынос архивов на NAS (put/get/delete)")
except Exception as e: # noqa: BLE001
from core.errors import ConfigError
raise ConfigError("backups: нет шлюза outbound: %s" % e)
api = ctx.api.register
api("backups", "list", self.api_list)
api("backups", "create", self.api_create)
api("backups", "delete", self.api_delete)
api("backups", "restore", self.api_restore)
api("backups", "remote_sync", self.api_remote_sync)
api("backups", "remote_list", self.api_remote_list)
api("backups", "status", self.api_status)
def start(self) -> None:
if self._thread is None:
self._thread = threading.Thread(target=self._loop,
name="backups-tick", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
# --- внутреннее ---
def _audit_rec(self, action, target, actor="", detail="", ok=True):
try:
self.ctx.api.call("audit.record", actor or "?", action,
target, detail, ok)
except Exception: # noqa: BLE001
pass
def _loop(self):
while not self._stop.wait(60):
try:
now = time.strftime("%H:%M")
today = time.strftime("%Y-%m-%d")
if now >= self._hhmm and self._last_auto != today:
self._last_auto = today
path = self._eng.make_backup()
if path:
logger.info("backups: авто %s", path)
self._audit_rec("backup.auto",
os.path.basename(path), "system")
self._eng.rotate(self._keep)
try:
pushed, _total = self._push_missing()
dropped = self._rotate_remote()
logger.info("backups: авто на NAS: +%d, ротация -%d",
pushed, dropped)
except Exception as e: # noqa: BLE001
# NAS недоступен — локальный архив уже есть,
# это best-effort, не ошибка задачи
logger.warning("backups: авто NAS пропущен: %s",
e)
else:
logger.warning("backups: авто не создан")
self._audit_rec("backup.auto", "", "system",
"ошибка создания", False)
except Exception as e: # noqa: BLE001
logger.exception("backups tick: %s", e)
def _remote(self, op, params):
res = self.ctx.api.call("outbound.call", CONSUMER, op,
params) or {}
if not res.get("ok"):
raise RuntimeError(res.get("error") or "outbound: нет ответа")
return res
def _remote_names(self):
try:
res = self._remote("smb.list", {"path": self._remote_dir})
except Exception as e: # noqa: BLE001
# каталога ещё нет (первый бэкап) — не ошибка, а пусто
ue = str(e).upper().replace(" ", "_")
if "NOT_FOUND" in ue or "NO_SUCH" in ue or "OBJECT_NAME" in ue:
return []
raise RuntimeError("NAS недоступен: %s" % e)
return [n for n in (res.get("names") or [])
if n.startswith("bot_backup_")
and (n.endswith(".tar.gz") or n.endswith(".tar.gz.enc"))]
def _ensure_remote(self):
try:
self._remote("smb.mkdir", {"remote": self._remote_dir})
except Exception: # noqa: BLE001
pass
def _push_missing(self):
"""Догнать NAS недостающими. Возвращает (pushed, total_remote)."""
self._ensure_remote()
remote = set(self._remote_names())
pushed = 0
for it in self._eng.list_backups():
if it["name"] not in remote:
self._remote("smb.put", {
"remote": "%s/%s" % (self._remote_dir, it["name"]),
"local": it["path"]})
pushed += 1
return pushed, len(remote) + pushed
def _rotate_remote(self):
"""Ротация на NAS: оставить keep свежих. Вернуть число удалённых.
Раньше ротация шла только локально (engine.rotate), а на NAS
архивы копились бесконечно — старые не удалялись никогда.
Пофайлово best-effort: один неудачный delete не останавливает
остальные.
"""
remote = sorted(self._remote_names())
doomed = remote[:-self._keep] if len(remote) > self._keep else []
n = 0
for name in doomed:
try:
self._remote("smb.delete", {
"remote": "%s/%s" % (self._remote_dir, name)})
n += 1
except Exception as e: # noqa: BLE001
logger.warning("backups: NAS ротация %s: %s", name, e)
if n:
self._audit_rec("backup.rotate_nas", "", "system",
"удалено=%d" % n)
return n
# --- API ---
def api_list(self):
"""Архивы + флаг наличия на NAS (право backups_manage — в ui_web)."""
try:
remote = set(self._remote_names())
except Exception: # noqa: BLE001
remote = set()
out = []
for it in self._eng.list_backups():
it["remote"] = it["name"] in remote
out.append(it)
return out
def api_create(self, upload=False, actor=""):
"""Создать архив (+upload на NAS)."""
path = self._eng.make_backup()
if not path:
self._audit_rec("backup.create", "", actor, "ошибка", False)
raise RuntimeError("не создан (см. лог)")
name = os.path.basename(path)
self._eng.rotate(self._keep)
rem = ""
if upload:
self._ensure_remote()
self._remote("smb.put", {
"remote": "%s/%s" % (self._remote_dir, name), "local": path})
rem = " + NAS"
try:
dropped = self._rotate_remote()
except Exception as e: # noqa: BLE001
logger.warning("backups: NAS ротация: %s", e)
dropped = 0
if dropped:
rem += ", NAS-%d" % dropped
self._audit_rec("backup.create", name, actor, "remote=%s" % bool(upload))
return {"ok": True, "name": name, "remote": bool(upload),
"note": "создан%s" % rem}
def api_delete(self, name, remote_only=False, actor=""):
"""Удалить архив (локально и/или на NAS)."""
if remote_only:
self._remote("smb.delete", {
"remote": "%s/%s" % (self._remote_dir, name)})
self._audit_rec("backup.delete", name + " [NAS]", actor)
return {"ok": True}
ok, err = self._eng.delete_backup(name)
if not ok:
raise ValueError(err)
self._audit_rec("backup.delete", name, actor)
return {"ok": True}
def api_restore(self, name, from_remote=False, actor=""):
"""Восстановить (нужен рестарт бота)."""
if from_remote:
tmp = os.path.join(os.path.dirname(
self._eng.backup_path()), "dl_%s" % name)
self._remote("smb.get", {
"remote": "%s/%s" % (self._remote_dir, name), "local": tmp})
import shutil as _sh
_sh.move(tmp, os.path.join(os.path.dirname(tmp), name))
ok, err = self._eng.restore_backup(name)
if not ok:
self._audit_rec("backup.restore", name, actor, err, False)
raise ValueError(err)
self._audit_rec("backup.restore", name, actor, "нужен рестарт")
return {"ok": True, "apply": "restart"}
def api_remote_sync(self, actor=""):
"""Догнать NAS + ротация. Возвращает {pushed, dropped, remote}."""
pushed, total = self._push_missing()
dropped = self._rotate_remote()
self._audit_rec("backup.sync", "", actor,
"pushed=%d dropped=%d" % (pushed, dropped))
return {"ok": True, "pushed": pushed, "dropped": dropped,
"remote": total - dropped}
def api_remote_list(self):
"""Архивы на NAS."""
return self._remote_names()
def api_status(self):
"""Расписание и здоровье."""
try:
remote = self._remote_names()
remote_ok = True
except Exception as e: # noqa: BLE001
remote, remote_ok = [], False
logger.debug("backups remote: %s", e)
items = self._eng.list_backups()
return {"ok": True, "time": self._hhmm, "keep": self._keep,
"last": items[-1]["name"] if items else "",
"local_n": len(items), "remote_n": len(remote),
"remote_ok": remote_ok, "remote_dir": self._remote_dir,
"encrypted": self._eng.encrypted()}
def health(self) -> dict:
try:
st = self.api_status()
return {"ok": True, "module": self.name,
"local": st.get("local_n", 0),
"remote_ok": st.get("remote_ok", False)}
except Exception as e: # noqa: BLE001
return {"ok": False, "module": self.name, "error": str(e)}
state.py 0.4 КБ
"""backups.state — пути модуля (ставит module.setup из конфига)."""
import os
ROOT_DIR = ""
BACKUP_DIR = ""
def configure(root_dir, backup_dir):
global ROOT_DIR, BACKUP_DIR
ROOT_DIR = root_dir
BACKUP_DIR = backup_dir or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..",
"data", "backups")
ui_web.py 2.9 КБ
"""backups.ui_web — кабинет «Бэкапы»."""
ROUTES = [
("GET", "/api/backups/list", "list", {"right": "backups_manage",
"desc": "Архивы + статус NAS"}),
("POST", "/api/backups/create", "create", {"right": "backups_manage",
"desc": "Создать архив {upload?}"}),
("POST", "/api/backups/delete", "delete", {"right": "backups_manage",
"desc": "Удалить архив {name, remote?}"}),
("POST", "/api/backups/restore", "restore", {"right": "backups_manage",
"desc": "Восстановить {name, from_remote?} (нужен рестарт)"}),
("POST", "/api/backups/sync", "sync", {"right": "backups_manage",
"desc": "Догнать NAS недостающими"}),
]
SERVICE = [
{"key": "backups_manage", "icon": "💾", "title": "Бэкапы", "right": "backups_manage",
"phase": 1, "desc": "Архивы дерева, NAS, расписание, восстановление"},
]
PANELS = []
MONITOR_PANELS = [
{"id": "backups", "title": "Бэкапы: архивы",
"api": "/api/backups/list", "visibility": "right:backups_manage",
"refresh_s": 300},
]
def _actor(req):
sess = req.get("session") or {}
return "web:%s" % sess.get("uid") if sess.get("uid") else "?"
def handle_api(ctx, config, method, req):
from core.errors import UserError
if method == "list":
try:
return {"ok": True,
"backups": ctx.api.call("backups.list"),
"status": ctx.api.call("backups.status")}
except (ValueError, RuntimeError) as e:
return 500, {"ok": False, "error": str(e)}
if method == "create":
body = req.get("body") or {}
try:
return ctx.api.call("backups.create",
bool(body.get("upload")), _actor(req))
except (ValueError, RuntimeError) as e:
return 500, {"ok": False, "error": str(e)}
if method == "delete":
body = req.get("body") or {}
try:
return ctx.api.call("backups.delete",
(body.get("name") or "").strip(),
bool(body.get("remote")), _actor(req))
except (ValueError, RuntimeError) as e:
return 400, {"ok": False, "error": str(e)}
if method == "restore":
body = req.get("body") or {}
try:
return ctx.api.call("backups.restore",
(body.get("name") or "").strip(),
bool(body.get("from_remote")), _actor(req))
except (ValueError, RuntimeError) as e:
return 400, {"ok": False, "error": str(e)}
if method == "sync":
try:
return ctx.api.call("backups.remote_sync", _actor(req))
except (ValueError, RuntimeError) as e:
return 500, {"ok": False, "error": str(e)}
raise UserError("неизвестный метод: %s" % method)