Hide the old pobsync_home field from the Django admin and replace legacy operator-facing labels with runtime state root and backup root terminology. Rename admin compatibility fieldsets, update self-check/config-check text, and refresh management command help so Django/systemd stays the primary mental model.
330 lines
11 KiB
Python
330 lines
11 KiB
Python
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
|