from __future__ import annotations import os import pwd import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Literal from django.conf import settings from django.db import connection from .models import GlobalConfig CheckStatus = Literal["ok", "warning", "failed", "skipped"] @dataclass(frozen=True) class SelfCheck: name: str status: CheckStatus message: str detail: str = "" def collect_self_checks() -> list[SelfCheck]: checks: list[SelfCheck] = [] checks.extend(_django_checks()) checks.extend(_install_checks()) checks.extend(_path_checks()) checks.extend(_binary_checks()) checks.extend(_database_checks()) checks.extend(_config_checks()) checks.extend(_systemd_checks()) return checks def _native_runtime_available() -> bool: return Path("/run/systemd/system").exists() and shutil.which("systemctl") is not None def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]: return { "ok": sum(1 for check in checks if check.status == "ok"), "warning": sum(1 for check in checks if check.status == "warning"), "failed": sum(1 for check in checks if check.status == "failed"), "skipped": sum(1 for check in checks if check.status == "skipped"), } def _django_checks() -> list[SelfCheck]: checks = [ SelfCheck( "Django debug", "warning" if settings.DEBUG else "ok", "DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.", ), SelfCheck( "Django secret key", "failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok", "Default development secret key is still active." if settings.SECRET_KEY == "dev-only-change-me" else "Secret key is configured.", ), SelfCheck( "Allowed hosts", "ok" if settings.ALLOWED_HOSTS else "failed", ", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.", ), ] return checks def _path_checks() -> list[SelfCheck]: checks = [] checks.append( _path_check( "State root", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True, ) ) checks.append( _path_check( "Backup root", Path(settings.POBSYNC_BACKUP_ROOT), must_be_absolute=True, must_exist=True, must_be_writable=True, ) ) checks.append( _path_check( "Static root", Path(settings.STATIC_ROOT), must_be_absolute=True, must_exist=False, must_be_writable=True, ) ) db_settings = settings.DATABASES["default"] if db_settings["ENGINE"] == "django.db.backends.sqlite3": sqlite_path = Path(str(db_settings["NAME"])) checks.append( _path_check( "SQLite directory", sqlite_path.parent, must_be_absolute=True, must_exist=True, must_be_writable=True, ) ) checks.append(_sqlite_database_check(sqlite_path)) return checks def _install_checks() -> list[SelfCheck]: if not _native_runtime_available() and not Path(settings.POBSYNC_ENV_FILE).exists(): return [ SelfCheck( "Environment file", "skipped", "Native environment file is not configured in this runtime.", "This is expected inside Docker or local development.", ), SelfCheck( "Service user", "skipped", "Native service user check is not available in this runtime.", "This is expected inside Docker or local development.", ), SelfCheck( "Backup root owner", "skipped", "Native backup root ownership check is not available in this runtime.", "This is expected inside Docker or local development.", ), ] checks = [_env_file_check(Path(settings.POBSYNC_ENV_FILE)), _service_user_check()] checks.append(_backup_root_owner_check(Path(settings.POBSYNC_BACKUP_ROOT))) return checks def _env_file_check(path: Path) -> SelfCheck: if not path.is_absolute(): return SelfCheck("Environment file", "failed", f"{path} is not absolute.") if not path.exists(): return SelfCheck("Environment file", "failed", f"{path} does not exist.") if not path.is_file(): return SelfCheck("Environment file", "failed", f"{path} is not a regular file.") if not os.access(path, os.R_OK): return SelfCheck("Environment file", "failed", f"{path} is not readable by this process.") return SelfCheck("Environment file", "ok", str(path)) def _service_user_check() -> SelfCheck: expected_user = settings.POBSYNC_SERVICE_USER try: current_user = pwd.getpwuid(os.geteuid()).pw_name except KeyError: return SelfCheck("Service user", "failed", f"Current uid {os.geteuid()} has no passwd entry.") if current_user != expected_user: return SelfCheck( "Service user", "warning", f"Current process runs as {current_user}, expected {expected_user}.", "Run terminal checks with sudo -u pobsync-manage check_pobsync_install.", ) return SelfCheck("Service user", "ok", current_user) def _backup_root_owner_check(path: Path) -> SelfCheck: if not path.exists(): return SelfCheck("Backup root owner", "failed", f"{path} does not exist.") expected_user = settings.POBSYNC_SERVICE_USER try: owner = pwd.getpwuid(path.stat().st_uid).pw_name except KeyError: return SelfCheck("Backup root owner", "warning", f"{path} owner uid {path.stat().st_uid} has no passwd entry.") if owner != expected_user: return SelfCheck( "Backup root owner", "warning", f"{path} is owned by {owner}, expected {expected_user}.", ) return SelfCheck("Backup root owner", "ok", f"{path} owner={owner}") def _sqlite_database_check(path: Path) -> SelfCheck: if not path.is_absolute(): return SelfCheck("SQLite database", "failed", f"{path} is not absolute.") if not path.exists(): return SelfCheck("SQLite database", "warning", f"{path} does not exist yet.") if not path.is_file(): return SelfCheck("SQLite database", "failed", f"{path} is not a regular file.") if not os.access(path, os.R_OK | os.W_OK): return SelfCheck("SQLite database", "failed", f"{path} is not readable and writable by this process.") return SelfCheck("SQLite database", "ok", str(path)) def _path_check( name: str, path: Path, *, must_be_absolute: bool, must_exist: bool = False, must_be_writable: bool, ) -> SelfCheck: if must_be_absolute and not path.is_absolute(): return SelfCheck(name, "failed", f"{path} is not absolute.") if must_exist and not path.exists(): return SelfCheck(name, "failed", f"{path} does not exist.") target = path if path.exists() else path.parent if not target.exists(): return SelfCheck(name, "failed", f"{target} does not exist.") if must_be_writable and not os.access(target, os.W_OK): return SelfCheck(name, "failed", f"{target} is not writable by this process.") return SelfCheck(name, "ok", str(path)) def _binary_checks() -> list[SelfCheck]: checks = [] for binary in ("rsync", "ssh", "ssh-keygen"): path = shutil.which(binary) checks.append( SelfCheck( f"Binary: {binary}", "ok" if path else "failed", path or f"{binary} was not found in PATH.", ) ) gunicorn_path = shutil.which("gunicorn") or Path(sys.executable).parent / "gunicorn" checks.append( SelfCheck( "Binary: gunicorn", "ok" if Path(gunicorn_path).exists() else "failed", str(gunicorn_path) if Path(gunicorn_path).exists() else "gunicorn was not found in PATH or next to Python.", ) ) return checks def _database_checks() -> list[SelfCheck]: try: with connection.cursor() as cursor: cursor.execute("SELECT 1") cursor.fetchone() except Exception as exc: return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")] return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])] def _config_checks() -> list[SelfCheck]: try: global_config = GlobalConfig.objects.get(name="default") except GlobalConfig.DoesNotExist: return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")] status: CheckStatus = "ok" message = "Default global config exists." if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT: status = "warning" message = "Saved backup root differs from the active backup root." return [ SelfCheck( "Global config", status, message, f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}", ) ] def _systemd_checks() -> list[SelfCheck]: if not _native_runtime_available(): return [ SelfCheck( "Systemd services", "skipped", "systemd is not available in this runtime.", "This is expected inside Docker.", ) ] checks = [] for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"): result = subprocess.run( ["systemctl", "is-active", service], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5, ) active_state = result.stdout.strip() or result.stderr.strip() checks.append( SelfCheck( service, "ok" if result.returncode == 0 else "failed", active_state, ) ) if shutil.which("journalctl") is not None: result = subprocess.run( ["journalctl", "--no-pager", "-n", "1", "-u", "pobsync-web.service"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5, ) journal_error = result.stderr.strip() journal_denied = "No journal files were opened" in journal_error or "permission" in journal_error.lower() has_journal_access = result.returncode == 0 and not journal_denied checks.append( SelfCheck( "Journal access", "ok" if has_journal_access else "failed", "pobsync can read service logs." if has_journal_access else "pobsync cannot read service logs.", journal_error, ) ) return checks