From 58d567f9bc55e8c8918416b98fdd8cec6edca1ab Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:14:16 +0200 Subject: [PATCH 1/7] (refactor) Retire configuration command aliases Remove the short pobsync aliases for global config, host config, and schedule changes so the public CLI no longer points operators toward the old configuration workflow. Keep operational aliases for backup, discovery, retention, worker, and scheduler debugging, and document explicit Django management commands for automation use. --- docs/development.md | 11 +++++++++++ src/pobsync/cli.py | 6 +++--- .../tests/test_console_entrypoint.py | 18 +++++++++--------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/development.md b/docs/development.md index 5d512a6..5b1ce63 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,6 +47,9 @@ pobsync django check python3 manage.py showmigrations pobsync_backend ``` +The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install. +Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead. + Worker and scheduler commands are normally run by systemd services: ``` @@ -62,6 +65,14 @@ pobsync discover-snapshots --host pobsync retention ``` +For scripted configuration changes, call the Django management command explicitly so it is clear that this is an +automation/debugging path rather than the normal UI workflow: + +``` +pobsync django configure_pobsync_host --address +pobsync django configure_pobsync_schedule --cron "15 2 * * *" +``` + ## Installer Development The native installer is interactive by default when stdin is a terminal. It should keep every prompt backed by a command diff --git a/src/pobsync/cli.py b/src/pobsync/cli.py index 6bbdca3..46a7c8f 100644 --- a/src/pobsync/cli.py +++ b/src/pobsync/cli.py @@ -8,9 +8,6 @@ from django.core.management import execute_from_command_line COMMAND_ALIASES = { - "configure-global": "configure_pobsync_global", - "configure-host": "configure_pobsync_host", - "schedule": "configure_pobsync_schedule", "backup": "run_pobsync_backup", "retention": "run_pobsync_retention", "discover-snapshots": "discover_pobsync_snapshots", @@ -29,6 +26,9 @@ Usage: Commands: {commands} + +Configuration is managed from the Django control panel. Use +`pobsync django ` for automation or debugging. """ diff --git a/src/pobsync_backend/tests/test_console_entrypoint.py b/src/pobsync_backend/tests/test_console_entrypoint.py index 2584582..8baa148 100644 --- a/src/pobsync_backend/tests/test_console_entrypoint.py +++ b/src/pobsync_backend/tests/test_console_entrypoint.py @@ -31,15 +31,6 @@ class ConsoleEntrypointTests(SimpleTestCase): self.assertEqual(exit_code, 0) execute.assert_called_once_with(["pobsync", "check"]) - def test_maps_schedule_alias_to_django_command(self) -> None: - with patch("pobsync.cli.execute_from_command_line") as execute: - exit_code = main(["schedule", "web-01", "--cron", "15 2 * * *"]) - - self.assertEqual(exit_code, 0) - execute.assert_called_once_with( - ["pobsync", "configure_pobsync_schedule", "web-01", "--cron", "15 2 * * *"] - ) - def test_maps_discover_snapshots_alias_to_django_command(self) -> None: with patch("pobsync.cli.execute_from_command_line") as execute: exit_code = main(["discover-snapshots", "--host", "web-01"]) @@ -53,3 +44,12 @@ class ConsoleEntrypointTests(SimpleTestCase): self.assertEqual(exit_code, 0) execute.assert_called_once_with(["pobsync", "run_pobsync_worker", "--once"]) + + def test_configuration_aliases_are_not_public_commands(self) -> None: + stderr = StringIO() + with patch("sys.stderr", stderr): + exit_code = main(["schedule", "web-01", "--cron", "15 2 * * *"]) + + self.assertEqual(exit_code, 2) + self.assertIn("Unknown pobsync command", stderr.getvalue()) + self.assertIn("pobsync django ", stderr.getvalue()) From c5865a537947b7d5db1380e56389a088c57640e6 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:24:55 +0200 Subject: [PATCH 2/7] (refactor) Normalize runtime config labels 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. --- README.md | 4 ++-- docs/development.md | 6 +++--- src/pobsync_backend/admin.py | 6 +++--- src/pobsync_backend/config_checks.py | 4 ++-- .../commands/configure_pobsync_global.py | 2 +- .../commands/export_pobsync_configs.py | 2 +- .../commands/import_pobsync_configs.py | 2 +- .../management/commands/run_pobsync_backup.py | 2 +- .../commands/run_pobsync_retention.py | 2 +- .../commands/run_pobsync_scheduler.py | 2 +- .../management/commands/run_pobsync_worker.py | 2 +- src/pobsync_backend/self_check.py | 13 ++++++++++--- src/pobsync_backend/tests/test_admin.py | 18 ++++++++++++++++-- src/pobsync_backend/tests/test_views.py | 8 +++++--- 14 files changed, 48 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 70a68eb..2a3416c 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,8 @@ rsync. ## SSH Keys SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the -installer. pobsync stores the private key on disk under `POBSYNC_HOME`, keeps the public key visible in the UI, and lets -you select a credential either as the global default or as a per-host override. +installer. pobsync stores the private key on disk under the runtime state root (`POBSYNC_HOME`), keeps the public key +visible in the UI, and lets you select a credential either as the global default or as a per-host override. Generated private keys are stored at: diff --git a/docs/development.md b/docs/development.md index 5b1ce63..a8dcfd7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -98,13 +98,13 @@ run the Django/runtime refresh steps needed after a code update. ## Migration Helpers -Import existing legacy YAML configs: +Import pre-Django YAML configs during a one-time migration: ``` python3 manage.py import_pobsync_configs --prefix /opt/pobsync ``` -Export SQL config to legacy runtime YAML for inspection or one-off compatibility: +Export SQL config back to YAML for inspection or one-off compatibility: ``` python3 manage.py export_pobsync_configs --prefix /opt/pobsync @@ -192,4 +192,4 @@ Next refactor targets: - Move more snapshot lifecycle details into typed domain objects. - Replace remaining dictionary-shaped config at engine boundaries. -- Remove legacy YAML import/export once production migration no longer needs it. +- Remove YAML migration import/export once production migration no longer needs it. diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index d0afe15..708ae5a 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -34,7 +34,7 @@ class GlobalConfigAdmin(admin.ModelAdmin): list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at") readonly_fields = ("created_at", "updated_at") fieldsets = ( - (None, {"fields": ("name", "backup_root", "pobsync_home")}), + (None, {"fields": ("name", "backup_root")}), ("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}), ( "Rsync", @@ -50,7 +50,7 @@ class GlobalConfigAdmin(admin.ModelAdmin): ), ("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}), ("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), - ("Legacy JSON", {"fields": ("data",), "classes": ("collapse",)}), + ("Compatibility data", {"fields": ("data",), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) @@ -76,7 +76,7 @@ class HostConfigAdmin(admin.ModelAdmin): ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}), ("Rsync override", {"fields": ("rsync_extra_args",)}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), - ("Legacy JSON", {"fields": ("config",), "classes": ("collapse",)}), + ("Compatibility data", {"fields": ("config",), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) diff --git a/src/pobsync_backend/config_checks.py b/src/pobsync_backend/config_checks.py index 8fafc71..ea2ed9c 100644 --- a/src/pobsync_backend/config_checks.py +++ b/src/pobsync_backend/config_checks.py @@ -17,7 +17,7 @@ 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), + _absolute_path_check("Runtime state root", settings.POBSYNC_HOME), _runtime_backup_root_check(global_config), _rsync_binary_check(global_config.rsync_binary), _rsync_recursion_check( @@ -97,7 +97,7 @@ def _runtime_backup_root_check(global_config: GlobalConfig) -> SelfCheck: return SelfCheck( "Runtime backup root", "warning", - "Database backup root differs from runtime POBSYNC_BACKUP_ROOT.", + "Database backup root differs from the runtime backup root.", f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}", ) diff --git a/src/pobsync_backend/management/commands/configure_pobsync_global.py b/src/pobsync_backend/management/commands/configure_pobsync_global.py index f46eb31..a7cc318 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_global.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_global.py @@ -18,7 +18,7 @@ class Command(BaseCommand): def add_arguments(self, parser) -> None: parser.add_argument("--name", default="default") parser.add_argument("--backup-root", required=True) - parser.add_argument("--pobsync-home", default=settings.POBSYNC_HOME) + parser.add_argument("--pobsync-home", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--ssh-user", default="root") parser.add_argument("--ssh-port", type=int, default=22) parser.add_argument("--source-root", default="/") diff --git a/src/pobsync_backend/management/commands/export_pobsync_configs.py b/src/pobsync_backend/management/commands/export_pobsync_configs.py index 76317b2..78623bb 100644 --- a/src/pobsync_backend/management/commands/export_pobsync_configs.py +++ b/src/pobsync_backend/management/commands/export_pobsync_configs.py @@ -13,7 +13,7 @@ class Command(BaseCommand): help = "Export Django database configs to pobsync runtime YAML files." def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory") + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--host", default=None, help="Export only one enabled host") def handle(self, *args: Any, **options: Any) -> None: diff --git a/src/pobsync_backend/management/commands/import_pobsync_configs.py b/src/pobsync_backend/management/commands/import_pobsync_configs.py index c64e8ce..a2de4b4 100644 --- a/src/pobsync_backend/management/commands/import_pobsync_configs.py +++ b/src/pobsync_backend/management/commands/import_pobsync_configs.py @@ -15,7 +15,7 @@ class Command(BaseCommand): help = "Import pobsync YAML configs into the Django database." def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory") + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") def handle(self, *args: Any, **options: Any) -> None: paths = PobsyncPaths(home=Path(options["prefix"])) diff --git a/src/pobsync_backend/management/commands/run_pobsync_backup.py b/src/pobsync_backend/management/commands/run_pobsync_backup.py index d2a9d7d..57e18c9 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_backup.py +++ b/src/pobsync_backend/management/commands/run_pobsync_backup.py @@ -16,7 +16,7 @@ class Command(BaseCommand): def add_arguments(self, parser) -> None: parser.add_argument("host", help="Host to back up") - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory") + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run") parser.add_argument("--verbose-rsync", action="store_true", help="Write itemized rsync output to the run log") parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run") diff --git a/src/pobsync_backend/management/commands/run_pobsync_retention.py b/src/pobsync_backend/management/commands/run_pobsync_retention.py index 63eca5b..5067e40 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_retention.py +++ b/src/pobsync_backend/management/commands/run_pobsync_retention.py @@ -16,7 +16,7 @@ class Command(BaseCommand): def add_arguments(self, parser) -> None: parser.add_argument("host") - parser.add_argument("--prefix", default=settings.POBSYNC_HOME) + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--kind", default="scheduled", choices=["scheduled", "manual", "all"]) parser.add_argument("--protect-bases", action="store_true") parser.add_argument("--apply", action="store_true") diff --git a/src/pobsync_backend/management/commands/run_pobsync_scheduler.py b/src/pobsync_backend/management/commands/run_pobsync_scheduler.py index f4e7638..3f70763 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_scheduler.py +++ b/src/pobsync_backend/management/commands/run_pobsync_scheduler.py @@ -18,7 +18,7 @@ class Command(BaseCommand): help = "Run due pobsync schedules from the Django database." def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory") + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--once", action="store_true", help="Check once and exit") parser.add_argument("--loop", action="store_true", help="Keep checking schedules") parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds") diff --git a/src/pobsync_backend/management/commands/run_pobsync_worker.py b/src/pobsync_backend/management/commands/run_pobsync_worker.py index e1288c0..1b3b200 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_worker.py +++ b/src/pobsync_backend/management/commands/run_pobsync_worker.py @@ -15,7 +15,7 @@ class Command(BaseCommand): help = "Run queued pobsync backup jobs from the Django database." def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory") + parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--once", action="store_true", help="Process one queued run and exit") parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs") parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds") diff --git a/src/pobsync_backend/self_check.py b/src/pobsync_backend/self_check.py index ed7a9ff..f8bde76 100644 --- a/src/pobsync_backend/self_check.py +++ b/src/pobsync_backend/self_check.py @@ -76,10 +76,17 @@ def _django_checks() -> list[SelfCheck]: def _path_checks() -> list[SelfCheck]: checks = [] - checks.append(_path_check("POBSYNC_HOME", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True)) checks.append( _path_check( - "POBSYNC_BACKUP_ROOT", + "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, @@ -259,7 +266,7 @@ def _config_checks() -> list[SelfCheck]: message = "Default global config exists." if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT: status = "warning" - message = "Global config backup root differs from runtime POBSYNC_BACKUP_ROOT." + message = "Global config backup root differs from the runtime backup root." return [ SelfCheck( "Global config", diff --git a/src/pobsync_backend/tests/test_admin.py b/src/pobsync_backend/tests/test_admin.py index 1263dbf..c87d707 100644 --- a/src/pobsync_backend/tests/test_admin.py +++ b/src/pobsync_backend/tests/test_admin.py @@ -5,11 +5,25 @@ from datetime import datetime, timezone from django.contrib.admin.sites import AdminSite from django.test import TestCase -from pobsync_backend.admin import BackupRunAdmin, HostConfigAdmin, SnapshotRecordAdmin -from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from pobsync_backend.admin import BackupRunAdmin, GlobalConfigAdmin, HostConfigAdmin, SnapshotRecordAdmin +from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord class AdminDisplayTests(TestCase): + def test_admin_hides_old_global_state_field_and_uses_compatibility_label(self) -> None: + site = AdminSite() + global_admin = GlobalConfigAdmin(GlobalConfig, site) + host_admin = HostConfigAdmin(HostConfig, site) + + global_fieldsets = list(global_admin.fieldsets) + host_fieldsets = list(host_admin.fieldsets) + global_fields = [field for _name, options in global_fieldsets for field in options["fields"]] + fieldset_names = [name for name, _options in [*global_fieldsets, *host_fieldsets]] + + self.assertNotIn("pobsync_home", global_fields) + self.assertIn("Compatibility data", fieldset_names) + self.assertNotIn("Legacy JSON", fieldset_names) + def test_host_admin_links_to_related_snapshots_and_runs(self) -> None: site = AdminSite() admin = HostConfigAdmin(HostConfig, site) diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 031d0ed..08c3360 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -237,7 +237,7 @@ class ViewTests(TestCase): self.assertContains(response, "Self Check") self.assertContains(response, "Django debug") self.assertContains(response, "Database connection") - self.assertContains(response, "POBSYNC_HOME") + self.assertContains(response, "State root") def test_logs_view_renders_filtered_journal_messages(self) -> None: self.client.force_login(self.staff_user) @@ -502,7 +502,7 @@ class ViewTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/mnt/pobsync/backups", - pobsync_home="/custom/legacy/home", + pobsync_home="/custom/state/home", ) response = self.client.get(reverse("edit_global_config")) @@ -512,8 +512,10 @@ class ViewTests(TestCase): self.assertContains(response, "/backups") self.assertContains(response, "Config Check") self.assertContains(response, "Runtime backup root") + self.assertContains(response, "Runtime state root") self.assertNotContains(response, "/opt/pobsync/backups") self.assertNotContains(response, "Pobsync home") + self.assertNotContains(response, "Global pobsync home") def test_global_config_form_renders_config_check_for_non_recursive_rsync(self) -> None: self.client.force_login(self.staff_user) @@ -530,7 +532,7 @@ class ViewTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/mnt/pobsync/backups", - pobsync_home="/custom/legacy/home", + pobsync_home="/custom/state/home", ) response = self.client.post( From bb62382e1886a44f924a9e96f19024f300aafc8d Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:34:09 +0200 Subject: [PATCH 3/7] (refactor) Remove YAML config import and export path Drop the pre-Django YAML import/export management commands and remove the file-based config loader fallback from the backup and retention engines. Keep the runtime config bridge backed by Django models, and add tests that ensure engine operations require an explicit Django config source. --- docs/development.md | 18 --- src/pobsync/commands/retention_plan.py | 10 +- src/pobsync/commands/run_scheduled.py | 7 +- src/pobsync/config/load.py | 53 -------- src/pobsync/config/source.py | 14 --- src/pobsync_backend/config_repository.py | 48 +------- .../commands/export_pobsync_configs.py | 23 ---- .../commands/import_pobsync_configs.py | 81 ------------- .../tests/test_config_repository.py | 114 ++++++++---------- .../tests/test_retention_config_source.py | 10 ++ .../tests/test_run_scheduled_config_source.py | 5 + 11 files changed, 82 insertions(+), 301 deletions(-) delete mode 100644 src/pobsync/config/load.py delete mode 100644 src/pobsync_backend/management/commands/export_pobsync_configs.py delete mode 100644 src/pobsync_backend/management/commands/import_pobsync_configs.py diff --git a/docs/development.md b/docs/development.md index a8dcfd7..994235c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -96,23 +96,6 @@ The updater is intentionally a small wrapper around the installer for routine pr non-interactive, preserve the existing environment file, skip OS package installation, skip superuser creation, and still run the Django/runtime refresh steps needed after a code update. -## Migration Helpers - -Import pre-Django YAML configs during a one-time migration: - -``` -python3 manage.py import_pobsync_configs --prefix /opt/pobsync -``` - -Export SQL config back to YAML for inspection or one-off compatibility: - -``` -python3 manage.py export_pobsync_configs --prefix /opt/pobsync -``` - -These commands are migration helpers, not the normal operating model. After import, review and continue operating from -the Django control panel. - ## Docker With SQLite Docker Compose is useful for local development and disposable test installs. Native systemd is preferred for production @@ -192,4 +175,3 @@ Next refactor targets: - Move more snapshot lifecycle details into typed domain objects. - Replace remaining dictionary-shaped config at engine boundaries. -- Remove YAML migration import/export once production migration no longer needs it. diff --git a/src/pobsync/commands/retention_plan.py b/src/pobsync/commands/retention_plan.py index a2fb5f9..2b96d57 100644 --- a/src/pobsync/commands/retention_plan.py +++ b/src/pobsync/commands/retention_plan.py @@ -4,9 +4,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, List -from ..config.source import ConfigSource, FileConfigSource +from ..config.source import ConfigSource from ..errors import ConfigError -from ..paths import PobsyncPaths from ..retention import Snapshot, apply_base_protection, build_retention_plan from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root from ..util import sanitize_host @@ -40,10 +39,9 @@ def run_retention_plan( if kind not in {"scheduled", "manual", "all"}: raise ConfigError("kind must be scheduled, manual, or all") - paths = PobsyncPaths(home=prefix) - - source = config_source or FileConfigSource(prefix=paths.home) - cfg = source.effective_config_for_host(host) + if config_source is None: + raise ConfigError("A Django config source is required.") + cfg = config_source.effective_config_for_host(host) retention = cfg.get("retention") if not isinstance(retention, dict): diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index 890035d..4a321b0 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from typing import Any, Callable -from ..config.source import ConfigSource, FileConfigSource +from ..config.source import ConfigSource from ..errors import ConfigError from ..lock import acquire_host_lock from ..paths import PobsyncPaths @@ -163,8 +163,9 @@ def run_scheduled( host = sanitize_host(host) paths = PobsyncPaths(home=prefix) - source = config_source or FileConfigSource(prefix=paths.home) - cfg = source.effective_config_for_host(host) + if config_source is None: + raise ConfigError("A Django config source is required.") + cfg = config_source.effective_config_for_host(host) backup_root = cfg.get("backup_root") if not isinstance(backup_root, str) or not backup_root.startswith("/"): diff --git a/src/pobsync/config/load.py b/src/pobsync/config/load.py deleted file mode 100644 index 1a0e0a4..0000000 --- a/src/pobsync/config/load.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml - -from ..errors import ConfigError, ValidationError -from ..validate import validate_dict -from .schemas import GLOBAL_SCHEMA, HOST_SCHEMA - - -def load_yaml_file(path: Path) -> dict[str, Any]: - if not path.exists(): - raise ConfigError(f"Missing config file: {path}") - try: - raw = path.read_text(encoding="utf-8") - except OSError as e: - raise ConfigError(f"Cannot read config file: {path}: {e}") from e - - try: - data = yaml.safe_load(raw) - except yaml.YAMLError as e: - raise ConfigError(f"Invalid YAML in {path}: {e}") from e - - if data is None: - data = {} - if not isinstance(data, dict): - raise ConfigError(f"Config root must be a mapping in {path}") - return data - - -def load_global_config(path: Path) -> dict[str, Any]: - data = load_yaml_file(path) - try: - return validate_dict(data, GLOBAL_SCHEMA, path="global") - except ValidationError as e: - raise ConfigError(f"Invalid global config at {path}: {format_validation_error(e)}") from e - - -def load_host_config(path: Path) -> dict[str, Any]: - data = load_yaml_file(path) - try: - return validate_dict(data, HOST_SCHEMA, path="host") - except ValidationError as e: - raise ConfigError(f"Invalid host config at {path}: {format_validation_error(e)}") from e - - -def format_validation_error(err: ValidationError) -> str: - if err.path: - return f"{err.path}: {err}" - return str(err) - diff --git a/src/pobsync/config/source.py b/src/pobsync/config/source.py index bcd6ee6..476179a 100644 --- a/src/pobsync/config/source.py +++ b/src/pobsync/config/source.py @@ -1,22 +1,8 @@ from __future__ import annotations -from pathlib import Path from typing import Any, Protocol -from .load import load_global_config, load_host_config -from .merge import build_effective_config - class ConfigSource(Protocol): def effective_config_for_host(self, host: str) -> dict[str, Any]: """Return the fully merged effective config for a host.""" - - -class FileConfigSource: - def __init__(self, prefix: Path) -> None: - self.prefix = prefix - - def effective_config_for_host(self, host: str) -> dict[str, Any]: - global_cfg = load_global_config(self.prefix / "config" / "global.yaml") - host_cfg = load_host_config(self.prefix / "config" / "hosts" / f"{host}.yaml") - return build_effective_config(global_cfg, host_cfg) diff --git a/src/pobsync_backend/config_repository.py b/src/pobsync_backend/config_repository.py index 5cf5f40..7f08e5d 100644 --- a/src/pobsync_backend/config_repository.py +++ b/src/pobsync_backend/config_repository.py @@ -1,13 +1,10 @@ from __future__ import annotations -from pathlib import Path from typing import Any from django.core.exceptions import ObjectDoesNotExist from pobsync.config.schemas import GLOBAL_SCHEMA, HOST_SCHEMA -from pobsync.paths import PobsyncPaths -from pobsync.util import write_yaml_atomic from pobsync.validate import validate_dict from .models import GlobalConfig, HostConfig @@ -17,7 +14,7 @@ class ConfigRepositoryError(RuntimeError): pass -def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]: +def _global_runtime_data(global_config: GlobalConfig) -> dict[str, Any]: data = { "backup_root": global_config.backup_root, "pobsync_home": global_config.pobsync_home, @@ -48,7 +45,7 @@ def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]: return validate_dict(data, GLOBAL_SCHEMA, path="global") -def _host_yaml_data(host_config: HostConfig) -> dict[str, Any]: +def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]: data: dict[str, Any] = { "host": host_config.host, "address": host_config.address, @@ -78,11 +75,11 @@ def _host_yaml_data(host_config: HostConfig) -> dict[str, Any]: def global_config_object_data(global_config: GlobalConfig) -> dict[str, Any]: - return _global_yaml_data(global_config) + return _global_runtime_data(global_config) def host_config_object_data(host_config: HostConfig) -> dict[str, Any]: - return _host_yaml_data(host_config) + return _host_runtime_data(host_config) def global_config_data(name: str = "default") -> dict[str, Any]: @@ -90,7 +87,7 @@ def global_config_data(name: str = "default") -> dict[str, Any]: global_config = GlobalConfig.objects.get(name=name) except ObjectDoesNotExist as exc: raise ConfigRepositoryError(f"Missing GlobalConfig {name!r}") from exc - return _global_yaml_data(global_config) + return _global_runtime_data(global_config) def host_config_data(host: str) -> dict[str, Any]: @@ -98,37 +95,4 @@ def host_config_data(host: str) -> dict[str, Any]: host_config = HostConfig.objects.get(host=host, enabled=True) except ObjectDoesNotExist as exc: raise ConfigRepositoryError(f"Missing enabled HostConfig {host!r}") from exc - return _host_yaml_data(host_config) - - -def export_global_config(prefix: Path, name: str = "default") -> Path: - try: - global_config = GlobalConfig.objects.get(name=name) - except ObjectDoesNotExist as exc: - raise ConfigRepositoryError(f"Missing GlobalConfig {name!r}") from exc - - paths = PobsyncPaths(home=prefix) - write_yaml_atomic(paths.global_config_path, _global_yaml_data(global_config)) - return paths.global_config_path - - -def export_host_config(prefix: Path, host: str) -> Path: - try: - host_config = HostConfig.objects.get(host=host, enabled=True) - except ObjectDoesNotExist as exc: - raise ConfigRepositoryError(f"Missing enabled HostConfig {host!r}") from exc - - paths = PobsyncPaths(home=prefix) - target = paths.hosts_dir / f"{host_config.host}.yaml" - write_yaml_atomic(target, _host_yaml_data(host_config)) - return target - - -def export_runtime_configs(prefix: Path, host: str | None = None) -> list[Path]: - written = [export_global_config(prefix)] - hosts = HostConfig.objects.filter(enabled=True).order_by("host") - if host is not None: - hosts = hosts.filter(host=host) - for host_config in hosts: - written.append(export_host_config(prefix, host_config.host)) - return written + return _host_runtime_data(host_config) diff --git a/src/pobsync_backend/management/commands/export_pobsync_configs.py b/src/pobsync_backend/management/commands/export_pobsync_configs.py deleted file mode 100644 index 78623bb..0000000 --- a/src/pobsync_backend/management/commands/export_pobsync_configs.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from django.conf import settings -from django.core.management.base import BaseCommand - -from pobsync_backend.config_repository import export_runtime_configs - - -class Command(BaseCommand): - help = "Export Django database configs to pobsync runtime YAML files." - - def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") - parser.add_argument("--host", default=None, help="Export only one enabled host") - - def handle(self, *args: Any, **options: Any) -> None: - written = export_runtime_configs(prefix=Path(options["prefix"]), host=options["host"]) - for path in written: - self.stdout.write(str(path)) - self.stdout.write(self.style.SUCCESS(f"Exported {len(written)} config file(s).")) diff --git a/src/pobsync_backend/management/commands/import_pobsync_configs.py b/src/pobsync_backend/management/commands/import_pobsync_configs.py deleted file mode 100644 index a2de4b4..0000000 --- a/src/pobsync_backend/management/commands/import_pobsync_configs.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from pobsync.config.load import load_global_config, load_host_config -from pobsync.paths import PobsyncPaths -from pobsync_backend.models import GlobalConfig, HostConfig - - -class Command(BaseCommand): - help = "Import pobsync YAML configs into the Django database." - - def add_arguments(self, parser) -> None: - parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root") - - def handle(self, *args: Any, **options: Any) -> None: - paths = PobsyncPaths(home=Path(options["prefix"])) - if not paths.global_config_path.exists(): - raise CommandError(f"Missing global config: {paths.global_config_path}") - - global_cfg = load_global_config(paths.global_config_path) - global_ssh = global_cfg.get("ssh") or {} - global_rsync = global_cfg.get("rsync") or {} - global_defaults = global_cfg.get("defaults") or {} - retention_defaults = global_cfg.get("retention_defaults") or {} - GlobalConfig.objects.update_or_create( - name="default", - defaults={ - "backup_root": global_cfg["backup_root"], - "pobsync_home": global_cfg.get("pobsync_home", str(paths.home)), - "ssh_user": global_ssh.get("user") or "root", - "ssh_port": global_ssh.get("port") or 22, - "ssh_options": global_ssh.get("options") or [], - "rsync_binary": global_rsync.get("binary") or "rsync", - "rsync_args": global_rsync.get("args") or [], - "rsync_extra_args": global_rsync.get("extra_args") or [], - "rsync_timeout_seconds": global_rsync.get("timeout_seconds") or 0, - "rsync_bwlimit_kbps": global_rsync.get("bwlimit_kbps") or 0, - "default_source_root": global_defaults.get("source_root") or "/", - "default_destination_subdir": global_defaults.get("destination_subdir") or "", - "excludes_default": global_cfg.get("excludes_default") or [], - "retention_daily": retention_defaults.get("daily", 14), - "retention_weekly": retention_defaults.get("weekly", 8), - "retention_monthly": retention_defaults.get("monthly", 12), - "retention_yearly": retention_defaults.get("yearly", 0), - "data": global_cfg, - }, - ) - - count = 0 - for host_path in sorted(paths.hosts_dir.glob("*.yaml")): - host_cfg = load_host_config(host_path) - host_ssh = host_cfg.get("ssh") or {} - host_rsync = host_cfg.get("rsync") or {} - host_retention = host_cfg.get("retention") or {} - HostConfig.objects.update_or_create( - host=host_cfg["host"], - defaults={ - "address": host_cfg["address"], - "ssh_user": host_ssh.get("user") or "", - "ssh_port": host_ssh.get("port"), - "source_root": host_cfg.get("source_root") or "", - "includes": host_cfg.get("includes") or [], - "excludes_add": host_cfg.get("excludes_add") or [], - "excludes_replace": host_cfg.get("excludes_replace"), - "rsync_extra_args": host_rsync.get("extra_args") or [], - "retention_daily": host_retention.get("daily", 14), - "retention_weekly": host_retention.get("weekly", 8), - "retention_monthly": host_retention.get("monthly", 12), - "retention_yearly": host_retention.get("yearly", 0), - "config": host_cfg, - "enabled": True, - }, - ) - count += 1 - - self.stdout.write(self.style.SUCCESS(f"Imported global config and {count} host config(s).")) diff --git a/src/pobsync_backend/tests/test_config_repository.py b/src/pobsync_backend/tests/test_config_repository.py index 312aab2..409294a 100644 --- a/src/pobsync_backend/tests/test_config_repository.py +++ b/src/pobsync_backend/tests/test_config_repository.py @@ -1,71 +1,63 @@ from __future__ import annotations -import tempfile -from pathlib import Path - from django.test import TestCase -from pobsync.config.load import load_global_config, load_host_config -from pobsync_backend.config_repository import export_runtime_configs +from pobsync_backend.config_repository import global_config_data, host_config_data from pobsync_backend.models import GlobalConfig, HostConfig class ConfigRepositoryTests(TestCase): - def test_exports_database_configs_to_engine_yaml(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - prefix = Path(tmp) - GlobalConfig.objects.create( - name="default", - backup_root="/backups", - pobsync_home=str(prefix), - ssh_user="backup", - ssh_port=2222, - rsync_args=["--archive"], - excludes_default=["/proc/***"], - retention_daily=7, - retention_weekly=4, - retention_monthly=3, - retention_yearly=1, - data={ - "backup_root": "/ignored", - "pobsync_home": "/ignored", - "ssh": {"user": "ignored", "port": 22, "options": []}, - "unknown": "must-not-leak", - "retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, - }, - ) - HostConfig.objects.create( - host="web-01", - address="web-01.example.test", - ssh_user="root", - includes=[], - excludes_add=["/tmp/***"], - retention_daily=7, - retention_weekly=4, - retention_monthly=3, - retention_yearly=1, - config={ - "host": "ignored", - "address": "ignored", - "retention": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, - "excludes_add": ["/ignored/***"], - "unknown": "must-not-leak", - }, - ) + def test_builds_runtime_config_from_database_fields(self) -> None: + GlobalConfig.objects.create( + name="default", + backup_root="/backups", + pobsync_home="/var/lib/pobsync", + ssh_user="backup", + ssh_port=2222, + rsync_args=["--archive"], + excludes_default=["/proc/***"], + retention_daily=7, + retention_weekly=4, + retention_monthly=3, + retention_yearly=1, + data={ + "backup_root": "/ignored", + "pobsync_home": "/ignored", + "ssh": {"user": "ignored", "port": 22, "options": []}, + "unknown": "must-not-leak", + "retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, + }, + ) + HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + ssh_user="root", + includes=[], + excludes_add=["/tmp/***"], + retention_daily=7, + retention_weekly=4, + retention_monthly=3, + retention_yearly=1, + config={ + "host": "ignored", + "address": "ignored", + "retention": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, + "excludes_add": ["/ignored/***"], + "unknown": "must-not-leak", + }, + ) - written = export_runtime_configs(prefix=prefix, host="web-01") + global_cfg = global_config_data() + host_cfg = host_config_data("web-01") - self.assertEqual(len(written), 2) - global_cfg = load_global_config(prefix / "config" / "global.yaml") - host_cfg = load_host_config(prefix / "config" / "hosts" / "web-01.yaml") - self.assertEqual(global_cfg["backup_root"], "/backups") - self.assertEqual(global_cfg["pobsync_home"], str(prefix)) - self.assertEqual(global_cfg["ssh"]["user"], "backup") - self.assertEqual(global_cfg["ssh"]["port"], 2222) - self.assertEqual(global_cfg["retention_defaults"]["daily"], 7) - self.assertEqual(host_cfg["host"], "web-01") - self.assertEqual(host_cfg["address"], "web-01.example.test") - self.assertEqual(host_cfg["retention"]["daily"], 7) - self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"]) - self.assertNotIn("unknown", global_cfg) - self.assertNotIn("unknown", host_cfg) + self.assertEqual(global_cfg["backup_root"], "/backups") + self.assertEqual(global_cfg["pobsync_home"], "/var/lib/pobsync") + self.assertEqual(global_cfg["ssh"]["user"], "backup") + self.assertEqual(global_cfg["ssh"]["port"], 2222) + self.assertEqual(global_cfg["retention_defaults"]["daily"], 7) + self.assertEqual(host_cfg["host"], "web-01") + self.assertEqual(host_cfg["address"], "web-01.example.test") + self.assertEqual(host_cfg["retention"]["daily"], 7) + self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"]) + self.assertNotIn("unknown", global_cfg) + self.assertNotIn("unknown", host_cfg) diff --git a/src/pobsync_backend/tests/test_retention_config_source.py b/src/pobsync_backend/tests/test_retention_config_source.py index 5a28dd7..8df8818 100644 --- a/src/pobsync_backend/tests/test_retention_config_source.py +++ b/src/pobsync_backend/tests/test_retention_config_source.py @@ -7,6 +7,7 @@ from tempfile import TemporaryDirectory from django.test import SimpleTestCase from pobsync.commands.retention_plan import run_retention_plan +from pobsync.errors import ConfigError from pobsync.util import write_yaml_atomic @@ -24,6 +25,15 @@ class FakeConfigSource: class RetentionConfigSourceTests(SimpleTestCase): + def test_retention_plan_requires_explicit_config_source(self) -> None: + with self.assertRaisesMessage(ConfigError, "A Django config source is required."): + run_retention_plan( + prefix=Path("/missing-prefix"), + host="web-01", + kind="scheduled", + protect_bases=False, + ) + def test_retention_plan_uses_injected_config_source(self) -> None: with TemporaryDirectory() as tmp: root = Path(tmp) / "backups" diff --git a/src/pobsync_backend/tests/test_run_scheduled_config_source.py b/src/pobsync_backend/tests/test_run_scheduled_config_source.py index 0d599c5..fce2dc1 100644 --- a/src/pobsync_backend/tests/test_run_scheduled_config_source.py +++ b/src/pobsync_backend/tests/test_run_scheduled_config_source.py @@ -7,6 +7,7 @@ from unittest.mock import patch from django.test import SimpleTestCase from pobsync.commands.run_scheduled import run_scheduled +from pobsync.errors import ConfigError from pobsync.rsync import RsyncResult @@ -34,6 +35,10 @@ class FakeConfigSource: class RunScheduledConfigSourceTests(SimpleTestCase): + def test_requires_explicit_config_source(self) -> None: + with self.assertRaisesMessage(ConfigError, "A Django config source is required."): + run_scheduled(prefix=Path("/missing-prefix"), host="web-01", dry_run=True) + def test_dry_run_uses_injected_config_source(self) -> None: with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync: run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"]) From 2642f14e497c982315c4644715d1038f5cf02c1b Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:41:02 +0200 Subject: [PATCH 4/7] (refactor) Remove pobsync_home from global config Drop the obsolete pobsync_home field from GlobalConfig and remove it from runtime config generation, form saves, and configuration commands. The runtime state root now comes exclusively from POBSYNC_HOME/settings, which keeps the Django model focused on backup behavior instead of install layout. --- src/pobsync/config/schemas.py | 2 -- src/pobsync_backend/config_repository.py | 1 - src/pobsync_backend/forms.py | 1 - .../commands/configure_pobsync_global.py | 5 ----- .../0010_remove_globalconfig_pobsync_home.py | 14 ++++++++++++++ src/pobsync_backend/models.py | 1 - .../tests/test_config_repository.py | 3 --- .../tests/test_configure_commands.py | 1 - .../tests/test_django_config_source.py | 6 ------ src/pobsync_backend/tests/test_views.py | 4 ---- 10 files changed, 14 insertions(+), 24 deletions(-) create mode 100644 src/pobsync_backend/migrations/0010_remove_globalconfig_pobsync_home.py diff --git a/src/pobsync/config/schemas.py b/src/pobsync/config/schemas.py index 2ab7bb2..c8baa1c 100644 --- a/src/pobsync/config/schemas.py +++ b/src/pobsync/config/schemas.py @@ -83,7 +83,6 @@ OUTPUT_SCHEMA = Schema( GLOBAL_SCHEMA = Schema( fields={ "backup_root": FieldSpec(str, required=True), - "pobsync_home": FieldSpec(str, required=False, default="/opt/pobsync"), "ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA), "rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA), "defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA), @@ -131,4 +130,3 @@ HOST_SCHEMA = Schema( }, allow_unknown=False, ) - diff --git a/src/pobsync_backend/config_repository.py b/src/pobsync_backend/config_repository.py index 7f08e5d..91a53d8 100644 --- a/src/pobsync_backend/config_repository.py +++ b/src/pobsync_backend/config_repository.py @@ -17,7 +17,6 @@ class ConfigRepositoryError(RuntimeError): def _global_runtime_data(global_config: GlobalConfig) -> dict[str, Any]: data = { "backup_root": global_config.backup_root, - "pobsync_home": global_config.pobsync_home, "ssh": { "user": global_config.ssh_user, "port": global_config.ssh_port, diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index ec2f6cc..b177a40 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -119,7 +119,6 @@ class GlobalConfigForm(forms.ModelForm): def save(self, commit: bool = True): instance = super().save(commit=False) instance.backup_root = settings.POBSYNC_BACKUP_ROOT - instance.pobsync_home = settings.POBSYNC_HOME if commit: instance.save() self.save_m2m() diff --git a/src/pobsync_backend/management/commands/configure_pobsync_global.py b/src/pobsync_backend/management/commands/configure_pobsync_global.py index a7cc318..730f053 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_global.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_global.py @@ -1,9 +1,7 @@ from __future__ import annotations -from pathlib import Path from typing import Any -from django.conf import settings from django.core.management.base import BaseCommand, CommandError from pobsync.config.retention import parse_retention @@ -18,7 +16,6 @@ class Command(BaseCommand): def add_arguments(self, parser) -> None: parser.add_argument("--name", default="default") parser.add_argument("--backup-root", required=True) - parser.add_argument("--pobsync-home", default=settings.POBSYNC_HOME, help="Runtime state root") parser.add_argument("--ssh-user", default="root") parser.add_argument("--ssh-port", type=int, default=22) parser.add_argument("--source-root", default="/") @@ -30,11 +27,9 @@ class Command(BaseCommand): if not is_absolute_non_root(backup_root): raise CommandError("--backup-root must be an absolute path and must not be '/'") - pobsync_home = str(Path(options["pobsync_home"])) retention = parse_retention(options["retention"]) defaults = { "backup_root": backup_root, - "pobsync_home": pobsync_home, "ssh_user": options["ssh_user"], "ssh_port": options["ssh_port"], "ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"], diff --git a/src/pobsync_backend/migrations/0010_remove_globalconfig_pobsync_home.py b/src/pobsync_backend/migrations/0010_remove_globalconfig_pobsync_home.py new file mode 100644 index 0000000..e88081a --- /dev/null +++ b/src/pobsync_backend/migrations/0010_remove_globalconfig_pobsync_home.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0009_remove_scheduleconfig_user"), + ] + + operations = [ + migrations.RemoveField( + model_name="globalconfig", + name="pobsync_home", + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index 830458c..c97ffe3 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -14,7 +14,6 @@ class TimestampedModel(models.Model): class GlobalConfig(TimestampedModel): name = models.CharField(max_length=64, default="default", unique=True) backup_root = models.CharField(max_length=512) - pobsync_home = models.CharField(max_length=512, default="/opt/pobsync") default_ssh_credential = models.ForeignKey( "SshCredential", on_delete=models.SET_NULL, diff --git a/src/pobsync_backend/tests/test_config_repository.py b/src/pobsync_backend/tests/test_config_repository.py index 409294a..64d32d1 100644 --- a/src/pobsync_backend/tests/test_config_repository.py +++ b/src/pobsync_backend/tests/test_config_repository.py @@ -11,7 +11,6 @@ class ConfigRepositoryTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/var/lib/pobsync", ssh_user="backup", ssh_port=2222, rsync_args=["--archive"], @@ -22,7 +21,6 @@ class ConfigRepositoryTests(TestCase): retention_yearly=1, data={ "backup_root": "/ignored", - "pobsync_home": "/ignored", "ssh": {"user": "ignored", "port": 22, "options": []}, "unknown": "must-not-leak", "retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, @@ -51,7 +49,6 @@ class ConfigRepositoryTests(TestCase): host_cfg = host_config_data("web-01") self.assertEqual(global_cfg["backup_root"], "/backups") - self.assertEqual(global_cfg["pobsync_home"], "/var/lib/pobsync") self.assertEqual(global_cfg["ssh"]["user"], "backup") self.assertEqual(global_cfg["ssh"]["port"], 2222) self.assertEqual(global_cfg["retention_defaults"]["daily"], 7) diff --git a/src/pobsync_backend/tests/test_configure_commands.py b/src/pobsync_backend/tests/test_configure_commands.py index f96798b..bf5ef99 100644 --- a/src/pobsync_backend/tests/test_configure_commands.py +++ b/src/pobsync_backend/tests/test_configure_commands.py @@ -16,7 +16,6 @@ class ConfigureCommandsTests(TestCase): call_command( "configure_pobsync_global", backup_root="/backups", - pobsync_home="/opt/pobsync", retention="daily=3,weekly=2,monthly=1,yearly=0", stdout=out, ) diff --git a/src/pobsync_backend/tests/test_django_config_source.py b/src/pobsync_backend/tests/test_django_config_source.py index 0d78740..0dbc549 100644 --- a/src/pobsync_backend/tests/test_django_config_source.py +++ b/src/pobsync_backend/tests/test_django_config_source.py @@ -15,7 +15,6 @@ class DjangoConfigSourceTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/opt/pobsync", rsync_args=["--archive"], rsync_extra_args=["--numeric-ids"], excludes_default=["/proc/***"], @@ -25,7 +24,6 @@ class DjangoConfigSourceTests(TestCase): retention_yearly=1, data={ "backup_root": "/ignored", - "pobsync_home": "/ignored", "ssh": {"user": "root", "port": 22, "options": []}, "rsync": { "binary": "rsync", @@ -72,7 +70,6 @@ class DjangoConfigSourceTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/opt/pobsync", default_ssh_credential=credential, ssh_options=["-oBatchMode=yes"], ) @@ -99,7 +96,6 @@ class DjangoConfigSourceTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/opt/pobsync", default_ssh_credential=global_credential, ) HostConfig.objects.create( @@ -127,7 +123,6 @@ class DjangoConfigSourceTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/opt/pobsync", default_ssh_credential=credential, ) HostConfig.objects.create(host="web-01", address="web-01.example.test") @@ -146,7 +141,6 @@ class DjangoConfigSourceTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/backups", - pobsync_home="/opt/pobsync", default_ssh_credential=credential, ) HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 08c3360..0303bf9 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -475,7 +475,6 @@ class ViewTests(TestCase): self.assertContains(response, "Global config saved for default.") config = GlobalConfig.objects.get(name="default") self.assertEqual(config.backup_root, "/backups") - self.assertEqual(config.pobsync_home, "/opt/pobsync") self.assertEqual(config.default_ssh_credential, credential) self.assertEqual(config.ssh_user, "backup") self.assertEqual(config.ssh_port, 2222) @@ -502,7 +501,6 @@ class ViewTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/mnt/pobsync/backups", - pobsync_home="/custom/state/home", ) response = self.client.get(reverse("edit_global_config")) @@ -532,7 +530,6 @@ class ViewTests(TestCase): GlobalConfig.objects.create( name="default", backup_root="/mnt/pobsync/backups", - pobsync_home="/custom/state/home", ) response = self.client.post( @@ -561,7 +558,6 @@ class ViewTests(TestCase): self.assertRedirects(response, reverse("dashboard")) config = GlobalConfig.objects.get(name="default") self.assertEqual(config.backup_root, "/backups") - self.assertEqual(config.pobsync_home, "/opt/pobsync") def test_create_host_config_form_creates_host(self) -> None: self.client.force_login(self.staff_user) From 86873bd0352af9dfa71b455eb474b8547c5e144c Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:46:09 +0200 Subject: [PATCH 5/7] (refactor) Remove obsolete global config JSON storage Drop the unused GlobalConfig.data field and remove the remaining YAML config path helpers from PobsyncPaths. Keep HostConfig.config as runtime state for preflight data, and relabel it in the admin so it no longer reads as legacy compatibility storage. --- src/pobsync/commands/run_scheduled.py | 1 - src/pobsync/config/schemas.py | 1 - src/pobsync/paths.py | 13 ------------- src/pobsync_backend/admin.py | 3 +-- .../migrations/0011_remove_globalconfig_data.py | 14 ++++++++++++++ src/pobsync_backend/models.py | 1 - src/pobsync_backend/tests/test_admin.py | 6 ++++-- .../tests/test_config_repository.py | 6 ------ .../tests/test_django_config_source.py | 14 -------------- 9 files changed, 19 insertions(+), 40 deletions(-) create mode 100644 src/pobsync_backend/migrations/0011_remove_globalconfig_data.py diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index 4a321b0..1cd76c9 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -317,7 +317,6 @@ def run_scheduled( "duration_seconds": None, "base": _base_meta_from_path(base_dir, link_dest), "rsync": {"exit_code": None, "command": cmd, "stats": {}}, - # Keep existing fields for future expansion / compatibility with current structure. "overrides": {"includes": [], "excludes": [], "base": None}, } diff --git a/src/pobsync/config/schemas.py b/src/pobsync/config/schemas.py index c8baa1c..e840965 100644 --- a/src/pobsync/config/schemas.py +++ b/src/pobsync/config/schemas.py @@ -94,7 +94,6 @@ GLOBAL_SCHEMA = Schema( ), "logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA), "output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA), - # Used by `init-host` as a convenience default "retention_defaults": FieldSpec( dict, required=False, diff --git a/src/pobsync/paths.py b/src/pobsync/paths.py index 28151db..e57a554 100644 --- a/src/pobsync/paths.py +++ b/src/pobsync/paths.py @@ -8,14 +8,6 @@ from pathlib import Path class PobsyncPaths: home: Path # usually /opt/pobsync - @property - def config_dir(self) -> Path: - return self.home / "config" - - @property - def hosts_dir(self) -> Path: - return self.config_dir / "hosts" - @property def state_dir(self) -> Path: return self.home / "state" @@ -28,11 +20,6 @@ class PobsyncPaths: def logs_dir(self) -> Path: return self.home / "logs" - @property - def global_config_path(self) -> Path: - return self.config_dir / "global.yaml" - @property def central_log_path(self) -> Path: return self.logs_dir / "pobsync.log" - diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index 708ae5a..327ecb0 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -50,7 +50,6 @@ class GlobalConfigAdmin(admin.ModelAdmin): ), ("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}), ("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), - ("Compatibility data", {"fields": ("data",), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) @@ -76,7 +75,7 @@ class HostConfigAdmin(admin.ModelAdmin): ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}), ("Rsync override", {"fields": ("rsync_extra_args",)}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), - ("Compatibility data", {"fields": ("config",), "classes": ("collapse",)}), + ("Runtime state", {"fields": ("config",), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ) diff --git a/src/pobsync_backend/migrations/0011_remove_globalconfig_data.py b/src/pobsync_backend/migrations/0011_remove_globalconfig_data.py new file mode 100644 index 0000000..4f41a1f --- /dev/null +++ b/src/pobsync_backend/migrations/0011_remove_globalconfig_data.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0010_remove_globalconfig_pobsync_home"), + ] + + operations = [ + migrations.RemoveField( + model_name="globalconfig", + name="data", + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index c97ffe3..93faa50 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -36,7 +36,6 @@ class GlobalConfig(TimestampedModel): retention_weekly = models.PositiveIntegerField(default=8) retention_monthly = models.PositiveIntegerField(default=12) retention_yearly = models.PositiveIntegerField(default=0) - data = models.JSONField(default=dict, blank=True) class Meta: verbose_name = "global config" diff --git a/src/pobsync_backend/tests/test_admin.py b/src/pobsync_backend/tests/test_admin.py index c87d707..29a05bc 100644 --- a/src/pobsync_backend/tests/test_admin.py +++ b/src/pobsync_backend/tests/test_admin.py @@ -10,7 +10,7 @@ from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, Schedule class AdminDisplayTests(TestCase): - def test_admin_hides_old_global_state_field_and_uses_compatibility_label(self) -> None: + def test_admin_hides_old_global_state_fields_and_labels_host_runtime_state(self) -> None: site = AdminSite() global_admin = GlobalConfigAdmin(GlobalConfig, site) host_admin = HostConfigAdmin(HostConfig, site) @@ -21,7 +21,9 @@ class AdminDisplayTests(TestCase): fieldset_names = [name for name, _options in [*global_fieldsets, *host_fieldsets]] self.assertNotIn("pobsync_home", global_fields) - self.assertIn("Compatibility data", fieldset_names) + self.assertNotIn("data", global_fields) + self.assertIn("Runtime state", fieldset_names) + self.assertNotIn("Compatibility data", fieldset_names) self.assertNotIn("Legacy JSON", fieldset_names) def test_host_admin_links_to_related_snapshots_and_runs(self) -> None: diff --git a/src/pobsync_backend/tests/test_config_repository.py b/src/pobsync_backend/tests/test_config_repository.py index 64d32d1..e25f366 100644 --- a/src/pobsync_backend/tests/test_config_repository.py +++ b/src/pobsync_backend/tests/test_config_repository.py @@ -19,12 +19,6 @@ class ConfigRepositoryTests(TestCase): retention_weekly=4, retention_monthly=3, retention_yearly=1, - data={ - "backup_root": "/ignored", - "ssh": {"user": "ignored", "port": 22, "options": []}, - "unknown": "must-not-leak", - "retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, - }, ) HostConfig.objects.create( host="web-01", diff --git a/src/pobsync_backend/tests/test_django_config_source.py b/src/pobsync_backend/tests/test_django_config_source.py index 0dbc549..6070061 100644 --- a/src/pobsync_backend/tests/test_django_config_source.py +++ b/src/pobsync_backend/tests/test_django_config_source.py @@ -22,20 +22,6 @@ class DjangoConfigSourceTests(TestCase): retention_weekly=4, retention_monthly=3, retention_yearly=1, - data={ - "backup_root": "/ignored", - "ssh": {"user": "root", "port": 22, "options": []}, - "rsync": { - "binary": "rsync", - "args": ["--archive"], - "timeout_seconds": 0, - "bwlimit_kbps": 0, - "extra_args": ["--numeric-ids"], - }, - "defaults": {"source_root": "/", "destination_subdir": ""}, - "excludes_default": ["/proc/***"], - "retention_defaults": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1}, - }, ) HostConfig.objects.create( host="web-01", From 1c8cbd96ca41c7c80d107b177a038844d9a65714 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:52:42 +0200 Subject: [PATCH 6/7] (refactor) Normalize maintainer command labels Prefer --schedule-expression for scripted schedule updates while keeping --cron as a compatibility alias. Clean up management command help, errors, and output so operator-facing text talks about hosts, global config, and Django backup configuration instead of model names or old SQL-backed pobsync wording. --- docs/development.md | 2 +- .../commands/configure_pobsync_global.py | 6 +++--- .../commands/configure_pobsync_host.py | 6 +++--- .../commands/configure_pobsync_schedule.py | 20 ++++++++++++------- .../commands/discover_pobsync_snapshots.py | 4 ++-- .../management/commands/run_pobsync_backup.py | 2 +- .../commands/run_pobsync_retention.py | 2 +- .../tests/test_configure_commands.py | 4 ++-- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/development.md b/docs/development.md index 994235c..a221f9d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -70,7 +70,7 @@ automation/debugging path rather than the normal UI workflow: ``` pobsync django configure_pobsync_host --address -pobsync django configure_pobsync_schedule --cron "15 2 * * *" +pobsync django configure_pobsync_schedule --schedule-expression "15 2 * * *" ``` ## Installer Development diff --git a/src/pobsync_backend/management/commands/configure_pobsync_global.py b/src/pobsync_backend/management/commands/configure_pobsync_global.py index 730f053..c093c0b 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_global.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_global.py @@ -11,7 +11,7 @@ from pobsync_backend.models import GlobalConfig class Command(BaseCommand): - help = "Create or update the SQL-backed global pobsync configuration." + help = "Create or update the default global backup configuration." def add_arguments(self, parser) -> None: parser.add_argument("--name", default="default") @@ -48,8 +48,8 @@ class Command(BaseCommand): } if GlobalConfig.objects.filter(name=options["name"]).exists() and not options["force"]: - raise CommandError(f"GlobalConfig {options['name']!r} already exists; use --force to update") + raise CommandError(f"Global config {options['name']!r} already exists; use --force to update") _obj, created = GlobalConfig.objects.update_or_create(name=options["name"], defaults=defaults) action = "Created" if created else "Updated" - self.stdout.write(self.style.SUCCESS(f"{action} GlobalConfig {options['name']!r}.")) + self.stdout.write(self.style.SUCCESS(f"{action} global config {options['name']!r}.")) diff --git a/src/pobsync_backend/management/commands/configure_pobsync_host.py b/src/pobsync_backend/management/commands/configure_pobsync_host.py index 6b79c04..a22961c 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_host.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_host.py @@ -10,7 +10,7 @@ from pobsync_backend.models import GlobalConfig, HostConfig class Command(BaseCommand): - help = "Create or update a SQL-backed host pobsync configuration." + help = "Create or update a host backup configuration." def add_arguments(self, parser) -> None: parser.add_argument("host") @@ -29,7 +29,7 @@ class Command(BaseCommand): def handle(self, *args: Any, **options: Any) -> None: host = sanitize_host(options["host"]) if HostConfig.objects.filter(host=host).exists() and not options["force"]: - raise CommandError(f"HostConfig {host!r} already exists; use --force to update") + raise CommandError(f"Host {host!r} already exists; use --force to update") retention = self._retention(options["retention"]) defaults = { @@ -49,7 +49,7 @@ class Command(BaseCommand): } _obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults) action = "Created" if created else "Updated" - self.stdout.write(self.style.SUCCESS(f"{action} HostConfig {host!r}.")) + self.stdout.write(self.style.SUCCESS(f"{action} host {host!r}.")) def _retention(self, value: str | None) -> dict[str, int]: if value: diff --git a/src/pobsync_backend/management/commands/configure_pobsync_schedule.py b/src/pobsync_backend/management/commands/configure_pobsync_schedule.py index b646389..fbef47e 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_schedule.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_schedule.py @@ -9,11 +9,16 @@ from pobsync_backend.scheduler import parse_cron_expr class Command(BaseCommand): - help = "Create, update, disable, or remove a SQL-backed pobsync schedule." + help = "Create, update, disable, or remove a scheduler-managed host schedule." def add_arguments(self, parser) -> None: parser.add_argument("host") - parser.add_argument("--cron", help='Cron expression, e.g. "15 2 * * *"') + parser.add_argument( + "--schedule-expression", + "--cron", + dest="schedule_expression", + help='Five-field schedule expression, e.g. "15 2 * * *"', + ) parser.add_argument("--prune", action="store_true") parser.add_argument("--prune-max-delete", type=int, default=10) parser.add_argument("--prune-protect-bases", action="store_true") @@ -24,24 +29,25 @@ class Command(BaseCommand): try: host = HostConfig.objects.get(host=options["host"]) except HostConfig.DoesNotExist as exc: - raise CommandError(f"Missing HostConfig {options['host']!r}") from exc + raise CommandError(f"Missing host {options['host']!r}") from exc if options["delete"]: deleted, _details = ScheduleConfig.objects.filter(host=host).delete() self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} schedule row(s) for {host.host!r}.")) return - if not options["cron"]: - raise CommandError("--cron is required unless --delete is used") + schedule_expression = options["schedule_expression"] + if not schedule_expression: + raise CommandError("--schedule-expression is required unless --delete is used") try: - parse_cron_expr(options["cron"]) + parse_cron_expr(schedule_expression) except ValueError as exc: raise CommandError(str(exc)) from exc schedule, created = ScheduleConfig.objects.update_or_create( host=host, defaults={ - "cron_expr": options["cron"], + "cron_expr": schedule_expression, "enabled": not options["disabled"], "prune": bool(options["prune"]), "prune_max_delete": int(options["prune_max_delete"]), diff --git a/src/pobsync_backend/management/commands/discover_pobsync_snapshots.py b/src/pobsync_backend/management/commands/discover_pobsync_snapshots.py index 1e5ca32..fab9c74 100644 --- a/src/pobsync_backend/management/commands/discover_pobsync_snapshots.py +++ b/src/pobsync_backend/management/commands/discover_pobsync_snapshots.py @@ -20,14 +20,14 @@ class Command(BaseCommand): try: global_config = GlobalConfig.objects.get(name="default") except GlobalConfig.DoesNotExist as exc: - raise CommandError("Missing GlobalConfig 'default'") from exc + raise CommandError("Missing default global config") from exc host = None if options["host"]: try: host = HostConfig.objects.get(host=options["host"], enabled=True) except HostConfig.DoesNotExist as exc: - raise CommandError(f"Missing enabled HostConfig {options['host']!r}") from exc + raise CommandError(f"Missing enabled host {options['host']!r}") from exc kind = normalize_kind(options["kind"]) kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind] diff --git a/src/pobsync_backend/management/commands/run_pobsync_backup.py b/src/pobsync_backend/management/commands/run_pobsync_backup.py index 57e18c9..874f7af 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_backup.py +++ b/src/pobsync_backend/management/commands/run_pobsync_backup.py @@ -30,7 +30,7 @@ class Command(BaseCommand): try: host = HostConfig.objects.get(host=host_name, enabled=True) except HostConfig.DoesNotExist as exc: - raise CommandError(f"Missing enabled HostConfig {host_name!r}") from exc + raise CommandError(f"Missing enabled host {host_name!r}") from exc run = BackupRun.objects.create( host=host, diff --git a/src/pobsync_backend/management/commands/run_pobsync_retention.py b/src/pobsync_backend/management/commands/run_pobsync_retention.py index 5067e40..2100a09 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_retention.py +++ b/src/pobsync_backend/management/commands/run_pobsync_retention.py @@ -12,7 +12,7 @@ from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention class Command(BaseCommand): - help = "Plan or apply retention using SQL-backed pobsync configuration." + help = "Plan or apply retention using the Django backup configuration." def add_arguments(self, parser) -> None: parser.add_argument("host") diff --git a/src/pobsync_backend/tests/test_configure_commands.py b/src/pobsync_backend/tests/test_configure_commands.py index bf5ef99..7656e1e 100644 --- a/src/pobsync_backend/tests/test_configure_commands.py +++ b/src/pobsync_backend/tests/test_configure_commands.py @@ -23,7 +23,7 @@ class ConfigureCommandsTests(TestCase): config = GlobalConfig.objects.get(name="default") self.assertEqual(config.backup_root, "/backups") self.assertEqual(config.retention_daily, 3) - self.assertIn("Created GlobalConfig", out.getvalue()) + self.assertIn("Created global config", out.getvalue()) def test_configure_host_uses_global_retention_defaults(self) -> None: GlobalConfig.objects.create( @@ -61,7 +61,7 @@ class ConfigureCommandsTests(TestCase): call_command( "configure_pobsync_schedule", host.host, - cron="15 2 * * *", + schedule_expression="15 2 * * *", prune=True, stdout=out, ) From a73d34ac9f628e9def2bb746f289300ef6820843 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 02:56:00 +0200 Subject: [PATCH 7/7] (refactor) Use operator-facing config errors Replace remaining model-name based configuration errors with labels that match the Django-first operating model. Add coverage for missing global config and host configuration errors so operator-facing messages stay readable. --- src/pobsync_backend/config_repository.py | 4 ++-- src/pobsync_backend/retention.py | 2 +- src/pobsync_backend/tests/test_config_repository.py | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pobsync_backend/config_repository.py b/src/pobsync_backend/config_repository.py index 91a53d8..560be5e 100644 --- a/src/pobsync_backend/config_repository.py +++ b/src/pobsync_backend/config_repository.py @@ -85,7 +85,7 @@ def global_config_data(name: str = "default") -> dict[str, Any]: try: global_config = GlobalConfig.objects.get(name=name) except ObjectDoesNotExist as exc: - raise ConfigRepositoryError(f"Missing GlobalConfig {name!r}") from exc + raise ConfigRepositoryError(f"Missing global config {name!r}") from exc return _global_runtime_data(global_config) @@ -93,5 +93,5 @@ def host_config_data(host: str) -> dict[str, Any]: try: host_config = HostConfig.objects.get(host=host, enabled=True) except ObjectDoesNotExist as exc: - raise ConfigRepositoryError(f"Missing enabled HostConfig {host!r}") from exc + raise ConfigRepositoryError(f"Missing enabled host {host!r}") from exc return _host_runtime_data(host_config) diff --git a/src/pobsync_backend/retention.py b/src/pobsync_backend/retention.py index 0d17a45..d054078 100644 --- a/src/pobsync_backend/retention.py +++ b/src/pobsync_backend/retention.py @@ -135,7 +135,7 @@ def _enabled_host_config(host: str) -> HostConfig: try: return HostConfig.objects.get(host=host, enabled=True) except HostConfig.DoesNotExist as exc: - raise ConfigError(f"Missing enabled HostConfig {host!r}") from exc + raise ConfigError(f"Missing enabled host {host!r}") from exc def _retention_for_host(host_config: HostConfig) -> dict[str, int]: diff --git a/src/pobsync_backend/tests/test_config_repository.py b/src/pobsync_backend/tests/test_config_repository.py index e25f366..8b0e42a 100644 --- a/src/pobsync_backend/tests/test_config_repository.py +++ b/src/pobsync_backend/tests/test_config_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from django.test import TestCase -from pobsync_backend.config_repository import global_config_data, host_config_data +from pobsync_backend.config_repository import ConfigRepositoryError, global_config_data, host_config_data from pobsync_backend.models import GlobalConfig, HostConfig @@ -52,3 +52,12 @@ class ConfigRepositoryTests(TestCase): self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"]) self.assertNotIn("unknown", global_cfg) self.assertNotIn("unknown", host_cfg) + + def test_missing_config_errors_use_operator_labels(self) -> None: + with self.assertRaisesMessage(ConfigRepositoryError, "Missing global config 'default'"): + global_config_data() + + GlobalConfig.objects.create(name="default", backup_root="/backups") + + with self.assertRaisesMessage(ConfigRepositoryError, "Missing enabled host 'web-01'"): + host_config_data("web-01")