Merge pull request '## Summary' (#58) from issue-51-bandwidth-limit into master
Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
13
README.md
13
README.md
@@ -154,6 +154,19 @@ The UI includes:
|
|||||||
- `/self-check/` for runtime checks
|
- `/self-check/` for runtime checks
|
||||||
- `/logs/` for filtered pobsync service logs
|
- `/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=<KB/s>` and shows it on the host detail and run detail pages.
|
||||||
|
|
||||||
## Restoring Data
|
## Restoring Data
|
||||||
|
|
||||||
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ def run_scheduled(
|
|||||||
"exit_code": result.exit_code,
|
"exit_code": result.exit_code,
|
||||||
"command": result.command,
|
"command": result.command,
|
||||||
"log_tail": log_tail,
|
"log_tail": log_tail,
|
||||||
|
"bwlimit_kbps": bwlimit_kbps,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if result.exit_code != 0:
|
if result.exit_code != 0:
|
||||||
@@ -336,7 +337,7 @@ def run_scheduled(
|
|||||||
"ended_at": None,
|
"ended_at": None,
|
||||||
"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": {}, "bwlimit_kbps": bwlimit_kbps},
|
||||||
"overrides": {"includes": [], "excludes": [], "base": None},
|
"overrides": {"includes": [], "excludes": [], "base": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +350,7 @@ def run_scheduled(
|
|||||||
"phase": "preparing",
|
"phase": "preparing",
|
||||||
"snapshot": str(incomplete_dir),
|
"snapshot": str(incomplete_dir),
|
||||||
"log": str(log_path),
|
"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",
|
"phase": "rsync",
|
||||||
"snapshot": str(incomplete_dir),
|
"snapshot": str(incomplete_dir),
|
||||||
"log": str(log_path),
|
"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",
|
"phase": "finalizing",
|
||||||
"snapshot": str(incomplete_dir),
|
"snapshot": str(incomplete_dir),
|
||||||
"log": str(log_path),
|
"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,
|
"exit_code": result.exit_code,
|
||||||
"command": result.command,
|
"command": result.command,
|
||||||
"log_tail": log_tail,
|
"log_tail": log_tail,
|
||||||
|
"bwlimit_kbps": bwlimit_kbps,
|
||||||
},
|
},
|
||||||
"failure": classify_rsync_failure(result.exit_code, log_tail),
|
"failure": classify_rsync_failure(result.exit_code, log_tail),
|
||||||
}
|
}
|
||||||
@@ -470,7 +472,7 @@ def run_scheduled(
|
|||||||
"log": str(final_log_path),
|
"log": str(final_log_path),
|
||||||
"status": meta["status"],
|
"status": meta["status"],
|
||||||
"warning": warning,
|
"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),
|
"verbose_output": bool(verbose_output),
|
||||||
"duration_seconds": meta["duration_seconds"],
|
"duration_seconds": meta["duration_seconds"],
|
||||||
"stats": meta["stats"],
|
"stats": meta["stats"],
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ GLOBAL_SCHEMA = Schema(
|
|||||||
|
|
||||||
HOST_RSYNC_SCHEMA = Schema(
|
HOST_RSYNC_SCHEMA = Schema(
|
||||||
fields={
|
fields={
|
||||||
|
"bwlimit_kbps": FieldSpec(int, required=False, min_value=0),
|
||||||
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
|
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
|
||||||
},
|
},
|
||||||
allow_unknown=False,
|
allow_unknown=False,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class HostConfigAdmin(admin.ModelAdmin):
|
|||||||
(None, {"fields": ("host", "address", "enabled")}),
|
(None, {"fields": ("host", "address", "enabled")}),
|
||||||
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
|
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
|
||||||
("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", "rsync_bwlimit_kbps")}),
|
||||||
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
||||||
("Runtime state", {"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",)}),
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
|
|||||||
data["excludes_replace"] = list(host_config.excludes_replace or [])
|
data["excludes_replace"] = list(host_config.excludes_replace or [])
|
||||||
else:
|
else:
|
||||||
data["excludes_add"] = list(host_config.excludes_add or [])
|
data["excludes_add"] = list(host_config.excludes_add or [])
|
||||||
if host_config.rsync_extra_args:
|
if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None:
|
||||||
data["rsync"] = {"extra_args": list(host_config.rsync_extra_args or [])}
|
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")
|
return validate_dict(data, HOST_SCHEMA, path="host")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class HostConfigForm(forms.ModelForm):
|
|||||||
"excludes_add",
|
"excludes_add",
|
||||||
"excludes_replace",
|
"excludes_replace",
|
||||||
"rsync_extra_args",
|
"rsync_extra_args",
|
||||||
|
"rsync_bwlimit_kbps",
|
||||||
"retention_daily",
|
"retention_daily",
|
||||||
"retention_weekly",
|
"retention_weekly",
|
||||||
"retention_monthly",
|
"retention_monthly",
|
||||||
@@ -70,6 +71,7 @@ class HostConfigForm(forms.ModelForm):
|
|||||||
"ssh_user": "Leave empty to use the global SSH user.",
|
"ssh_user": "Leave empty to use the global SSH user.",
|
||||||
"ssh_port": "Leave empty to use the global SSH port.",
|
"ssh_port": "Leave empty to use the global SSH port.",
|
||||||
"source_root": "Leave empty to use the global default source root.",
|
"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 = {
|
help_texts = {
|
||||||
"name": "Usually 'default'. The backup engine currently reads the default config.",
|
"name": "Usually 'default'. The backup engine currently reads the default config.",
|
||||||
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
|
"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_source_root": "Used by hosts without a custom source root.",
|
||||||
"default_destination_subdir": "Optional subdirectory below each snapshot.",
|
"default_destination_subdir": "Optional subdirectory below each snapshot.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("--exclude-add", action="append", default=[])
|
parser.add_argument("--exclude-add", action="append", default=[])
|
||||||
parser.add_argument("--exclude-replace", action="append", default=None)
|
parser.add_argument("--exclude-replace", action="append", default=None)
|
||||||
parser.add_argument("--rsync-extra-arg", action="append", default=[])
|
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("--retention", default=None)
|
||||||
parser.add_argument("--disabled", action="store_true")
|
parser.add_argument("--disabled", action="store_true")
|
||||||
parser.add_argument("--force", action="store_true", help="Update existing host")
|
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_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]),
|
||||||
"excludes_replace": options["exclude_replace"],
|
"excludes_replace": options["exclude_replace"],
|
||||||
"rsync_extra_args": list(options["rsync_extra_arg"]),
|
"rsync_extra_args": list(options["rsync_extra_arg"]),
|
||||||
|
"rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"],
|
||||||
"retention_daily": retention["daily"],
|
"retention_daily": retention["daily"],
|
||||||
"retention_weekly": retention["weekly"],
|
"retention_weekly": retention["weekly"],
|
||||||
"retention_monthly": retention["monthly"],
|
"retention_monthly": retention["monthly"],
|
||||||
|
|||||||
18
src/pobsync_backend/migrations/0014_host_bwlimit_override.py
Normal file
18
src/pobsync_backend/migrations/0014_host_bwlimit_override.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -63,6 +63,7 @@ class HostConfig(TimestampedModel):
|
|||||||
excludes_add = models.JSONField(default=list, blank=True)
|
excludes_add = models.JSONField(default=list, blank=True)
|
||||||
excludes_replace = models.JSONField(null=True, blank=True)
|
excludes_replace = models.JSONField(null=True, blank=True)
|
||||||
rsync_extra_args = models.JSONField(default=list, 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_daily = models.PositiveIntegerField(default=14)
|
||||||
retention_weekly = models.PositiveIntegerField(default=8)
|
retention_weekly = models.PositiveIntegerField(default=8)
|
||||||
retention_monthly = models.PositiveIntegerField(default=12)
|
retention_monthly = models.PositiveIntegerField(default=12)
|
||||||
|
|||||||
@@ -373,7 +373,10 @@
|
|||||||
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
|
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
|
||||||
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
|
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
|
||||||
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
|
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
|
||||||
<div class="record-fact"><span class="label">Bandwidth limit:</span><strong>{{ effective_config.rsync.bwlimit_kbps }} KB/s</strong></div>
|
<div class="record-fact">
|
||||||
|
<span class="label">Bandwidth limit:</span>
|
||||||
|
<strong>{% if effective_config.rsync.bwlimit_kbps %}{{ effective_config.rsync.bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="record-card">
|
<article class="record-card">
|
||||||
|
|||||||
@@ -50,6 +50,10 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Rsync Command</h2>
|
<h2>Rsync Command</h2>
|
||||||
|
<p class="muted">
|
||||||
|
<strong>Bandwidth limit:</strong>
|
||||||
|
{% if rsync_bwlimit_kbps %}{{ rsync_bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}
|
||||||
|
</p>
|
||||||
{% if rsync_command %}
|
{% if rsync_command %}
|
||||||
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
|
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
|
||||||
{% endif %}{% endfor %}</pre>
|
{% endif %}{% endfor %}</pre>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
address="web-01.example.test",
|
address="web-01.example.test",
|
||||||
exclude_add=["/tmp/***"],
|
exclude_add=["/tmp/***"],
|
||||||
rsync_extra_arg=["--delete"],
|
rsync_extra_arg=["--delete"],
|
||||||
|
rsync_bwlimit_kbps=4096,
|
||||||
stdout=out,
|
stdout=out,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +50,12 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
self.assertEqual(host.retention_daily, 5)
|
self.assertEqual(host.retention_daily, 5)
|
||||||
self.assertEqual(host.excludes_add, ["/tmp/***"])
|
self.assertEqual(host.excludes_add, ["/tmp/***"])
|
||||||
self.assertEqual(host.rsync_extra_args, ["--delete"])
|
self.assertEqual(host.rsync_extra_args, ["--delete"])
|
||||||
|
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
|
||||||
|
|
||||||
effective = DjangoConfigSource().effective_config_for_host("web-01")
|
effective = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
self.assertEqual(effective["retention"]["yearly"], 2)
|
self.assertEqual(effective["retention"]["yearly"], 2)
|
||||||
self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
|
self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
|
||||||
|
self.assertEqual(effective["rsync"]["bwlimit_kbps"], 4096)
|
||||||
|
|
||||||
def test_configure_schedule_creates_sql_schedule(self) -> None:
|
def test_configure_schedule_creates_sql_schedule(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
backup_root="/backups",
|
backup_root="/backups",
|
||||||
rsync_args=["--archive"],
|
rsync_args=["--archive"],
|
||||||
rsync_extra_args=["--numeric-ids"],
|
rsync_extra_args=["--numeric-ids"],
|
||||||
|
rsync_bwlimit_kbps=10000,
|
||||||
excludes_default=["/proc/***"],
|
excludes_default=["/proc/***"],
|
||||||
retention_daily=7,
|
retention_daily=7,
|
||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
@@ -28,6 +29,7 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
address="web-01.example.test",
|
address="web-01.example.test",
|
||||||
excludes_add=["/tmp/***"],
|
excludes_add=["/tmp/***"],
|
||||||
rsync_extra_args=["--delete"],
|
rsync_extra_args=["--delete"],
|
||||||
|
rsync_bwlimit_kbps=2500,
|
||||||
retention_daily=7,
|
retention_daily=7,
|
||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
retention_monthly=3,
|
retention_monthly=3,
|
||||||
@@ -46,6 +48,24 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
self.assertEqual(cfg["address"], "web-01.example.test")
|
self.assertEqual(cfg["address"], "web-01.example.test")
|
||||||
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
||||||
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
|
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:
|
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
|
||||||
credential = SshCredential.objects.create(
|
credential = SshCredential.objects.create(
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from pobsync.rsync import RsyncResult
|
|||||||
|
|
||||||
|
|
||||||
class FakeConfigSource:
|
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.backup_root = backup_root
|
||||||
|
self.bwlimit_kbps = bwlimit_kbps
|
||||||
|
|
||||||
def effective_config_for_host(self, host: str) -> dict:
|
def effective_config_for_host(self, host: str) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -25,7 +26,7 @@ class FakeConfigSource:
|
|||||||
"binary": "rsync",
|
"binary": "rsync",
|
||||||
"args_effective": ["--archive"],
|
"args_effective": ["--archive"],
|
||||||
"timeout_seconds": 0,
|
"timeout_seconds": 0,
|
||||||
"bwlimit_kbps": 0,
|
"bwlimit_kbps": self.bwlimit_kbps,
|
||||||
},
|
},
|
||||||
"source_root": "/",
|
"source_root": "/",
|
||||||
"includes": [],
|
"includes": [],
|
||||||
@@ -54,6 +55,21 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
self.assertEqual(result["host"], "web-01")
|
self.assertEqual(result["host"], "web-01")
|
||||||
run_rsync.assert_called_once()
|
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 test_failed_dry_run_includes_log_tail(self) -> None:
|
||||||
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -186,11 +202,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
host="web-01",
|
host="web-01",
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
verbose_output=True,
|
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]
|
command = run_rsync.call_args.args[0]
|
||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertIn("--bwlimit=2048", command)
|
||||||
|
self.assertEqual(result["rsync"]["bwlimit_kbps"], 2048)
|
||||||
self.assertIn("--stats", command)
|
self.assertIn("--stats", command)
|
||||||
self.assertIn("--itemize-changes", command)
|
self.assertIn("--itemize-changes", command)
|
||||||
self.assertIn("--info=flist2,progress2,stats2", command)
|
self.assertIn("--info=flist2,progress2,stats2", command)
|
||||||
|
|||||||
@@ -921,6 +921,7 @@ class ViewTests(TestCase):
|
|||||||
"excludes_add": "*.tmp",
|
"excludes_add": "*.tmp",
|
||||||
"excludes_replace": "",
|
"excludes_replace": "",
|
||||||
"rsync_extra_args": "--numeric-ids",
|
"rsync_extra_args": "--numeric-ids",
|
||||||
|
"rsync_bwlimit_kbps": "4096",
|
||||||
"retention_daily": "7",
|
"retention_daily": "7",
|
||||||
"retention_weekly": "4",
|
"retention_weekly": "4",
|
||||||
"retention_monthly": "2",
|
"retention_monthly": "2",
|
||||||
@@ -938,6 +939,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
|
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
|
||||||
self.assertEqual(host.excludes_add, ["*.tmp"])
|
self.assertEqual(host.excludes_add, ["*.tmp"])
|
||||||
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
|
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
|
||||||
|
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
|
||||||
self.assertEqual(host.retention_weekly, 4)
|
self.assertEqual(host.retention_weekly, 4)
|
||||||
|
|
||||||
def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None:
|
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, "default-key")
|
||||||
self.assertContains(response, "-oBatchMode=yes")
|
self.assertContains(response, "-oBatchMode=yes")
|
||||||
self.assertContains(response, "--archive --numeric-ids --delete --one-file-system")
|
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/***")
|
||||||
self.assertContains(response, "/srv/www/cache/***")
|
self.assertContains(response, "/srv/www/cache/***")
|
||||||
self.assertContains(response, "d14")
|
self.assertContains(response, "d14")
|
||||||
@@ -1473,9 +1476,10 @@ class ViewTests(TestCase):
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"snapshot": snapshot.path,
|
"snapshot": snapshot.path,
|
||||||
"rsync": {
|
"rsync": {
|
||||||
"command": ["rsync", "--archive", "root@web-01:/", snapshot.path],
|
"command": ["rsync", "--archive", "--bwlimit=2048", "root@web-01:/", snapshot.path],
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
"log_tail": ["sending incremental file list", "sent 500 bytes"],
|
"log_tail": ["sending incremental file list", "sent 500 bytes"],
|
||||||
|
"bwlimit_kbps": 2048,
|
||||||
},
|
},
|
||||||
"requested": {
|
"requested": {
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
@@ -1510,6 +1514,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Dry run:</strong> yes")
|
self.assertContains(response, "Dry run:</strong> yes")
|
||||||
self.assertContains(response, "Verbose rsync output:</strong> yes")
|
self.assertContains(response, "Verbose rsync output:</strong> yes")
|
||||||
self.assertContains(response, "Rsync Command")
|
self.assertContains(response, "Rsync Command")
|
||||||
|
self.assertContains(response, "Bandwidth limit:</strong>")
|
||||||
|
self.assertContains(response, "2048 KB/s")
|
||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "Rsync Log")
|
self.assertContains(response, "Rsync Log")
|
||||||
self.assertContains(response, "sending incremental file list")
|
self.assertContains(response, "sending incremental file list")
|
||||||
@@ -2464,6 +2470,7 @@ class ViewTests(TestCase):
|
|||||||
"excludes_add": "*.tmp\ncache/",
|
"excludes_add": "*.tmp\ncache/",
|
||||||
"excludes_replace": "",
|
"excludes_replace": "",
|
||||||
"rsync_extra_args": "--numeric-ids\n--delete",
|
"rsync_extra_args": "--numeric-ids\n--delete",
|
||||||
|
"rsync_bwlimit_kbps": "8192",
|
||||||
"retention_daily": "7",
|
"retention_daily": "7",
|
||||||
"retention_weekly": "4",
|
"retention_weekly": "4",
|
||||||
"retention_monthly": "2",
|
"retention_monthly": "2",
|
||||||
@@ -2483,6 +2490,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
|
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
|
||||||
self.assertIsNone(host.excludes_replace)
|
self.assertIsNone(host.excludes_replace)
|
||||||
self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"])
|
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_daily, 7)
|
||||||
self.assertEqual(host.retention_yearly, 1)
|
self.assertEqual(host.retention_yearly, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
|||||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||||
"rsync": rsync_result,
|
"rsync": rsync_result,
|
||||||
"rsync_command": _run_rsync_command(rsync_result),
|
"rsync_command": _run_rsync_command(rsync_result),
|
||||||
|
"rsync_bwlimit_kbps": _run_rsync_bwlimit_kbps(rsync_result),
|
||||||
"failure": failure,
|
"failure": failure,
|
||||||
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
||||||
"prune_result": prune_result,
|
"prune_result": prune_result,
|
||||||
@@ -1249,6 +1250,23 @@ def _run_rsync_command(rsync_result: dict) -> list[str]:
|
|||||||
return [str(part) for part in command]
|
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]:
|
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")
|
log_tail = rsync_result.get("log_tail")
|
||||||
if isinstance(log_tail, list):
|
if isinstance(log_tail, list):
|
||||||
|
|||||||
Reference in New Issue
Block a user