agent-control-center/docs/operations/BACKUP.md

704 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`
```python
#!/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 Ручной снапшот
```bash
# Стандартный снапшот
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
```bash
# Добавить задание в 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`
```python
#!/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 Ручное восстановление
```bash
# Список доступных снапшотов
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`
```gitignore
# Исключить файлы с секретами
.env
*.env
auth.json
credentials/
secrets/
# Исключить чувствительные данные сессий
sessions/
state.db
# Исключить временные файлы
*.pyc
__pycache__/
backups/archive/
backups/latest/
```
### 5.3 Синхронизация: `scripts/sync_to_git.sh`
```bash
#!/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
```bash
# Проверить снапшот на наличие паттернов секретов
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 Плановый бэкап (ежедневно)
```bash
#!/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 Восстановление после сбоя
```bash
#!/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](../adapters/HERMES.md) — спецификация Hermes-адаптера (конфигурация)
- [MONITORING.md](MONITORING.md) — мониторинг и алерты (включая ALT-BKP-001)
- [CREDENTIAL_POOLS.md](../patterns/CREDENTIAL_POOLS.md) — управление ключами
- [SPECIFICATION.md](../SPECIFICATION.md) §10 — Data lifecycle
- [SPECIFICATION.md](../SPECIFICATION.md) §7.2 — Reliability, consistency and recovery