(feature) Add optional verbose rsync output for manual backups

Expose a verbose rsync output option in the Django manual backup form and
store the selected value with the queued run request.

Propagate the option through the worker, direct management command, and
rsync command builder so real backups can emit itemized changes, file-list
progress, and stats when requested. Dry-runs continue to use verbose output
by default and report that consistently in requested options.

Cover the queue, worker, view, and rsync command behavior with focused
tests.
This commit is contained in:
2026-05-19 22:13:33 +02:00
parent d52a9167d1
commit 728e5c740a
12 changed files with 89 additions and 5 deletions

View File

@@ -18,6 +18,7 @@ def queue_backup_run(
host: HostConfig,
run_type: str = BackupRun.RunType.MANUAL,
dry_run: bool = False,
verbose_output: bool = False,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
@@ -29,6 +30,7 @@ def queue_backup_run(
result={
"requested": {
"dry_run": bool(dry_run),
"verbose_output": bool(dry_run or verbose_output),
"prune": bool(prune),
"prune_max_delete": int(prune_max_delete),
"prune_protect_bases": bool(prune_protect_bases),
@@ -42,6 +44,7 @@ def execute_backup_run(
run: BackupRun,
prefix: Path,
dry_run: bool = False,
verbose_output: bool = False,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
@@ -60,6 +63,7 @@ def execute_backup_run(
config_source=DjangoConfigSource(),
run_id=run.id,
cancel_check=lambda: _run_cancel_requested(run.id),
verbose_output=bool(dry_run or verbose_output),
)
except Exception as exc:
run.refresh_from_db()

View File

@@ -133,6 +133,11 @@ class ManualBackupForm(forms.Form):
initial=True,
help_text="Queue rsync in dry-run mode without writing a snapshot.",
)
verbose_output = forms.BooleanField(
label="Verbose rsync output",
required=False,
help_text="Write itemized rsync changes, file-list progress, and stats to the run log. Dry-runs always use this.",
)
prune = forms.BooleanField(
label="Apply retention after success",
required=False,

View File

@@ -18,6 +18,7 @@ class Command(BaseCommand):
parser.add_argument("host", help="Host to back up")
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
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")
parser.add_argument("--prune-max-delete", type=int, default=10)
parser.add_argument("--prune-protect-bases", action="store_true")
@@ -35,11 +36,21 @@ class Command(BaseCommand):
host=host,
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
status=BackupRun.Status.RUNNING,
result={
"requested": {
"dry_run": bool(options["dry_run"]),
"verbose_output": bool(options["dry_run"] or options["verbose_rsync"]),
"prune": bool(options["prune"]),
"prune_max_delete": int(options["prune_max_delete"]),
"prune_protect_bases": bool(options["prune_protect_bases"]),
}
},
)
execute_backup_run(
run=run,
prefix=paths.home,
dry_run=bool(options["dry_run"]),
verbose_output=bool(options["dry_run"] or options["verbose_rsync"]),
prune=bool(options["prune"]),
prune_max_delete=int(options["prune_max_delete"]),
prune_protect_bases=bool(options["prune_protect_bases"]),

View File

@@ -44,6 +44,7 @@ class Command(BaseCommand):
run=run,
prefix=prefix,
dry_run=bool(options.get("dry_run", False)),
verbose_output=bool(options.get("verbose_output", False)),
prune=bool(options.get("prune", False)),
prune_max_delete=int(options.get("prune_max_delete", 10)),
prune_protect_bases=bool(options.get("prune_protect_bases", False)),

View File

@@ -127,6 +127,7 @@
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="dry_run" value="on">
<input type="hidden" name="verbose_output" value="on">
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" class="secondary" {% if not can_queue_backup %}disabled{% endif %}>Queue dry-run</button>
</form>

View File

@@ -46,6 +46,7 @@
<h2>Requested Options</h2>
<div class="stack">
<div><strong>Dry run:</strong> {{ requested.dry_run|yesno:"yes,no" }}</div>
<div><strong>Verbose rsync output:</strong> {{ requested.verbose_output|yesno:"yes,no" }}</div>
<div><strong>Apply retention:</strong> {{ requested.prune|yesno:"yes,no" }}</div>
<div><strong>Retention max delete:</strong> {{ requested.prune_max_delete }}</div>
<div><strong>Protect bases:</strong> {{ requested.prune_protect_bases|yesno:"yes,no" }}</div>

View File

@@ -32,12 +32,20 @@ class BackupWorkerTests(TestCase):
run.result["requested"],
{
"dry_run": True,
"verbose_output": True,
"prune": True,
"prune_max_delete": 3,
"prune_protect_bases": True,
},
)
def test_queue_backup_run_can_request_verbose_output(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host, verbose_output=True)
self.assertTrue(run.result["requested"]["verbose_output"])
def test_worker_executes_next_queued_run(self) -> None:
with TemporaryDirectory() as tmp:
backup_root = Path(tmp) / "backups"
@@ -47,7 +55,7 @@ class BackupWorkerTests(TestCase):
meta_dir = snapshot_dir / "meta"
meta_dir.mkdir(parents=True)
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
run = queue_backup_run(host=host)
run = queue_backup_run(host=host, verbose_output=True)
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
def fake_run_scheduled(**kwargs):
@@ -68,6 +76,7 @@ class BackupWorkerTests(TestCase):
self.assertEqual(count, 1)
self.assertEqual(run_scheduled.call_args.kwargs["run_id"], run.id)
self.assertTrue(run_scheduled.call_args.kwargs["verbose_output"])
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
self.assertEqual(SnapshotRecord.objects.count(), 1)

View File

@@ -160,6 +160,45 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
self.assertNotIn("--info=flist2,progress2,stats2", command)
self.assertIn("--info=name1,stats2", command)
def test_real_run_can_request_verbose_output_args(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
result = run_scheduled(
prefix=prefix,
host="web-01",
dry_run=False,
verbose_output=True,
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
)
command = run_rsync.call_args.args[0]
self.assertTrue(result["ok"])
self.assertIn("--itemize-changes", command)
self.assertIn("--info=flist2,progress2,stats2", command)
self.assertTrue(result["verbose_output"])
def test_real_run_keeps_default_output_quiet(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
result = run_scheduled(
prefix=prefix,
host="web-01",
dry_run=False,
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
)
command = run_rsync.call_args.args[0]
self.assertTrue(result["ok"])
self.assertNotIn("--itemize-changes", command)
self.assertNotIn("--info=flist2,progress2,stats2", command)
self.assertFalse(result["verbose_output"])
def test_dry_run_reports_cancelled_rsync(self) -> None:
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
self.assertTrue(cancel_check())

View File

@@ -655,6 +655,7 @@ class ViewTests(TestCase):
reverse("queue_manual_backup", args=[host.host]),
{
"dry_run": "on",
"verbose_output": "on",
"prune": "on",
"prune_max_delete": "4",
"prune_protect_bases": "on",
@@ -671,6 +672,7 @@ class ViewTests(TestCase):
run.result["requested"],
{
"dry_run": True,
"verbose_output": True,
"prune": True,
"prune_max_delete": 4,
"prune_protect_bases": True,
@@ -694,6 +696,7 @@ class ViewTests(TestCase):
run.result["requested"],
{
"dry_run": False,
"verbose_output": False,
"prune": False,
"prune_max_delete": 10,
"prune_protect_bases": False,
@@ -745,6 +748,7 @@ class ViewTests(TestCase):
"snapshot": snapshot.path,
"requested": {
"dry_run": True,
"verbose_output": True,
"prune": False,
"prune_max_delete": 10,
"prune_protect_bases": False,
@@ -761,6 +765,7 @@ class ViewTests(TestCase):
self.assertContains(response, "ABCDEFGH")
self.assertContains(response, "Requested Options")
self.assertContains(response, "Dry run:</strong> yes")
self.assertContains(response, "Verbose rsync output:</strong> yes")
self.assertContains(response, "&quot;ok&quot;: true")
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))

View File

@@ -336,6 +336,7 @@ def queue_manual_backup(request, host: str):
run = queue_backup_run(
host=host_config,
dry_run=form.cleaned_data["dry_run"],
verbose_output=form.cleaned_data["verbose_output"],
prune=form.cleaned_data["prune"],
prune_max_delete=form.cleaned_data["prune_max_delete"],
prune_protect_bases=form.cleaned_data["prune_protect_bases"],