From 515330c436e4393902efa9c24efa58fbd48140a3 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Sat, 23 May 2026 00:59:55 +0200 Subject: [PATCH] ## Summary - Add per-host rsync bandwidth limit overrides with inherit/unlimited semantics. - Store the effective bwlimit in run metadata/results and show it in host/run detail views. - Document recommended starting values for VPN and remote backups. ## Tests - `.venv/bin/python manage.py makemigrations --check --dry-run` - `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_returns_effective_config_from_database src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_host_can_disable_global_rsync_bandwidth_limit src.pobsync_backend.tests.test_configure_commands.ConfigureCommandsTests.test_configure_host_uses_global_retention_defaults src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_dry_run_applies_configured_bandwidth_limit src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_real_run_can_request_verbose_output_args --verbosity 2` - `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_create_host_config_form_creates_host src.pobsync_backend.tests.test_views.ViewTests.test_host_detail_renders_effective_config_preview src.pobsync_backend.tests.test_views.ViewTests.test_run_detail_renders_result_payload src.pobsync_backend.tests.test_views.ViewTests.test_host_config_form_updates_host_config --verbosity 2` - `.venv/bin/python manage.py check` Closes #51 --- README.md | 13 ++++++++++ src/pobsync/commands/run_scheduled.py | 12 ++++++---- src/pobsync/config/schemas.py | 1 + src/pobsync_backend/admin.py | 2 +- src/pobsync_backend/config_repository.py | 8 +++++-- src/pobsync_backend/forms.py | 3 +++ .../commands/configure_pobsync_host.py | 7 ++++++ .../migrations/0014_host_bwlimit_override.py | 18 ++++++++++++++ src/pobsync_backend/models.py | 1 + .../pobsync_backend/host_detail.html | 5 +++- .../templates/pobsync_backend/run_detail.html | 4 ++++ .../tests/test_configure_commands.py | 3 +++ .../tests/test_django_config_source.py | 20 ++++++++++++++++ .../tests/test_run_scheduled_config_source.py | 24 ++++++++++++++++--- src/pobsync_backend/tests/test_views.py | 10 +++++++- src/pobsync_backend/views.py | 18 ++++++++++++++ 16 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 src/pobsync_backend/migrations/0014_host_bwlimit_override.py diff --git a/README.md b/README.md index 1f9a399..791d36a 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,19 @@ The UI includes: - `/self-check/` for runtime checks - `/logs/` for filtered pobsync service logs +## Bandwidth Limits + +Global config can set an rsync bandwidth limit in KB/s. The default `0` means unlimited. Each host can inherit the +global value, set `0` to explicitly run unlimited, or set its own limit for slower remote links. + +For VPN-backed or remote backups, start conservatively and adjust after watching normal traffic: + +- `2500` KB/s is roughly 20 Mbit/s +- `5000` KB/s is roughly 40 Mbit/s +- `10000` KB/s is roughly 80 Mbit/s + +pobsync passes the effective value to rsync as `--bwlimit=` and shows it on the host detail and run detail pages. + ## Restoring Data pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index b8acf10..b9353f4 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -278,6 +278,7 @@ def run_scheduled( "exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, + "bwlimit_kbps": bwlimit_kbps, }, } if result.exit_code != 0: @@ -336,7 +337,7 @@ def run_scheduled( "ended_at": None, "duration_seconds": None, "base": _base_meta_from_path(base_dir, link_dest), - "rsync": {"exit_code": None, "command": cmd, "stats": {}}, + "rsync": {"exit_code": None, "command": cmd, "stats": {}, "bwlimit_kbps": bwlimit_kbps}, "overrides": {"includes": [], "excludes": [], "base": None}, } @@ -349,7 +350,7 @@ def run_scheduled( "phase": "preparing", "snapshot": str(incomplete_dir), "log": str(log_path), - "rsync": {"command": cmd, "exit_code": None}, + "rsync": {"command": cmd, "exit_code": None, "bwlimit_kbps": bwlimit_kbps}, } ) @@ -362,7 +363,7 @@ def run_scheduled( "phase": "rsync", "snapshot": str(incomplete_dir), "log": str(log_path), - "rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid}, + "rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid, "bwlimit_kbps": bwlimit_kbps}, } ) @@ -384,7 +385,7 @@ def run_scheduled( "phase": "finalizing", "snapshot": str(incomplete_dir), "log": str(log_path), - "rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail}, + "rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps}, } ) @@ -430,6 +431,7 @@ def run_scheduled( "exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, + "bwlimit_kbps": bwlimit_kbps, }, "failure": classify_rsync_failure(result.exit_code, log_tail), } @@ -470,7 +472,7 @@ def run_scheduled( "log": str(final_log_path), "status": meta["status"], "warning": warning, - "rsync": {"exit_code": result.exit_code, "log_tail": log_tail}, + "rsync": {"exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps}, "verbose_output": bool(verbose_output), "duration_seconds": meta["duration_seconds"], "stats": meta["stats"], diff --git a/src/pobsync/config/schemas.py b/src/pobsync/config/schemas.py index e840965..b29acb9 100644 --- a/src/pobsync/config/schemas.py +++ b/src/pobsync/config/schemas.py @@ -110,6 +110,7 @@ GLOBAL_SCHEMA = Schema( HOST_RSYNC_SCHEMA = Schema( fields={ + "bwlimit_kbps": FieldSpec(int, required=False, min_value=0), "extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)), }, allow_unknown=False, diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index f5a2cde..694e76d 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -73,7 +73,7 @@ class HostConfigAdmin(admin.ModelAdmin): (None, {"fields": ("host", "address", "enabled")}), ("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}), ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}), - ("Rsync override", {"fields": ("rsync_extra_args",)}), + ("Rsync override", {"fields": ("rsync_extra_args", "rsync_bwlimit_kbps")}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), ("Runtime state", {"fields": ("config",), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), diff --git a/src/pobsync_backend/config_repository.py b/src/pobsync_backend/config_repository.py index 560be5e..b08714f 100644 --- a/src/pobsync_backend/config_repository.py +++ b/src/pobsync_backend/config_repository.py @@ -68,8 +68,12 @@ def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]: data["excludes_replace"] = list(host_config.excludes_replace or []) else: data["excludes_add"] = list(host_config.excludes_add or []) - if host_config.rsync_extra_args: - data["rsync"] = {"extra_args": list(host_config.rsync_extra_args or [])} + if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None: + data["rsync"] = {} + if host_config.rsync_extra_args: + data["rsync"]["extra_args"] = list(host_config.rsync_extra_args or []) + if host_config.rsync_bwlimit_kbps is not None: + data["rsync"]["bwlimit_kbps"] = host_config.rsync_bwlimit_kbps return validate_dict(data, HOST_SCHEMA, path="host") diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index 0fa1c71..9647e06 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -60,6 +60,7 @@ class HostConfigForm(forms.ModelForm): "excludes_add", "excludes_replace", "rsync_extra_args", + "rsync_bwlimit_kbps", "retention_daily", "retention_weekly", "retention_monthly", @@ -70,6 +71,7 @@ class HostConfigForm(forms.ModelForm): "ssh_user": "Leave empty to use the global SSH user.", "ssh_port": "Leave empty to use the global SSH port.", "source_root": "Leave empty to use the global default source root.", + "rsync_bwlimit_kbps": "Leave empty to inherit the global limit. Use 0 for unlimited on this host.", } @@ -112,6 +114,7 @@ class GlobalConfigForm(forms.ModelForm): help_texts = { "name": "Usually 'default'. The backup engine currently reads the default config.", "default_ssh_credential": "Optional. Used by hosts without their own SSH credential.", + "rsync_bwlimit_kbps": "Rsync bandwidth limit in KB/s. Use 0 for unlimited.", "default_source_root": "Used by hosts without a custom source root.", "default_destination_subdir": "Optional subdirectory below each snapshot.", } diff --git a/src/pobsync_backend/management/commands/configure_pobsync_host.py b/src/pobsync_backend/management/commands/configure_pobsync_host.py index a22961c..bdcba2b 100644 --- a/src/pobsync_backend/management/commands/configure_pobsync_host.py +++ b/src/pobsync_backend/management/commands/configure_pobsync_host.py @@ -22,6 +22,12 @@ class Command(BaseCommand): parser.add_argument("--exclude-add", action="append", default=[]) parser.add_argument("--exclude-replace", action="append", default=None) parser.add_argument("--rsync-extra-arg", action="append", default=[]) + parser.add_argument( + "--rsync-bwlimit-kbps", + type=int, + default=None, + help="Host rsync bandwidth limit in KB/s. Omit to inherit global; set 0 for unlimited.", + ) parser.add_argument("--retention", default=None) parser.add_argument("--disabled", action="store_true") parser.add_argument("--force", action="store_true", help="Update existing host") @@ -42,6 +48,7 @@ class Command(BaseCommand): "excludes_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]), "excludes_replace": options["exclude_replace"], "rsync_extra_args": list(options["rsync_extra_arg"]), + "rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"], "retention_daily": retention["daily"], "retention_weekly": retention["weekly"], "retention_monthly": retention["monthly"], diff --git a/src/pobsync_backend/migrations/0014_host_bwlimit_override.py b/src/pobsync_backend/migrations/0014_host_bwlimit_override.py new file mode 100644 index 0000000..fd5fd58 --- /dev/null +++ b/src/pobsync_backend/migrations/0014_host_bwlimit_override.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-22 22:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pobsync_backend', '0013_purgedsnapshot'), + ] + + operations = [ + migrations.AddField( + model_name='hostconfig', + name='rsync_bwlimit_kbps', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index e890165..4f89927 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -63,6 +63,7 @@ class HostConfig(TimestampedModel): excludes_add = models.JSONField(default=list, blank=True) excludes_replace = models.JSONField(null=True, blank=True) rsync_extra_args = models.JSONField(default=list, blank=True) + rsync_bwlimit_kbps = models.PositiveIntegerField(null=True, blank=True) retention_daily = models.PositiveIntegerField(default=14) retention_weekly = models.PositiveIntegerField(default=8) retention_monthly = models.PositiveIntegerField(default=12) diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 76b67bb..b69d6a2 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -373,7 +373,10 @@
Rsync binary:{{ effective_config.rsync.binary }}
Rsync args:{{ effective_config.rsync.args|join:" " }}
Timeout:{{ effective_config.rsync.timeout_seconds }}s
-
Bandwidth limit:{{ effective_config.rsync.bwlimit_kbps }} KB/s
+
+ Bandwidth limit: + {% if effective_config.rsync.bwlimit_kbps %}{{ effective_config.rsync.bwlimit_kbps }} KB/s{% else %}unlimited{% endif %} +
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 310a8e5..bcab6bb 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -50,6 +50,10 @@

Rsync Command

+

+ Bandwidth limit: + {% if rsync_bwlimit_kbps %}{{ rsync_bwlimit_kbps }} KB/s{% else %}unlimited{% endif %} +

{% if rsync_command %}
{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
 {% endif %}{% endfor %}
diff --git a/src/pobsync_backend/tests/test_configure_commands.py b/src/pobsync_backend/tests/test_configure_commands.py index 7656e1e..9c85a88 100644 --- a/src/pobsync_backend/tests/test_configure_commands.py +++ b/src/pobsync_backend/tests/test_configure_commands.py @@ -42,6 +42,7 @@ class ConfigureCommandsTests(TestCase): address="web-01.example.test", exclude_add=["/tmp/***"], rsync_extra_arg=["--delete"], + rsync_bwlimit_kbps=4096, stdout=out, ) @@ -49,10 +50,12 @@ class ConfigureCommandsTests(TestCase): self.assertEqual(host.retention_daily, 5) self.assertEqual(host.excludes_add, ["/tmp/***"]) self.assertEqual(host.rsync_extra_args, ["--delete"]) + self.assertEqual(host.rsync_bwlimit_kbps, 4096) effective = DjangoConfigSource().effective_config_for_host("web-01") self.assertEqual(effective["retention"]["yearly"], 2) self.assertEqual(effective["excludes_effective"], ["/tmp/***"]) + self.assertEqual(effective["rsync"]["bwlimit_kbps"], 4096) def test_configure_schedule_creates_sql_schedule(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/tests/test_django_config_source.py b/src/pobsync_backend/tests/test_django_config_source.py index 6070061..beb897b 100644 --- a/src/pobsync_backend/tests/test_django_config_source.py +++ b/src/pobsync_backend/tests/test_django_config_source.py @@ -17,6 +17,7 @@ class DjangoConfigSourceTests(TestCase): backup_root="/backups", rsync_args=["--archive"], rsync_extra_args=["--numeric-ids"], + rsync_bwlimit_kbps=10000, excludes_default=["/proc/***"], retention_daily=7, retention_weekly=4, @@ -28,6 +29,7 @@ class DjangoConfigSourceTests(TestCase): address="web-01.example.test", excludes_add=["/tmp/***"], rsync_extra_args=["--delete"], + rsync_bwlimit_kbps=2500, retention_daily=7, retention_weekly=4, retention_monthly=3, @@ -46,6 +48,24 @@ class DjangoConfigSourceTests(TestCase): self.assertEqual(cfg["address"], "web-01.example.test") self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"]) self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"]) + self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 2500) + + def test_host_can_disable_global_rsync_bandwidth_limit(self) -> None: + GlobalConfig.objects.create( + name="default", + backup_root="/backups", + rsync_args=["--archive"], + rsync_bwlimit_kbps=5000, + ) + HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + rsync_bwlimit_kbps=0, + ) + + cfg = DjangoConfigSource().effective_config_for_host("web-01") + + self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 0) def test_materializes_global_ssh_credential_for_runtime_config(self) -> None: credential = SshCredential.objects.create( 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 117c91d..e15995c 100644 --- a/src/pobsync_backend/tests/test_run_scheduled_config_source.py +++ b/src/pobsync_backend/tests/test_run_scheduled_config_source.py @@ -12,8 +12,9 @@ from pobsync.rsync import RsyncResult class FakeConfigSource: - def __init__(self, backup_root: str = "/tmp/pobsync-test-backups") -> None: + def __init__(self, backup_root: str = "/tmp/pobsync-test-backups", bwlimit_kbps: int = 0) -> None: self.backup_root = backup_root + self.bwlimit_kbps = bwlimit_kbps def effective_config_for_host(self, host: str) -> dict: return { @@ -25,7 +26,7 @@ class FakeConfigSource: "binary": "rsync", "args_effective": ["--archive"], "timeout_seconds": 0, - "bwlimit_kbps": 0, + "bwlimit_kbps": self.bwlimit_kbps, }, "source_root": "/", "includes": [], @@ -54,6 +55,21 @@ class RunScheduledConfigSourceTests(SimpleTestCase): self.assertEqual(result["host"], "web-01") run_rsync.assert_called_once() + def test_dry_run_applies_configured_bandwidth_limit(self) -> None: + with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync: + run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--bwlimit=4096"]) + + result = run_scheduled( + prefix=Path("/missing-prefix"), + host="web-01", + dry_run=True, + config_source=FakeConfigSource(bwlimit_kbps=4096), + ) + + command = run_rsync.call_args.args[0] + self.assertIn("--bwlimit=4096", command) + self.assertEqual(result["rsync"]["bwlimit_kbps"], 4096) + def test_failed_dry_run_includes_log_tail(self) -> None: def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): log_path.parent.mkdir(parents=True, exist_ok=True) @@ -186,11 +202,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase): host="web-01", dry_run=False, verbose_output=True, - config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")), + config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups"), bwlimit_kbps=2048), ) command = run_rsync.call_args.args[0] self.assertTrue(result["ok"]) + self.assertIn("--bwlimit=2048", command) + self.assertEqual(result["rsync"]["bwlimit_kbps"], 2048) self.assertIn("--stats", command) self.assertIn("--itemize-changes", command) self.assertIn("--info=flist2,progress2,stats2", command) diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 7012307..893a7ca 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -921,6 +921,7 @@ class ViewTests(TestCase): "excludes_add": "*.tmp", "excludes_replace": "", "rsync_extra_args": "--numeric-ids", + "rsync_bwlimit_kbps": "4096", "retention_daily": "7", "retention_weekly": "4", "retention_monthly": "2", @@ -938,6 +939,7 @@ class ViewTests(TestCase): self.assertEqual(host.includes, ["/srv/www", "/srv/db"]) self.assertEqual(host.excludes_add, ["*.tmp"]) self.assertEqual(host.rsync_extra_args, ["--numeric-ids"]) + self.assertEqual(host.rsync_bwlimit_kbps, 4096) self.assertEqual(host.retention_weekly, 4) def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None: @@ -1077,6 +1079,7 @@ class ViewTests(TestCase): self.assertContains(response, "default-key") self.assertContains(response, "-oBatchMode=yes") self.assertContains(response, "--archive --numeric-ids --delete --one-file-system") + self.assertContains(response, "2048 KB/s") self.assertContains(response, "/srv/www/***") self.assertContains(response, "/srv/www/cache/***") self.assertContains(response, "d14") @@ -1473,9 +1476,10 @@ class ViewTests(TestCase): "ok": True, "snapshot": snapshot.path, "rsync": { - "command": ["rsync", "--archive", "root@web-01:/", snapshot.path], + "command": ["rsync", "--archive", "--bwlimit=2048", "root@web-01:/", snapshot.path], "exit_code": 0, "log_tail": ["sending incremental file list", "sent 500 bytes"], + "bwlimit_kbps": 2048, }, "requested": { "dry_run": True, @@ -1510,6 +1514,8 @@ class ViewTests(TestCase): self.assertContains(response, "Dry run: yes") self.assertContains(response, "Verbose rsync output: yes") self.assertContains(response, "Rsync Command") + self.assertContains(response, "Bandwidth limit:") + self.assertContains(response, "2048 KB/s") self.assertContains(response, "--archive") self.assertContains(response, "Rsync Log") self.assertContains(response, "sending incremental file list") @@ -2464,6 +2470,7 @@ class ViewTests(TestCase): "excludes_add": "*.tmp\ncache/", "excludes_replace": "", "rsync_extra_args": "--numeric-ids\n--delete", + "rsync_bwlimit_kbps": "8192", "retention_daily": "7", "retention_weekly": "4", "retention_monthly": "2", @@ -2483,6 +2490,7 @@ class ViewTests(TestCase): self.assertEqual(host.excludes_add, ["*.tmp", "cache/"]) self.assertIsNone(host.excludes_replace) self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"]) + self.assertEqual(host.rsync_bwlimit_kbps, 8192) self.assertEqual(host.retention_daily, 7) self.assertEqual(host.retention_yearly, 1) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index f08001c..02aa302 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -704,6 +704,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]: "stats": run_stats if isinstance(run_stats, dict) else {}, "rsync": rsync_result, "rsync_command": _run_rsync_command(rsync_result), + "rsync_bwlimit_kbps": _run_rsync_bwlimit_kbps(rsync_result), "failure": failure, "failure_summary": failure.get("message") or failure.get("summary") or "", "prune_result": prune_result, @@ -1249,6 +1250,23 @@ def _run_rsync_command(rsync_result: dict) -> list[str]: return [str(part) for part in command] +def _run_rsync_bwlimit_kbps(rsync_result: dict) -> int: + stored_limit = rsync_result.get("bwlimit_kbps") + if stored_limit is not None: + try: + return max(0, int(stored_limit)) + except (TypeError, ValueError): + return 0 + + for part in _run_rsync_command(rsync_result): + if part.startswith("--bwlimit="): + try: + return max(0, int(part.split("=", 1)[1])) + except ValueError: + return 0 + return 0 + + def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines: int = 30) -> list[str]: log_tail = rsync_result.get("log_tail") if isinstance(log_tail, list):