lts-backup-cli 2.0.0__py3-none-any.whl
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/__init__.py +3 -0
- lts_backup_cli/__main__.py +3 -0
- lts_backup_cli/apache.py +45 -0
- lts_backup_cli/cli.py +559 -0
- lts_backup_cli/config.py +308 -0
- lts_backup_cli/doctor.py +278 -0
- lts_backup_cli/mariadb.py +157 -0
- lts_backup_cli/restic.py +122 -0
- lts_backup_cli/runner.py +172 -0
- lts_backup_cli/schedule.py +111 -0
- lts_backup_cli/services.py +604 -0
- lts_backup_cli/sshconfig.py +142 -0
- lts_backup_cli/validation.py +189 -0
- lts_backup_cli/wizard.py +419 -0
- lts_backup_cli-2.0.0.dist-info/METADATA +522 -0
- lts_backup_cli-2.0.0.dist-info/RECORD +19 -0
- lts_backup_cli-2.0.0.dist-info/WHEEL +4 -0
- lts_backup_cli-2.0.0.dist-info/entry_points.txt +2 -0
- lts_backup_cli-2.0.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Adapter around the MariaDB client binaries. Runs on the web server (via its Runner) against
|
|
2
|
+
that server's localhost database; credentials via --defaults-extra-file."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from . import validation as v
|
|
10
|
+
from .runner import Runner
|
|
11
|
+
|
|
12
|
+
# Positional args keep the script constant: nothing user-controlled is interpolated.
|
|
13
|
+
_PROBE = 'for n in "$@"; do command -v "$n" 2>/dev/null; done; true'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def probe_binaries(runner: Runner) -> tuple[str, str]:
|
|
17
|
+
"""(client, dumper) available on the runner's host, preferring the mariadb* names."""
|
|
18
|
+
out = runner.run(
|
|
19
|
+
["sh", "-c", _PROBE, "sh", "mariadb", "mysql", "mariadb-dump", "mysqldump"],
|
|
20
|
+
check=False,
|
|
21
|
+
mutating=False,
|
|
22
|
+
).stdout
|
|
23
|
+
found = {line.rsplit("/", 1)[-1] for line in out.splitlines()}
|
|
24
|
+
client = next((n for n in ("mariadb", "mysql") if n in found), "mysql")
|
|
25
|
+
dumper = next((n for n in ("mariadb-dump", "mysqldump") if n in found), "mysqldump")
|
|
26
|
+
return client, dumper
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NATIVE_PLUGIN = "mysql_native_password"
|
|
30
|
+
EXCLUDED_USERS = ("", "root", "mariadb.sys")
|
|
31
|
+
SYSTEM_SCHEMAS = ("information_schema", "mysql", "performance_schema", "sys")
|
|
32
|
+
|
|
33
|
+
# Database-level privilege columns of mysql.db shared by MariaDB 10.3-10.11 (Grant_priv left out on purpose).
|
|
34
|
+
PRIV_COLUMNS = (
|
|
35
|
+
("Select_priv", "SELECT"), ("Insert_priv", "INSERT"), ("Update_priv", "UPDATE"),
|
|
36
|
+
("Delete_priv", "DELETE"), ("Create_priv", "CREATE"), ("Drop_priv", "DROP"),
|
|
37
|
+
("References_priv", "REFERENCES"), ("Index_priv", "INDEX"), ("Alter_priv", "ALTER"),
|
|
38
|
+
("Create_tmp_table_priv", "CREATE TEMPORARY TABLES"), ("Lock_tables_priv", "LOCK TABLES"),
|
|
39
|
+
("Create_view_priv", "CREATE VIEW"), ("Show_view_priv", "SHOW VIEW"),
|
|
40
|
+
("Create_routine_priv", "CREATE ROUTINE"), ("Alter_routine_priv", "ALTER ROUTINE"),
|
|
41
|
+
("Execute_priv", "EXECUTE"), ("Event_priv", "EVENT"), ("Trigger_priv", "TRIGGER"),
|
|
42
|
+
) # fmt: skip
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DbUser:
|
|
47
|
+
user: str
|
|
48
|
+
host: str
|
|
49
|
+
plugin: str
|
|
50
|
+
password_hash: str
|
|
51
|
+
privileges: list[str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_db_users(tsv: str) -> list[DbUser]:
|
|
55
|
+
"""Rows of the db_users() query; a user granted on both `db` and `db\\_` forms is merged."""
|
|
56
|
+
users: dict[tuple[str, str], DbUser] = {}
|
|
57
|
+
for line in tsv.splitlines():
|
|
58
|
+
if not line.strip():
|
|
59
|
+
continue
|
|
60
|
+
user, host, plugin, pw_hash, *flags = line.split("\t")
|
|
61
|
+
if len(flags) != len(PRIV_COLUMNS):
|
|
62
|
+
raise ValueError(f"unexpected mysql.db row: {line!r}")
|
|
63
|
+
privs = [name for (_, name), flag in zip(PRIV_COLUMNS, flags) if flag == "Y"]
|
|
64
|
+
u = users.setdefault(
|
|
65
|
+
(user, host), DbUser(user, host, plugin or NATIVE_PLUGIN, pw_hash, [])
|
|
66
|
+
) # MariaDB 10.3 leaves plugin empty for native passwords
|
|
67
|
+
u.privileges += [p for p in privs if p not in u.privileges]
|
|
68
|
+
return list(users.values())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MariaDBClient:
|
|
72
|
+
def __init__(self, runner: Runner, defaults_file: str, binaries: tuple[str, str]) -> None:
|
|
73
|
+
"""binaries: (client, dumper) as resolved on the runner's host by probe_binaries()."""
|
|
74
|
+
self.runner = runner
|
|
75
|
+
self.defaults_file = defaults_file
|
|
76
|
+
self.client, self.dumper = binaries
|
|
77
|
+
|
|
78
|
+
def _defaults(self) -> list[str]:
|
|
79
|
+
return [f"--defaults-extra-file={self.defaults_file}"]
|
|
80
|
+
|
|
81
|
+
def version(self) -> str:
|
|
82
|
+
return self.runner.run([self.client, "--version"], mutating=False).stdout.strip()
|
|
83
|
+
|
|
84
|
+
def ping(self) -> bool:
|
|
85
|
+
res = self.runner.run(
|
|
86
|
+
[self.client, *self._defaults(), "-N", "-e", "SELECT 1"],
|
|
87
|
+
check=False,
|
|
88
|
+
mutating=False,
|
|
89
|
+
timeout=10,
|
|
90
|
+
)
|
|
91
|
+
return res.returncode == 0
|
|
92
|
+
|
|
93
|
+
def list_databases(self) -> list[str]:
|
|
94
|
+
"""User databases, unvalidated: the caller decides what to do with odd names."""
|
|
95
|
+
res = self.runner.run(
|
|
96
|
+
[self.client, *self._defaults(), "-N", "-B", "-e", "SHOW DATABASES"], mutating=False
|
|
97
|
+
)
|
|
98
|
+
return [d for d in res.stdout.splitlines() if d and d not in SYSTEM_SCHEMAS]
|
|
99
|
+
|
|
100
|
+
def dump_argv(self, db: str) -> list[str]:
|
|
101
|
+
# Without --databases: no CREATE DATABASE/USE, so the dump can be loaded into any name.
|
|
102
|
+
return [
|
|
103
|
+
self.dumper,
|
|
104
|
+
*self._defaults(),
|
|
105
|
+
"--single-transaction",
|
|
106
|
+
"--quick",
|
|
107
|
+
"--routines",
|
|
108
|
+
"--triggers",
|
|
109
|
+
v.db_name(db), # validated: cannot start with '-', so no '--' separator needed
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def load_argv(self, db: str) -> list[str]:
|
|
113
|
+
return [self.client, *self._defaults(), v.db_name(db)]
|
|
114
|
+
|
|
115
|
+
def create_database(self, db: str) -> None:
|
|
116
|
+
db = v.db_name(db) # validated identifier, safe to interpolate inside backticks
|
|
117
|
+
self.runner.run(
|
|
118
|
+
[self.client, *self._defaults(), "-e", f"CREATE DATABASE IF NOT EXISTS `{db}`"]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def db_users(self, db: str) -> list[DbUser]:
|
|
122
|
+
"""Accounts with database-level grants on db (read-only)."""
|
|
123
|
+
db = v.db_name(db) # validated: safe inside '...'
|
|
124
|
+
escaped = db.replace("_", "\\\\_") # grants made on `shop\_db` (e.g. by Virtualmin)
|
|
125
|
+
cols = ", ".join(f"d.{col}" for col, _ in PRIV_COLUMNS)
|
|
126
|
+
excluded = ", ".join(f"'{u}'" for u in EXCLUDED_USERS)
|
|
127
|
+
sql = (
|
|
128
|
+
f"SELECT d.User, d.Host, u.plugin, " # noqa: S608 - db is validated by v.db_name
|
|
129
|
+
f"IF(u.authentication_string <> '', u.authentication_string, u.Password), {cols} "
|
|
130
|
+
f"FROM mysql.db d JOIN mysql.user u ON u.User = d.User AND u.Host = d.Host "
|
|
131
|
+
f"WHERE d.Db IN ('{db}', '{escaped}') AND d.User NOT IN ({excluded}) "
|
|
132
|
+
f"ORDER BY d.User, d.Host"
|
|
133
|
+
)
|
|
134
|
+
res = self.runner.run(
|
|
135
|
+
[self.client, *self._defaults(), "-N", "-B", "-e", sql], mutating=False
|
|
136
|
+
)
|
|
137
|
+
return parse_db_users(res.stdout)
|
|
138
|
+
|
|
139
|
+
def apply_users(self, db: str, users: list[DbUser]) -> None:
|
|
140
|
+
"""Create/update accounts and grant them on db. SQL is built only from validated fields
|
|
141
|
+
and sent on stdin, so hashes never reach argv."""
|
|
142
|
+
db = v.db_name(db)
|
|
143
|
+
stmts: list[str] = []
|
|
144
|
+
for u in users:
|
|
145
|
+
acct = f"'{v.db_user(u.user)}'@'{v.db_host(u.host)}'"
|
|
146
|
+
pw_hash = v.password_hash(u.password_hash)
|
|
147
|
+
stmts += [
|
|
148
|
+
f"CREATE USER IF NOT EXISTS {acct} IDENTIFIED BY PASSWORD '{pw_hash}';",
|
|
149
|
+
f"ALTER USER {acct} IDENTIFIED BY PASSWORD '{pw_hash}';",
|
|
150
|
+
f"GRANT {', '.join(v.privileges(u.privileges))} ON `{db}`.* TO {acct};",
|
|
151
|
+
]
|
|
152
|
+
if not stmts:
|
|
153
|
+
return
|
|
154
|
+
with tempfile.TemporaryFile() as sql: # real fd for subprocess; unlinked on creation
|
|
155
|
+
sql.write(("\n".join(stmts) + "\n").encode())
|
|
156
|
+
sql.seek(0)
|
|
157
|
+
self.runner.run([self.client, *self._defaults()], stdin=sql)
|
lts_backup_cli/restic.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Adapter around the restic binary. Only uses flags available in restic >= 0.12 (Ubuntu 22.04)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .runner import Runner
|
|
11
|
+
|
|
12
|
+
GIT_EXCLUDE = ".git" # restic matches patterns without a leading '/' at any depth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Snapshot:
|
|
17
|
+
id: str
|
|
18
|
+
short_id: str
|
|
19
|
+
time: str
|
|
20
|
+
hostname: str
|
|
21
|
+
paths: list[str]
|
|
22
|
+
tags: list[str]
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_json(cls, d: dict[str, Any]) -> Snapshot:
|
|
26
|
+
return cls(
|
|
27
|
+
id=d["id"],
|
|
28
|
+
short_id=d.get("short_id", d["id"][:8]),
|
|
29
|
+
time=d["time"],
|
|
30
|
+
hostname=d.get("hostname", ""),
|
|
31
|
+
paths=list(d.get("paths") or []),
|
|
32
|
+
tags=list(d.get("tags") or []),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResticClient:
|
|
37
|
+
def __init__(self, runner: Runner, repository: str, password_file: str) -> None:
|
|
38
|
+
self.runner = runner
|
|
39
|
+
self.repository = repository
|
|
40
|
+
self.password_file = password_file
|
|
41
|
+
|
|
42
|
+
def _base(self, *args: str) -> list[str]:
|
|
43
|
+
return ["restic", "-r", self.repository, "--password-file", self.password_file, *args]
|
|
44
|
+
|
|
45
|
+
def version(self) -> str:
|
|
46
|
+
return self.runner.run(["restic", "version"], mutating=False).stdout.strip()
|
|
47
|
+
|
|
48
|
+
def is_initialized(self) -> bool:
|
|
49
|
+
res = self.runner.run(self._base("cat", "config"), check=False, mutating=False)
|
|
50
|
+
return res.returncode == 0
|
|
51
|
+
|
|
52
|
+
def init(self) -> None:
|
|
53
|
+
self.runner.run(self._base("init"))
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _tag_filter(tags: Sequence[str]) -> list[str]:
|
|
57
|
+
# One comma-joined --tag means "all of these"; repeated --tag would mean "any of these".
|
|
58
|
+
return ["--tag", ",".join(tags)] if tags else []
|
|
59
|
+
|
|
60
|
+
def backup(
|
|
61
|
+
self,
|
|
62
|
+
paths: Sequence[str],
|
|
63
|
+
tags: Sequence[str],
|
|
64
|
+
excludes: Sequence[str] = (),
|
|
65
|
+
verbose: bool = False,
|
|
66
|
+
host: str | None = None,
|
|
67
|
+
) -> str:
|
|
68
|
+
argv = self._base("backup")
|
|
69
|
+
if host:
|
|
70
|
+
argv += ["--host", host]
|
|
71
|
+
for t in tags:
|
|
72
|
+
argv += ["--tag", t]
|
|
73
|
+
for e in excludes:
|
|
74
|
+
argv += ["--exclude", e]
|
|
75
|
+
if verbose:
|
|
76
|
+
argv.append("--verbose")
|
|
77
|
+
argv += ["--", *paths]
|
|
78
|
+
return self.runner.run(argv).stdout
|
|
79
|
+
|
|
80
|
+
def stdin_backup_argv(self, filename: str, tags: Sequence[str], host: str) -> list[str]:
|
|
81
|
+
"""argv for the right side of `dump | restic backup --stdin` (see runner.pipeline)."""
|
|
82
|
+
argv = self._base("backup", "--stdin", "--stdin-filename", filename, "--host", host)
|
|
83
|
+
for t in tags:
|
|
84
|
+
argv += ["--tag", t]
|
|
85
|
+
return argv
|
|
86
|
+
|
|
87
|
+
def snapshots(self, tags: Sequence[str] = ()) -> list[Snapshot]:
|
|
88
|
+
argv = self._base("snapshots", "--json", *self._tag_filter(tags))
|
|
89
|
+
out = self.runner.run(argv, mutating=False).stdout
|
|
90
|
+
return [Snapshot.from_json(d) for d in json.loads(out or "[]")]
|
|
91
|
+
|
|
92
|
+
def restore(self, snapshot_id: str, target: str, includes: Sequence[str] = ()) -> None:
|
|
93
|
+
argv = self._base("restore", snapshot_id, "--target", target)
|
|
94
|
+
for i in includes:
|
|
95
|
+
argv += ["--include", i]
|
|
96
|
+
self.runner.run(argv)
|
|
97
|
+
|
|
98
|
+
def dump_argv(self, snapshot_id: str, path: str) -> list[str]:
|
|
99
|
+
return self._base("dump", snapshot_id, path)
|
|
100
|
+
|
|
101
|
+
def forget(
|
|
102
|
+
self,
|
|
103
|
+
tags: Sequence[str],
|
|
104
|
+
*,
|
|
105
|
+
hourly: int | None = None,
|
|
106
|
+
daily: int | None = None,
|
|
107
|
+
weekly: int | None = None,
|
|
108
|
+
monthly: int | None = None,
|
|
109
|
+
prune: bool = False,
|
|
110
|
+
) -> str:
|
|
111
|
+
argv = self._base("forget", *self._tag_filter(tags))
|
|
112
|
+
for flag, val in (
|
|
113
|
+
("--keep-hourly", hourly),
|
|
114
|
+
("--keep-daily", daily),
|
|
115
|
+
("--keep-weekly", weekly),
|
|
116
|
+
("--keep-monthly", monthly),
|
|
117
|
+
):
|
|
118
|
+
if val is not None:
|
|
119
|
+
argv += [flag, str(val)]
|
|
120
|
+
if prune:
|
|
121
|
+
argv.append("--prune")
|
|
122
|
+
return self.runner.run(argv).stdout
|
lts_backup_cli/runner.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The only module allowed to spawn processes. Everything else depends on the Runner protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import IO, Protocol
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("ltsbu")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommandError(RuntimeError):
|
|
16
|
+
def __init__(self, argv: Sequence[str], returncode: int, stderr: str) -> None:
|
|
17
|
+
self.argv = list(argv)
|
|
18
|
+
self.returncode = returncode
|
|
19
|
+
self.stderr = stderr
|
|
20
|
+
super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[-2000:]}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Result:
|
|
25
|
+
returncode: int
|
|
26
|
+
stdout: str = ""
|
|
27
|
+
stderr: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Runner(Protocol):
|
|
31
|
+
dry_run: bool
|
|
32
|
+
|
|
33
|
+
def run(
|
|
34
|
+
self,
|
|
35
|
+
argv: Sequence[str],
|
|
36
|
+
*,
|
|
37
|
+
env: Mapping[str, str] | None = None,
|
|
38
|
+
stdin: IO[bytes] | None = None,
|
|
39
|
+
stdout: IO[bytes] | None = None,
|
|
40
|
+
check: bool = True,
|
|
41
|
+
timeout: float | None = None,
|
|
42
|
+
mutating: bool = True,
|
|
43
|
+
) -> Result: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SubprocessRunner:
|
|
47
|
+
dry_run = False
|
|
48
|
+
|
|
49
|
+
def run(
|
|
50
|
+
self,
|
|
51
|
+
argv: Sequence[str],
|
|
52
|
+
*,
|
|
53
|
+
env: Mapping[str, str] | None = None,
|
|
54
|
+
stdin: IO[bytes] | None = None,
|
|
55
|
+
stdout: IO[bytes] | None = None,
|
|
56
|
+
check: bool = True,
|
|
57
|
+
timeout: float | None = None,
|
|
58
|
+
mutating: bool = True,
|
|
59
|
+
) -> Result:
|
|
60
|
+
log.debug("exec: %s", shlex.join(argv))
|
|
61
|
+
import os
|
|
62
|
+
|
|
63
|
+
full_env = {**os.environ, **(env or {})}
|
|
64
|
+
try:
|
|
65
|
+
proc = subprocess.run( # noqa: S603 - argv list, never shell
|
|
66
|
+
list(argv),
|
|
67
|
+
env=full_env,
|
|
68
|
+
stdin=stdin,
|
|
69
|
+
stdout=stdout if stdout is not None else subprocess.PIPE,
|
|
70
|
+
stderr=subprocess.PIPE,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
raise CommandError(argv, 127, f"command not found: {argv[0]}") from None
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
raise CommandError(argv, 124, f"timed out after {timeout}s") from None
|
|
78
|
+
out = proc.stdout.decode(errors="replace") if isinstance(proc.stdout, bytes) else ""
|
|
79
|
+
err = proc.stderr.decode(errors="replace") if proc.stderr else ""
|
|
80
|
+
if check and proc.returncode != 0:
|
|
81
|
+
raise CommandError(argv, proc.returncode, err)
|
|
82
|
+
return Result(proc.returncode, out, err)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DryRunRunner(SubprocessRunner):
|
|
86
|
+
"""Executes read-only commands (mutating=False); only prints mutating ones."""
|
|
87
|
+
|
|
88
|
+
dry_run = True
|
|
89
|
+
|
|
90
|
+
def __init__(self, echo: bool = True) -> None:
|
|
91
|
+
self.echo = echo
|
|
92
|
+
self.planned: list[list[str]] = []
|
|
93
|
+
|
|
94
|
+
def run(
|
|
95
|
+
self,
|
|
96
|
+
argv: Sequence[str],
|
|
97
|
+
*,
|
|
98
|
+
env: Mapping[str, str] | None = None,
|
|
99
|
+
stdin: IO[bytes] | None = None,
|
|
100
|
+
stdout: IO[bytes] | None = None,
|
|
101
|
+
check: bool = True,
|
|
102
|
+
timeout: float | None = None,
|
|
103
|
+
mutating: bool = True,
|
|
104
|
+
) -> Result:
|
|
105
|
+
if not mutating:
|
|
106
|
+
return super().run(
|
|
107
|
+
argv, env=env, stdin=stdin, stdout=stdout, check=check, timeout=timeout
|
|
108
|
+
)
|
|
109
|
+
self.planned.append(list(argv))
|
|
110
|
+
if self.echo:
|
|
111
|
+
import click
|
|
112
|
+
|
|
113
|
+
click.echo(f"[dry-run] {shlex.join(argv)}")
|
|
114
|
+
return Result(0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pipeline(left: Sequence[str], right: Sequence[str]) -> list[str]:
|
|
118
|
+
"""`left | right` without shell=True: each side is quoted; pipefail surfaces left failures."""
|
|
119
|
+
return ["bash", "-o", "pipefail", "-c", f"{shlex.join(left)} | {shlex.join(right)}"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SshRunner:
|
|
123
|
+
"""Runs argv on a remote host. sshd hands the command line to the remote shell, so every
|
|
124
|
+
argument is shlex-quoted; a drop-in Runner for adapters."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, inner: Runner, alias: str, connect_timeout: int = 10) -> None:
|
|
127
|
+
if not alias or alias.startswith("-") or not all(c.isalnum() or c in "._-" for c in alias):
|
|
128
|
+
raise ValueError(f"unsafe ssh host alias {alias!r}")
|
|
129
|
+
self.inner = inner
|
|
130
|
+
self.alias = alias
|
|
131
|
+
self.connect_timeout = connect_timeout
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def dry_run(self) -> bool:
|
|
135
|
+
return self.inner.dry_run
|
|
136
|
+
|
|
137
|
+
@dry_run.setter
|
|
138
|
+
def dry_run(self, value: bool) -> None:
|
|
139
|
+
self.inner.dry_run = value
|
|
140
|
+
|
|
141
|
+
def wrap(self, argv: Sequence[str]) -> list[str]:
|
|
142
|
+
return [
|
|
143
|
+
"ssh",
|
|
144
|
+
"-o",
|
|
145
|
+
"BatchMode=yes",
|
|
146
|
+
"-o",
|
|
147
|
+
f"ConnectTimeout={self.connect_timeout}",
|
|
148
|
+
self.alias,
|
|
149
|
+
"--",
|
|
150
|
+
shlex.join(argv),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def run(
|
|
154
|
+
self,
|
|
155
|
+
argv: Sequence[str],
|
|
156
|
+
*,
|
|
157
|
+
env: Mapping[str, str] | None = None,
|
|
158
|
+
stdin: IO[bytes] | None = None,
|
|
159
|
+
stdout: IO[bytes] | None = None,
|
|
160
|
+
check: bool = True,
|
|
161
|
+
timeout: float | None = None,
|
|
162
|
+
mutating: bool = True,
|
|
163
|
+
) -> Result:
|
|
164
|
+
# env is not forwarded: sshd drops it unless AcceptEnv, and secrets never travel that way
|
|
165
|
+
return self.inner.run(
|
|
166
|
+
self.wrap(argv),
|
|
167
|
+
stdin=stdin,
|
|
168
|
+
stdout=stdout,
|
|
169
|
+
check=check,
|
|
170
|
+
timeout=timeout,
|
|
171
|
+
mutating=mutating,
|
|
172
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Periodic backups as systemd timers. Rendering is pure; install/uninstall go through the Runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import zlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import config as c
|
|
10
|
+
from . import validation as v
|
|
11
|
+
from .runner import Runner
|
|
12
|
+
|
|
13
|
+
# Night-time weekly slots (01:00-04:30, every 30 min) spread over the week, so heavy
|
|
14
|
+
# weekly runs (prune) of different repos land on different nights.
|
|
15
|
+
WEEKLY_TIMES = tuple(f"{h:02d}:{m:02d}" for h in range(1, 5) for m in (0, 30))
|
|
16
|
+
_SLOTS = tuple((d, t) for d in v.WEEKDAYS for t in WEEKLY_TIMES)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_schedule(
|
|
20
|
+
server: str, repo: str, taken: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset()
|
|
21
|
+
) -> c.Schedule:
|
|
22
|
+
"""Every 2h at a per-repo minute, plus a weekly run in a free night slot. Deterministic."""
|
|
23
|
+
h = zlib.crc32(f"{server}/{repo}".encode())
|
|
24
|
+
start = h % len(_SLOTS)
|
|
25
|
+
order = _SLOTS[start:] + _SLOTS[:start]
|
|
26
|
+
day, time = next((s for s in order if s not in taken), order[0])
|
|
27
|
+
return c.Schedule(every_hours=2, minute=h % 60, weekly_day=day, weekly_time=time)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def interval_calendar(s: c.Schedule) -> str:
|
|
31
|
+
# shortcut: N/h restarts at midnight, so a non-divisor of 24 gives one short gap a day
|
|
32
|
+
return f"*-*-* 00/{s.every_hours}:{s.minute:02d}:00"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def weekly_calendar(s: c.Schedule) -> str:
|
|
36
|
+
return f"{s.weekly_day} *-*-* {s.weekly_time}:00"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unit_base(server: str, repo: str) -> str:
|
|
40
|
+
return f"ltsbu-{server}-{repo}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _no_space(path: str) -> str:
|
|
44
|
+
if any(ch.isspace() for ch in path):
|
|
45
|
+
raise v.ValidationError(f"path with whitespace cannot be used in a systemd unit: {path!r}")
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_units(
|
|
50
|
+
server: str, repo: str, sch: c.Schedule, python: str, config_path: str
|
|
51
|
+
) -> dict[str, str]:
|
|
52
|
+
"""{filename: text} for the interval and weekly service/timer pairs."""
|
|
53
|
+
base = unit_base(v.label(server), v.label(repo))
|
|
54
|
+
cmd = (
|
|
55
|
+
f"{_no_space(python)} -m lts_backup_cli --config {_no_space(config_path)} backup "
|
|
56
|
+
f"--server {server} --repo {repo}"
|
|
57
|
+
)
|
|
58
|
+
units: dict[str, str] = {}
|
|
59
|
+
for name, calendar, extra, what in (
|
|
60
|
+
(base, interval_calendar(sch), "", f"every {sch.every_hours}h"),
|
|
61
|
+
(f"{base}-weekly", weekly_calendar(sch), " --weekly", "weekly, with prune"),
|
|
62
|
+
):
|
|
63
|
+
units[f"{name}.service"] = (
|
|
64
|
+
"[Unit]\n"
|
|
65
|
+
f"Description=ltsbu backup {server}/{repo} ({what})\n"
|
|
66
|
+
"Wants=network-online.target\nAfter=network-online.target\n\n"
|
|
67
|
+
"[Service]\nType=oneshot\nNice=10\nIOSchedulingClass=idle\n"
|
|
68
|
+
f"ExecStart={cmd}{extra}\n"
|
|
69
|
+
)
|
|
70
|
+
units[f"{name}.timer"] = (
|
|
71
|
+
"[Unit]\n"
|
|
72
|
+
f"Description=ltsbu backup {server}/{repo} ({what})\n\n"
|
|
73
|
+
"[Timer]\n"
|
|
74
|
+
f"OnCalendar={calendar}\n"
|
|
75
|
+
"Persistent=true\n"
|
|
76
|
+
"RandomizedDelaySec=5min\n\n"
|
|
77
|
+
"[Install]\nWantedBy=timers.target\n"
|
|
78
|
+
)
|
|
79
|
+
return units
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def unit_dir(root: bool) -> Path:
|
|
83
|
+
if root:
|
|
84
|
+
return Path("/etc/systemd/system")
|
|
85
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
86
|
+
return Path(base) / "systemd" / "user"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _systemctl(user: bool, *args: str) -> list[str]:
|
|
90
|
+
return ["systemctl", *(["--user"] if user else []), *args]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def install(runner: Runner, units: dict[str, str], directory: Path, user: bool) -> None:
|
|
94
|
+
if not runner.dry_run:
|
|
95
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
for name, text in units.items():
|
|
97
|
+
(directory / name).write_text(text)
|
|
98
|
+
runner.run(_systemctl(user, "daemon-reload"))
|
|
99
|
+
runner.run(
|
|
100
|
+
_systemctl(user, "enable", "--now", *sorted(n for n in units if n.endswith(".timer")))
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def uninstall(runner: Runner, server: str, repo: str, directory: Path, user: bool) -> None:
|
|
105
|
+
base = unit_base(server, repo)
|
|
106
|
+
names = [f"{base}{sfx}" for sfx in (".timer", "-weekly.timer", ".service", "-weekly.service")]
|
|
107
|
+
runner.run(_systemctl(user, "disable", "--now", *names[:2]), check=False)
|
|
108
|
+
if not runner.dry_run:
|
|
109
|
+
for n in names:
|
|
110
|
+
(directory / n).unlink(missing_ok=True)
|
|
111
|
+
runner.run(_systemctl(user, "daemon-reload"))
|