lts-backup-cli 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/PKG-INFO +4 -1
  2. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/README.md +3 -0
  3. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/__init__.py +1 -1
  4. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/doctor.py +13 -0
  5. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/restic.py +4 -0
  6. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/schedule.py +1 -0
  7. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/services.py +7 -2
  8. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_doctor.py +26 -0
  9. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_schedule.py +2 -0
  10. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_services.py +10 -1
  11. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/.gitignore +0 -0
  12. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/CLAUDE.md +0 -0
  13. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/LICENSE +0 -0
  14. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/backup.feature +0 -0
  15. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/credentials.feature +0 -0
  16. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/discover.feature +0 -0
  17. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/doctor.feature +0 -0
  18. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/e2e.feature +0 -0
  19. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/environment.py +0 -0
  20. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/forget.feature +0 -0
  21. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/list.feature +0 -0
  22. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/restore.feature +0 -0
  23. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/snapshot.feature +0 -0
  24. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/steps/cli_steps.py +0 -0
  25. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/features/steps/e2e_steps.py +0 -0
  26. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/pyproject.toml +0 -0
  27. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/__main__.py +0 -0
  28. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/apache.py +0 -0
  29. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/cli.py +0 -0
  30. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/config.py +0 -0
  31. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/docker.py +0 -0
  32. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/mariadb.py +0 -0
  33. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/rsync.py +0 -0
  34. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/runner.py +0 -0
  35. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/sshconfig.py +0 -0
  36. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/validation.py +0 -0
  37. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/wizard.py +0 -0
  38. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/Dockerfile.rocky8 +0 -0
  39. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/Dockerfile.ubuntu2204 +0 -0
  40. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/docker-compose.yml +0 -0
  41. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/docker-shim.sh +0 -0
  42. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/e2e.sh +0 -0
  43. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/vhosts.conf +0 -0
  44. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/docker/web-setup.sh +0 -0
  45. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/smoke/smoke.sh +0 -0
  46. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/support/fake_runner.py +0 -0
  47. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/conftest.py +0 -0
  48. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_adapters.py +0 -0
  49. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_apache.py +0 -0
  50. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_config.py +0 -0
  51. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_credentials.py +0 -0
  52. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_discover.py +0 -0
  53. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_docker.py +0 -0
  54. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_rsync.py +0 -0
  55. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_runner.py +0 -0
  56. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_ssh_runner.py +0 -0
  57. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_sshconfig.py +0 -0
  58. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_validation.py +0 -0
  59. {lts_backup_cli-3.2.0 → lts_backup_cli-3.2.1}/tests/unit/test_wizard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lts-backup-cli
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: LTS backup utility: a central backup server pulls Apache vhosts, MariaDB databases, folders and Docker hosts over SSH into local restic repositories, with discovery and scheduled rotation.
5
5
  Author-email: LTS <joaomartins@lts.pt>
6
6
  License: BSD-3-Clause
@@ -233,6 +233,7 @@ that ltsbu can't log in to.
233
233
  | containers | each container to dump is running and has `mariadb-dump`/`mysqldump` inside |
234
234
  | restic repository | every local repo opens with its password |
235
235
  | timers | both timers of every repo are installed and enabled; user timers warn if linger is off |
236
+ | last run | the last interval and weekly run of every repo succeeded (else: which `journalctl -u` to read) |
236
237
 
237
238
  ### `ltsbu list`
238
239
 
@@ -349,6 +350,8 @@ Exit codes: `0` success, `1` error, `2` invalid usage, `3` doctor found failures
349
350
  `files` on a docker repo's file snapshot, and `weekly` on weekly runs.
350
351
  - **One at a time:** a lock (`~/.cache/ltsbu/backup.lock`) makes overlapping timer runs queue
351
352
  instead of loading the servers in parallel.
353
+ - **Stale locks:** each backup first runs `restic unlock`, which only removes locks of restic
354
+ processes that died (a lock left behind would otherwise make the rotation fail).
352
355
  - Every argument sent over SSH is shell-quoted. The only secret ever written to a server (the MariaDB
353
356
  login) travels **on stdin** and is written `0600` with `umask 077`.
354
357
 
@@ -198,6 +198,7 @@ that ltsbu can't log in to.
198
198
  | containers | each container to dump is running and has `mariadb-dump`/`mysqldump` inside |
199
199
  | restic repository | every local repo opens with its password |
200
200
  | timers | both timers of every repo are installed and enabled; user timers warn if linger is off |
201
+ | last run | the last interval and weekly run of every repo succeeded (else: which `journalctl -u` to read) |
201
202
 
202
203
  ### `ltsbu list`
203
204
 
@@ -314,6 +315,8 @@ Exit codes: `0` success, `1` error, `2` invalid usage, `3` doctor found failures
314
315
  `files` on a docker repo's file snapshot, and `weekly` on weekly runs.
315
316
  - **One at a time:** a lock (`~/.cache/ltsbu/backup.lock`) makes overlapping timer runs queue
316
317
  instead of loading the servers in parallel.
318
+ - **Stale locks:** each backup first runs `restic unlock`, which only removes locks of restic
319
+ processes that died (a lock left behind would otherwise make the rotation fail).
317
320
  - Every argument sent over SSH is shell-quoted. The only secret ever written to a server (the MariaDB
318
321
  login) travels **on stdin** and is written `0600` with `umask 077`.
319
322
 
@@ -1,3 +1,3 @@
1
1
  """lts-backup-cli: restic + MariaDB backup utility."""
2
2
 
3
- __version__ = "3.2.0"
3
+ __version__ = "3.2.1"
@@ -257,6 +257,19 @@ def check_timers(ctx: Context) -> Iterable[CheckResult]:
257
257
  OK if enabled else FAIL,
258
258
  res.stdout.strip().replace("\n", ", "),
259
259
  )
260
+ services_ = [f"{base}.service", f"{base}-weekly.service"]
261
+ res = ctx.runner.run(
262
+ ["systemctl", *(["--user"] if ctx.user_units else []), "show", "-p", "Result",
263
+ "--value", *services_],
264
+ check=False,
265
+ mutating=False,
266
+ ) # fmt: skip
267
+ bad = [u for u, r in zip(services_, res.stdout.split("\n")) if r and r != "success"]
268
+ yield CheckResult(
269
+ f"[{s.label}/{repo.name}] last run",
270
+ FAIL if bad else OK,
271
+ "; ".join(f"failed, see journalctl -u {u}" for u in bad),
272
+ )
260
273
  if ctx.user_units and any(s.repos for s in ctx.servers):
261
274
  res = ctx.runner.run(
262
275
  ["loginctl", "show-user", getpass.getuser(), "-p", "Linger"],
@@ -49,6 +49,10 @@ class ResticClient:
49
49
  res = self.runner.run(self._base("cat", "config"), check=False, mutating=False)
50
50
  return res.returncode == 0
51
51
 
52
+ def unlock(self) -> None:
53
+ """Remove stale locks only (dead process on this host, or older than 30 minutes)."""
54
+ self.runner.run(self._base("unlock"))
55
+
52
56
  def init(self) -> None:
53
57
  self.runner.run(self._base("init"))
54
58
 
@@ -65,6 +65,7 @@ def render_units(
65
65
  f"Description=ltsbu backup {server}/{repo} ({what})\n"
66
66
  "Wants=network-online.target\nAfter=network-online.target\n\n"
67
67
  "[Service]\nType=oneshot\nNice=10\nIOSchedulingClass=idle\n"
68
+ "Environment=HOME=%h\n" # system units have no $HOME: restic's cache needs one
68
69
  f"ExecStart={cmd}{extra}\n"
69
70
  )
70
71
  units[f"{name}.timer"] = (
@@ -126,8 +126,13 @@ class BackupService:
126
126
  restic = restic_for(self.runner, self.server, repo)
127
127
  tags = repo_tags(self.server, repo) + (["weekly"] if weekly else [])
128
128
  failed: list[str] = []
129
- if repo.kind == "db":
130
- db = mariadb_for(runner_for(self.runner, self.server), self.server)
129
+ db = (
130
+ mariadb_for(runner_for(self.runner, self.server), self.server)
131
+ if repo.kind == "db"
132
+ else None
133
+ )
134
+ restic.unlock() # a killed restic run leaves a lock that makes forget fail
135
+ if db:
131
136
  for name in repo.databases:
132
137
  if not self._dump(restic, repo, name, db.dump_argv(name), [*tags, f"db:{name}"]):
133
138
  failed.append(name)
@@ -244,3 +244,29 @@ def test_docker_images_to_save_must_exist(
244
244
  runner.on("image", "inspect", "lts/gone:1", returncode=1, stderr="No such image")
245
245
  res = {r.name: r.status for r in d.check_server_docker(ctx(runner, cfg, tmp_path))}
246
246
  assert res["[web1] image lts/a:1"] == d.OK and res["[web1] image lts/gone:1"] == d.FAIL
247
+
248
+
249
+ def test_failed_last_run_is_reported_with_where_to_look(
250
+ runner: FakeRunner, cfg: c.Config, tmp_path: Path
251
+ ) -> None:
252
+ cx = ctx(healthy(runner), cfg, tmp_path)
253
+ schedule.install(
254
+ FakeRunner(), schedule.render_units("web1", "shop", c.Schedule(), "/py", "/c"),
255
+ cx.units_dir, True,
256
+ ) # fmt: skip
257
+ runner.on("Result", "ltsbu-web1-shop.service", stdout="exit-code\nsuccess\n")
258
+ res = {r.name: r for r in d.check_timers(cx)}
259
+ last = res["[web1/shop] last run"]
260
+ assert last.status == d.FAIL and "journalctl -u ltsbu-web1-shop.service" in last.detail
261
+
262
+
263
+ def test_last_run_ok_when_systemd_reports_success_or_nothing(
264
+ runner: FakeRunner, cfg: c.Config, tmp_path: Path
265
+ ) -> None:
266
+ cx = ctx(healthy(runner), cfg, tmp_path)
267
+ schedule.install(
268
+ FakeRunner(), schedule.render_units("web1", "shop", c.Schedule(), "/py", "/c"),
269
+ cx.units_dir, True,
270
+ ) # fmt: skip
271
+ runner.on("Result", "ltsbu-web1-shop.service", stdout="success\nsuccess\n")
272
+ assert {r.name: r.status for r in d.check_timers(cx)}["[web1/shop] last run"] == d.OK
@@ -61,6 +61,8 @@ def test_render_units() -> None:
61
61
  "--server web1 --repo shop\n" in svc
62
62
  )
63
63
  assert "--weekly" in units["ltsbu-web1-shop-weekly.service"]
64
+ # system units get no $HOME: restic would run without its cache
65
+ assert "Environment=HOME=%h\n" in svc
64
66
  timer = units["ltsbu-web1-shop.timer"]
65
67
  assert "OnCalendar=*-*-* 00/2:07:00" in timer and "Persistent=true" in timer
66
68
  assert "RandomizedDelaySec=" in timer and "WantedBy=timers.target" in timer
@@ -59,7 +59,8 @@ def test_vhost_backup_pulls_then_backs_up_the_local_mirror_then_rotates(
59
59
  runner: FakeRunner, cfg: c.Config
60
60
  ) -> None:
61
61
  s.BackupService(runner, web1(cfg), quiet).run(web1(cfg).repos["shop"])
62
- mkdir, rsync, backup, forget = runner.calls
62
+ unlock, mkdir, rsync, backup, forget = runner.calls
63
+ assert unlock.argv[-1] == "unlock"
63
64
  mirror = f"{web1(cfg).mirror_root}/web1/shop"
64
65
  assert mkdir.argv == ["mkdir", "-p", "-m", "700", mirror]
65
66
  assert rsync.argv[0] == "rsync" and rsync.argv[-2:] == [
@@ -115,6 +116,14 @@ def test_vanished_files_are_reported_not_fatal(runner: FakeRunner, cfg: c.Config
115
116
  assert any("vanished" in line for line in lines) and runner.find("backup")
116
117
 
117
118
 
119
+ def test_backup_first_clears_stale_restic_locks(runner: FakeRunner, cfg: c.Config) -> None:
120
+ # a killed restic (e.g. a manual `restic dump | head`) leaves a lock that blocks forget
121
+ s.BackupService(runner, web1(cfg), quiet).run(web1(cfg).repos["shop"])
122
+ unlock = next(i for i, call in enumerate(runner.calls) if call.has("unlock"))
123
+ assert runner.calls[unlock].mutating and "--remove-all" not in runner.calls[unlock].argv
124
+ assert unlock < next(i for i, call in enumerate(runner.calls) if call.has("rsync"))
125
+
126
+
118
127
  def test_backup_prunes_mirror_entries_of_dropped_paths_before_the_snapshot(
119
128
  runner: FakeRunner, cfg: c.Config
120
129
  ) -> None:
File without changes
File without changes