1391 lines
59 KiB
Python
1391 lines
59 KiB
Python
import asyncio
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
BACKUP_SCRIPT = REPO_ROOT / "app" / "sovran_systemsos_web" / "scripts" / "sovran-hub-backup.sh"
|
|
SERVER_FILE = REPO_ROOT / "app" / "sovran_systemsos_web" / "server.py"
|
|
SUPPORT_JS = REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js" / "support.js"
|
|
NIX_HUB_FILE = REPO_ROOT / "modules" / "core" / "sovran-hub.nix"
|
|
|
|
|
|
class ManualBackupWorkflowTests(unittest.TestCase):
|
|
# ── Core design: rsync, no tar, no DB, no LND ─────────────────────────────
|
|
|
|
def test_backup_script_uses_rsync_not_tar(self):
|
|
"""New design: backup must use rsync, not tar archives."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("rsync", source, "backup script must use rsync")
|
|
self.assertIn("--archive", source, "rsync must be called with --archive flag")
|
|
self.assertNotIn("tar --create", source, "backup script must not create tar archives")
|
|
self.assertNotIn("--file ", source, "backup script must not write tar --file output")
|
|
|
|
def test_backup_script_has_no_database_dump_functions(self):
|
|
"""Removed: PostgreSQL and MariaDB dump functions must not exist."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn("pg_dump", source, "backup script must not contain pg_dump")
|
|
self.assertNotIn("pg_dumpall", source, "backup script must not contain pg_dumpall")
|
|
self.assertNotIn("mariadb-dump", source, "backup script must not contain mariadb-dump")
|
|
self.assertNotIn("mysqldump", source, "backup script must not contain mysqldump")
|
|
self.assertNotIn("export_postgresql_dumps", source)
|
|
self.assertNotIn("export_mariadb_dumps", source)
|
|
|
|
def test_backup_script_has_no_lnd_service_orchestration(self):
|
|
"""Removed: LND stop/restart and SCB export must not exist."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn("export_lnd_scb_if_possible", source)
|
|
self.assertNotIn("stop_lnd_stack_if_needed", source)
|
|
self.assertNotIn("lncli", source, "backup script must not call lncli")
|
|
self.assertNotIn("LND_STOPPED", source, "backup script must not track LND_STOPPED state")
|
|
self.assertNotIn("LND_UNITS_TO_RESTART", source)
|
|
|
|
def test_backup_script_has_no_tar_dependencies(self):
|
|
"""Removed: sha256sum checksums for tar artifacts must not exist."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn("sha256sum", source, "backup script must not generate tar checksums")
|
|
self.assertNotIn("SHA256SUMS", source, "backup script must not write SHA256SUMS.txt")
|
|
|
|
# ── Rsync options ──────────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_rsync_uses_metadata_preserving_options(self):
|
|
"""Rsync must be called with Linux metadata-preserving options."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("--archive", source)
|
|
self.assertIn("--acls", source)
|
|
self.assertIn("--xattrs", source)
|
|
self.assertIn("--hard-links", source)
|
|
self.assertIn("--numeric-ids", source)
|
|
self.assertIn("--one-file-system", source)
|
|
|
|
def test_backup_script_rsync_no_delete(self):
|
|
"""Rsync must NOT use --delete or --delete-delay.
|
|
Accidental source deletion must not silently wipe the backup copy."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn("--delete", source,
|
|
"rsync must not use --delete; accidental source deletion must not erase backup")
|
|
|
|
def test_backup_script_uses_stable_current_mirror_path(self):
|
|
"""Backup must write to a stable 'current/' path, not timestamped directories.
|
|
Later runs should update the same mirror."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("Sovran_SystemsOS_Backup/current", source,
|
|
"backup must use a stable 'current' mirror path")
|
|
self.assertIn("BACKUP_SUBPATH", source,
|
|
"stable sub-path must be defined in BACKUP_SUBPATH variable")
|
|
# Must not create new timestamped directories per run
|
|
self.assertNotIn(
|
|
"date '+%Y%m%d_%H%M%S'",
|
|
source,
|
|
"backup must not create timestamped per-run directories",
|
|
)
|
|
|
|
def test_backup_script_source_destination_mapping(self):
|
|
"""Each source tree must be synced to a matching destination path."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("/etc/nixos/", source)
|
|
self.assertIn('"$BACKUP_DIR/etc/nixos/"', source)
|
|
self.assertIn("/home/", source)
|
|
self.assertIn('"$BACKUP_DIR/home/"', source)
|
|
self.assertIn("/var/lib/", source)
|
|
self.assertIn('"$BACKUP_DIR/var/lib/"', source)
|
|
|
|
# ── ext4 filesystem validation ─────────────────────────────────────────────
|
|
|
|
def test_backup_script_requires_ext4_filesystem(self):
|
|
"""Backup script must reject non-ext4 filesystems and require ext4."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
'fstype" != "ext4"',
|
|
source,
|
|
"backup script must check for ext4 filesystem",
|
|
)
|
|
self.assertIn("ext4", source, "backup script must reference ext4")
|
|
self.assertNotIn(
|
|
'"exfat"',
|
|
source,
|
|
"backup script must not accept exFAT (old requirement)",
|
|
)
|
|
self.assertNotIn(
|
|
'"fuseblk"',
|
|
source,
|
|
"backup script must not accept fuseblk/NTFS",
|
|
)
|
|
|
|
def test_backup_script_rejects_unsupported_filesystems_in_error_message(self):
|
|
"""The ext4 rejection message must mention the unsupported filesystem types."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
"exFAT, FAT32, and NTFS are not supported",
|
|
source,
|
|
"error message must clearly list unsupported filesystem types",
|
|
)
|
|
|
|
def test_backend_accepts_ext4_rejects_exfat(self):
|
|
"""Backend _is_supported_backup_fstype must accept ext4 and reject exFAT."""
|
|
server_source = SERVER_FILE.read_text()
|
|
# Must accept ext4
|
|
self.assertIn(
|
|
'return fstype == "ext4"',
|
|
server_source,
|
|
"backend must accept only ext4",
|
|
)
|
|
# Must not accept exFAT
|
|
self.assertNotIn(
|
|
'fstype == "exfat"',
|
|
server_source,
|
|
"backend must not accept exFAT",
|
|
)
|
|
self.assertNotIn(
|
|
'fuseblk',
|
|
server_source.split("_is_supported_backup_fstype")[1][:500],
|
|
"backend must not accept fuseblk",
|
|
)
|
|
|
|
def test_frontend_requires_ext4_not_exfat(self):
|
|
"""Frontend UI copy must require ext4 and not mention exFAT as the requirement."""
|
|
support_source = SUPPORT_JS.read_text()
|
|
self.assertIn(
|
|
"ext4",
|
|
support_source,
|
|
"support.js must mention ext4 as the required filesystem",
|
|
)
|
|
# The word exFAT must not appear as a requirement (it may appear in a
|
|
# note about unsupported formats, but the requirement itself must say ext4)
|
|
requirements_section = re.search(
|
|
r"Requirements.*?What gets backed up",
|
|
support_source,
|
|
re.DOTALL,
|
|
)
|
|
if requirements_section:
|
|
req_text = requirements_section.group(0)
|
|
self.assertNotIn(
|
|
"Drive must be formatted as <strong>exFAT</strong>",
|
|
req_text,
|
|
"Requirements section must not say 'formatted as exFAT'",
|
|
)
|
|
self.assertIn(
|
|
"ext4",
|
|
req_text,
|
|
"Requirements section must say ext4",
|
|
)
|
|
|
|
def test_frontend_failure_message_says_ext4(self):
|
|
"""Failure message in renderBackupDone must say ext4, not exFAT."""
|
|
support_source = SUPPORT_JS.read_text()
|
|
self.assertNotIn(
|
|
"formatted as exFAT",
|
|
support_source,
|
|
"failure message must not say 'formatted as exFAT'",
|
|
)
|
|
self.assertIn(
|
|
"formatted as ext4",
|
|
support_source,
|
|
"failure message must say 'formatted as ext4'",
|
|
)
|
|
|
|
# ── Database exclusions ────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_excludes_postgresql_and_mariadb(self):
|
|
"""PostgreSQL and MariaDB raw database directories must be excluded."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("--exclude='postgresql/'", source,
|
|
"rsync must exclude postgresql directory")
|
|
self.assertIn("--exclude='mysql/'", source,
|
|
"rsync must exclude mysql directory")
|
|
self.assertIn("--exclude='mariadb/'", source,
|
|
"rsync must exclude mariadb directory")
|
|
|
|
def test_backup_script_excludes_bitcoin_and_electrs(self):
|
|
"""Bitcoin blockchain and Electrs index data must be excluded."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("--exclude='bitcoind/'", source,
|
|
"rsync must exclude bitcoind directory")
|
|
self.assertIn("--exclude='electrs/'", source,
|
|
"rsync must exclude electrs directory")
|
|
|
|
def test_manifest_states_database_exclusion(self):
|
|
"""The manifest must explicitly state that PostgreSQL and MariaDB are excluded."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
"PostgreSQL and MariaDB/MySQL application databases are NOT included",
|
|
source,
|
|
"manifest must state that databases are not included",
|
|
)
|
|
self.assertIn(
|
|
"Bitcoin blockchain data and Electrs indexes are NOT included",
|
|
source,
|
|
"manifest must state that blockchain data is not included",
|
|
)
|
|
|
|
def test_manifest_has_no_tar_restore_instructions(self):
|
|
"""Manifest must not mention tar extraction, pg_restore, or LND SCB."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn(
|
|
"tar --acls --xattrs",
|
|
source,
|
|
"manifest must not give tar restore instructions",
|
|
)
|
|
self.assertNotIn(
|
|
"pg_restore",
|
|
source,
|
|
"manifest must not reference pg_restore",
|
|
)
|
|
self.assertNotIn(
|
|
"lnd-static-channel-backup.scb",
|
|
source,
|
|
"manifest must not reference LND SCB restore procedures",
|
|
)
|
|
|
|
def test_manifest_has_rsync_restore_guidance(self):
|
|
"""Manifest must provide rsync-based restore guidance."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
"rsync -aAXH --numeric-ids",
|
|
source,
|
|
"manifest must show rsync restore command",
|
|
)
|
|
|
|
# ── sync_tree helper design ────────────────────────────────────────────────
|
|
|
|
def test_backup_script_uses_sync_tree_not_run_rsync(self):
|
|
"""Script must define sync_tree (new helper) and not the old run_rsync."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("sync_tree()", source, "script must define sync_tree helper")
|
|
self.assertNotIn("run_rsync()", source, "old run_rsync function must be removed")
|
|
self.assertNotIn("run_rsync ", source, "old run_rsync call sites must be removed")
|
|
|
|
def test_sync_tree_signature_has_explicit_source_and_destination(self):
|
|
"""sync_tree must have explicit <source> <destination> parameters."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("local source=", source,
|
|
"sync_tree must declare explicit 'source' local variable")
|
|
self.assertIn("local destination=", source,
|
|
"sync_tree must declare explicit 'destination' local variable")
|
|
|
|
def test_sync_tree_creates_destination_with_mkdir_p(self):
|
|
"""sync_tree must run mkdir -p on the destination before rsync."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn('mkdir -p -- "$destination"', source,
|
|
"sync_tree must create destination hierarchy with 'mkdir -p -- \"$destination\"'")
|
|
|
|
def test_sync_tree_mkdir_failure_is_fatal(self):
|
|
"""sync_tree must fail with a logged message if mkdir -p fails."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("failed to create destination directory", source,
|
|
"sync_tree must log a descriptive failure message when mkdir fails")
|
|
|
|
def test_sync_tree_re_verifies_mountpoint_before_rsync(self):
|
|
"""sync_tree must re-verify $TARGET is a mount point before each rsync stage."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn('mountpoint -q "$TARGET"', source,
|
|
"sync_tree must call mountpoint -q \"$TARGET\" to re-verify mount before each stage")
|
|
self.assertIn("no longer mounted", source,
|
|
"sync_tree must emit a clear message when the drive is no longer mounted")
|
|
|
|
def test_sync_tree_checks_destination_within_backup_dir(self):
|
|
"""sync_tree must verify destination is beneath BACKUP_DIR."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("outside BACKUP_DIR", source,
|
|
"sync_tree must reject destinations outside BACKUP_DIR")
|
|
|
|
def test_new_run_removes_stale_backup_complete(self):
|
|
"""A new backup run must remove a stale BACKUP_COMPLETE before starting."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
# rm -f BACKUP_COMPLETE must appear BEFORE the touch INCOMPLETE line
|
|
complete_pos = source.find('rm -f "$BACKUP_DIR/BACKUP_COMPLETE"')
|
|
incomplete_pos = source.find('touch "$BACKUP_DIR/INCOMPLETE"')
|
|
self.assertGreater(complete_pos, 0,
|
|
"script must remove stale BACKUP_COMPLETE at the start of each run")
|
|
self.assertGreater(incomplete_pos, 0,
|
|
"script must touch INCOMPLETE after removing stale BACKUP_COMPLETE")
|
|
self.assertLess(complete_pos, incomplete_pos,
|
|
"BACKUP_COMPLETE removal must come before INCOMPLETE marker is written")
|
|
|
|
def test_backup_script_exit_code_24_nonfatal_for_home(self):
|
|
"""Rsync exit code 24 (vanished files) must be nonfatal for /home only."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
'"$rc" -eq 24',
|
|
source,
|
|
"backup script must handle rsync exit code 24",
|
|
)
|
|
self.assertIn(
|
|
'allow_vanished" == "yes"',
|
|
source,
|
|
"exit 24 acceptance must be gated on allow_vanished flag",
|
|
)
|
|
|
|
def test_backup_script_home_stage_allows_vanished(self):
|
|
"""Stage 3 (/home) must call sync_tree with allow_vanished=yes."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
# The /home call must pass "yes" as the allow_vanished argument
|
|
self.assertIn(
|
|
'sync_tree "/home" yes /home/',
|
|
source,
|
|
"/home stage must pass allow_vanished=yes to sync_tree",
|
|
)
|
|
|
|
def test_backup_script_other_stages_disallow_vanished(self):
|
|
"""Stages other than /home must call sync_tree with allow_vanished=no."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
'sync_tree "/etc/nixos" no /etc/nixos/',
|
|
source,
|
|
"/etc/nixos stage must use allow_vanished=no",
|
|
)
|
|
self.assertIn(
|
|
'sync_tree "/var/lib" no /var/lib/',
|
|
source,
|
|
"/var/lib stage must use allow_vanished=no",
|
|
)
|
|
|
|
# ── INCOMPLETE / BACKUP_COMPLETE markers ──────────────────────────────────
|
|
|
|
def test_backup_script_writes_incomplete_marker(self):
|
|
"""A backup directory must be marked INCOMPLETE immediately after
|
|
creation, so interrupted or failed runs are identifiable."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
'touch "$BACKUP_DIR/INCOMPLETE"',
|
|
source,
|
|
"backup script must create INCOMPLETE marker after mkdir",
|
|
)
|
|
|
|
def test_backup_script_writes_backup_complete_marker(self):
|
|
"""A BACKUP_COMPLETE file must be written only after all work succeeds."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn(
|
|
'BACKUP_COMPLETE"',
|
|
source,
|
|
"backup script must write BACKUP_COMPLETE file on success",
|
|
)
|
|
self.assertIn(
|
|
'rm -f "$BACKUP_DIR/INCOMPLETE"',
|
|
source,
|
|
"backup script must remove INCOMPLETE marker on success",
|
|
)
|
|
|
|
# ── Concurrency lock ──────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_uses_flock_concurrency_lock(self):
|
|
"""The script must acquire an exclusive flock before starting work."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("flock", source, "backup script must use flock")
|
|
self.assertIn("LOCK_FILE", source, "backup script must define LOCK_FILE")
|
|
|
|
# ── Status states ─────────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_uses_running_success_failed_states(self):
|
|
"""Status values must be RUNNING, SUCCESS, and FAILED."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn('set_status "RUNNING"', source)
|
|
self.assertIn('set_status "SUCCESS"', source)
|
|
self.assertIn('set_status "FAILED"', source)
|
|
|
|
def test_backend_and_frontend_use_explicit_backup_terminal_states(self):
|
|
"""Backend and frontend must use consistent terminal state handling."""
|
|
server_source = SERVER_FILE.read_text()
|
|
support_source = SUPPORT_JS.read_text()
|
|
|
|
self.assertIn("_write_backup_status, \"RUNNING\"", server_source)
|
|
self.assertIn("_monitor_backup_subprocess", server_source)
|
|
self.assertIn("asyncio.create_task(_monitor_backup_subprocess(proc))", server_source)
|
|
self.assertIn("result === \"success\" || result === \"failed\"", support_source)
|
|
|
|
# ── Nix service PATH ──────────────────────────────────────────────────────
|
|
|
|
def test_nix_service_path_includes_rsync_and_acl(self):
|
|
"""pkgs.rsync and pkgs.acl must appear in the sovran-hub-web service path
|
|
so that rsync with ACL/xattr support is available at runtime."""
|
|
nix_source = NIX_HUB_FILE.read_text()
|
|
self.assertIn(
|
|
"pkgs.rsync",
|
|
nix_source,
|
|
"pkgs.rsync must be declared in the sovran-hub-web service path",
|
|
)
|
|
self.assertIn(
|
|
"pkgs.acl",
|
|
nix_source,
|
|
"pkgs.acl must be declared in the sovran-hub-web service path",
|
|
)
|
|
|
|
def test_nix_service_path_includes_bash_and_gawk(self):
|
|
"""Regression: pkgs.bash and pkgs.gawk must appear in the service path."""
|
|
nix_source = NIX_HUB_FILE.read_text()
|
|
self.assertIn("pkgs.bash", nix_source,
|
|
"pkgs.bash must be declared in the sovran-hub-web service path")
|
|
self.assertIn("pkgs.gawk", nix_source,
|
|
"pkgs.gawk must be declared in the sovran-hub-web service path")
|
|
|
|
def test_nix_service_path_has_no_gnutar(self):
|
|
"""pkgs.gnutar must be removed from the service path (no longer needed)."""
|
|
nix_source = NIX_HUB_FILE.read_text()
|
|
self.assertNotIn(
|
|
"pkgs.gnutar",
|
|
nix_source,
|
|
"pkgs.gnutar must be removed from the service path (tar is no longer used)",
|
|
)
|
|
|
|
# ── Regression tests for exit-code-127 / missing-interpreter bug ──────────
|
|
|
|
def test_server_resolves_bash_via_shutil_which(self):
|
|
"""Regression: server must locate bash with shutil.which()."""
|
|
source = SERVER_FILE.read_text()
|
|
self.assertIn('shutil.which("bash")', source)
|
|
self.assertNotIn('"/usr/bin/env", "bash"', source)
|
|
|
|
def test_server_logs_actionable_message_when_bash_missing(self):
|
|
"""Regression: when bash is absent the server must write an actionable log."""
|
|
source = SERVER_FILE.read_text()
|
|
self.assertIn("bash_path is None", source)
|
|
self.assertIn("interpreter", source)
|
|
|
|
def test_server_captures_stderr_from_backup_subprocess(self):
|
|
"""Regression: backup subprocess must use stderr=PIPE."""
|
|
source = SERVER_FILE.read_text()
|
|
self.assertIn("stderr=asyncio.subprocess.PIPE", source)
|
|
self.assertIn("stderr_text", source)
|
|
|
|
def test_monitor_includes_stderr_detail_in_failed_message(self):
|
|
"""Regression: _monitor_backup_subprocess must append captured stderr."""
|
|
source = SERVER_FILE.read_text()
|
|
self.assertIn("stderr_chunks", source)
|
|
self.assertIn("stderr_text", source)
|
|
self.assertIn('detail = f" — stderr: {stderr_text}"', source)
|
|
|
|
def test_exit_code_127_subprocess_stderr_drain(self):
|
|
"""Behavioral regression: a subprocess that exits 127 drains stderr without deadlock."""
|
|
async def _run():
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"bash", "-c", "echo 'bash: command not found' >&2; exit 127",
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
chunks: list[bytes] = []
|
|
|
|
async def _drain():
|
|
async for line in proc.stderr:
|
|
chunks.append(line)
|
|
|
|
drain_task = asyncio.create_task(_drain())
|
|
rc = await proc.wait()
|
|
await drain_task
|
|
return rc, b"".join(chunks).decode()
|
|
|
|
rc, stderr_out = asyncio.run(_run())
|
|
self.assertEqual(rc, 127)
|
|
self.assertIn("bash: command not found", stderr_out)
|
|
|
|
# ── Rsync exit code 24 behavioral tests ──────────────────────────────────
|
|
|
|
def test_run_rsync_exit_24_nonfatal_for_home(self):
|
|
"""Behavioral: run_rsync with allow_vanished=yes treats exit 24 as a
|
|
nonfatal warning (recorded in RSYNC_WARNINGS) and does not fail."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
dest = os.path.join(tmpdir, "backup")
|
|
os.makedirs(dest, exist_ok=True)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "FAILED: $*" >&2; exit 1; }}
|
|
|
|
run_rsync() {{
|
|
local label="$1"
|
|
local allow_vanished="$2"
|
|
shift 2
|
|
|
|
local rc=24 # simulate rsync exit 24
|
|
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
return 0
|
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
|
log "NOTE: $label — some files vanished during sync (normal on an active desktop)."
|
|
RSYNC_WARNINGS+=("$label: some files vanished during sync")
|
|
return 0
|
|
else
|
|
fail "rsync failed for $label (exit code $rc)"
|
|
fi
|
|
}}
|
|
|
|
run_rsync "/home" yes /home/ "{dest}/home/"
|
|
echo "WARNINGS: ${{#RSYNC_WARNINGS[@]}}"
|
|
echo "SUCCESS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
|
self.assertIn("SUCCESS", result.stdout)
|
|
self.assertIn("WARNINGS: 1", result.stdout)
|
|
|
|
def test_run_rsync_exit_24_fatal_for_non_home(self):
|
|
"""Behavioral: run_rsync with allow_vanished=no treats exit 24 as fatal."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
dest = os.path.join(tmpdir, "backup")
|
|
os.makedirs(dest, exist_ok=True)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }}
|
|
|
|
run_rsync() {{
|
|
local label="$1"
|
|
local allow_vanished="$2"
|
|
shift 2
|
|
|
|
local rc=24 # simulate rsync exit 24
|
|
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
return 0
|
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
|
RSYNC_WARNINGS+=("$label: vanished")
|
|
return 0
|
|
else
|
|
fail "rsync failed for $label (exit code $rc)"
|
|
fi
|
|
}}
|
|
|
|
run_rsync "/etc/nixos" no /etc/nixos/ "{dest}/etc/nixos/"
|
|
echo "SHOULD_NOT_REACH_HERE"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("CORRECTLY_FAILED", result.stdout)
|
|
self.assertNotIn("SHOULD_NOT_REACH_HERE", result.stdout)
|
|
|
|
def test_run_rsync_other_nonzero_codes_always_fatal(self):
|
|
"""Behavioral: rsync exit codes other than 0 and 24 (for home) are fatal."""
|
|
for rc in [1, 10, 11, 12, 23, 25]:
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
dest = os.path.join(tmpdir, "backup")
|
|
os.makedirs(dest, exist_ok=True)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
RSYNC_WARNINGS=()
|
|
|
|
fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }}
|
|
log() {{ echo "$*"; }}
|
|
|
|
run_rsync() {{
|
|
local label="$1"
|
|
local allow_vanished="$2"
|
|
shift 2
|
|
|
|
local rc={rc} # simulated exit code
|
|
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
return 0
|
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
|
RSYNC_WARNINGS+=("$label: vanished")
|
|
return 0
|
|
else
|
|
fail "rsync failed for $label (exit code $rc)"
|
|
fi
|
|
}}
|
|
|
|
run_rsync "/home" yes /home/ "{dest}/home/"
|
|
echo "SHOULD_NOT_REACH_HERE"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 1,
|
|
f"Exit code {rc} should fail: {result.stdout}")
|
|
self.assertIn("CORRECTLY_FAILED", result.stdout)
|
|
|
|
# ── Behavioral: repeated runs target same 'current' mirror ──────────────
|
|
|
|
def test_repeated_runs_use_same_current_directory(self):
|
|
"""Behavioral: a second call to the backup logic must sync to the same
|
|
BACKUP_DIR (not create a new timestamped directory) so only changed
|
|
files are transferred."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
target = os.path.join(tmpdir, "drive")
|
|
os.makedirs(target, exist_ok=True)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
TARGET="{target}"
|
|
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
|
|
BACKUP_DIR="${{TARGET}}/${{BACKUP_SUBPATH}}"
|
|
mkdir -p "$BACKUP_DIR"
|
|
echo "$BACKUP_DIR"
|
|
"""
|
|
for _ in range(2):
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0)
|
|
backup_dir = result.stdout.strip()
|
|
self.assertEqual(
|
|
backup_dir,
|
|
os.path.join(target, "Sovran_SystemsOS_Backup", "current"),
|
|
)
|
|
|
|
# ── Behavioral: INCOMPLETE/BACKUP_COMPLETE marker lifecycle ─────────────
|
|
|
|
def test_incomplete_marker_written_before_work_starts(self):
|
|
"""Behavioral: INCOMPLETE marker must exist during backup and be removed on success."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
backup_dir = os.path.join(tmpdir, "current")
|
|
os.makedirs(backup_dir, exist_ok=True)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
BACKUP_DIR="{backup_dir}"
|
|
BACKUP_COMPLETE=0
|
|
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
[[ -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_EXISTS"
|
|
|
|
# Simulate successful completion
|
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
|
echo "done" > "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
BACKUP_COMPLETE=1
|
|
|
|
[[ ! -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_REMOVED"
|
|
[[ -f "$BACKUP_DIR/BACKUP_COMPLETE" ]] && echo "COMPLETE_EXISTS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
|
self.assertIn("INCOMPLETE_EXISTS", result.stdout)
|
|
self.assertIn("INCOMPLETE_REMOVED", result.stdout)
|
|
self.assertIn("COMPLETE_EXISTS", result.stdout)
|
|
|
|
# ── Behavioral: actual rsync with real source/dest trees ─────────────────
|
|
|
|
def test_rsync_mirrors_source_to_dest(self):
|
|
"""Behavioral: rsync correctly mirrors a source tree to a destination."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "etc", "nixos")
|
|
dest = os.path.join(tmpdir, "backup", "etc", "nixos")
|
|
os.makedirs(src, exist_ok=True)
|
|
os.makedirs(os.path.join(tmpdir, "backup", "etc"), exist_ok=True)
|
|
|
|
# Write test files
|
|
with open(os.path.join(src, "configuration.nix"), "w") as f:
|
|
f.write("{ ... }: {}\n")
|
|
with open(os.path.join(src, "custom.nix"), "w") as f:
|
|
f.write("{ ... }: {}\n")
|
|
|
|
result = subprocess.run(
|
|
["rsync", "--archive", "--one-file-system",
|
|
src + "/", dest + "/"],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, f"rsync failed: {result.stderr}")
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix")))
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "custom.nix")))
|
|
|
|
def test_rsync_second_run_only_transfers_changes(self):
|
|
"""Behavioral: a second rsync run to the same dest only copies changed files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "source")
|
|
dest = os.path.join(tmpdir, "backup")
|
|
os.makedirs(src)
|
|
os.makedirs(dest)
|
|
|
|
with open(os.path.join(src, "file.txt"), "w") as f:
|
|
f.write("initial content\n")
|
|
|
|
# First run
|
|
r1 = subprocess.run(
|
|
["rsync", "--archive", "--one-file-system", src + "/", dest + "/"],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(r1.returncode, 0)
|
|
|
|
# Modify one file and add another; advance mtime so rsync detects the change
|
|
with open(os.path.join(src, "file.txt"), "w") as f:
|
|
f.write("updated content\n")
|
|
import time as _time
|
|
future_mtime = _time.time() + 2
|
|
os.utime(os.path.join(src, "file.txt"), (future_mtime, future_mtime))
|
|
|
|
with open(os.path.join(src, "new.txt"), "w") as f:
|
|
f.write("new file\n")
|
|
|
|
# Second run
|
|
r2 = subprocess.run(
|
|
["rsync", "--archive", "--one-file-system", src + "/", dest + "/"],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(r2.returncode, 0)
|
|
|
|
# Both files exist in dest
|
|
with open(os.path.join(dest, "file.txt")) as fh:
|
|
self.assertEqual(fh.read(), "updated content\n")
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "new.txt")))
|
|
|
|
# ── Script syntax check ──────────────────────────────────────────────────
|
|
|
|
def test_backup_script_passes_bash_syntax_check(self):
|
|
"""bash -n must report no syntax errors in the backup script."""
|
|
result = subprocess.run(
|
|
["bash", "-n", str(BACKUP_SCRIPT)],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(
|
|
result.returncode, 0,
|
|
f"bash -n failed:\n{result.stderr}",
|
|
)
|
|
|
|
# ── Home exclusions ──────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_home_excludes_browser_caches(self):
|
|
"""Home sync must exclude browser disk caches."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("--exclude='.mozilla/firefox/*/cache2/'", source)
|
|
self.assertIn("--exclude='.config/google-chrome/*/Cache/'", source)
|
|
self.assertIn("--exclude='.config/chromium/*/Cache/'", source)
|
|
self.assertIn("--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/'", source)
|
|
|
|
def test_backup_script_home_excludes_other_volatile_caches(self):
|
|
"""Home sync must exclude baloo, thumbnails, and X session error logs."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("--exclude='.local/share/baloo/'", source)
|
|
self.assertIn("--exclude='.thumbnails/'", source)
|
|
self.assertIn("--exclude='.xsession-errors'", source)
|
|
|
|
# ── require_cmd ──────────────────────────────────────────────────────────
|
|
|
|
def test_backup_script_requires_rsync_and_flock(self):
|
|
"""Script must require rsync and flock via require_cmd."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertIn("require_cmd rsync", source)
|
|
self.assertIn("require_cmd flock", source)
|
|
|
|
def test_backup_script_does_not_require_tar_or_sha256sum(self):
|
|
"""Script must not require tar or sha256sum (no longer used)."""
|
|
source = BACKUP_SCRIPT.read_text()
|
|
self.assertNotIn("require_cmd tar", source)
|
|
self.assertNotIn("require_cmd sha256sum", source)
|
|
|
|
# ── Frontend database limitation notice ──────────────────────────────────
|
|
|
|
def test_frontend_mentions_database_limitation(self):
|
|
"""Frontend must explain that PostgreSQL/MariaDB databases are excluded."""
|
|
support_source = SUPPORT_JS.read_text()
|
|
self.assertIn(
|
|
"PostgreSQL",
|
|
support_source,
|
|
"UI must mention PostgreSQL exclusion",
|
|
)
|
|
self.assertIn(
|
|
"not included",
|
|
support_source,
|
|
"UI must explain that databases are not included",
|
|
)
|
|
|
|
# ── Behavioral: sync_tree creates nested destination dirs (regression) ─────
|
|
|
|
def test_sync_tree_creates_nested_dest_dirs_regression(self):
|
|
"""Regression: sync_tree must create the full destination hierarchy before
|
|
calling rsync so that rsync never fails with
|
|
'mkdir current/etc/nixos failed: No such file or directory'.
|
|
|
|
Initial state: only Sovran_SystemsOS_Backup/current/ exists.
|
|
Expected: current/etc/nixos/ is created and populated by sync_tree."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Source tree
|
|
src = os.path.join(tmpdir, "src", "etc", "nixos")
|
|
os.makedirs(src)
|
|
with open(os.path.join(src, "configuration.nix"), "w") as f:
|
|
f.write("{ }: {}\n")
|
|
|
|
# Backup target: only current/ exists, NOT current/etc/
|
|
target = os.path.join(tmpdir, "target")
|
|
backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current")
|
|
os.makedirs(backup_dir)
|
|
dest = os.path.join(backup_dir, "etc", "nixos")
|
|
|
|
self.assertFalse(os.path.exists(dest),
|
|
"destination must not exist before sync_tree runs")
|
|
self.assertFalse(os.path.exists(os.path.join(backup_dir, "etc")),
|
|
"intermediate parent current/etc/ must not exist before sync_tree runs")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
TARGET="{target}"
|
|
BACKUP_DIR="{backup_dir}"
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "FAIL: $*" >&2; exit 1; }}
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
|
|
# Path-safety check
|
|
case "$destination" in
|
|
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
|
*) fail "Stage $label: destination outside BACKUP_DIR"; ;;
|
|
esac
|
|
|
|
# THE FIX: create full hierarchy before rsync
|
|
mkdir -p -- "$destination" || fail "Stage $label: mkdir failed for '$destination'"
|
|
|
|
local rc=0
|
|
rsync --archive --one-file-system "$@" "$source" "$destination" || rc=$?
|
|
|
|
if [[ "$rc" -eq 0 ]]; then return 0
|
|
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
|
RSYNC_WARNINGS+=("$label: vanished"); return 0
|
|
else
|
|
fail "rsync failed for $label (exit code $rc)"
|
|
fi
|
|
}}
|
|
|
|
sync_tree "/etc/nixos" no "{src}/" "{dest}/"
|
|
echo "SUCCESS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0,
|
|
f"sync_tree failed:\nstdout: {result.stdout}\nstderr: {result.stderr}")
|
|
self.assertIn("SUCCESS", result.stdout)
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix")),
|
|
"configuration.nix must be present after sync_tree")
|
|
|
|
def test_sync_tree_all_four_stages_create_nested_dirs(self):
|
|
"""End-to-end: all four production stage mappings create and populate
|
|
their nested destination directories starting from an empty current/."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Source trees
|
|
src_etc_nixos = os.path.join(tmpdir, "src", "etc", "nixos")
|
|
src_secrets = os.path.join(tmpdir, "src", "etc", "nix-bitcoin-secrets")
|
|
src_home = os.path.join(tmpdir, "src", "home")
|
|
src_varlib = os.path.join(tmpdir, "src", "var", "lib")
|
|
for d in (src_etc_nixos, src_secrets, src_home, src_varlib):
|
|
os.makedirs(d)
|
|
|
|
with open(os.path.join(src_etc_nixos, "configuration.nix"), "w") as f:
|
|
f.write("{ }: {}\n")
|
|
with open(os.path.join(src_secrets, "bitcoin-key.txt"), "w") as f:
|
|
f.write("test-key-data\n")
|
|
with open(os.path.join(src_home, "user.txt"), "w") as f:
|
|
f.write("home\n")
|
|
with open(os.path.join(src_varlib, "app.db"), "w") as f:
|
|
f.write("db\n")
|
|
|
|
# Backup target: only current/ exists
|
|
target = os.path.join(tmpdir, "target")
|
|
backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
dest_nixos = os.path.join(backup_dir, "etc", "nixos")
|
|
dest_secrets = os.path.join(backup_dir, "etc", "nix-bitcoin-secrets")
|
|
dest_home = os.path.join(backup_dir, "home")
|
|
dest_varlib = os.path.join(backup_dir, "var", "lib")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
TARGET="{target}"
|
|
BACKUP_DIR="{backup_dir}"
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "FAIL: $*" >&2; exit 1; }}
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
case "$destination" in
|
|
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
|
*) fail "unsafe destination"; ;;
|
|
esac
|
|
mkdir -p -- "$destination" || fail "mkdir failed for $destination"
|
|
local rc=0
|
|
rsync --archive --one-file-system "$@" "$source" "$destination" || rc=$?
|
|
[[ "$rc" -eq 0 ]] || ([[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]] && return 0) || \
|
|
fail "rsync failed (exit $rc)"
|
|
}}
|
|
|
|
sync_tree "/etc/nixos" no "{src_etc_nixos}/" "{dest_nixos}/"
|
|
sync_tree "/etc/nix-bitcoin-secrets" no "{src_secrets}/" "{dest_secrets}/"
|
|
sync_tree "/home" yes "{src_home}/" "{dest_home}/"
|
|
sync_tree "/var/lib" no "{src_varlib}/" "{dest_varlib}/"
|
|
echo "ALL_STAGES_OK"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0,
|
|
f"stdout: {result.stdout}\nstderr: {result.stderr}")
|
|
self.assertIn("ALL_STAGES_OK", result.stdout)
|
|
self.assertTrue(os.path.exists(os.path.join(dest_nixos, "configuration.nix")))
|
|
self.assertTrue(os.path.exists(os.path.join(dest_secrets, "bitcoin-key.txt")))
|
|
self.assertTrue(os.path.exists(os.path.join(dest_home, "user.txt")))
|
|
self.assertTrue(os.path.exists(os.path.join(dest_varlib, "app.db")))
|
|
|
|
def test_sync_tree_spaces_in_paths_work(self):
|
|
"""sync_tree must handle spaces in target and backup-dir paths correctly."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "src with spaces", "etc", "nixos")
|
|
os.makedirs(src)
|
|
with open(os.path.join(src, "configuration.nix"), "w") as f:
|
|
f.write("{ }: {}\n")
|
|
|
|
target = os.path.join(tmpdir, "S_S Backup")
|
|
backup_dir = os.path.join(target, "Sovran_SystemsOS_Backup", "current")
|
|
os.makedirs(backup_dir)
|
|
dest = os.path.join(backup_dir, "etc", "nixos")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
TARGET="{target}"
|
|
BACKUP_DIR="{backup_dir}"
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "FAIL: $*" >&2; exit 1; }}
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
mkdir -p -- "$destination" || fail "mkdir failed"
|
|
rsync --archive --one-file-system "$@" "$source" "$destination" || fail "rsync failed"
|
|
}}
|
|
|
|
sync_tree "/etc/nixos" no "{src}/" "{dest}/"
|
|
echo "SUCCESS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0,
|
|
f"stdout: {result.stdout}\nstderr: {result.stderr}")
|
|
self.assertIn("SUCCESS", result.stdout)
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "configuration.nix")))
|
|
|
|
def test_no_delete_dest_only_file_survives_second_run(self):
|
|
"""Behavioral: without --delete, a file present only in the destination
|
|
must survive a second sync_tree run. Accidental source-side deletion
|
|
must NOT silently wipe the backup copy."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "src")
|
|
dest = os.path.join(tmpdir, "dest")
|
|
os.makedirs(src)
|
|
os.makedirs(dest)
|
|
|
|
with open(os.path.join(src, "keep.txt"), "w") as f:
|
|
f.write("keep\n")
|
|
with open(os.path.join(dest, "dest_only.txt"), "w") as f:
|
|
f.write("destination-only file\n")
|
|
|
|
# First run
|
|
r1 = subprocess.run(
|
|
["rsync", "--archive", "--one-file-system", src + "/", dest + "/"],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(r1.returncode, 0, r1.stderr)
|
|
|
|
# Second run (no new source files added)
|
|
r2 = subprocess.run(
|
|
["rsync", "--archive", "--one-file-system", src + "/", dest + "/"],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(r2.returncode, 0, r2.stderr)
|
|
|
|
# dest_only.txt must still be present — no --delete was used
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "dest_only.txt")),
|
|
"destination-only file must survive because --delete is not used")
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "keep.txt")))
|
|
|
|
def test_sync_tree_mkdir_failure_is_fatal_with_context(self):
|
|
"""Behavioral: when mkdir -p fails, sync_tree must exit non-zero with a
|
|
message that includes the stage label, source, and destination."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
target = os.path.join(tmpdir, "target")
|
|
backup_dir = os.path.join(target, "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
# Make the destination parent read-only so mkdir -p fails
|
|
os.chmod(backup_dir, 0o555)
|
|
|
|
dest = os.path.join(backup_dir, "nested", "dir")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
TARGET="{target}"
|
|
BACKUP_DIR="{backup_dir}"
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "MKDIR_FAIL_MSG: $*" >&2; exit 1; }}
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
mkdir -p -- "$destination" 2>/dev/null || \
|
|
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
|
|
echo "SHOULD_NOT_REACH"
|
|
}}
|
|
|
|
sync_tree "test-stage" no /some/source "{dest}"
|
|
"""
|
|
try:
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertNotEqual(result.returncode, 0,
|
|
"sync_tree must fail when mkdir -p fails")
|
|
self.assertIn("MKDIR_FAIL_MSG", result.stderr,
|
|
"failure message must be present in stderr")
|
|
self.assertIn("test-stage", result.stderr,
|
|
"failure message must include the stage label")
|
|
self.assertNotIn("SHOULD_NOT_REACH", result.stdout,
|
|
"rsync must not run after mkdir failure")
|
|
finally:
|
|
os.chmod(backup_dir, 0o755)
|
|
|
|
def test_sync_tree_mountpoint_check_prevents_write_on_disconnect(self):
|
|
"""Behavioral: sync_tree must abort with a clear message if $TARGET is
|
|
no longer a mount point (simulating drive disconnection)."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "src")
|
|
target = os.path.join(tmpdir, "target")
|
|
backup_dir = os.path.join(target, "current")
|
|
dest = os.path.join(backup_dir, "etc", "nixos")
|
|
os.makedirs(src)
|
|
os.makedirs(backup_dir)
|
|
with open(os.path.join(src, "file.nix"), "w") as f:
|
|
f.write("{ }: {}\n")
|
|
|
|
# Inject a fake 'mountpoint' that always returns 1 (drive gone)
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
TARGET="{target}"
|
|
BACKUP_DIR="{backup_dir}"
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "MOUNT_FAIL: $*" >&2; exit 1; }}
|
|
|
|
# Fake mountpoint: always returns failure (simulates drive disconnection)
|
|
mountpoint() {{ return 1; }}
|
|
export -f mountpoint
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
mountpoint -q "$TARGET" 2>/dev/null || \
|
|
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
|
|
mkdir -p -- "$destination" || fail "mkdir failed"
|
|
rsync --archive "$@" "$source" "$destination" || fail "rsync failed"
|
|
echo "RSYNC_RAN"
|
|
}}
|
|
|
|
sync_tree "/etc/nixos" no "{src}/" "{dest}/"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertNotEqual(result.returncode, 0,
|
|
"sync_tree must fail when mount check fails")
|
|
self.assertIn("MOUNT_FAIL", result.stderr,
|
|
"failure message must be in stderr")
|
|
self.assertIn("no longer mounted", result.stderr,
|
|
"failure message must say 'no longer mounted'")
|
|
self.assertNotIn("RSYNC_RAN", result.stdout,
|
|
"rsync must not run after mount check fails")
|
|
|
|
def test_failed_run_leaves_incomplete_not_backup_complete(self):
|
|
"""Behavioral: a failed run must leave INCOMPLETE and must NOT create
|
|
BACKUP_COMPLETE."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
backup_dir = os.path.join(tmpdir, "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
BACKUP_DIR="{backup_dir}"
|
|
BACKUP_COMPLETE=0
|
|
FAILED_ALREADY=0
|
|
|
|
log() {{ echo "$*"; }}
|
|
set_status() {{ echo "STATUS: $1"; }}
|
|
|
|
fail() {{
|
|
FAILED_ALREADY=1
|
|
log "ERROR: $*"
|
|
set_status "FAILED"
|
|
exit 1
|
|
}}
|
|
|
|
cleanup() {{
|
|
local rc=$?
|
|
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then return; fi
|
|
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
|
log "ERROR: unexpected exit $rc"
|
|
set_status "FAILED"
|
|
fi
|
|
if [[ -n "${{BACKUP_DIR:-}}" && -d "${{BACKUP_DIR:-}}" && ! -f "${{BACKUP_DIR:-}}/BACKUP_COMPLETE" ]]; then
|
|
touch "${{BACKUP_DIR}}/INCOMPLETE" 2>/dev/null || true
|
|
fi
|
|
}}
|
|
trap cleanup EXIT
|
|
|
|
# Remove stale BACKUP_COMPLETE from prior run
|
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
|
|
# Simulate a stage failure
|
|
fail "stage failed"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertTrue(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")),
|
|
"INCOMPLETE marker must exist after a failed run")
|
|
self.assertFalse(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")),
|
|
"BACKUP_COMPLETE must NOT be created after a failed run")
|
|
|
|
def test_successful_run_removes_incomplete_writes_backup_complete(self):
|
|
"""Behavioral: a successful run must remove INCOMPLETE and write BACKUP_COMPLETE."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
backup_dir = os.path.join(tmpdir, "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
BACKUP_DIR="{backup_dir}"
|
|
BACKUP_COMPLETE=0
|
|
|
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
|
|
# Simulate all work succeeding
|
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
|
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
BACKUP_COMPLETE=1
|
|
echo "SUCCESS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0,
|
|
f"stderr: {result.stderr}")
|
|
self.assertFalse(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")),
|
|
"INCOMPLETE must be removed after success")
|
|
self.assertTrue(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")),
|
|
"BACKUP_COMPLETE must be written after success")
|
|
|
|
def test_stale_backup_complete_removed_before_new_run(self):
|
|
"""Behavioral: a stale BACKUP_COMPLETE from a prior successful run must
|
|
be removed at the start of the new run so the run is unambiguously marked
|
|
as INCOMPLETE while in progress."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
backup_dir = os.path.join(tmpdir, "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
# Simulate state after a prior successful run
|
|
with open(os.path.join(backup_dir, "BACKUP_COMPLETE"), "w") as f:
|
|
f.write("2026-07-01T00:00:00Z\n")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
BACKUP_DIR="{backup_dir}"
|
|
BACKUP_COMPLETE=0
|
|
|
|
# New run starts: remove stale BACKUP_COMPLETE, write INCOMPLETE
|
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
|
|
[[ ! -f "$BACKUP_DIR/BACKUP_COMPLETE" ]] && echo "STALE_COMPLETE_REMOVED"
|
|
[[ -f "$BACKUP_DIR/INCOMPLETE" ]] && echo "INCOMPLETE_PRESENT"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0)
|
|
self.assertIn("STALE_COMPLETE_REMOVED", result.stdout,
|
|
"stale BACKUP_COMPLETE must be removed at run start")
|
|
self.assertIn("INCOMPLETE_PRESENT", result.stdout,
|
|
"INCOMPLETE must be present during the run")
|
|
|
|
def test_rerun_after_failed_run_succeeds_without_manual_cleanup(self):
|
|
"""Behavioral: a directory left with only INCOMPLETE (prior failed run)
|
|
can be rerun successfully without any manual cleanup."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src = os.path.join(tmpdir, "src")
|
|
os.makedirs(src)
|
|
with open(os.path.join(src, "file.txt"), "w") as f:
|
|
f.write("data\n")
|
|
|
|
backup_dir = os.path.join(tmpdir, "backup", "current")
|
|
os.makedirs(backup_dir)
|
|
|
|
# Simulate state after a prior failed run: only INCOMPLETE exists
|
|
with open(os.path.join(backup_dir, "INCOMPLETE"), "w") as f:
|
|
f.write("")
|
|
|
|
dest = os.path.join(backup_dir, "data")
|
|
|
|
script = f"""#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
BACKUP_DIR="{backup_dir}"
|
|
BACKUP_COMPLETE=0
|
|
RSYNC_WARNINGS=()
|
|
|
|
log() {{ echo "$*"; }}
|
|
fail() {{ echo "FAIL: $*" >&2; exit 1; }}
|
|
|
|
sync_tree() {{
|
|
local label="$1" allow_vanished="$2" source="$3" destination="$4"
|
|
shift 4
|
|
mkdir -p -- "$destination" || fail "mkdir failed"
|
|
rsync --archive --one-file-system "$@" "$source" "$destination" || fail "rsync failed"
|
|
}}
|
|
|
|
# New run: remove stale BACKUP_COMPLETE (none here), write INCOMPLETE
|
|
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
touch "$BACKUP_DIR/INCOMPLETE"
|
|
|
|
# Run stage
|
|
sync_tree "data" no "{src}/" "{dest}/"
|
|
|
|
# Mark success
|
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
|
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
|
BACKUP_COMPLETE=1
|
|
echo "RERUN_SUCCESS"
|
|
"""
|
|
script_file = os.path.join(tmpdir, "test.sh")
|
|
with open(script_file, "w") as sf:
|
|
sf.write(script)
|
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0,
|
|
f"stdout: {result.stdout}\nstderr: {result.stderr}")
|
|
self.assertIn("RERUN_SUCCESS", result.stdout)
|
|
self.assertFalse(os.path.exists(os.path.join(backup_dir, "INCOMPLETE")),
|
|
"INCOMPLETE must be removed after successful rerun")
|
|
self.assertTrue(os.path.exists(os.path.join(backup_dir, "BACKUP_COMPLETE")),
|
|
"BACKUP_COMPLETE must exist after successful rerun")
|
|
self.assertTrue(os.path.exists(os.path.join(dest, "file.txt")),
|
|
"backed-up file must exist after successful rerun")
|
|
|
|
def test_behavioral_database_exclusions(self):
|
|
"""Behavioral: postgresql, mysql, mariadb, bitcoind, electrs directories
|
|
must be excluded from the var/lib rsync stage."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
src_varlib = os.path.join(tmpdir, "var", "lib")
|
|
for d in ("postgresql", "mysql", "mariadb", "bitcoind", "electrs", "myapp"):
|
|
os.makedirs(os.path.join(src_varlib, d))
|
|
with open(os.path.join(src_varlib, d, "data.bin"), "w") as f:
|
|
f.write("data\n")
|
|
|
|
dest_varlib = os.path.join(tmpdir, "backup", "var", "lib")
|
|
os.makedirs(dest_varlib)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"rsync", "--archive", "--one-file-system",
|
|
"--exclude=postgresql/",
|
|
"--exclude=mysql/",
|
|
"--exclude=mariadb/",
|
|
"--exclude=bitcoind/",
|
|
"--exclude=electrs/",
|
|
src_varlib + "/", dest_varlib + "/",
|
|
],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
# Excluded directories must NOT appear in dest
|
|
for excluded in ("postgresql", "mysql", "mariadb", "bitcoind", "electrs"):
|
|
self.assertFalse(
|
|
os.path.exists(os.path.join(dest_varlib, excluded)),
|
|
f"{excluded}/ must be excluded from the backup",
|
|
)
|
|
# Non-excluded directory must appear
|
|
self.assertTrue(
|
|
os.path.exists(os.path.join(dest_varlib, "myapp", "data.bin")),
|
|
"non-excluded directories must be backed up",
|
|
)
|
|
|
|
def test_behavioral_home_cache_exclusion(self):
|
|
"""Behavioral: .cache/ and browser cache dirs must be excluded from /home."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
user_home = os.path.join(tmpdir, "home", "user")
|
|
cache_dir = os.path.join(user_home, ".cache", "something")
|
|
docs_dir = os.path.join(user_home, "Documents")
|
|
ff_cache = os.path.join(user_home, ".mozilla", "firefox", "default", "cache2")
|
|
brave_cache = os.path.join(
|
|
user_home, ".config", "BraveSoftware", "Brave-Browser", "Default", "Cache"
|
|
)
|
|
for d in (cache_dir, docs_dir, ff_cache, brave_cache):
|
|
os.makedirs(d)
|
|
with open(os.path.join(cache_dir, "tmp.bin"), "w") as f: f.write("cache\n")
|
|
with open(os.path.join(docs_dir, "note.txt"), "w") as f: f.write("note\n")
|
|
with open(os.path.join(ff_cache, "entry"), "w") as f: f.write("ff\n")
|
|
with open(os.path.join(brave_cache, "entry"), "w") as f: f.write("brave\n")
|
|
|
|
src = os.path.join(tmpdir, "home")
|
|
dest = os.path.join(tmpdir, "backup", "home")
|
|
os.makedirs(dest)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"rsync", "--archive", "--one-file-system",
|
|
"--exclude=.cache/",
|
|
"--exclude=.mozilla/firefox/*/cache2/",
|
|
"--exclude=.config/BraveSoftware/Brave-Browser/*/Cache/",
|
|
src + "/", dest + "/",
|
|
],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
dest_user = os.path.join(dest, "user")
|
|
self.assertFalse(
|
|
os.path.exists(os.path.join(dest_user, ".cache")),
|
|
".cache/ must be excluded",
|
|
)
|
|
self.assertFalse(
|
|
os.path.exists(os.path.join(dest_user, ".mozilla", "firefox",
|
|
"default", "cache2")),
|
|
"Firefox cache2/ must be excluded",
|
|
)
|
|
self.assertFalse(
|
|
os.path.exists(os.path.join(dest_user, ".config", "BraveSoftware",
|
|
"Brave-Browser", "Default", "Cache")),
|
|
"Brave Cache/ must be excluded",
|
|
)
|
|
self.assertTrue(
|
|
os.path.exists(os.path.join(dest_user, "Documents", "note.txt")),
|
|
"Documents must be backed up",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|