personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_backup_integration.py
11 kB 330 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import hashlib 7import json 8import os 9import shlex 10import shutil 11import socket 12from pathlib import Path 13from typing import Any 14 15import pytest 16 17from solstone.think.backup import restore, rotation, teardown 18from solstone.think.backup.destination import Destination, validate_destination 19from solstone.think.backup.engine import BACKUP_EXCLUDES 20from solstone.think.backup.install import ensure_restic 21from solstone.think.backup.readiness import ( 22 RESTIC_SCHEMA_VERSION, 23 RESTIC_TOOL, 24 RESTIC_VERSION, 25 _binary_path, 26 _platform_info, 27 _sentinel_path, 28 _tool_dir, 29) 30from solstone.think.backup.repo import init_repository 31from solstone.think.backup.runner import run_restic 32from solstone.think.backup.state import BACKUP_DEFAULTS, get_destination 33from solstone.think.indexer.journal import ScanReport 34from tests.helpers.journal_config import seed_journal_config 35 36RESTIC_BIN = shutil.which("restic") 37 38 39def _config_path(journal: Path) -> Path: 40 return journal / "config" / "journal.json" 41 42 43def _write_json(path: Path, payload: dict[str, Any]) -> None: 44 path.parent.mkdir(parents=True, exist_ok=True) 45 path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") 46 47 48def _read_json(path: Path) -> dict[str, Any]: 49 return json.loads(path.read_text(encoding="utf-8")) 50 51 52def _destination(repo: Path) -> Destination: 53 return Destination( 54 repository=f"local:{repo}", 55 backend="s3", 56 credentials={ 57 "access_key_id": "test-access-key", 58 "secret_access_key": "test-secret-key", 59 }, 60 ) 61 62 63def _seed_backup_config( 64 journal: Path, 65 *, 66 destination: Destination, 67 daily_key: str, 68 recovery_key: str, 69) -> None: 70 seed_journal_config( 71 { 72 "backup": { 73 "enabled": True, 74 "destination": { 75 "repository": destination.repository, 76 "backend": destination.backend, 77 "credentials": destination.credentials, 78 }, 79 "daily_key": daily_key, 80 "recovery_key": recovery_key, 81 "confirmed_recovery_key": True, 82 } 83 }, 84 journal, 85 ) 86 87 88def _install_restic_wrapper(restic_bin: str) -> Path: 89 os_name, arch = _platform_info() 90 tool_dir = _tool_dir(os_name) 91 tool_dir.mkdir(parents=True, exist_ok=True) 92 binary_path = _binary_path(tool_dir) 93 binary_path.write_text( 94 "#!/usr/bin/env sh\n" 95 'if [ "$1" = "version" ]; then\n' 96 f" echo 'restic {RESTIC_VERSION} test wrapper'\n" 97 " exit 0\n" 98 "fi\n" 99 f'exec {shlex.quote(restic_bin)} "$@"\n', 100 encoding="utf-8", 101 ) 102 binary_path.chmod(0o755) 103 digest = hashlib.sha256(binary_path.read_bytes()).hexdigest() 104 _write_json( 105 _sentinel_path(tool_dir), 106 { 107 "schema_version": RESTIC_SCHEMA_VERSION, 108 "tool": RESTIC_TOOL, 109 "version": RESTIC_VERSION, 110 "sha256": digest, 111 "platform": {"os": os_name, "arch": arch}, 112 "binary_path": str(binary_path), 113 }, 114 ) 115 return binary_path 116 117 118def _backup_journal( 119 *, 120 journal: Path, 121 destination: Destination, 122 daily_key: str, 123 recovery_key: str, 124 restic_path: Path, 125) -> None: 126 init_repository( 127 destination, 128 daily_key=daily_key, 129 recovery_key=recovery_key, 130 restic_path=restic_path, 131 timeout=30, 132 ) 133 args = ["backup", str(journal)] 134 for pattern in BACKUP_EXCLUDES: 135 args.extend(["--exclude", pattern]) 136 result = run_restic( 137 args, 138 repository=destination.repository, 139 password=daily_key, 140 restic_path=restic_path, 141 timeout=60, 142 ) 143 assert result.returncode == 0, result.stderr 144 145 146@pytest.mark.skipif(RESTIC_BIN is None, reason="restic is not installed") 147@pytest.mark.integration 148@pytest.mark.timeout(120) 149def test_backup_restore_rotation_teardown_real_local_round_trip( 150 tmp_path: Path, 151 monkeypatch: pytest.MonkeyPatch, 152) -> None: 153 home = tmp_path / "home" 154 home.mkdir() 155 monkeypatch.setenv("HOME", str(home)) 156 restic_path = _install_restic_wrapper(RESTIC_BIN or "") 157 158 repo = tmp_path / "repo" 159 source_journal = tmp_path / "source-journal" 160 restored_journal = tmp_path / "restored-journal" 161 source_journal.mkdir() 162 restored_journal.mkdir() 163 destination = _destination(repo) 164 daily_key = "daily-password" 165 recovery_key = ("0" * 32) + ("1" * 32) 166 entered_recovery_key = ("O" * 32) + ("I" * 32) 167 168 _seed_backup_config( 169 source_journal, 170 destination=destination, 171 daily_key=daily_key, 172 recovery_key=recovery_key, 173 ) 174 (source_journal / "chronicle" / "20260612").mkdir(parents=True) 175 (source_journal / "chronicle" / "20260612" / "note.txt").write_text( 176 "journal content\n", 177 encoding="utf-8", 178 ) 179 (source_journal / "health" / "pruning-runs").mkdir(parents=True) 180 (source_journal / "health" / "retention.log").write_text( 181 "retention audit\n", 182 encoding="utf-8", 183 ) 184 (source_journal / "health" / "pruning-runs" / "20260701.jsonl").write_text( 185 "pruning audit\n", 186 encoding="utf-8", 187 ) 188 (source_journal / "health" / "supervisor.pid").write_text( 189 "12345\n", 190 encoding="utf-8", 191 ) 192 (source_journal / "health" / "supervisor.ready").write_text( 193 "ready\n", 194 encoding="utf-8", 195 ) 196 (source_journal / "health" / "convey.port").write_text( 197 "5015\n", 198 encoding="utf-8", 199 ) 200 (source_journal / "health" / ".steward.lock").write_text( 201 "locked\n", 202 encoding="utf-8", 203 ) 204 (source_journal / "health" / ".steward_inflight.tmp").write_text( 205 "partial\n", 206 encoding="utf-8", 207 ) 208 (source_journal / "chronicle" / "20260701" / "health" / "talent-provenance").mkdir( 209 parents=True 210 ) 211 ( 212 source_journal 213 / "chronicle" 214 / "20260701" 215 / "health" 216 / "talent-provenance" 217 / "run.json" 218 ).write_text('{"run":"audit"}\n', encoding="utf-8") 219 callosum_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 220 cwd = os.getcwd() 221 try: 222 os.chdir(source_journal / "health") 223 callosum_socket.bind("callosum.sock") 224 finally: 225 os.chdir(cwd) 226 227 monkeypatch.setenv("SOLSTONE_JOURNAL", str(source_journal)) 228 assert ensure_restic() == restic_path 229 try: 230 _backup_journal( 231 journal=source_journal, 232 destination=destination, 233 daily_key=daily_key, 234 recovery_key=recovery_key, 235 restic_path=restic_path, 236 ) 237 finally: 238 callosum_socket.close() 239 240 scan_calls: list[tuple[str, dict[str, Any]]] = [] 241 242 def fake_scan_journal(journal: str, **kwargs: Any) -> ScanReport: 243 scan_calls.append((journal, kwargs)) 244 return ScanReport(changed=True, edge_rows_inserted=0) 245 246 monkeypatch.setattr(restore, "scan_journal", fake_scan_journal) 247 monkeypatch.setenv("SOLSTONE_JOURNAL", str(restored_journal)) 248 249 restore_result = restore.restore_journal(destination, entered_recovery_key) 250 251 assert restore_result.status == "ok" 252 assert restore_result.integrity_ok is True 253 assert restore_result.resumable is True 254 assert isinstance(restore_result.bytes_restored, int) 255 assert restore_result.bytes_restored > 0 256 assert (restored_journal / "chronicle" / "20260612" / "note.txt").read_text( 257 encoding="utf-8" 258 ) == "journal content\n" 259 assert (restored_journal / "health" / "retention.log").read_text( 260 encoding="utf-8" 261 ) == "retention audit\n" 262 assert (restored_journal / "health" / "pruning-runs" / "20260701.jsonl").read_text( 263 encoding="utf-8" 264 ) == "pruning audit\n" 265 assert ( 266 restored_journal 267 / "chronicle" 268 / "20260701" 269 / "health" 270 / "talent-provenance" 271 / "run.json" 272 ).read_text(encoding="utf-8") == '{"run":"audit"}\n' 273 assert not (restored_journal / "health" / "callosum.sock").exists() 274 assert not (restored_journal / "health" / "supervisor.pid").exists() 275 assert not (restored_journal / "health" / "supervisor.ready").exists() 276 assert not (restored_journal / "health" / "convey.port").exists() 277 assert not (restored_journal / "health" / ".steward.lock").exists() 278 assert not (restored_journal / "health" / ".steward_inflight.tmp").exists() 279 restored_config = _read_json(_config_path(restored_journal)) 280 assert restored_config["backup"]["daily_key"] == daily_key 281 assert restored_config["backup"]["destination"] == { 282 "repository": destination.repository, 283 "backend": destination.backend, 284 "credentials": destination.credentials, 285 } 286 assert restored_config["backup"]["recovery_key"] == recovery_key 287 assert restored_config["backup"]["confirmed_recovery_key"] is True 288 assert scan_calls == [(str(restored_journal), {"full": True})] 289 290 rotation_result = rotation.rotate_recovery_key() 291 292 assert rotation_result.status == "ok" 293 assert rotation_result.recovery_key is not None 294 assert rotation_result.recovery_key != recovery_key 295 active_config = _read_json(_config_path(restored_journal)) 296 assert active_config["backup"]["daily_key"] == daily_key 297 assert active_config["backup"]["recovery_key"] == rotation_result.recovery_key 298 assert active_config["backup"]["confirmed_recovery_key"] is False 299 300 active_destination = get_destination() 301 assert active_destination == destination 302 new_status = validate_destination( 303 active_destination, 304 rotation_result.recovery_key, 305 restic_path=restic_path, 306 timeout=30, 307 ) 308 old_status = validate_destination( 309 active_destination, 310 recovery_key, 311 restic_path=restic_path, 312 timeout=30, 313 ) 314 assert new_status.reason_code == "repo_exists" 315 assert old_status.reason_code == "auth_failed" 316 317 teardown_result = teardown.teardown_backup() 318 319 assert teardown_result.status == "ok" 320 snapshots = run_restic( 321 ["snapshots"], 322 repository=destination.repository, 323 password=daily_key, 324 restic_path=restic_path, 325 json=True, 326 timeout=30, 327 ) 328 assert snapshots.returncode == 0, snapshots.stderr 329 assert snapshots.json == [] 330 assert _read_json(_config_path(restored_journal))["backup"] == BACKUP_DEFAULTS