Files
pobsync/src/pobsync_backend/self_check.py

330 lines
11 KiB
Python
Raw Normal View History

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 <service-user> 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 = "Global config backup root differs from the runtime backup root."
return [
SelfCheck(
"Global config",
status,
message,
f"database={global_config.backup_root} runtime={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