#!/usr/bin/env python3 import argparse import json import os import shutil import tarfile import tempfile from datetime import datetime, timezone from pathlib import Path from huggingface_hub import HfApi, hf_hub_download try: from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError except ImportError: from huggingface_hub.utils import EntryNotFoundError, HfHubHTTPError DEFAULT_EXCLUDES = { ".cache", "__pycache__", "tmp", "temp", } def env(name: str, default: str | None = None) -> str | None: value = os.environ.get(name) return value if value not in (None, "") else default def required_env(name: str) -> str: value = env(name) if not value: raise SystemExit(f"{name} is required") return value def should_exclude(path: Path) -> bool: parts = set(path.parts) if parts & DEFAULT_EXCLUDES: return True name = path.name return name.endswith((".log", ".pid", ".pyc", ".tmp", ".lock")) def state_has_content(root: Path) -> bool: if not root.exists(): return False for child in root.rglob("*"): if child.is_file() and not should_exclude(child.relative_to(root)): return True return False def state_stats(root: Path) -> tuple[int, int]: files = 0 bytes_total = 0 if not root.exists(): return files, bytes_total for child in root.rglob("*"): if not child.is_file(): continue rel = child.relative_to(root) if should_exclude(rel): continue files += 1 try: bytes_total += child.stat().st_size except OSError: pass return files, bytes_total def add_tree(tar: tarfile.TarFile, root: Path) -> None: for item in root.rglob("*"): rel = item.relative_to(root) if should_exclude(rel): continue tar.add(item, arcname=str(rel), recursive=False) def validate_member(target: Path, member: tarfile.TarInfo) -> Path: destination = (target / member.name).resolve() target_resolved = target.resolve() if target_resolved != destination and target_resolved not in destination.parents: raise RuntimeError(f"Unsafe archive member: {member.name}") return destination def safe_extract(archive: Path, target: Path) -> None: with tarfile.open(archive, "r:gz") as tar: for member in tar.getmembers(): validate_member(target, member) tar.extractall(target) def backup() -> None: repo = required_env("HERMES_BACKUP_REPO") token = required_env("HF_BACKUP_TOKEN") root = Path(env("HERMES_BACKUP_DIR", "/home/hermeswebui/.hermes")).resolve() filename = env("HERMES_BACKUP_FILENAME", "hermes-state.tar.gz") file_count, byte_count = state_stats(root) print(f"Hermes state backup source: {root} ({file_count} files, {byte_count} bytes).") if file_count == 0: print("Hermes state backup skipped: no meaningful local state yet.") return timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") api = HfApi(token=token) with tempfile.TemporaryDirectory(prefix="hermes-backup-") as tmp: tmpdir = Path(tmp) archive = tmpdir / filename manifest = tmpdir / "manifest.json" with tarfile.open(archive, "w:gz") as tar: add_tree(tar, root) archive_size = archive.stat().st_size manifest.write_text( json.dumps( { "created_at": timestamp, "source": "Acrabohan/hermes-webui", "path_in_repo": filename, "file_count": file_count, "source_bytes": byte_count, "archive_bytes": archive_size, }, indent=2, ) + "\n", encoding="utf-8", ) api.upload_file( path_or_fileobj=str(archive), path_in_repo=filename, repo_id=repo, repo_type="dataset", token=token, commit_message=f"Backup Hermes state {timestamp}", ) api.upload_file( path_or_fileobj=str(manifest), path_in_repo="manifest.json", repo_id=repo, repo_type="dataset", token=token, commit_message=f"Update Hermes backup manifest {timestamp}", ) print(f"Hermes state backup uploaded to dataset {repo} ({archive_size} bytes).") def restore() -> None: repo = required_env("HERMES_BACKUP_REPO") token = required_env("HF_BACKUP_TOKEN") root = Path(env("HERMES_BACKUP_DIR", "/home/hermeswebui/.hermes")).resolve() filename = env("HERMES_BACKUP_FILENAME", "hermes-state.tar.gz") force = env("HERMES_FORCE_RESTORE", "0") == "1" if state_has_content(root) and not force: print("Hermes state restore skipped: local state already exists.") return root.mkdir(parents=True, exist_ok=True) try: archive_path = hf_hub_download( repo_id=repo, filename=filename, repo_type="dataset", token=token, ) except EntryNotFoundError: print(f"Hermes state restore skipped: {filename} not found in {repo}.") return except HfHubHTTPError as exc: if getattr(exc.response, "status_code", None) == 404: print(f"Hermes state restore skipped: backup dataset or file not found.") return raise if force and state_has_content(root): stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") backup_existing = root.parent / f"{root.name}.pre-restore-{stamp}" shutil.move(str(root), str(backup_existing)) root.mkdir(parents=True, exist_ok=True) print(f"Existing Hermes state moved to {backup_existing}.") safe_extract(Path(archive_path), root) print(f"Hermes state restored from dataset {repo}.") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("command", choices=("backup", "restore")) args = parser.parse_args() if args.command == "backup": backup() else: restore() if __name__ == "__main__": main()