24 KiB
24 KiB
Backup & Recovery
Версия: 0.1.0-draft
Дата: 2026-07-20
Статус: Draft
Зависит от: SPECIFICATION.md (§10 Data lifecycle, §7.2 Reliability), docs/adapters/HERMES.md
Этот документ описывает процедуры бэкапа и восстановления конфигурации Hermes Agent в составе Agent Control Center. Реализация запрещена до G0/G1 и утверждения оператором.
1. Обзор
Система бэкапа ACC охватывает:
- Конфигурацию Hermes Agent (config.yaml, .env, профили)
- Пользовательские навыки (skills)
- Память (MEMORY.md, USER.md)
- Сессионные данные (опционально)
Бэкап НЕ включает:
- Секреты (API-ключи) — управляются отдельно через secret manager
- Бинарные артефакты и кэши (audio_cache, сессионные файлы)
- Логи (сохраняются согласно retention policy)
2. Структура конфигурации Hermes
2.1 Основные компоненты
~/.hermes/
├── config.yaml # Основная конфигурация
├── .env # Переменные окружения (API-ключи, секреты)
├── auth.json # OAuth-токены и credential pools
├── state.db # SQLite база сессий (state store)
├── profiles/ # Профили
│ ├── default/
│ │ ├── config.yaml
│ │ ├── .env
│ │ ├── skills/
│ │ └── memories/
│ ├── worker-code/
│ │ └── ...
│ ├── worker-fast/
│ │ └── ...
│ ├── worker-research/
│ │ └── ...
│ └── worker-review/
│ └── ...
├── skills/ # Пользовательские навыки
│ ├── *.md # SKILL.md файлы
│ └── .usage.json # Метаданные curator'а
├── sessions/ # Сессионные файлы (опционально)
├── logs/ # Логи (НЕ бэкапируются)
├── audio_cache/ # Аудиокэш (НЕ бэкапируется)
└── backups/ # Локальные снапшоты
├── latest/ # Последний снапшот
│ ├── config.yaml
│ ├── config.yaml.sig
│ └── manifest.json
└── archive/ # Архив снапшотов
└── 2026-07-20T14-00-00Z.tar.gz
2.2 Приоритеты бэкапа
| Компонент | Приоритет | RPO | Без секретов |
|---|---|---|---|
config.yaml (все профили) |
CRITICAL | 1 час | Да |
skills/ |
HIGH | 6 часов | Да |
memories/ (MEMORY.md, USER.md) |
HIGH | 6 часов | Да |
profiles/*/config.yaml |
HIGH | 1 час | Да |
cron/ конфигурации |
MEDIUM | 24 часа | Да |
sessions/ |
LOW | 24 часа | Нет (секреты в сессиях) |
state.db |
LOW | 24 часа | Нет |
3. Процедура снапшота
3.1 Скрипт: scripts/update_backup.py
#!/usr/bin/env python3
"""
update_backup.py — создание снапшота конфигурации Hermes Agent.
Использование:
python scripts/update_backup.py [--output DIR] [--exclude-secrets] [--dry-run]
Снапшот включает:
- config.yaml всех профилей
- .env (с redacted секретами при --exclude-secrets)
- skills/ (только SKILL.md файлы)
- memories/ (MEMORY.md, USER.md)
- cron/ конфигурации
Исключает:
- sessions/
- logs/
- audio_cache/
- state.db (опционально)
- auth.json (секреты)
"""
import os
import json
import shutil
import tarfile
import hashlib
from datetime import datetime, timezone
from pathlib import Path
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
BACKUP_DIR = HERMES_HOME / "backups"
LATEST_DIR = BACKUP_DIR / "latest"
ARCHIVE_DIR = BACKUP_DIR / "archive"
# Директории для бэкапа
INCLUDE_DIRS = [
HERMES_HOME / "skills",
HERMES_HOME / "profiles",
]
# Файлы для бэкапа
INCLUDE_FILES = [
HERMES_HOME / "config.yaml",
HERMES_HOME / ".env",
]
# Директории для исключения
EXCLUDE_PATTERNS = [
"**/sessions/",
"**/logs/",
"**/audio_cache/",
"**/state.db",
"**/auth.json",
"**/__pycache__/",
"**/*.pyc",
]
def create_snapshot(output_dir: Path, exclude_secrets: bool = False):
"""Создать снапшот конфигурации."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
snapshot_dir = output_dir / timestamp
snapshot_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"version": "0.1.0",
"created_at": timestamp,
"hermes_home": str(HERMES_HOME),
"files": [],
"exclude_secrets": exclude_secrets,
}
# Копирование конфигурационных файлов
for src in INCLUDE_FILES:
if src.exists():
dst = snapshot_dir / src.relative_to(HERMES_HOME)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# Redact секреты в .env если нужно
if exclude_secrets and dst.name == ".env":
redact_env_secrets(dst)
file_hash = hash_file(dst)
manifest["files"].append({
"path": str(src.relative_to(HERMES_HOME)),
"hash": file_hash,
"size": dst.stat().st_size,
})
# Копирование директорий с исключениями
for src_dir in INCLUDE_DIRS:
if src_dir.exists() and src_dir.is_dir():
_copy_with_exclusions(
src_dir,
snapshot_dir / src_dir.relative_to(HERMES_HOME),
manifest,
)
# Сохранение манифеста
manifest_path = snapshot_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
# Обновление симлинка latest
if LATEST_DIR.exists():
if LATEST_DIR.is_symlink():
LATEST_DIR.unlink()
else:
shutil.rmtree(LATEST_DIR)
os.symlink(snapshot_dir, LATEST_DIR, target_is_directory=True)
# Создание архива
archive_path = ARCHIVE_DIR / f"{timestamp}.tar.gz"
archive_path.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(snapshot_dir, arcname=timestamp)
print(f"Snapshot created: {snapshot_dir}")
print(f"Archive: {archive_path}")
print(f"Manifest: {len(manifest['files'])} files")
# Очистка старых архивов (> 30 дней)
cleanup_old_archives(days=30)
def redact_env_secrets(env_path: Path):
"""Заменить значения секретов на REDACTED."""
content = env_path.read_text()
redacted_lines = []
for line in content.split("\n"):
if "=" in line and not line.startswith("#"):
key, _ = line.split("=", 1)
if any(s in key.upper() for s in ["KEY", "TOKEN", "SECRET", "PASSWORD"]):
redacted_lines.append(f"{key}=REDACTED")
else:
redacted_lines.append(line)
else:
redacted_lines.append(line)
env_path.write_text("\n".join(redacted_lines))
def hash_file(path: Path) -> str:
"""SHA-256 хеш файла."""
sha = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha.update(chunk)
return sha.hexdigest()
def cleanup_old_archives(days: int = 30):
"""Удалить архивы старше N дней."""
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
for archive in ARCHIVE_DIR.glob("*.tar.gz"):
if archive.stat().st_mtime < cutoff:
archive.unlink()
print(f"Removed old archive: {archive.name}")
def _copy_with_exclusions(src: Path, dst: Path, manifest: dict):
"""Копировать директорию с исключениями."""
from fnmatch import fnmatch
for root, dirs, files in os.walk(src):
root_path = Path(root)
# Пропустить исключённые директории
dirs[:] = [
d for d in dirs
if not any(fnmatch(str(root_path / d), pat) for pat in EXCLUDE_PATTERNS)
]
for fname in files:
fpath = root_path / fname
if any(fnmatch(str(fpath), pat) for pat in EXCLUDE_PATTERNS):
continue
rel = fpath.relative_to(HERMES_HOME)
dest = dst / rel.relative_to(src)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(fpath, dest)
file_hash = hash_file(dest)
manifest["files"].append({
"path": str(rel),
"hash": file_hash,
"size": dest.stat().st_size,
})
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Hermes config snapshot")
parser.add_argument("--output", type=Path, default=BACKUP_DIR,
help="Output directory")
parser.add_argument("--exclude-secrets", action="store_true",
help="Redact secrets in .env files")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be backed up, without copying")
args = parser.parse_args()
if args.dry_run:
print("Files to backup:")
for f in INCLUDE_FILES:
if f.exists():
print(f" {f}")
for d in INCLUDE_DIRS:
if d.exists():
print(f" {d}/")
else:
create_snapshot(args.output, exclude_secrets=args.exclude_secrets)
3.2 Ручной снапшот
# Стандартный снапшот
python scripts/update_backup.py
# Снапшот с redacted секретами (для хранения в Git)
python scripts/update_backup.py --exclude-secrets
# Предварительный просмотр
python scripts/update_backup.py --dry-run
# В указанную директорию
python scripts/update_backup.py --output /mnt/backups/hermes/
3.3 Автоматизация через cron
# Добавить задание в crontab
# Ежечасный снапшот с исключением секретов
0 * * * * cd ~/acc-local && python scripts/update_backup.py --exclude-secrets --output ~/.hermes/backups/
# Ежедневный полный снапшот
0 2 * * * cd ~/acc-local && python scripts/update_backup.py --output ~/.hermes/backups/
4. Процедура восстановления
4.1 Скрипт: scripts/restore_backup.py
#!/usr/bin/env python3
"""
restore_backup.py — восстановление конфигурации Hermes Agent из снапшота.
Использование:
python scripts/restore_backup.py --snapshot DIR
python scripts/restore_backup.py --latest
python scripts/restore_backup.py --list
python scripts/restore_backup.py --verify DIR
"""
import os
import json
import shutil
import hashlib
from pathlib import Path
from datetime import datetime, timezone
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
BACKUP_DIR = HERMES_HOME / "backups"
LATEST_DIR = BACKUP_DIR / "latest"
ARCHIVE_DIR = BACKUP_DIR / "archive"
RESTORE_DIR = HERMES_HOME / "restore_tmp"
def list_snapshots():
"""Показать доступные снапшоты."""
if not ARCHIVE_DIR.exists():
print("No archives found.")
return
archives = sorted(ARCHIVE_DIR.glob("*.tar.gz"), reverse=True)
for i, arc in enumerate(archives):
size_mb = arc.stat().st_size / (1024 * 1024)
mtime = datetime.fromtimestamp(arc.stat().st_mtime)
print(f" [{i}] {arc.name} {size_mb:.1f} MB {mtime.isoformat()}")
def verify_snapshot(snapshot_dir: Path):
"""Проверить целостность снапшота."""
manifest_path = snapshot_dir / "manifest.json"
if not manifest_path.exists():
print("ERROR: manifest.json not found")
return False
manifest = json.loads(manifest_path.read_text())
all_ok = True
for fspec in manifest.get("files", []):
fpath = snapshot_dir / fspec["path"]
if not fpath.exists():
print(f" MISSING: {fspec['path']}")
all_ok = False
continue
actual_hash = hashlib.sha256(fpath.read_bytes()).hexdigest()
if actual_hash != fspec["hash"]:
print(f" HASH MISMATCH: {fspec['path']}")
all_ok = False
if all_ok:
print(f"VERIFIED: {len(manifest['files'])} files OK")
return all_ok
def restore_snapshot(snapshot_dir: Path, dry_run: bool = False):
"""Восстановить конфигурацию из снапшота."""
manifest_path = snapshot_dir / "manifest.json"
manifest = json.loads(manifest_path.read_text())
print(f"Restoring from: {snapshot_dir}")
print(f"Created: {manifest.get('created_at', 'unknown')}")
print(f"Files: {len(manifest['files'])}")
print(f"{'[DRY RUN] ' if dry_run else ''}")
conflicts = []
for fspec in manifest.get("files", []):
src = snapshot_dir / fspec["path"]
dst = HERMES_HOME / fspec["path"]
if not src.exists():
print(f" SKIP (missing): {fspec['path']}")
continue
# Проверить конфликт
if dst.exists() and not dry_run:
dst_hash = hashlib.sha256(dst.read_bytes()).hexdigest()
if dst_hash != fspec["hash"]:
conflicts.append(fspec["path"])
if dry_run:
action = "CONFLICT" if dst.exists() else "CREATE"
print(f" [{action}] {fspec['path']}")
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
print(f" RESTORED: {fspec['path']}")
if conflicts:
print(f"\nWARNING: {len(conflicts)} files had local modifications (overwritten):")
for p in conflicts:
print(f" - {p}")
if dry_run:
print(f"\nDry run complete. {len(manifest['files'])} files would be restored.")
print("Restore complete.")
print("NOTE: .env files were backed up with redacted secrets.")
print(" Manually restore API keys and secrets after restore.")
def restore_from_archive(archive_path: Path):
"""Восстановить из tar.gz архива."""
import tarfile
extract_dir = RESTORE_DIR / archive_path.stem
extract_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(extract_dir)
# Найти директорию снапшота внутри архива
snapshots = list(extract_dir.glob("*/manifest.json"))
if not snapshots:
print("ERROR: No manifest.json found in archive")
return
snapshot_dir = snapshots[0].parent
restore_snapshot(snapshot_dir)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Hermes config restore")
parser.add_argument("--snapshot", type=Path,
help="Snapshot directory to restore from")
parser.add_argument("--latest", action="store_true",
help="Restore from latest snapshot")
parser.add_argument("--list", action="store_true",
help="List available snapshots")
parser.add_argument("--verify", type=Path,
help="Verify snapshot integrity")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be restored")
args = parser.parse_args()
if args.list:
list_snapshots()
elif args.verify:
verify_snapshot(Path(args.verify))
elif args.latest:
if not LATEST_DIR.exists():
print("ERROR: No latest snapshot found")
exit(1)
if args.dry_run:
restore_snapshot(LATEST_DIR, dry_run=True)
else:
verify_snapshot(LATEST_DIR)
restore_snapshot(LATEST_DIR)
elif args.snapshot:
snapshot = Path(args.snapshot)
if snapshot.suffix == ".gz":
restore_from_archive(snapshot)
else:
if args.dry_run:
restore_snapshot(snapshot, dry_run=True)
else:
verify_snapshot(snapshot)
restore_snapshot(snapshot)
else:
parser.print_help()
4.2 Ручное восстановление
# Список доступных снапшотов
python scripts/restore_backup.py --list
# Восстановить последний снапшот
python scripts/restore_backup.py --latest
# Восстановить конкретный снапшот
python scripts/restore_backup.py --snapshot ~/.hermes/backups/2026-07-20T14-00-00Z
# Восстановить из архива
python scripts/restore_backup.py --snapshot ~/.hermes/backups/archive/2026-07-20T14-00-00Z.tar.gz
# Проверить целостность без восстановления
python scripts/restore_backup.py --verify ~/.hermes/backups/latest
# Предварительный просмотр
python scripts/restore_backup.py --latest --dry-run
5. Git-репозиторий как хранилище
5.1 Структура репозитория ochenstarik-ui/hermes-config-backup
hermes-config-backup/
├── README.md
├── config/
│ ├── default.yaml # Без секретов
│ ├── worker-code.yaml
│ ├── worker-fast.yaml
│ ├── worker-research.yaml
│ └── worker-review.yaml
├── skills/ # Пользовательские навыки
│ └── *.md
├── memories/ # Память (без чувствительных данных)
│ ├── MEMORY.md
│ └── USER.md
├── scripts/
│ ├── update_backup.py
│ ├── restore_backup.py
│ └── sync_to_git.sh
└── .gitignore
5.2 .gitignore
# Исключить файлы с секретами
.env
*.env
auth.json
credentials/
secrets/
# Исключить чувствительные данные сессий
sessions/
state.db
# Исключить временные файлы
*.pyc
__pycache__/
backups/archive/
backups/latest/
5.3 Синхронизация: scripts/sync_to_git.sh
#!/bin/bash
# sync_to_git.sh — синхронизация снапшота в Git-репозиторий
set -euo pipefail
REPO_DIR="$HOME/hermes-config-backup"
SNAPSHOT_DIR="$HOME/.hermes/backups/latest"
cd "$REPO_DIR"
# Копировать файлы из снапшота (без секретов)
if [ -d "$SNAPSHOT_DIR" ]; then
# config.yaml профилей
cp "$SNAPSHOT_DIR/config.yaml" config/default.yaml 2>/dev/null || true
cp "$SNAPSHOT_DIR/profiles/worker-code/config.yaml" config/worker-code.yaml 2>/dev/null || true
cp "$SNAPSHOT_DIR/profiles/worker-fast/config.yaml" config/worker-fast.yaml 2>/dev/null || true
cp "$SNAPSHOT_DIR/profiles/worker-research/config.yaml" config/worker-research.yaml 2>/dev/null || true
cp "$SNAPSHOT_DIR/profiles/worker-review/config.yaml" config/worker-review.yaml 2>/dev/null || true
# Навыки
rm -rf skills/
cp -r "$SNAPSHOT_DIR/skills/" skills/ 2>/dev/null || true
# Память
mkdir -p memories/
cp "$SNAPSHOT_DIR/profiles/default/memories/MEMORY.md" memories/ 2>/dev/null || true
cp "$SNAPSHOT_DIR/profiles/default/memories/USER.md" memories/ 2>/dev/null || true
fi
# Commit и push
git add -A
if git diff --cached --quiet; then
echo "No changes to commit."
else
git commit -m "backup: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
git push origin main
echo "Backup synced to GitHub."
fi
6. Исключение секретов
| Компонент | Статус секретов | Действие при бэкапе |
|---|---|---|
config.yaml |
Может содержать api_key references |
Проверить на прямые ключи; использовать ${ENV_VAR} синтаксис |
.env |
Содержит API-ключи | REDACT при --exclude-secrets; NEVER commit в Git |
auth.json |
OAuth-токены | Исключён из бэкапа полностью |
state.db |
Может содержать ключи в сессиях | Исключён из бэкапа |
skills/ |
Обычно без секретов | Бэкапируется; проверить на embedded ключи |
memories/ |
Может содержать косвенные ссылки | Бэкапируется; проверить на чувствительные данные |
6.1 Проверка на секреты перед commit
# Проверить снапшот на наличие паттернов секретов
grep -rE "(sk-|api_key|token|secret|password|-----BEGIN)" ~/.hermes/backups/latest/ \
--exclude-dir=sessions --exclude="*.pyc" && echo "WARNING: Potential secrets found!"
7. Процедуры
7.1 Плановый бэкап (ежедневно)
#!/bin/bash
# daily_backup.sh
echo "=== Daily backup $(date) ==="
# 1. Создать снапшот
python scripts/update_backup.py --exclude-secrets
echo "Snapshot created."
# 2. Проверить целостность
python scripts/restore_backup.py --verify ~/.hermes/backups/latest
echo "Integrity verified."
# 3. Синхронизировать в Git
bash scripts/sync_to_git.sh
echo "Git sync complete."
# 4. Очистить старые архивы (> 30 дней)
find ~/.hermes/backups/archive/ -name "*.tar.gz" -mtime +30 -delete
echo "Old archives cleaned."
7.2 Восстановление после сбоя
#!/bin/bash
# disaster_recovery.sh
echo "=== Disaster Recovery $(date) ==="
echo "WARNING: This will overwrite current Hermes configuration!"
read -p "Continue? (yes/no): " confirm
[[ "$confirm" != "yes" ]] && exit 0
# 1. Создать аварийный снапшот текущего состояния
python scripts/update_backup.py --output ~/.hermes/backups/pre_recovery
echo "Pre-recovery snapshot created."
# 2. Восстановить из последнего снапшота
python scripts/restore_backup.py --latest
echo "Config restored."
# 3. Проверить конфигурацию
hermes config check
echo "Config check complete."
# 4. Проверить health
hermes doctor
echo "Health check complete."
# 5. Перезапустить gateway
hermes gateway restart
echo "Gateway restarted."
echo "=== Recovery complete ==="
echo "NOTE: API keys (.env) were redacted in backup."
echo " Manually restore API keys if needed."
8. RPO и RTO
| Сценарий | RPO | RTO | Процедура |
|---|---|---|---|
Повреждение config.yaml |
≤ 1 час | ≤ 5 мин | restore_backup.py --latest |
| Потеря навыков | ≤ 6 часов | ≤ 10 мин | restore_backup.py --latest |
Полная потеря ~/.hermes/ |
≤ 1 час (config) / ≤ 24 часа (sessions) | ≤ 30 мин | disaster_recovery.sh |
| Миграция на новый сервер | ≤ 1 час | ≤ 1 час | Клонировать Git-репозиторий + восстановить секреты |
9. Ссылки
- HERMES.md — спецификация Hermes-адаптера (конфигурация)
- MONITORING.md — мониторинг и алерты (включая ALT-BKP-001)
- CREDENTIAL_POOLS.md — управление ключами
- SPECIFICATION.md §10 — Data lifecycle
- SPECIFICATION.md §7.2 — Reliability, consistency and recovery