lts-backup-cli 3.1.0__tar.gz → 3.2.0__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.1.0 → lts_backup_cli-3.2.0}/PKG-INFO +3 -3
  2. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/README.md +2 -2
  3. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/backup.feature +13 -0
  4. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/discover.feature +7 -0
  5. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/__init__.py +1 -1
  6. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/cli.py +6 -4
  7. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/services.py +6 -3
  8. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/wizard.py +11 -3
  9. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/smoke/smoke.sh +2 -0
  10. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_wizard.py +32 -0
  11. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/.gitignore +0 -0
  12. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/CLAUDE.md +0 -0
  13. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/LICENSE +0 -0
  14. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/credentials.feature +0 -0
  15. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/doctor.feature +0 -0
  16. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/e2e.feature +0 -0
  17. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/environment.py +0 -0
  18. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/forget.feature +0 -0
  19. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/list.feature +0 -0
  20. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/restore.feature +0 -0
  21. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/snapshot.feature +0 -0
  22. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/steps/cli_steps.py +0 -0
  23. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/features/steps/e2e_steps.py +0 -0
  24. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/pyproject.toml +0 -0
  25. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/__main__.py +0 -0
  26. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/apache.py +0 -0
  27. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/config.py +0 -0
  28. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/docker.py +0 -0
  29. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/doctor.py +0 -0
  30. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/mariadb.py +0 -0
  31. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/restic.py +0 -0
  32. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/rsync.py +0 -0
  33. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/runner.py +0 -0
  34. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/schedule.py +0 -0
  35. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/sshconfig.py +0 -0
  36. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/src/lts_backup_cli/validation.py +0 -0
  37. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/Dockerfile.rocky8 +0 -0
  38. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/Dockerfile.ubuntu2204 +0 -0
  39. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/docker-compose.yml +0 -0
  40. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/docker-shim.sh +0 -0
  41. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/e2e.sh +0 -0
  42. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/vhosts.conf +0 -0
  43. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/docker/web-setup.sh +0 -0
  44. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/support/fake_runner.py +0 -0
  45. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/conftest.py +0 -0
  46. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_adapters.py +0 -0
  47. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_apache.py +0 -0
  48. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_config.py +0 -0
  49. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_credentials.py +0 -0
  50. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_discover.py +0 -0
  51. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_docker.py +0 -0
  52. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_doctor.py +0 -0
  53. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_rsync.py +0 -0
  54. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_runner.py +0 -0
  55. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_schedule.py +0 -0
  56. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_services.py +0 -0
  57. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_ssh_runner.py +0 -0
  58. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_sshconfig.py +0 -0
  59. {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.0}/tests/unit/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lts-backup-cli
3
- Version: 3.1.0
3
+ Version: 3.2.0
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
@@ -209,7 +209,7 @@ ltsbu discover lab.example.com --non-interactive \
209
209
  | `--repo-root`, `--mirror-root` | `/srv/restic`, `/srv/ltsbu/mirror` (on this backup server) |
210
210
  | `--db-user` / `--no-db-login` | password from `$LTSBU_DB_PASSWORD` / skip databases |
211
211
  | `--vhosts`, `--databases` | `all`, `none` or a comma-separated list; new server: all; edit: unchanged |
212
- | `--folders "/a /b"` | none |
212
+ | `--folders "/a /b"` | `~/public_html` of the SSH user on a new server, if it exists and no vhost already covers it; otherwise none (`-` = none) |
213
213
  | `--docker all\|none`, `--docker-containers` | non-interactive: `none`; `all` = one repo per compose project plus `docker`; containers: `all`, `none` or a list (default: every running container with a `/var/lib/mysql` mount) |
214
214
  | `--every-hours N`, `--weekly-day DAY`, `--weekly-time HH:MM` | `2`, staggered night slot |
215
215
  | `--keep-hourly/-daily/-weekly/-monthly N` | `12 / 7 / 4 / 6` |
@@ -315,7 +315,7 @@ A server that already exists here needs `--force`, and then **keeps its local co
315
315
  | Option | Meaning |
316
316
  |---|---|
317
317
  | `--server LABEL`, `-s` | server to act on; optional when only one matches |
318
- | `--repo NAME`, `-r` | repo on that server; optional when only one matches |
318
+ | `--repo NAME`, `-r`, `--label` | repo on that server; optional when only one matches |
319
319
  | `--dry-run` | print the commands that would change something instead of running them; read-only commands still run (a new server's `discover` logs in through a temporary copy of `~/.ssh/config` with the block it would add) |
320
320
  | `--verbose`, `-v` | log every external command (secrets are never on a command line) |
321
321
  | `--config FILE` | alternative config file (default `~/.config/ltsbu/config.toml`); put it before the command |
@@ -174,7 +174,7 @@ ltsbu discover lab.example.com --non-interactive \
174
174
  | `--repo-root`, `--mirror-root` | `/srv/restic`, `/srv/ltsbu/mirror` (on this backup server) |
175
175
  | `--db-user` / `--no-db-login` | password from `$LTSBU_DB_PASSWORD` / skip databases |
176
176
  | `--vhosts`, `--databases` | `all`, `none` or a comma-separated list; new server: all; edit: unchanged |
177
- | `--folders "/a /b"` | none |
177
+ | `--folders "/a /b"` | `~/public_html` of the SSH user on a new server, if it exists and no vhost already covers it; otherwise none (`-` = none) |
178
178
  | `--docker all\|none`, `--docker-containers` | non-interactive: `none`; `all` = one repo per compose project plus `docker`; containers: `all`, `none` or a list (default: every running container with a `/var/lib/mysql` mount) |
179
179
  | `--every-hours N`, `--weekly-day DAY`, `--weekly-time HH:MM` | `2`, staggered night slot |
180
180
  | `--keep-hourly/-daily/-weekly/-monthly N` | `12 / 7 / 4 / 6` |
@@ -280,7 +280,7 @@ A server that already exists here needs `--force`, and then **keeps its local co
280
280
  | Option | Meaning |
281
281
  |---|---|
282
282
  | `--server LABEL`, `-s` | server to act on; optional when only one matches |
283
- | `--repo NAME`, `-r` | repo on that server; optional when only one matches |
283
+ | `--repo NAME`, `-r`, `--label` | repo on that server; optional when only one matches |
284
284
  | `--dry-run` | print the commands that would change something instead of running them; read-only commands still run (a new server's `discover` logs in through a temporary copy of `~/.ssh/config` with the block it would add) |
285
285
  | `--verbose`, `-v` | log every external command (secrets are never on a command line) |
286
286
  | `--config FILE` | alternative config file (default `~/.config/ltsbu/config.toml`); put it before the command |
@@ -100,3 +100,16 @@ Feature: Back up a server's repos and rotate them
100
100
  When I run "ltsbu backup --server lab --repo docker --weekly"
101
101
  Then the exit code should be 0
102
102
  And the image "lts/electro-site:php7.2" should have been saved into restic
103
+
104
+ Scenario: --label is an alias of --repo
105
+ Given a configured server "lab" with repos
106
+ | repo | kind | what |
107
+ | docker | docker | /srv/docker/volumes |
108
+ | proxy | docker | /home/lts/proxy |
109
+ When I run "ltsbu backup --server lab --label proxy"
110
+ Then the exit code should be 0
111
+ And rsync should have been called with "ltsbu-lab:/home/lts/proxy"
112
+ And rsync should not have been called with "ltsbu-lab:/srv/docker/volumes"
113
+ When I run "ltsbu --label docker backup --server lab"
114
+ Then the exit code should be 0
115
+ And rsync should have been called with "ltsbu-lab:/srv/docker/volumes"
@@ -117,3 +117,10 @@ Feature: Discover a server and configure its backups
117
117
  When I run "ltsbu discover lab.example.com --non-interactive --ssh-user root --no-db-login --docker none"
118
118
  Then the exit code should be 0
119
119
  And server "lab" should have repos "blog-example-com, shop-example-com"
120
+
121
+ Scenario: A new server's public_html is proposed as an extra folder
122
+ Given the server's home is "/home/backup"
123
+ And the server has the paths "/home/backup/public_html"
124
+ When I run "ltsbu discover web1.example.com --non-interactive --ssh-user backup --no-db-login --vhosts none"
125
+ Then the exit code should be 0
126
+ And server "web1" should have repos "home-backup-public_html"
@@ -1,3 +1,3 @@
1
1
  """lts-backup-cli: restic + MariaDB backup utility."""
2
2
 
3
- __version__ = "3.1.0"
3
+ __version__ = "3.2.0"
@@ -123,7 +123,7 @@ def common(f: F) -> F:
123
123
  """--server, --repo, --dry-run, --verbose also accepted after the subcommand name."""
124
124
 
125
125
  @click.option("--server", "-s", metavar="LABEL", help="Server label.")
126
- @click.option("--repo", "-r", metavar="NAME", help="Repo name on the server.")
126
+ @click.option("--repo", "-r", "--label", metavar="NAME", help="Repo name on the server.")
127
127
  @click.option(
128
128
  "--dry-run", is_flag=True, help="Print mutating commands instead of running them."
129
129
  )
@@ -158,7 +158,7 @@ def _confirm(st: State, yes: bool, message: str) -> None:
158
158
  help="Config file (default ~/.config/ltsbu/config.toml).",
159
159
  )
160
160
  @click.option("--server", "-s", metavar="LABEL", help="Server label.")
161
- @click.option("--repo", "-r", metavar="NAME", help="Repo name on the server.")
161
+ @click.option("--repo", "-r", "--label", metavar="NAME", help="Repo name on the server.")
162
162
  @click.option("--dry-run", is_flag=True, help="Print mutating commands instead of running them.")
163
163
  @click.option("--verbose", "-v", is_flag=True, help="Show executed commands.")
164
164
  @click.version_option(__version__, prog_name="ltsbu")
@@ -231,7 +231,10 @@ def _run_doctor(st: State) -> None:
231
231
  @click.option("--no-db-login", is_flag=True, help="Do not set up a MariaDB login now.")
232
232
  @click.option("--vhosts", help="'all', 'none' or comma-separated repo names.")
233
233
  @click.option("--databases", help="'all', 'none' or comma-separated database names.")
234
- @click.option("--folders", help="Extra folders on the server, space separated.")
234
+ @click.option(
235
+ "--folders",
236
+ help="Extra folders on the server, space separated; '-' for none [new server: ~/public_html if it exists].",
237
+ )
235
238
  @click.option(
236
239
  "--docker", type=click.Choice(["all", "none"]), help="Back up the server's Docker host."
237
240
  )
@@ -311,7 +314,6 @@ def _discover_presets(non_interactive: bool, o: dict[str, Any]) -> dict[str, Any
311
314
  if non_interactive:
312
315
  p.setdefault("db_login", "db_user" in p)
313
316
  p.setdefault("docker", False)
314
- p.setdefault("folders", "-")
315
317
  p.setdefault("customize", False)
316
318
  p["confirm_remove"] = o["remove"]
317
319
  p["apply"] = True
@@ -444,11 +444,14 @@ class DiscoverService:
444
444
  """docker.propose, keeping only paths that exist on the server (Portainer's compose
445
445
  dirs are container paths, already inside its data volume)."""
446
446
  split = docker.propose(found.containers, str(found.docker_root))
447
- wanted = sorted({p for paths, _, _ in split.values() for p in paths})
448
- out = runner.run(["sh", "-c", _EXISTING, "sh", *wanted], mutating=False).stdout
449
- present = set(out.splitlines())
447
+ present = self.existing(runner, sorted({p for ps, _, _ in split.values() for p in ps}))
450
448
  return {n: ([p for p in ps if p in present], ex, dbs) for n, (ps, ex, dbs) in split.items()}
451
449
 
450
+ def existing(self, runner: Runner, paths: list[str]) -> set[str]:
451
+ """The `paths` that exist on the server (one constant script, paths as argv)."""
452
+ out = runner.run(["sh", "-c", _EXISTING, "sh", *paths], mutating=False).stdout
453
+ return set(out.splitlines())
454
+
452
455
  def databases(
453
456
  self, runner: Runner, server: c.ServerConfig
454
457
  ) -> tuple[list[str] | None, list[str]]:
@@ -251,6 +251,10 @@ def _db_login(
251
251
  return None
252
252
 
253
253
 
254
+ def _overlaps(a: str, b: str) -> bool:
255
+ return a == b or a.startswith(b.rstrip("/") + "/") or b.startswith(a.rstrip("/") + "/")
256
+
257
+
254
258
  def _folder_name(path: str) -> str:
255
259
  return apache.repo_name_for(path.strip("/").replace("/", "-") or "root")
256
260
 
@@ -403,10 +407,14 @@ def run(
403
407
 
404
408
  # --- extra folders (one repo each) ---
405
409
  folder_paths = [p for r in existing.values() if r.kind == "folder" for p in r.paths]
410
+ default = " ".join(folder_paths) or "-"
411
+ if not old and found.home.startswith("/"):
412
+ pub = f"{found.home.rstrip('/')}/public_html" # v1's default files path
413
+ covered = any(_overlaps(pub, h.document_root) for h in found.vhosts)
414
+ if not covered and pub in svc.existing(runner, [pub]):
415
+ default = pub
406
416
  raw = ask.text(
407
- "folders",
408
- "Extra folders on the server (space separated, empty for none):",
409
- " ".join(folder_paths) or "-",
417
+ "folders", "Extra folders on the server (space separated, '-' for none):", default
410
418
  )
411
419
  for path in (p for p in raw.split() if p != "-"):
412
420
  path = v.remote_abs_path(path)
@@ -20,6 +20,8 @@ done
20
20
  if ltsbu configure --help >/dev/null 2>&1; then fail "v1 'configure' command still present"; fi
21
21
  ltsbu restore --help | grep -q -- "--container" || fail "restore --container not wired"
22
22
  ltsbu restore --help | grep -q -- "--image" || fail "restore --image not wired"
23
+ ltsbu backup --help | grep -q -- "--label" || fail "backup --label not wired"
24
+ ltsbu --help | grep -q -- "--label" || fail "global --label not wired"
23
25
  ltsbu discover --help | grep -q -- "--docker" || fail "discover --docker not wired"
24
26
  if ltsbu discover --help | grep -q -- "--storage-host"; then fail "v2 --storage-host still present"; fi
25
27
 
@@ -110,6 +110,38 @@ def test_select_subset_and_folders(server_runner: FakeRunner, tmp_path: Path) ->
110
110
  assert req.server.repos["srv-data"].kind == "folder"
111
111
 
112
112
 
113
+ @pytest.mark.parametrize(
114
+ ("present", "vhost_root", "expected"),
115
+ [
116
+ ("/home/backup/public_html\n", "/var/www/shop", {"home-backup-public_html"}),
117
+ ("", "/var/www/shop", set()), # no public_html on the server
118
+ ("/home/backup/public_html\n", "/home/backup/public_html", set()), # already a vhost
119
+ ("/home/backup/public_html\n", "/home/backup/public_html/shop", set()), # vhost inside
120
+ ],
121
+ )
122
+ def test_new_server_proposes_public_html_as_the_default_folder(
123
+ server_runner: FakeRunner, tmp_path: Path, present: str, vhost_root: str, expected: set[str]
124
+ ) -> None:
125
+ server_runner.rules = [r for r in server_runner.rules if r.stdout != APACHE]
126
+ server_runner.on(s._READ_APACHE, stdout=APACHE.replace("/var/www/shop", vhost_root))
127
+ server_runner.on(s._EXISTING, "/home/backup/public_html", stdout=present)
128
+ req = run(server_runner, c.Config(), tmp_path, fresh(vhosts=["shop-example-com"]))
129
+ folders = {n for n, r in req.server.repos.items() if r.kind == "folder"}
130
+ assert folders == expected
131
+ if expected:
132
+ assert req.server.repos["home-backup-public_html"].paths == ["/home/backup/public_html"]
133
+
134
+
135
+ def test_editing_never_adds_public_html_by_itself(
136
+ server_runner: FakeRunner, tmp_path: Path
137
+ ) -> None:
138
+ server_runner.on(s._EXISTING, "/home/backup/public_html", stdout="/home/backup/public_html\n")
139
+ cfg = c.Config()
140
+ cfg.servers["web1"] = run(server_runner, cfg, tmp_path, fresh(folders="-")).server
141
+ again = run(server_runner, cfg, tmp_path, fresh()).server
142
+ assert not [r for r in again.repos.values() if r.kind == "folder"]
143
+
144
+
113
145
  def test_invalid_folder_is_rejected(server_runner: FakeRunner, tmp_path: Path) -> None:
114
146
  with pytest.raises(Exception, match="invalid remote path"):
115
147
  run(server_runner, c.Config(), tmp_path, fresh(folders="relative/dir"))
File without changes
File without changes