diff --git a/src/pobsync_backend/config_checks.py b/src/pobsync_backend/config_checks.py new file mode 100644 index 0000000..8fafc71 --- /dev/null +++ b/src/pobsync_backend/config_checks.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from django.conf import settings + +from .models import GlobalConfig, HostConfig, SshCredential +from .self_check import SelfCheck +from .ssh_keys import identity_path + + +CRITICAL_ROOT_EXCLUDES = ("/proc/***", "/sys/***", "/dev/***", "/run/***", "/tmp/***") + + +def collect_global_config_checks(global_config: GlobalConfig) -> list[SelfCheck]: + checks = [ + _absolute_path_check("Global backup root", global_config.backup_root), + _absolute_path_check("Global pobsync home", global_config.pobsync_home), + _runtime_backup_root_check(global_config), + _rsync_binary_check(global_config.rsync_binary), + _rsync_recursion_check( + "Global rsync recursion", + [*list(global_config.rsync_args or []), *list(global_config.rsync_extra_args or [])], + ), + _source_root_check("Global source root", global_config.default_source_root), + _root_excludes_check(global_config.default_source_root, list(global_config.excludes_default or [])), + _retention_check( + "Global retention", + global_config.retention_daily, + global_config.retention_weekly, + global_config.retention_monthly, + global_config.retention_yearly, + ), + _ssh_port_check("Global SSH port", global_config.ssh_port), + _credential_check("Global SSH credential", global_config.default_ssh_credential), + ] + return checks + + +def collect_effective_host_config_checks(host: HostConfig, global_config: GlobalConfig) -> list[SelfCheck]: + source_root = host.source_root or global_config.default_source_root + ssh_user = host.ssh_user or global_config.ssh_user + ssh_port = host.ssh_port or global_config.ssh_port + credential = host.ssh_credential or global_config.default_ssh_credential + if host.excludes_replace is not None: + excludes = list(host.excludes_replace) + else: + excludes = [*list(global_config.excludes_default or []), *list(host.excludes_add or [])] + rsync_args = [ + *list(global_config.rsync_args or []), + *list(global_config.rsync_extra_args or []), + *list(host.rsync_extra_args or []), + ] + + checks = [ + _source_root_check("Host effective source root", source_root), + _ssh_user_check(ssh_user), + _ssh_port_check("Host effective SSH port", ssh_port), + _credential_check("Host effective SSH credential", credential), + _rsync_recursion_check("Host effective rsync recursion", rsync_args), + _root_excludes_check(source_root, excludes, host=host), + _includes_check(host), + _retention_check( + "Host retention", + host.retention_daily, + host.retention_weekly, + host.retention_monthly, + host.retention_yearly, + ), + ] + return checks + + +def has_recursive_rsync_arg(args: list[str]) -> bool: + for arg in args: + if arg in {"--archive", "--recursive"}: + return True + if arg.startswith("-") and not arg.startswith("--") and any(flag in arg for flag in ("a", "r")): + return True + return False + + +def _absolute_path_check(name: str, value: str) -> SelfCheck: + path = Path(value) + if not value: + return SelfCheck(name, "failed", "Path is empty.") + if not path.is_absolute(): + return SelfCheck(name, "failed", f"{value} is not absolute.") + return SelfCheck(name, "ok", value) + + +def _runtime_backup_root_check(global_config: GlobalConfig) -> SelfCheck: + if global_config.backup_root == settings.POBSYNC_BACKUP_ROOT: + return SelfCheck("Runtime backup root", "ok", global_config.backup_root) + return SelfCheck( + "Runtime backup root", + "warning", + "Database backup root differs from runtime POBSYNC_BACKUP_ROOT.", + f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}", + ) + + +def _rsync_binary_check(binary: str) -> SelfCheck: + if not binary: + return SelfCheck("Global rsync binary", "failed", "Rsync binary is empty.") + if Path(binary).is_absolute(): + exists = Path(binary).exists() + message = binary if exists else f"{binary} does not exist." + return SelfCheck("Global rsync binary", "ok" if exists else "failed", message) + path = shutil.which(binary) + return SelfCheck("Global rsync binary", "ok" if path else "failed", path or f"{binary} was not found in PATH.") + + +def _rsync_recursion_check(name: str, args: list[str]) -> SelfCheck: + if has_recursive_rsync_arg(args): + return SelfCheck(name, "ok", "Rsync args include archive or recursive transfer.", " ".join(args)) + return SelfCheck( + name, + "failed", + "Rsync args do not include archive or recursive transfer.", + "Add --archive or --recursive before running a real backup.", + ) + + +def _source_root_check(name: str, source_root: str) -> SelfCheck: + if not source_root: + return SelfCheck(name, "failed", "Source root is empty.") + if not source_root.startswith("/"): + return SelfCheck(name, "failed", f"{source_root} is not absolute.") + return SelfCheck(name, "ok", source_root) + + +def _root_excludes_check(source_root: str, excludes: list[str], host: HostConfig | None = None) -> SelfCheck: + if source_root != "/": + return SelfCheck("Effective root excludes", "ok", "Source root is not /, critical OS excludes are less important.") + missing = [pattern for pattern in CRITICAL_ROOT_EXCLUDES if pattern not in excludes] + if missing: + detail = ", ".join(missing) + if host and host.excludes_replace is not None: + detail = f"excludes_replace is active; missing {detail}" + return SelfCheck( + "Effective root excludes", + "warning", + "Source root is / but some critical default excludes are missing.", + detail, + ) + return SelfCheck("Effective root excludes", "ok", "Critical root excludes are present.") + + +def _includes_check(host: HostConfig) -> SelfCheck: + includes = list(host.includes or []) + if not includes: + return SelfCheck("Host includes", "ok", "No host include rules are configured.") + return SelfCheck( + "Host includes", + "warning", + "Includes are passed to rsync as raw --include rules.", + "Verify matching exclude rules if you intend to limit the backup scope.", + ) + + +def _retention_check(name: str, daily: int, weekly: int, monthly: int, yearly: int) -> SelfCheck: + if any(value > 0 for value in (daily, weekly, monthly, yearly)): + return SelfCheck(name, "ok", f"d{daily} w{weekly} m{monthly} y{yearly}") + return SelfCheck(name, "warning", "All retention windows are zero.") + + +def _ssh_user_check(user: str) -> SelfCheck: + if user.strip(): + return SelfCheck("Host effective SSH user", "ok", user.strip()) + return SelfCheck("Host effective SSH user", "failed", "SSH user is empty.") + + +def _ssh_port_check(name: str, port: int | None) -> SelfCheck: + if port is None: + return SelfCheck(name, "failed", "SSH port is empty.") + if 1 <= int(port) <= 65535: + return SelfCheck(name, "ok", str(port)) + return SelfCheck(name, "failed", f"{port} is outside the valid TCP port range.") + + +def _credential_check(name: str, credential: SshCredential | None) -> SelfCheck: + if credential is None: + return SelfCheck(name, "warning", "No SSH credential selected.") + if credential.key_path: + key_path = identity_path(credential) + if not key_path.exists(): + return SelfCheck(name, "failed", f"{key_path} does not exist.") + if not os.access(key_path, os.R_OK): + return SelfCheck(name, "failed", f"{key_path} is not readable by this process.") + return SelfCheck(name, "ok", str(credential), str(key_path)) + if credential.private_key: + return SelfCheck( + name, + "warning", + f"{credential} stores private key material in the database.", + "Generated filesystem keys are recommended for native installs.", + ) + return SelfCheck(name, "failed", f"{credential} has no private key material or key path.") diff --git a/src/pobsync_backend/host_ops.py b/src/pobsync_backend/host_ops.py index ff60eec..fc2bd2a 100644 --- a/src/pobsync_backend/host_ops.py +++ b/src/pobsync_backend/host_ops.py @@ -5,6 +5,7 @@ from pathlib import Path from pobsync.snapshot_meta import resolve_host_root +from .config_checks import collect_effective_host_config_checks from .models import GlobalConfig, HostConfig from .self_check import SelfCheck from .ssh_keys import identity_path @@ -78,31 +79,10 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True)) for subdir in HOST_BACKUP_SUBDIRS: checks.append(_host_path_check(f"Host directory: {subdir}", host_root / subdir, must_exist=True, must_be_writable=True)) - checks.append(_rsync_recursion_check(host, global_config)) + checks.extend(collect_effective_host_config_checks(host, global_config)) return checks -def _rsync_recursion_check(host: HostConfig, global_config: GlobalConfig) -> SelfCheck: - args = [*list(global_config.rsync_args or []), *list(global_config.rsync_extra_args or []), *list(host.rsync_extra_args or [])] - if _has_recursive_rsync_arg(args): - return SelfCheck("Host rsync recursion", "ok", "Rsync args include archive or recursive transfer.", " ".join(args)) - return SelfCheck( - "Host rsync recursion", - "failed", - "Rsync args do not include archive or recursive transfer.", - "Add --archive or --recursive before running a real backup.", - ) - - -def _has_recursive_rsync_arg(args: list[str]) -> bool: - for arg in args: - if arg in {"--archive", "--recursive"}: - return True - if arg.startswith("-") and not arg.startswith("--") and any(flag in arg for flag in ("a", "r")): - return True - return False - - def _host_path_check( name: str, path: Path, diff --git a/src/pobsync_backend/templates/pobsync_backend/global_form.html b/src/pobsync_backend/templates/pobsync_backend/global_form.html index 7cabf63..6ebad3b 100644 --- a/src/pobsync_backend/templates/pobsync_backend/global_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/global_form.html @@ -13,7 +13,7 @@

{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}

Backup root: {{ backup_root }}
-
This is the fixed path inside the Docker containers. Change the host directory by changing the Docker mount.
+
This path comes from the runtime environment and is written back when the config is saved.
{% csrf_token %} @@ -33,4 +33,36 @@
+ + {% if config_checks %} +
+

Config Check

+
+
OK
{{ config_check_summary.ok }}
+
Warnings
{{ config_check_summary.warning }}
+
Failed
{{ config_check_summary.failed }}
+
Skipped
{{ config_check_summary.skipped }}
+
+ + + + + + + + + + + {% for check in config_checks %} + + + + + + + {% endfor %} + +
StatusCheckMessageDetail
{{ check.status }}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+ {% endif %} {% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_form.html b/src/pobsync_backend/templates/pobsync_backend/host_form.html index c316b88..fa1b0e5 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_form.html @@ -33,4 +33,36 @@ + + {% if config_checks %} +
+

Effective Config Check

+
+
OK
{{ config_check_summary.ok }}
+
Warnings
{{ config_check_summary.warning }}
+
Failed
{{ config_check_summary.failed }}
+
Skipped
{{ config_check_summary.skipped }}
+
+ + + + + + + + + + + {% for check in config_checks %} + + + + + + + {% endfor %} + +
StatusCheckMessageDetail
{{ check.status }}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+ {% endif %} {% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index c8a6dcf..e1d92a7 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -347,10 +347,21 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Backup root:") self.assertContains(response, "/backups") - self.assertNotContains(response, "/mnt/pobsync/backups") + self.assertContains(response, "Config Check") + self.assertContains(response, "Runtime backup root") self.assertNotContains(response, "/opt/pobsync/backups") self.assertNotContains(response, "Pobsync home") + def test_global_config_form_renders_config_check_for_non_recursive_rsync(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups", rsync_args=["--numeric-ids"]) + + response = self.client.get(reverse("edit_global_config")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Global rsync recursion") + self.assertContains(response, "Rsync args do not include archive or recursive transfer.") + def test_global_config_form_resets_backup_root_to_static_container_path(self) -> None: self.client.force_login(self.staff_user) GlobalConfig.objects.create( @@ -532,9 +543,44 @@ class ViewTests(TestCase): response = self.client.get(reverse("host_detail", args=[host.host])) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Host rsync recursion") + self.assertContains(response, "Host effective rsync recursion") self.assertContains(response, "Rsync args do not include archive or recursive transfer.") + def test_host_detail_warns_when_replace_excludes_drops_root_defaults(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create( + name="default", + backup_root="/backups", + rsync_args=["--archive"], + excludes_default=["/proc/***", "/sys/***", "/dev/***", "/run/***", "/tmp/***"], + ) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + excludes_replace=[], + ) + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Effective root excludes") + self.assertContains(response, "excludes_replace is active") + + def test_host_detail_warns_that_includes_are_raw_rsync_rules(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups", rsync_args=["--archive"]) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + includes=["/srv/www"], + ) + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Host includes") + self.assertContains(response, "Includes are passed to rsync as raw --include rules.") + def test_scan_host_known_key_action_updates_selected_credential(self) -> None: self.client.force_login(self.staff_user) credential = SshCredential.objects.create(name="default-key", key_path="/var/lib/pobsync/state/ssh-credentials/1/identity") @@ -997,6 +1043,18 @@ class ViewTests(TestCase): self.assertContains(response, "*.tmp") self.assertContains(response, "--numeric-ids") + def test_host_config_form_renders_effective_config_check(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/backups", rsync_args=["--numeric-ids"]) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + response = self.client.get(reverse("edit_host_config", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Effective Config Check") + self.assertContains(response, "Host effective rsync recursion") + self.assertContains(response, "Rsync args do not include archive or recursive transfer.") + def test_host_config_form_updates_host_config(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="old.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 3fe1cf8..3ea753b 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -16,6 +16,7 @@ from pobsync.errors import PobsyncError from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS from .backup_runner import queue_backup_run +from .config_checks import collect_effective_host_config_checks, collect_global_config_checks from .forms import ( CreateHostConfigForm, GlobalConfigForm, @@ -203,6 +204,7 @@ def edit_global_config(request): return redirect("dashboard") else: form = GlobalConfigForm(instance=global_config) if global_config else GlobalConfigForm(initial=_default_global_initial()) + config_checks = collect_global_config_checks(global_config) if global_config else [] return render( request, @@ -211,6 +213,8 @@ def edit_global_config(request): "global_config": global_config, "form": form, "backup_root": settings.POBSYNC_BACKUP_ROOT, + "config_checks": config_checks, + "config_check_summary": summarize_self_checks(config_checks), }, ) @@ -452,6 +456,8 @@ def apply_host_retention(request, host: str): @staff_member_required def edit_host_config(request, host: str): host_config = get_object_or_404(HostConfig, host=host) + global_config = GlobalConfig.objects.filter(name="default").first() + config_checks = collect_effective_host_config_checks(host_config, global_config) if global_config else [] if request.method == "POST": form = HostConfigForm(request.POST, instance=host_config) if form.is_valid(): @@ -467,6 +473,8 @@ def edit_host_config(request, host: str): { "host": host_config, "form": form, + "config_checks": config_checks, + "config_check_summary": summarize_self_checks(config_checks), }, )