8 Commits

Author SHA1 Message Date
362a9dde62 Merge pull request 'issue-7-config-cleanup-legacy-removal' (#18) from issue-7-config-cleanup-legacy-removal into master
Reviewed-on: #18
2026-05-21 02:57:38 +02:00
a73d34ac9f (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.
2026-05-21 02:56:00 +02:00
1c8cbd96ca (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.
2026-05-21 02:52:42 +02:00
86873bd035 (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.
2026-05-21 02:46:09 +02:00
2642f14e49 (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.
2026-05-21 02:41:02 +02:00
bb62382e18 (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.
2026-05-21 02:34:09 +02:00
c5865a5379 (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.
2026-05-21 02:24:55 +02:00
58d567f9bc (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.
2026-05-21 02:14:16 +02:00
36 changed files with 202 additions and 403 deletions

View File

@@ -196,8 +196,8 @@ rsync.
## SSH Keys ## SSH Keys
SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the 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 installer. pobsync stores the private key on disk under the runtime state root (`POBSYNC_HOME`), keeps the public key
you select a credential either as the global default or as a per-host override. 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: Generated private keys are stored at:

View File

@@ -47,6 +47,9 @@ pobsync django check
python3 manage.py showmigrations pobsync_backend 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: Worker and scheduler commands are normally run by systemd services:
``` ```
@@ -62,6 +65,14 @@ pobsync discover-snapshots --host <host>
pobsync retention <host> pobsync retention <host>
``` ```
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 <host> --address <host.example>
pobsync django configure_pobsync_schedule <host> --schedule-expression "15 2 * * *"
```
## Installer Development ## Installer Development
The native installer is interactive by default when stdin is a terminal. It should keep every prompt backed by a command The native installer is interactive by default when stdin is a terminal. It should keep every prompt backed by a command
@@ -85,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 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. run the Django/runtime refresh steps needed after a code update.
## Migration Helpers
Import existing legacy YAML configs:
```
python3 manage.py import_pobsync_configs --prefix /opt/pobsync
```
Export SQL config to legacy runtime 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 With SQLite
Docker Compose is useful for local development and disposable test installs. Native systemd is preferred for production Docker Compose is useful for local development and disposable test installs. Native systemd is preferred for production
@@ -181,4 +175,3 @@ Next refactor targets:
- Move more snapshot lifecycle details into typed domain objects. - Move more snapshot lifecycle details into typed domain objects.
- Replace remaining dictionary-shaped config at engine boundaries. - Replace remaining dictionary-shaped config at engine boundaries.
- Remove legacy YAML import/export once production migration no longer needs it.

View File

@@ -8,9 +8,6 @@ from django.core.management import execute_from_command_line
COMMAND_ALIASES = { COMMAND_ALIASES = {
"configure-global": "configure_pobsync_global",
"configure-host": "configure_pobsync_host",
"schedule": "configure_pobsync_schedule",
"backup": "run_pobsync_backup", "backup": "run_pobsync_backup",
"retention": "run_pobsync_retention", "retention": "run_pobsync_retention",
"discover-snapshots": "discover_pobsync_snapshots", "discover-snapshots": "discover_pobsync_snapshots",
@@ -29,6 +26,9 @@ Usage:
Commands: Commands:
{commands} {commands}
Configuration is managed from the Django control panel. Use
`pobsync django <management-command>` for automation or debugging.
""" """

View File

@@ -4,9 +4,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, List from typing import Any, List
from ..config.source import ConfigSource, FileConfigSource from ..config.source import ConfigSource
from ..errors import ConfigError from ..errors import ConfigError
from ..paths import PobsyncPaths
from ..retention import Snapshot, apply_base_protection, build_retention_plan from ..retention import Snapshot, apply_base_protection, build_retention_plan
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
from ..util import sanitize_host from ..util import sanitize_host
@@ -40,10 +39,9 @@ def run_retention_plan(
if kind not in {"scheduled", "manual", "all"}: if kind not in {"scheduled", "manual", "all"}:
raise ConfigError("kind must be scheduled, manual, or all") raise ConfigError("kind must be scheduled, manual, or all")
paths = PobsyncPaths(home=prefix) if config_source is None:
raise ConfigError("A Django config source is required.")
source = config_source or FileConfigSource(prefix=paths.home) cfg = config_source.effective_config_for_host(host)
cfg = source.effective_config_for_host(host)
retention = cfg.get("retention") retention = cfg.get("retention")
if not isinstance(retention, dict): if not isinstance(retention, dict):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from ..config.source import ConfigSource, FileConfigSource from ..config.source import ConfigSource
from ..errors import ConfigError from ..errors import ConfigError
from ..lock import acquire_host_lock from ..lock import acquire_host_lock
from ..paths import PobsyncPaths from ..paths import PobsyncPaths
@@ -163,8 +163,9 @@ def run_scheduled(
host = sanitize_host(host) host = sanitize_host(host)
paths = PobsyncPaths(home=prefix) paths = PobsyncPaths(home=prefix)
source = config_source or FileConfigSource(prefix=paths.home) if config_source is None:
cfg = source.effective_config_for_host(host) raise ConfigError("A Django config source is required.")
cfg = config_source.effective_config_for_host(host)
backup_root = cfg.get("backup_root") backup_root = cfg.get("backup_root")
if not isinstance(backup_root, str) or not backup_root.startswith("/"): if not isinstance(backup_root, str) or not backup_root.startswith("/"):
@@ -316,7 +317,6 @@ def run_scheduled(
"duration_seconds": None, "duration_seconds": None,
"base": _base_meta_from_path(base_dir, link_dest), "base": _base_meta_from_path(base_dir, link_dest),
"rsync": {"exit_code": None, "command": cmd, "stats": {}}, "rsync": {"exit_code": None, "command": cmd, "stats": {}},
# Keep existing fields for future expansion / compatibility with current structure.
"overrides": {"includes": [], "excludes": [], "base": None}, "overrides": {"includes": [], "excludes": [], "base": None},
} }

View File

@@ -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)

View File

@@ -83,7 +83,6 @@ OUTPUT_SCHEMA = Schema(
GLOBAL_SCHEMA = Schema( GLOBAL_SCHEMA = Schema(
fields={ fields={
"backup_root": FieldSpec(str, required=True), "backup_root": FieldSpec(str, required=True),
"pobsync_home": FieldSpec(str, required=False, default="/opt/pobsync"),
"ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA), "ssh": FieldSpec(dict, required=False, schema=SSH_SCHEMA),
"rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA), "rsync": FieldSpec(dict, required=False, schema=RSYNC_SCHEMA),
"defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA), "defaults": FieldSpec(dict, required=False, schema=DEFAULTS_SCHEMA),
@@ -95,7 +94,6 @@ GLOBAL_SCHEMA = Schema(
), ),
"logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA), "logging": FieldSpec(dict, required=False, schema=LOGGING_SCHEMA),
"output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA), "output": FieldSpec(dict, required=False, schema=OUTPUT_SCHEMA),
# Used by `init-host` as a convenience default
"retention_defaults": FieldSpec( "retention_defaults": FieldSpec(
dict, dict,
required=False, required=False,
@@ -131,4 +129,3 @@ HOST_SCHEMA = Schema(
}, },
allow_unknown=False, allow_unknown=False,
) )

View File

@@ -1,22 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any, Protocol from typing import Any, Protocol
from .load import load_global_config, load_host_config
from .merge import build_effective_config
class ConfigSource(Protocol): class ConfigSource(Protocol):
def effective_config_for_host(self, host: str) -> dict[str, Any]: def effective_config_for_host(self, host: str) -> dict[str, Any]:
"""Return the fully merged effective config for a host.""" """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)

View File

@@ -8,14 +8,6 @@ from pathlib import Path
class PobsyncPaths: class PobsyncPaths:
home: Path # usually /opt/pobsync 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 @property
def state_dir(self) -> Path: def state_dir(self) -> Path:
return self.home / "state" return self.home / "state"
@@ -28,11 +20,6 @@ class PobsyncPaths:
def logs_dir(self) -> Path: def logs_dir(self) -> Path:
return self.home / "logs" return self.home / "logs"
@property
def global_config_path(self) -> Path:
return self.config_dir / "global.yaml"
@property @property
def central_log_path(self) -> Path: def central_log_path(self) -> Path:
return self.logs_dir / "pobsync.log" return self.logs_dir / "pobsync.log"

View File

@@ -34,7 +34,7 @@ class GlobalConfigAdmin(admin.ModelAdmin):
list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at") list_display = ("name", "backup_root", "ssh_user", "ssh_port", "updated_at")
readonly_fields = ("created_at", "updated_at") readonly_fields = ("created_at", "updated_at")
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "backup_root", "pobsync_home")}), (None, {"fields": ("name", "backup_root")}),
("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}), ("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}),
( (
"Rsync", "Rsync",
@@ -50,7 +50,6 @@ class GlobalConfigAdmin(admin.ModelAdmin):
), ),
("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}), ("Defaults", {"fields": ("default_source_root", "default_destination_subdir", "excludes_default")}),
("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), ("Retention defaults", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Legacy JSON", {"fields": ("data",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "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")}), ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
("Rsync override", {"fields": ("rsync_extra_args",)}), ("Rsync override", {"fields": ("rsync_extra_args",)}),
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Legacy JSON", {"fields": ("config",), "classes": ("collapse",)}), ("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
) )

View File

@@ -17,7 +17,7 @@ CRITICAL_ROOT_EXCLUDES = ("/proc/***", "/sys/***", "/dev/***", "/run/***", "/tmp
def collect_global_config_checks(global_config: GlobalConfig) -> list[SelfCheck]: def collect_global_config_checks(global_config: GlobalConfig) -> list[SelfCheck]:
checks = [ checks = [
_absolute_path_check("Global backup root", global_config.backup_root), _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), _runtime_backup_root_check(global_config),
_rsync_binary_check(global_config.rsync_binary), _rsync_binary_check(global_config.rsync_binary),
_rsync_recursion_check( _rsync_recursion_check(
@@ -97,7 +97,7 @@ def _runtime_backup_root_check(global_config: GlobalConfig) -> SelfCheck:
return SelfCheck( return SelfCheck(
"Runtime backup root", "Runtime backup root",
"warning", "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}", f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
) )

View File

@@ -1,13 +1,10 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from pobsync.config.schemas import GLOBAL_SCHEMA, HOST_SCHEMA 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 pobsync.validate import validate_dict
from .models import GlobalConfig, HostConfig from .models import GlobalConfig, HostConfig
@@ -17,10 +14,9 @@ class ConfigRepositoryError(RuntimeError):
pass pass
def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]: def _global_runtime_data(global_config: GlobalConfig) -> dict[str, Any]:
data = { data = {
"backup_root": global_config.backup_root, "backup_root": global_config.backup_root,
"pobsync_home": global_config.pobsync_home,
"ssh": { "ssh": {
"user": global_config.ssh_user, "user": global_config.ssh_user,
"port": global_config.ssh_port, "port": global_config.ssh_port,
@@ -48,7 +44,7 @@ def _global_yaml_data(global_config: GlobalConfig) -> dict[str, Any]:
return validate_dict(data, GLOBAL_SCHEMA, path="global") 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] = { data: dict[str, Any] = {
"host": host_config.host, "host": host_config.host,
"address": host_config.address, "address": host_config.address,
@@ -78,57 +74,24 @@ def _host_yaml_data(host_config: HostConfig) -> dict[str, Any]:
def global_config_object_data(global_config: GlobalConfig) -> 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]: 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]: def global_config_data(name: str = "default") -> dict[str, Any]:
try: try:
global_config = GlobalConfig.objects.get(name=name) global_config = GlobalConfig.objects.get(name=name)
except ObjectDoesNotExist as exc: 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_yaml_data(global_config) return _global_runtime_data(global_config)
def host_config_data(host: str) -> dict[str, Any]: def host_config_data(host: str) -> dict[str, Any]:
try: try:
host_config = HostConfig.objects.get(host=host, enabled=True) host_config = HostConfig.objects.get(host=host, enabled=True)
except ObjectDoesNotExist as exc: 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_yaml_data(host_config) return _host_runtime_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

View File

@@ -119,7 +119,6 @@ class GlobalConfigForm(forms.ModelForm):
def save(self, commit: bool = True): def save(self, commit: bool = True):
instance = super().save(commit=False) instance = super().save(commit=False)
instance.backup_root = settings.POBSYNC_BACKUP_ROOT instance.backup_root = settings.POBSYNC_BACKUP_ROOT
instance.pobsync_home = settings.POBSYNC_HOME
if commit: if commit:
instance.save() instance.save()
self.save_m2m() self.save_m2m()

View File

@@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from pobsync.config.retention import parse_retention from pobsync.config.retention import parse_retention
@@ -13,12 +11,11 @@ from pobsync_backend.models import GlobalConfig
class Command(BaseCommand): 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: def add_arguments(self, parser) -> None:
parser.add_argument("--name", default="default") parser.add_argument("--name", default="default")
parser.add_argument("--backup-root", required=True) parser.add_argument("--backup-root", required=True)
parser.add_argument("--pobsync-home", default=settings.POBSYNC_HOME)
parser.add_argument("--ssh-user", default="root") parser.add_argument("--ssh-user", default="root")
parser.add_argument("--ssh-port", type=int, default=22) parser.add_argument("--ssh-port", type=int, default=22)
parser.add_argument("--source-root", default="/") parser.add_argument("--source-root", default="/")
@@ -30,11 +27,9 @@ class Command(BaseCommand):
if not is_absolute_non_root(backup_root): if not is_absolute_non_root(backup_root):
raise CommandError("--backup-root must be an absolute path and must not be '/'") 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"]) retention = parse_retention(options["retention"])
defaults = { defaults = {
"backup_root": backup_root, "backup_root": backup_root,
"pobsync_home": pobsync_home,
"ssh_user": options["ssh_user"], "ssh_user": options["ssh_user"],
"ssh_port": options["ssh_port"], "ssh_port": options["ssh_port"],
"ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"], "ssh_options": ["-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new"],
@@ -53,8 +48,8 @@ class Command(BaseCommand):
} }
if GlobalConfig.objects.filter(name=options["name"]).exists() and not options["force"]: 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) _obj, created = GlobalConfig.objects.update_or_create(name=options["name"], defaults=defaults)
action = "Created" if created else "Updated" 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}."))

View File

@@ -10,7 +10,7 @@ from pobsync_backend.models import GlobalConfig, HostConfig
class Command(BaseCommand): 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: def add_arguments(self, parser) -> None:
parser.add_argument("host") parser.add_argument("host")
@@ -29,7 +29,7 @@ class Command(BaseCommand):
def handle(self, *args: Any, **options: Any) -> None: def handle(self, *args: Any, **options: Any) -> None:
host = sanitize_host(options["host"]) host = sanitize_host(options["host"])
if HostConfig.objects.filter(host=host).exists() and not options["force"]: 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"]) retention = self._retention(options["retention"])
defaults = { defaults = {
@@ -49,7 +49,7 @@ class Command(BaseCommand):
} }
_obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults) _obj, created = HostConfig.objects.update_or_create(host=host, defaults=defaults)
action = "Created" if created else "Updated" 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]: def _retention(self, value: str | None) -> dict[str, int]:
if value: if value:

View File

@@ -9,11 +9,16 @@ from pobsync_backend.scheduler import parse_cron_expr
class Command(BaseCommand): 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: def add_arguments(self, parser) -> None:
parser.add_argument("host") 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", action="store_true")
parser.add_argument("--prune-max-delete", type=int, default=10) parser.add_argument("--prune-max-delete", type=int, default=10)
parser.add_argument("--prune-protect-bases", action="store_true") parser.add_argument("--prune-protect-bases", action="store_true")
@@ -24,24 +29,25 @@ class Command(BaseCommand):
try: try:
host = HostConfig.objects.get(host=options["host"]) host = HostConfig.objects.get(host=options["host"])
except HostConfig.DoesNotExist as exc: 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"]: if options["delete"]:
deleted, _details = ScheduleConfig.objects.filter(host=host).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}.")) self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} schedule row(s) for {host.host!r}."))
return return
if not options["cron"]: schedule_expression = options["schedule_expression"]
raise CommandError("--cron is required unless --delete is used") if not schedule_expression:
raise CommandError("--schedule-expression is required unless --delete is used")
try: try:
parse_cron_expr(options["cron"]) parse_cron_expr(schedule_expression)
except ValueError as exc: except ValueError as exc:
raise CommandError(str(exc)) from exc raise CommandError(str(exc)) from exc
schedule, created = ScheduleConfig.objects.update_or_create( schedule, created = ScheduleConfig.objects.update_or_create(
host=host, host=host,
defaults={ defaults={
"cron_expr": options["cron"], "cron_expr": schedule_expression,
"enabled": not options["disabled"], "enabled": not options["disabled"],
"prune": bool(options["prune"]), "prune": bool(options["prune"]),
"prune_max_delete": int(options["prune_max_delete"]), "prune_max_delete": int(options["prune_max_delete"]),

View File

@@ -20,14 +20,14 @@ class Command(BaseCommand):
try: try:
global_config = GlobalConfig.objects.get(name="default") global_config = GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist as exc: except GlobalConfig.DoesNotExist as exc:
raise CommandError("Missing GlobalConfig 'default'") from exc raise CommandError("Missing default global config") from exc
host = None host = None
if options["host"]: if options["host"]:
try: try:
host = HostConfig.objects.get(host=options["host"], enabled=True) host = HostConfig.objects.get(host=options["host"], enabled=True)
except HostConfig.DoesNotExist as exc: 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"]) kind = normalize_kind(options["kind"])
kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind] kinds = ["scheduled", "manual", "incomplete"] if kind == "all" else [kind]

View File

@@ -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="Pobsync home directory")
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)."))

View File

@@ -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="Pobsync home directory")
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)."))

View File

@@ -16,7 +16,7 @@ class Command(BaseCommand):
def add_arguments(self, parser) -> None: def add_arguments(self, parser) -> None:
parser.add_argument("host", help="Host to back up") 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("--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("--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") parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run")
@@ -30,7 +30,7 @@ class Command(BaseCommand):
try: try:
host = HostConfig.objects.get(host=host_name, enabled=True) host = HostConfig.objects.get(host=host_name, enabled=True)
except HostConfig.DoesNotExist as exc: 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( run = BackupRun.objects.create(
host=host, host=host,

View File

@@ -12,11 +12,11 @@ from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention
class Command(BaseCommand): 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: def add_arguments(self, parser) -> None:
parser.add_argument("host") 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("--kind", default="scheduled", choices=["scheduled", "manual", "all"])
parser.add_argument("--protect-bases", action="store_true") parser.add_argument("--protect-bases", action="store_true")
parser.add_argument("--apply", action="store_true") parser.add_argument("--apply", action="store_true")

View File

@@ -18,7 +18,7 @@ class Command(BaseCommand):
help = "Run due pobsync schedules from the Django database." help = "Run due pobsync schedules from the Django database."
def add_arguments(self, parser) -> None: 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("--once", action="store_true", help="Check once and exit")
parser.add_argument("--loop", action="store_true", help="Keep checking schedules") parser.add_argument("--loop", action="store_true", help="Keep checking schedules")
parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds") parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds")

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
help = "Run queued pobsync backup jobs from the Django database." help = "Run queued pobsync backup jobs from the Django database."
def add_arguments(self, parser) -> None: 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("--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("--loop", action="store_true", help="Keep checking for queued runs")
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds") parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")

View File

@@ -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",
),
]

View File

@@ -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",
),
]

View File

@@ -14,7 +14,6 @@ class TimestampedModel(models.Model):
class GlobalConfig(TimestampedModel): class GlobalConfig(TimestampedModel):
name = models.CharField(max_length=64, default="default", unique=True) name = models.CharField(max_length=64, default="default", unique=True)
backup_root = models.CharField(max_length=512) backup_root = models.CharField(max_length=512)
pobsync_home = models.CharField(max_length=512, default="/opt/pobsync")
default_ssh_credential = models.ForeignKey( default_ssh_credential = models.ForeignKey(
"SshCredential", "SshCredential",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -37,7 +36,6 @@ class GlobalConfig(TimestampedModel):
retention_weekly = models.PositiveIntegerField(default=8) retention_weekly = models.PositiveIntegerField(default=8)
retention_monthly = models.PositiveIntegerField(default=12) retention_monthly = models.PositiveIntegerField(default=12)
retention_yearly = models.PositiveIntegerField(default=0) retention_yearly = models.PositiveIntegerField(default=0)
data = models.JSONField(default=dict, blank=True)
class Meta: class Meta:
verbose_name = "global config" verbose_name = "global config"

View File

@@ -135,7 +135,7 @@ def _enabled_host_config(host: str) -> HostConfig:
try: try:
return HostConfig.objects.get(host=host, enabled=True) return HostConfig.objects.get(host=host, enabled=True)
except HostConfig.DoesNotExist as exc: 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]: def _retention_for_host(host_config: HostConfig) -> dict[str, int]:

View File

@@ -76,10 +76,17 @@ def _django_checks() -> list[SelfCheck]:
def _path_checks() -> list[SelfCheck]: def _path_checks() -> list[SelfCheck]:
checks = [] checks = []
checks.append(_path_check("POBSYNC_HOME", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True))
checks.append( checks.append(
_path_check( _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), Path(settings.POBSYNC_BACKUP_ROOT),
must_be_absolute=True, must_be_absolute=True,
must_exist=True, must_exist=True,
@@ -259,7 +266,7 @@ def _config_checks() -> list[SelfCheck]:
message = "Default global config exists." message = "Default global config exists."
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT: if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
status = "warning" 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 [ return [
SelfCheck( SelfCheck(
"Global config", "Global config",

View File

@@ -5,11 +5,27 @@ from datetime import datetime, timezone
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.test import TestCase from django.test import TestCase
from pobsync_backend.admin import BackupRunAdmin, HostConfigAdmin, SnapshotRecordAdmin from pobsync_backend.admin import BackupRunAdmin, GlobalConfigAdmin, HostConfigAdmin, SnapshotRecordAdmin
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
class AdminDisplayTests(TestCase): class AdminDisplayTests(TestCase):
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)
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.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: def test_host_admin_links_to_related_snapshots_and_runs(self) -> None:
site = AdminSite() site = AdminSite()
admin = HostConfigAdmin(HostConfig, site) admin = HostConfigAdmin(HostConfig, site)

View File

@@ -1,71 +1,63 @@
from __future__ import annotations from __future__ import annotations
import tempfile
from pathlib import Path
from django.test import TestCase from django.test import TestCase
from pobsync.config.load import load_global_config, load_host_config from pobsync_backend.config_repository import ConfigRepositoryError, global_config_data, host_config_data
from pobsync_backend.config_repository import export_runtime_configs
from pobsync_backend.models import GlobalConfig, HostConfig from pobsync_backend.models import GlobalConfig, HostConfig
class ConfigRepositoryTests(TestCase): class ConfigRepositoryTests(TestCase):
def test_exports_database_configs_to_engine_yaml(self) -> None: def test_builds_runtime_config_from_database_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp: GlobalConfig.objects.create(
prefix = Path(tmp) name="default",
GlobalConfig.objects.create( backup_root="/backups",
name="default", ssh_user="backup",
backup_root="/backups", ssh_port=2222,
pobsync_home=str(prefix), rsync_args=["--archive"],
ssh_user="backup", excludes_default=["/proc/***"],
ssh_port=2222, retention_daily=7,
rsync_args=["--archive"], retention_weekly=4,
excludes_default=["/proc/***"], retention_monthly=3,
retention_daily=7, retention_yearly=1,
retention_weekly=4, )
retention_monthly=3, HostConfig.objects.create(
retention_yearly=1, host="web-01",
data={ address="web-01.example.test",
"backup_root": "/ignored", ssh_user="root",
"pobsync_home": "/ignored", includes=[],
"ssh": {"user": "ignored", "port": 22, "options": []}, excludes_add=["/tmp/***"],
"unknown": "must-not-leak", retention_daily=7,
"retention_defaults": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99}, retention_weekly=4,
}, retention_monthly=3,
) retention_yearly=1,
HostConfig.objects.create( config={
host="web-01", "host": "ignored",
address="web-01.example.test", "address": "ignored",
ssh_user="root", "retention": {"daily": 99, "weekly": 99, "monthly": 99, "yearly": 99},
includes=[], "excludes_add": ["/ignored/***"],
excludes_add=["/tmp/***"], "unknown": "must-not-leak",
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) self.assertEqual(global_cfg["backup_root"], "/backups")
global_cfg = load_global_config(prefix / "config" / "global.yaml") self.assertEqual(global_cfg["ssh"]["user"], "backup")
host_cfg = load_host_config(prefix / "config" / "hosts" / "web-01.yaml") self.assertEqual(global_cfg["ssh"]["port"], 2222)
self.assertEqual(global_cfg["backup_root"], "/backups") self.assertEqual(global_cfg["retention_defaults"]["daily"], 7)
self.assertEqual(global_cfg["pobsync_home"], str(prefix)) self.assertEqual(host_cfg["host"], "web-01")
self.assertEqual(global_cfg["ssh"]["user"], "backup") self.assertEqual(host_cfg["address"], "web-01.example.test")
self.assertEqual(global_cfg["ssh"]["port"], 2222) self.assertEqual(host_cfg["retention"]["daily"], 7)
self.assertEqual(global_cfg["retention_defaults"]["daily"], 7) self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"])
self.assertEqual(host_cfg["host"], "web-01") self.assertNotIn("unknown", global_cfg)
self.assertEqual(host_cfg["address"], "web-01.example.test") self.assertNotIn("unknown", host_cfg)
self.assertEqual(host_cfg["retention"]["daily"], 7)
self.assertEqual(host_cfg["excludes_add"], ["/tmp/***"]) def test_missing_config_errors_use_operator_labels(self) -> None:
self.assertNotIn("unknown", global_cfg) with self.assertRaisesMessage(ConfigRepositoryError, "Missing global config 'default'"):
self.assertNotIn("unknown", host_cfg) 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")

View File

@@ -16,7 +16,6 @@ class ConfigureCommandsTests(TestCase):
call_command( call_command(
"configure_pobsync_global", "configure_pobsync_global",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
retention="daily=3,weekly=2,monthly=1,yearly=0", retention="daily=3,weekly=2,monthly=1,yearly=0",
stdout=out, stdout=out,
) )
@@ -24,7 +23,7 @@ class ConfigureCommandsTests(TestCase):
config = GlobalConfig.objects.get(name="default") config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups") self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.retention_daily, 3) 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: def test_configure_host_uses_global_retention_defaults(self) -> None:
GlobalConfig.objects.create( GlobalConfig.objects.create(
@@ -62,7 +61,7 @@ class ConfigureCommandsTests(TestCase):
call_command( call_command(
"configure_pobsync_schedule", "configure_pobsync_schedule",
host.host, host.host,
cron="15 2 * * *", schedule_expression="15 2 * * *",
prune=True, prune=True,
stdout=out, stdout=out,
) )

View File

@@ -31,15 +31,6 @@ class ConsoleEntrypointTests(SimpleTestCase):
self.assertEqual(exit_code, 0) self.assertEqual(exit_code, 0)
execute.assert_called_once_with(["pobsync", "check"]) 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: def test_maps_discover_snapshots_alias_to_django_command(self) -> None:
with patch("pobsync.cli.execute_from_command_line") as execute: with patch("pobsync.cli.execute_from_command_line") as execute:
exit_code = main(["discover-snapshots", "--host", "web-01"]) exit_code = main(["discover-snapshots", "--host", "web-01"])
@@ -53,3 +44,12 @@ class ConsoleEntrypointTests(SimpleTestCase):
self.assertEqual(exit_code, 0) self.assertEqual(exit_code, 0)
execute.assert_called_once_with(["pobsync", "run_pobsync_worker", "--once"]) 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 <management-command>", stderr.getvalue())

View File

@@ -15,7 +15,6 @@ class DjangoConfigSourceTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
rsync_args=["--archive"], rsync_args=["--archive"],
rsync_extra_args=["--numeric-ids"], rsync_extra_args=["--numeric-ids"],
excludes_default=["/proc/***"], excludes_default=["/proc/***"],
@@ -23,21 +22,6 @@ class DjangoConfigSourceTests(TestCase):
retention_weekly=4, retention_weekly=4,
retention_monthly=3, retention_monthly=3,
retention_yearly=1, retention_yearly=1,
data={
"backup_root": "/ignored",
"pobsync_home": "/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( HostConfig.objects.create(
host="web-01", host="web-01",
@@ -72,7 +56,6 @@ class DjangoConfigSourceTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
default_ssh_credential=credential, default_ssh_credential=credential,
ssh_options=["-oBatchMode=yes"], ssh_options=["-oBatchMode=yes"],
) )
@@ -99,7 +82,6 @@ class DjangoConfigSourceTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
default_ssh_credential=global_credential, default_ssh_credential=global_credential,
) )
HostConfig.objects.create( HostConfig.objects.create(
@@ -127,7 +109,6 @@ class DjangoConfigSourceTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
default_ssh_credential=credential, default_ssh_credential=credential,
) )
HostConfig.objects.create(host="web-01", address="web-01.example.test") HostConfig.objects.create(host="web-01", address="web-01.example.test")
@@ -146,7 +127,6 @@ class DjangoConfigSourceTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/backups", backup_root="/backups",
pobsync_home="/opt/pobsync",
default_ssh_credential=credential, default_ssh_credential=credential,
) )
HostConfig.objects.create(host="web-01", address="web-01.example.test") HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -7,6 +7,7 @@ from tempfile import TemporaryDirectory
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pobsync.commands.retention_plan import run_retention_plan from pobsync.commands.retention_plan import run_retention_plan
from pobsync.errors import ConfigError
from pobsync.util import write_yaml_atomic from pobsync.util import write_yaml_atomic
@@ -24,6 +25,15 @@ class FakeConfigSource:
class RetentionConfigSourceTests(SimpleTestCase): 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: def test_retention_plan_uses_injected_config_source(self) -> None:
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = Path(tmp) / "backups" root = Path(tmp) / "backups"

View File

@@ -7,6 +7,7 @@ from unittest.mock import patch
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pobsync.commands.run_scheduled import run_scheduled from pobsync.commands.run_scheduled import run_scheduled
from pobsync.errors import ConfigError
from pobsync.rsync import RsyncResult from pobsync.rsync import RsyncResult
@@ -34,6 +35,10 @@ class FakeConfigSource:
class RunScheduledConfigSourceTests(SimpleTestCase): 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: def test_dry_run_uses_injected_config_source(self) -> None:
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync: with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"]) run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])

View File

@@ -237,7 +237,7 @@ class ViewTests(TestCase):
self.assertContains(response, "Self Check") self.assertContains(response, "Self Check")
self.assertContains(response, "Django debug") self.assertContains(response, "Django debug")
self.assertContains(response, "Database connection") 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: def test_logs_view_renders_filtered_journal_messages(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -475,7 +475,6 @@ class ViewTests(TestCase):
self.assertContains(response, "Global config saved for default.") self.assertContains(response, "Global config saved for default.")
config = GlobalConfig.objects.get(name="default") config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups") self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.pobsync_home, "/opt/pobsync")
self.assertEqual(config.default_ssh_credential, credential) self.assertEqual(config.default_ssh_credential, credential)
self.assertEqual(config.ssh_user, "backup") self.assertEqual(config.ssh_user, "backup")
self.assertEqual(config.ssh_port, 2222) self.assertEqual(config.ssh_port, 2222)
@@ -502,7 +501,6 @@ class ViewTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/mnt/pobsync/backups", backup_root="/mnt/pobsync/backups",
pobsync_home="/custom/legacy/home",
) )
response = self.client.get(reverse("edit_global_config")) response = self.client.get(reverse("edit_global_config"))
@@ -512,8 +510,10 @@ class ViewTests(TestCase):
self.assertContains(response, "/backups") self.assertContains(response, "/backups")
self.assertContains(response, "Config Check") self.assertContains(response, "Config Check")
self.assertContains(response, "Runtime backup root") self.assertContains(response, "Runtime backup root")
self.assertContains(response, "Runtime state root")
self.assertNotContains(response, "/opt/pobsync/backups") self.assertNotContains(response, "/opt/pobsync/backups")
self.assertNotContains(response, "Pobsync home") 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: def test_global_config_form_renders_config_check_for_non_recursive_rsync(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -530,7 +530,6 @@ class ViewTests(TestCase):
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
backup_root="/mnt/pobsync/backups", backup_root="/mnt/pobsync/backups",
pobsync_home="/custom/legacy/home",
) )
response = self.client.post( response = self.client.post(
@@ -559,7 +558,6 @@ class ViewTests(TestCase):
self.assertRedirects(response, reverse("dashboard")) self.assertRedirects(response, reverse("dashboard"))
config = GlobalConfig.objects.get(name="default") config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups") self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.pobsync_home, "/opt/pobsync")
def test_create_host_config_form_creates_host(self) -> None: def test_create_host_config_form_creates_host(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)