104 lines
4.2 KiB
Python
104 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BACKUP_SCRIPT = ROOT / "scripts" / "backup_pipeline.sh"
|
|
RESTORE_SCRIPT = ROOT / "scripts" / "restore_backup.sh"
|
|
|
|
|
|
class TestBackupPipeline(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.base = Path(self.tempdir.name)
|
|
self.home = self.base / "home"
|
|
self.source_dir = self.home / ".hermes"
|
|
self.source_dir.mkdir(parents=True)
|
|
(self.source_dir / "sessions").mkdir()
|
|
(self.source_dir / "cron").mkdir()
|
|
(self.source_dir / "config.yaml").write_text("model: local-first\n")
|
|
(self.source_dir / "sessions" / "session.jsonl").write_text('{"role":"assistant","content":"hello"}\n')
|
|
(self.source_dir / "cron" / "jobs.json").write_text('{"jobs": 1}\n')
|
|
(self.source_dir / "state.db").write_bytes(b"sqlite-state")
|
|
|
|
self.backup_root = self.base / "backup-root"
|
|
self.nas_target = self.base / "nas-target"
|
|
self.restore_root = self.base / "restore-root"
|
|
self.log_dir = self.base / "logs"
|
|
self.passphrase_file = self.base / "backup.passphrase"
|
|
self.passphrase_file.write_text("correct horse battery staple\n")
|
|
|
|
def tearDown(self) -> None:
|
|
self.tempdir.cleanup()
|
|
|
|
def _env(self, *, include_remote: bool = True) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"HOME": str(self.home),
|
|
"BACKUP_SOURCE_DIR": str(self.source_dir),
|
|
"BACKUP_ROOT": str(self.backup_root),
|
|
"BACKUP_LOG_DIR": str(self.log_dir),
|
|
"BACKUP_PASSPHRASE_FILE": str(self.passphrase_file),
|
|
}
|
|
)
|
|
if include_remote:
|
|
env["BACKUP_NAS_TARGET"] = str(self.nas_target)
|
|
return env
|
|
|
|
def test_backup_encrypts_and_restore_round_trips(self) -> None:
|
|
backup = subprocess.run(
|
|
["bash", str(BACKUP_SCRIPT)],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self._env(),
|
|
cwd=ROOT,
|
|
)
|
|
self.assertEqual(backup.returncode, 0, msg=backup.stdout + backup.stderr)
|
|
|
|
encrypted_archives = sorted(self.nas_target.rglob("*.tar.gz.enc"))
|
|
self.assertEqual(len(encrypted_archives), 1, msg=f"expected one encrypted archive, found: {encrypted_archives}")
|
|
archive_path = encrypted_archives[0]
|
|
self.assertNotIn(b"model: local-first", archive_path.read_bytes())
|
|
|
|
manifests = sorted(self.nas_target.rglob("*.json"))
|
|
self.assertEqual(len(manifests), 1, msg=f"expected one manifest, found: {manifests}")
|
|
|
|
plaintext_archives = sorted(self.backup_root.rglob("*.tar.gz")) + sorted(self.nas_target.rglob("*.tar.gz"))
|
|
self.assertEqual(plaintext_archives, [], msg=f"plaintext archives leaked: {plaintext_archives}")
|
|
|
|
restore = subprocess.run(
|
|
["bash", str(RESTORE_SCRIPT), str(archive_path), str(self.restore_root)],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self._env(),
|
|
cwd=ROOT,
|
|
)
|
|
self.assertEqual(restore.returncode, 0, msg=restore.stdout + restore.stderr)
|
|
|
|
restored_hermes = self.restore_root / ".hermes"
|
|
self.assertTrue(restored_hermes.exists())
|
|
self.assertEqual((restored_hermes / "config.yaml").read_text(), "model: local-first\n")
|
|
self.assertEqual((restored_hermes / "sessions" / "session.jsonl").read_text(), '{"role":"assistant","content":"hello"}\n')
|
|
self.assertEqual((restored_hermes / "cron" / "jobs.json").read_text(), '{"jobs": 1}\n')
|
|
self.assertEqual((restored_hermes / "state.db").read_bytes(), b"sqlite-state")
|
|
|
|
def test_backup_requires_remote_target(self) -> None:
|
|
backup = subprocess.run(
|
|
["bash", str(BACKUP_SCRIPT)],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self._env(include_remote=False),
|
|
cwd=ROOT,
|
|
)
|
|
self.assertNotEqual(backup.returncode, 0)
|
|
self.assertIn("BACKUP_NAS_TARGET or BACKUP_S3_URI", backup.stdout + backup.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|