higher level logging; sqlite db for logs

This commit is contained in:
Ludwig Lehnert
2026-08-01 04:36:25 +00:00
parent 17f5ef8560
commit fdd5649198
19 changed files with 1092 additions and 377 deletions
+93 -13
View File
@@ -5,6 +5,8 @@ import fcntl
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
@@ -14,7 +16,12 @@ from typing import Callable, Dict, List, Optional, Set, TextIO, Tuple
from urllib.parse import SplitResult, unquote, urlsplit
LOCK_PATH = "/state/backup.lock"
STATE_ROOT = os.getenv("STATE_ROOT", "/state")
STATE_DB_PATH = os.getenv(
"STATE_DB_PATH",
os.getenv("SHARE_DB_PATH", os.path.join(STATE_ROOT, "shares.db")),
)
LOCK_PATH = os.path.join(STATE_ROOT, "backup.lock")
DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log"
DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json"
DEFAULT_PROGRESS_MODE = "auto"
@@ -43,7 +50,7 @@ BACKUP_SOURCES: List[Tuple[str, str]] = [
("/data/private", "data/private"),
("/data/groups", "data/groups"),
("/data/fslogix", "data/fslogix"),
("/state", "state"),
(STATE_ROOT, "state"),
("/var/lib/samba/private", "samba/private"),
]
@@ -917,11 +924,75 @@ def redact_destination(raw_url: str) -> str:
return f"{parts.scheme}://{netloc}{parts.path}"
def available_sources() -> List[Tuple[str, str]]:
def prepare_state_snapshot(
state_root: str = STATE_ROOT,
database_path: str = STATE_DB_PATH,
) -> Tuple[str, str]:
"""Copy state and replace the live WAL database with an online snapshot."""
state_root = os.path.abspath(state_root)
database_path = os.path.abspath(database_path)
database_relative = os.path.relpath(database_path, state_root)
if database_relative == ".." or database_relative.startswith(f"..{os.sep}"):
raise RuntimeError("STATE_DB_PATH must be located below STATE_ROOT")
temporary_root = tempfile.mkdtemp(prefix="backup-state-")
staged_state = os.path.join(temporary_root, "state")
excluded = {
database_relative,
f"{database_relative}-wal",
f"{database_relative}-shm",
}
def ignore_live_database(directory: str, names: List[str]) -> Set[str]:
relative_directory = os.path.relpath(directory, state_root)
return {
name
for name in names
if os.path.normpath(os.path.join(relative_directory, name)) in excluded
}
try:
shutil.copytree(
state_root,
staged_state,
symlinks=True,
ignore=ignore_live_database,
)
if os.path.isfile(database_path):
staged_database = os.path.join(staged_state, database_relative)
os.makedirs(os.path.dirname(staged_database), exist_ok=True)
source = sqlite3.connect(
f"file:{database_path}?mode=ro",
uri=True,
timeout=30,
)
destination = sqlite3.connect(staged_database, timeout=30)
try:
source.execute("PRAGMA busy_timeout = 30000")
source.backup(destination)
if destination.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise RuntimeError("SQLite state snapshot failed its integrity check")
finally:
destination.close()
source.close()
return temporary_root, staged_state
except Exception:
shutil.rmtree(temporary_root, ignore_errors=True)
raise
def available_sources(
state_snapshot: Optional[str] = None,
) -> List[Tuple[str, str]]:
sources: List[Tuple[str, str]] = []
for source_path, destination_path in BACKUP_SOURCES:
if os.path.isdir(source_path):
sources.append((source_path, destination_path))
effective_source = (
state_snapshot
if destination_path == "state" and state_snapshot is not None
else source_path
)
if os.path.isdir(effective_source):
sources.append((effective_source, destination_path))
else:
log(f"Skipping missing source: {source_path}")
return sources
@@ -1455,11 +1526,6 @@ def run_backup() -> int:
return 0
policy = parse_retention_policy()
sources = available_sources()
if not sources:
log("No backup sources are available, skipping backup")
return 0
progress_interval_seconds = parse_int_env(
"BACKUP_PROGRESS_INTERVAL_SECONDS",
DEFAULT_PROGRESS_INTERVAL_SECONDS,
@@ -1474,8 +1540,19 @@ def run_backup() -> int:
os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip()
)
BACKUP_STATUS.begin(redact_destination(destination.raw_url))
backend = build_backend(destination)
backend = None
state_snapshot_root = None
try:
state_snapshot = None
if os.path.isdir(STATE_ROOT):
BACKUP_STATUS.write(message="Creating consistent state database snapshot")
state_snapshot_root, state_snapshot = prepare_state_snapshot()
sources = available_sources(state_snapshot)
if not sources:
log("No backup sources are available, skipping backup")
return 0
backend = build_backend(destination)
log(f"Starting backup to {redact_destination(destination.raw_url)}")
existing = set(backend.list_snapshots())
@@ -1494,7 +1571,7 @@ def run_backup() -> int:
)
for source_path, destination_path in sources:
log(f"Syncing {source_path}")
log(f"Syncing {destination_path}")
backend.sync_source(
snapshot_name,
source_path,
@@ -1527,7 +1604,10 @@ def run_backup() -> int:
)
return 0
finally:
backend.close()
if backend is not None:
backend.close()
if state_snapshot_root is not None:
shutil.rmtree(state_snapshot_root, ignore_errors=True)
def with_lock() -> int: