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.
+
+ {% 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 }}
+
+
+
+
+ | Status |
+ Check |
+ Message |
+ Detail |
+
+
+
+ {% for check in config_checks %}
+
+ | {{ check.status }} |
+ {{ check.name }} |
+ {{ check.message }} |
+ {{ check.detail }} |
+
+ {% endfor %}
+
+
+
+ {% 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 }}
+
+
+
+
+ | Status |
+ Check |
+ Message |
+ Detail |
+
+
+
+ {% for check in config_checks %}
+
+ | {{ check.status }} |
+ {{ check.name }} |
+ {{ check.message }} |
+ {{ check.detail }} |
+
+ {% endfor %}
+
+
+
+ {% 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),
},
)