lts-backup-cli 3.1.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.
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/PKG-INFO +6 -3
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/README.md +5 -2
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/backup.feature +13 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/discover.feature +7 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/__init__.py +1 -1
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/cli.py +6 -4
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/doctor.py +13 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/restic.py +4 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/schedule.py +1 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/services.py +13 -5
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/wizard.py +11 -3
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/smoke/smoke.sh +2 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_doctor.py +26 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_schedule.py +2 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_services.py +10 -1
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_wizard.py +32 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/.gitignore +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/CLAUDE.md +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/LICENSE +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/credentials.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/doctor.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/e2e.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/environment.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/forget.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/list.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/restore.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/snapshot.feature +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/steps/cli_steps.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/features/steps/e2e_steps.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/pyproject.toml +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/__main__.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/apache.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/config.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/docker.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/mariadb.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/rsync.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/runner.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/sshconfig.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/src/lts_backup_cli/validation.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/Dockerfile.rocky8 +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/Dockerfile.ubuntu2204 +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/docker-compose.yml +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/docker-shim.sh +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/e2e.sh +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/vhosts.conf +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/docker/web-setup.sh +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/support/fake_runner.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/conftest.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_adapters.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_apache.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_config.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_credentials.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_discover.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_docker.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_rsync.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_runner.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_ssh_runner.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/tests/unit/test_sshconfig.py +0 -0
- {lts_backup_cli-3.1.0 → lts_backup_cli-3.2.1}/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
|
|
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
|
|
@@ -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` |
|
|
@@ -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
|
|
|
@@ -315,7 +316,7 @@ A server that already exists here needs `--force`, and then **keeps its local co
|
|
|
315
316
|
| Option | Meaning |
|
|
316
317
|
|---|---|
|
|
317
318
|
| `--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 |
|
|
319
|
+
| `--repo NAME`, `-r`, `--label` | repo on that server; optional when only one matches |
|
|
319
320
|
| `--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
321
|
| `--verbose`, `-v` | log every external command (secrets are never on a command line) |
|
|
321
322
|
| `--config FILE` | alternative config file (default `~/.config/ltsbu/config.toml`); put it before the command |
|
|
@@ -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
|
|
|
@@ -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` |
|
|
@@ -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
|
|
|
@@ -280,7 +281,7 @@ A server that already exists here needs `--force`, and then **keeps its local co
|
|
|
280
281
|
| Option | Meaning |
|
|
281
282
|
|---|---|
|
|
282
283
|
| `--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 |
|
|
284
|
+
| `--repo NAME`, `-r`, `--label` | repo on that server; optional when only one matches |
|
|
284
285
|
| `--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
286
|
| `--verbose`, `-v` | log every external command (secrets are never on a command line) |
|
|
286
287
|
| `--config FILE` | alternative config file (default `~/.config/ltsbu/config.toml`); put it before the command |
|
|
@@ -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
|
|
|
@@ -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"
|
|
@@ -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(
|
|
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
|
|
@@ -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
|
-
|
|
130
|
-
|
|
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)
|
|
@@ -444,11 +449,14 @@ class DiscoverService:
|
|
|
444
449
|
"""docker.propose, keeping only paths that exist on the server (Portainer's compose
|
|
445
450
|
dirs are container paths, already inside its data volume)."""
|
|
446
451
|
split = docker.propose(found.containers, str(found.docker_root))
|
|
447
|
-
|
|
448
|
-
out = runner.run(["sh", "-c", _EXISTING, "sh", *wanted], mutating=False).stdout
|
|
449
|
-
present = set(out.splitlines())
|
|
452
|
+
present = self.existing(runner, sorted({p for ps, _, _ in split.values() for p in ps}))
|
|
450
453
|
return {n: ([p for p in ps if p in present], ex, dbs) for n, (ps, ex, dbs) in split.items()}
|
|
451
454
|
|
|
455
|
+
def existing(self, runner: Runner, paths: list[str]) -> set[str]:
|
|
456
|
+
"""The `paths` that exist on the server (one constant script, paths as argv)."""
|
|
457
|
+
out = runner.run(["sh", "-c", _EXISTING, "sh", *paths], mutating=False).stdout
|
|
458
|
+
return set(out.splitlines())
|
|
459
|
+
|
|
452
460
|
def databases(
|
|
453
461
|
self, runner: Runner, server: c.ServerConfig
|
|
454
462
|
) -> 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
|
|
|
@@ -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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|