Files
roko-backup-script/roko-backup.py
T
Roko a88b2604a1 Initial: Roko Backup Script v2 — Python rewrite with streaming progress & log rotation
Current deployed version (roko-backup.py) after refactoring from bash to Python.
Generated via OpenCode (kimi-k2.7-code). Includes:
- rclone streaming progress (log every 5% or 30s)
- RotatingFileHandler (max 1MB, 5 backups)
- LockFile for concurrent run prevention
- Yandex Cloud upload with NO_PROXY bypass
- Also includes comparison versions from mimo-v2.5-pro and kimi-k2.7-code
2026-07-07 11:27:02 +07:00

728 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
"""Roko Backup Script — refactored Python rewrite.
Collect -> archive -> upload -> verify -> clean -> report.
Runs from system crontab, standalone Python 3.10+.
"""
from __future__ import annotations
import abc
import argparse
import datetime
import json
import logging
import logging.handlers
import os
import re
import signal
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ContextManager, TypeVar
log = logging.getLogger("roko-backup")
T = TypeVar("T")
def assert_not_none(value: T | None) -> T:
"""Assert a value is not None at runtime, for type narrowing."""
assert value is not None # pragma: no cover
return value
# ─── Exceptions ──────────────────────────────────────────────────────────────
class BackupError(Exception):
"""Base exception for backup pipeline failures."""
class LockError(BackupError):
"""Raised when the lock file cannot be acquired."""
# ─── Config ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Config:
"""All paths and limits for the backup pipeline."""
home: Path = field(default_factory=lambda: Path.home())
report_file: Path = field(
default_factory=lambda: Path.home()
/ ".openclaw"
/ "workspace"
/ "roko-backup-report.json"
)
archive_dir: Path = field(default_factory=lambda: Path.home() / "backups")
log_file: Path = field(default_factory=lambda: Path.home() / "backups" / "roko-backup.log")
rclone_remote: str = "yabackup:for-backup/roko"
max_cloud_backups: int = 14
max_local_backups: int = 3
max_log_size: int = 1_048_576 # 1 MiB
max_log_backups: int = 5
lock_path: Path = Path("/tmp/roko-backup.lock")
@property
def workspace(self) -> Path:
"""Path to the OpenCode workspace directory."""
return self.home / ".openclaw" / "workspace"
@property
def config_file(self) -> Path:
"""Path to the main OpenCode config file."""
return self.home / ".openclaw" / "openclaw.json"
@property
def scripts_dir(self) -> Path:
"""Path to the OpenCode scripts directory."""
return self.home / ".openclaw" / "scripts"
@property
def media_dir(self) -> Path:
"""Path to the OpenCode media directory."""
return self.home / ".openclaw" / "media"
@property
def plugin_skills_dir(self) -> Path:
"""Path to the OpenCode plugin-skills directory."""
return self.home / ".openclaw" / "plugin-skills"
@property
def agent_db(self) -> Path:
"""Path to the main agent SQLite database."""
return self.home / ".openclaw" / "agents" / "main" / "agent" / "openclaw-agent.sqlite"
@property
def rclone_env(self) -> dict[str, str]:
"""Environment variables for rclone subprocesses."""
return {
**os.environ,
"NO_PROXY": "storage.yandexcloud.net,yandexcloud.net",
}
@classmethod
def from_env(cls) -> Config:
"""Build config from environment variables with built-in defaults."""
home = Path.home()
return cls(
home=home,
report_file=Path(
os.environ.get(
"ROKO_REPORT_FILE", str(home / ".openclaw/workspace/roko-backup-report.json")
)
),
archive_dir=Path(os.environ.get("ROKO_ARCHIVE_DIR", str(home / "backups"))),
log_file=Path(os.environ.get("ROKO_LOG_FILE", str(home / "backups/roko-backup.log"))),
rclone_remote=os.environ.get("ROKO_RCLONE_REMOTE", "yabackup:for-backup/roko"),
max_cloud_backups=int(os.environ.get("ROKO_MAX_CLOUD", "14")),
max_local_backups=int(os.environ.get("ROKO_MAX_LOCAL", "3")),
max_log_size=int(os.environ.get("ROKO_MAX_LOG_SIZE", str(1_048_576))),
max_log_backups=int(os.environ.get("ROKO_MAX_LOG_BACKUPS", "5")),
)
@classmethod
def from_file(cls, path: Path) -> Config:
"""Load config from a JSON file, falling back to defaults."""
data: dict[str, Any] = {}
if path.exists():
data = json.loads(path.read_text())
home = Path(data.get("home", str(Path.home())))
return cls(
home=home,
report_file=Path(
data.get(
"report_file", str(home / ".openclaw/workspace/roko-backup-report.json")
)
),
archive_dir=Path(data.get("archive_dir", str(home / "backups"))),
log_file=Path(data.get("log_file", str(home / "backups/roko-backup.log"))),
rclone_remote=data.get("rclone_remote", "yabackup:for-backup/roko"),
max_cloud_backups=data.get("max_cloud_backups", 14),
max_local_backups=data.get("max_local_backups", 3),
max_log_size=data.get("max_log_size", 1_048_576),
max_log_backups=data.get("max_log_backups", 5),
)
# ─── LockFile ────────────────────────────────────────────────────────────────
class LockFile(ContextManager["LockFile"]):
"""Prevents concurrent backup runs via a PID file."""
def __init__(self, path: Path) -> None:
self.path = path
self._acquired = False
def acquire(self) -> None:
"""Acquire the lock. Raises LockError if another live process holds it."""
if self.path.exists():
try:
pid = int(self.path.read_text().strip())
os.kill(pid, 0)
raise LockError(f"Another backup is already running (lock: {self.path})")
except (ValueError, ProcessLookupError, PermissionError, OSError):
log.warning("Stale lock file found (PID dead), removing")
self.path.unlink(missing_ok=True)
self.path.write_text(str(os.getpid()))
self._acquired = True
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
def release(self) -> None:
"""Release the lock and remove the file."""
if self._acquired:
self.path.unlink(missing_ok=True)
self._acquired = False
def _signal_handler(self, signum: int, _frame: Any) -> None:
self.release()
sys.exit(128 + signum)
def __enter__(self) -> LockFile:
self.acquire()
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.release()
# ─── Collector ───────────────────────────────────────────────────────────────
class Collector:
"""Gathers files to back up into a staging directory."""
EXCLUDE_PATTERNS = (
"node_modules",
"__pycache__",
"*.pyc",
".git",
"roko-pulse.jsonl",
"cache",
"*.tmp",
".cache",
)
def __init__(self, config: Config, exclude_patterns: tuple[str, ...] | None = None) -> None:
self.config = config
self.exclude_patterns = exclude_patterns or self.EXCLUDE_PATTERNS
def collect(self, staging: Path) -> tuple[list[tuple[Path, Path]], int]:
"""Copy workspace/config/scripts/media/plugin-skills/agent-db into staging.
Returns:
A tuple of (list of (source, dest) pairs, total bytes collected).
"""
collected: list[tuple[Path, Path]] = []
total_bytes = 0
ignore = shutil.ignore_patterns(*self.exclude_patterns)
targets: list[tuple[Path, str]] = [
(self.config.workspace, "openclaw/workspace"),
(self.config.config_file, "openclaw/openclaw.json"),
(self.config.scripts_dir, "openclaw/scripts"),
(self.config.media_dir, "openclaw/media"),
(self.config.plugin_skills_dir, "openclaw/plugin-skills"),
(self.config.agent_db, "openclaw/openclaw-agent.sqlite"),
]
for src, dest_rel in targets:
dest = staging / dest_rel
if not src.exists():
log.warning("Source not found, skipping: %s", src)
continue
dest.parent.mkdir(parents=True, exist_ok=True)
try:
if src.is_dir():
shutil.copytree(src, dest, ignore=ignore, dirs_exist_ok=True)
else:
shutil.copy2(src, dest)
except Exception as exc:
log.warning("Failed to copy %s: %s", src, exc)
continue
size = self._tree_size(dest)
collected.append((src, dest))
total_bytes += size
log.info("Collected %s -> %s (%d bytes)", src.name, dest_rel, size)
return collected, total_bytes
@staticmethod
def _tree_size(path: Path) -> int:
"""Return total size in bytes for a file or directory."""
if path.is_file():
return path.stat().st_size
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
# ─── Archiver ────────────────────────────────────────────────────────────────
class Archiver:
"""Creates a tar.gz archive from a staging directory."""
def __init__(self, config: Config) -> None:
self.config = config
def archive(self, staging: Path, backup_name: str) -> tuple[Path, int]:
"""Create tar.gz archive. Returns (archive_path, size_bytes)."""
self.config.archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = self.config.archive_dir / f"{backup_name}.tar.gz"
log.info("Creating archive: %s", archive_path)
with tarfile.open(archive_path, mode="w:gz") as tar:
tar.add(staging, arcname=".")
size = archive_path.stat().st_size
log.info("Archive created: %s (%d bytes)", archive_path.name, size)
return archive_path, size
# ─── Uploader ────────────────────────────────────────────────────────────────
class Uploader:
"""Uploads archive via rclone to Yandex Object Storage with retry."""
UPLOAD_PROGRESS_INTERVAL = 30 # seconds between progress log lines
PROGRESS_PERCENT_STEP = 5 # log every N percent
BACKOFF_SCHEDULE = (10, 30, 60)
_PROGRESS_RE = re.compile(
r"Transferred:\s*([\d.]+)\s*(\w*)\s*/\s*([\d.]+)\s*(\w*),\s*([\d.]+)%"
)
def __init__(self, config: Config) -> None:
self.config = config
def upload(self, archive_path: Path) -> bool:
"""Upload archive to remote. Returns True on success."""
max_attempts = len(self.BACKOFF_SCHEDULE) + 1
for attempt in range(1, max_attempts + 1):
log.info("Upload attempt %d/%d", attempt, max_attempts)
if self._upload_once(archive_path):
log.info("Upload succeeded")
return True
if attempt < max_attempts:
delay = self.BACKOFF_SCHEDULE[min(attempt - 1, len(self.BACKOFF_SCHEDULE) - 1)]
log.info("Retrying in %ds...", delay)
time.sleep(delay)
log.error("Upload failed after %d attempts", max_attempts)
return False
def _upload_once(self, archive_path: Path) -> bool:
"""Run a single upload attempt. Uses rclone --progress, reads stderr
with a timeout to handle carriage-return-based progress lines."""
cmd = [
"rclone",
"copy",
str(archive_path),
f"{self.config.rclone_remote}/",
"--stats", "30s",
"--progress",
]
log.debug("Running: %s", " ".join(cmd))
process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=self.config.rclone_env,
)
last_logged_percent = -1
last_log_time = 0.0
progress_lines: list[str] = []
stderr = assert_not_none(process.stderr)
while True:
chunk = stderr.read(4096)
if not chunk:
break
for line in chunk.splitlines():
line = line.strip()
if not line:
continue
progress_lines.append(line)
match = self._PROGRESS_RE.search(line)
if match:
parts = match.groups()
pct = float(parts[4])
bytes_done = f"{parts[0]} {parts[1] or 'B'}"
bytes_total = f"{parts[2]} {parts[3] or 'B'}"
now = time.monotonic()
new_bucket = int(pct) // self.PROGRESS_PERCENT_STEP * self.PROGRESS_PERCENT_STEP
if now - last_log_time >= self.UPLOAD_PROGRESS_INTERVAL or new_bucket > last_logged_percent:
log.info("Upload: %.0f%% (%s / %s)", pct, bytes_done, bytes_total)
last_logged_percent = new_bucket
last_log_time = now
if process.poll() is not None:
break
process.wait()
if process.returncode == 0:
log.info("Upload succeeded")
return True
log.warning("Upload failed (rc=%d)", process.returncode)
return False
# ─── Verifier ────────────────────────────────────────────────────────────────
class Verifier:
"""Confirms the uploaded file exists in the remote."""
def __init__(self, config: Config) -> None:
self.config = config
def verify(self, backup_name: str) -> tuple[bool, str]:
"""Check that backup_name.tar.gz is listed in the remote.
Returns:
A tuple of (success, details).
"""
result = subprocess.run(
["rclone", "ls", self.config.rclone_remote],
capture_output=True,
text=True,
check=False,
env=self.config.rclone_env,
)
if result.returncode != 0:
return False, f"rclone ls failed (rc={result.returncode}): {result.stderr[:200]}"
target = f"{backup_name}.tar.gz"
if target in result.stdout:
return True, f"Verified: {target} found in remote"
return False, f"Not found in remote: {target}"
# ─── Cleaner ─────────────────────────────────────────────────────────────────
class Cleaner:
"""Manages retention of local and cloud backups."""
def __init__(self, config: Config) -> None:
self.config = config
def clean_local(self) -> int:
"""Delete local archives beyond MAX_LOCAL_BACKUPS. Returns count deleted."""
archives = sorted(
self.config.archive_dir.glob("roko-*.tar.gz"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
deleted = 0
for old in archives[self.config.max_local_backups :]:
log.info("Deleting local: %s", old.name)
old.unlink()
deleted += 1
return deleted
def clean_cloud(self) -> int:
"""Delete cloud archives beyond MAX_CLOUD_BACKUPS. Returns count deleted."""
result = subprocess.run(
["rclone", "ls", self.config.rclone_remote],
capture_output=True,
text=True,
check=False,
env=self.config.rclone_env,
)
if result.returncode != 0:
log.warning("Cannot list cloud backups for cleanup: %s", result.stderr[:200])
return 0
entries: list[tuple[int, str]] = []
for line in result.stdout.strip().splitlines():
parts = line.split(None, 1)
if len(parts) == 2:
try:
entries.append((int(parts[0]), parts[1]))
except ValueError:
continue
entries.sort(key=lambda e: e[1])
deleted = 0
to_remove = entries[: max(0, len(entries) - self.config.max_cloud_backups)]
for _size, name in to_remove:
log.info("Deleting cloud: %s", name)
subprocess.run(
["rclone", "delete", f"{self.config.rclone_remote}/{name}"],
capture_output=True,
text=True,
check=False,
env=self.config.rclone_env,
)
deleted += 1
return deleted
# ─── Reporter ────────────────────────────────────────────────────────────────
class Reporter:
"""Writes JSON backup report to the workspace."""
def __init__(self, config: Config) -> None:
self.config = config
def write(
self,
status: str,
name: str | None = None,
size_bytes: int | None = None,
error: str | None = None,
) -> None:
"""Write the report JSON file."""
report = {
"timestamp": datetime.datetime.now().astimezone().isoformat(),
"status": status,
"name": name,
"size_bytes": size_bytes,
"error": error,
}
self.config.report_file.parent.mkdir(parents=True, exist_ok=True)
self.config.report_file.write_text(json.dumps(report, indent=2) + "\n")
log.info("Report written: %s (%s)", self.config.report_file, status)
# ─── Pipeline ────────────────────────────────────────────────────────────────
class BackupPipeline:
"""Orchestrates the full backup: collect -> archive -> upload -> verify -> clean -> report."""
def __init__(self, config: Config, *, dry_run: bool = False) -> None:
self.config = config
self.dry_run = dry_run
self.collector = Collector(config)
self.archiver = Archiver(config)
self.uploader = Uploader(config)
self.verifier = Verifier(config)
self.cleaner = Cleaner(config)
self.reporter = Reporter(config)
def run(self) -> int:
"""Execute the pipeline. Returns exit code."""
backup_name = f"roko-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
log.info("Starting backup: %s", backup_name)
free = shutil.disk_usage(self.config.archive_dir).free
log.info("Free disk: %s", self._fmt_bytes(free))
archive_path: Path | None = None
archive_size: int | None = None
with self._managed_staging() as staging:
# 1. Collect
t0 = time.monotonic()
collected, total_bytes = self.collector.collect(staging)
log.info(
"Collection done in %.1fs: %d items, %s",
time.monotonic() - t0,
len(collected),
self._fmt_bytes(total_bytes),
)
if self.dry_run:
log.info(
"[DRY RUN] Would archive and upload %d items (%s)",
len(collected),
self._fmt_bytes(total_bytes),
)
for src, dest in collected:
log.info(" %s -> %s", src, dest.relative_to(staging))
self.reporter.write("dry-run", name=backup_name, size_bytes=total_bytes)
return 0
# 2. Archive
t0 = time.monotonic()
archive_path, archive_size = self.archiver.archive(staging, backup_name)
log.info(
"Archiving done in %.1fs: %s",
time.monotonic() - t0,
self._fmt_bytes(archive_size),
)
# 3. Upload
if archive_path is None or archive_size is None:
msg = "Archive was not created"
log.error(msg)
self.reporter.write("error", name=backup_name, error=msg)
return 3
t0 = time.monotonic()
if not self.uploader.upload(archive_path):
msg = "Upload failed after retries"
log.error(msg)
self.reporter.write("error", name=backup_name, size_bytes=archive_size, error=msg)
return 3
log.info("Upload done in %.1fs", time.monotonic() - t0)
# 4. Verify
ok, detail = self.verifier.verify(backup_name)
if not ok:
log.error("Verification failed: %s", detail)
self.reporter.write("error", name=backup_name, size_bytes=archive_size, error=detail)
return 3
log.info("Verification: %s", detail)
# 5. Clean
local_deleted = self.cleaner.clean_local()
cloud_deleted = self.cleaner.clean_cloud()
log.info("Cleanup: %d local, %d cloud removed", local_deleted, cloud_deleted)
# 6. Report
self.reporter.write("success", name=backup_name, size_bytes=archive_size)
log.info("Backup complete: %s (%s)", backup_name, self._fmt_bytes(archive_size))
return 0
@contextmanager
def _managed_staging(self) -> Generator[Path, None, None]:
"""Create and always clean up a temporary staging directory."""
tmp_dir = tempfile.mkdtemp(prefix="roko-backup-")
staging = Path(tmp_dir)
try:
yield staging
finally:
try:
shutil.rmtree(staging, ignore_errors=True)
except Exception as exc:
log.warning("Failed to remove staging directory %s: %s", staging, exc)
@staticmethod
def _fmt_bytes(n: int) -> str:
"""Human-readable byte size."""
value = float(n)
for unit in ("B", "KB", "MB", "GB"):
if abs(value) < 1024:
return f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} TB"
# ─── Main ────────────────────────────────────────────────────────────────────
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
prog="roko-backup.py",
description="Roko backup: collect -> archive -> upload -> verify -> clean -> report",
)
parser.add_argument(
"--config",
type=Path,
default=None,
help="Config file path (default: env vars / built-in defaults)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Collect and print what would be backed up, don't archive/upload",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Detailed logging output (DEBUG level, also to stdout)",
)
return parser.parse_args(argv)
def setup_logging(
verbose: bool,
log_file: Path,
max_size: int,
max_backups: int,
) -> None:
"""Configure rotating file logging and optional stdout output."""
level = logging.DEBUG if verbose else logging.INFO
formatter = logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
str(log_file),
maxBytes=max_size,
backupCount=max_backups,
)
file_handler.setFormatter(formatter)
root = logging.getLogger()
root.setLevel(level)
root.addHandler(file_handler)
if verbose:
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
root.addHandler(stream_handler)
def main(argv: list[str] | None = None) -> int:
"""Entry point."""
args = parse_args(argv)
if args.config:
config = Config.from_file(args.config)
else:
config = Config.from_env()
setup_logging(
args.verbose,
config.log_file,
config.max_log_size,
config.max_log_backups,
)
if not shutil.which("rclone"):
log.error("rclone not found in PATH")
return 1
try:
with LockFile(config.lock_path):
pipeline = BackupPipeline(config, dry_run=args.dry_run)
return pipeline.run()
except LockError as exc:
log.error("%s", exc)
return 2
except Exception as exc:
log.exception("Unexpected error: %s", exc)
Reporter(config).write("error", error=str(exc))
return 3
if __name__ == "__main__":
sys.exit(main())