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,142 @@
|
|
|
1
|
+
"""Managed blocks in ~/.ssh/config, delimited by comment tags. Text ops are pure; I/O is at the bottom."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pwd
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def begin_tag(label: str) -> str:
|
|
16
|
+
return f"# >>> ltsbu:{label} >>>"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def end_tag(label: str) -> str:
|
|
20
|
+
return f"# <<< ltsbu:{label} <<<"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def host_alias(label: str) -> str:
|
|
24
|
+
return f"ltsbu-{label}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def storage_label(server_label: str) -> str:
|
|
28
|
+
"""Block label in the web server's ~/.ssh/config for its route to the storage host."""
|
|
29
|
+
return f"storage-{server_label}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def storage_alias(server_label: str) -> str:
|
|
33
|
+
return host_alias(storage_label(server_label))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SshHost:
|
|
38
|
+
label: str
|
|
39
|
+
hostname: str
|
|
40
|
+
user: str
|
|
41
|
+
port: int = 22
|
|
42
|
+
identity_file: str | None = None
|
|
43
|
+
|
|
44
|
+
def render(self) -> str:
|
|
45
|
+
lines = [
|
|
46
|
+
begin_tag(self.label),
|
|
47
|
+
"# Managed by lts-backup-cli. Edits inside this block are overwritten.",
|
|
48
|
+
f"Host {host_alias(self.label)}",
|
|
49
|
+
f" HostName {self.hostname}",
|
|
50
|
+
f" User {self.user}",
|
|
51
|
+
f" Port {self.port}",
|
|
52
|
+
]
|
|
53
|
+
if self.identity_file:
|
|
54
|
+
lines += [f" IdentityFile {self.identity_file}", " IdentitiesOnly yes"]
|
|
55
|
+
lines += [
|
|
56
|
+
" StrictHostKeyChecking accept-new", # TOFU: unattended runs on a fresh machine
|
|
57
|
+
" ServerAliveInterval 60",
|
|
58
|
+
" ServerAliveCountMax 240",
|
|
59
|
+
end_tag(self.label),
|
|
60
|
+
]
|
|
61
|
+
return "\n".join(lines) + "\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _block_re(label: str) -> re.Pattern[str]:
|
|
65
|
+
return re.compile(
|
|
66
|
+
rf"^{re.escape(begin_tag(label))}\n.*?^{re.escape(end_tag(label))}\n?",
|
|
67
|
+
re.MULTILINE | re.DOTALL,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def has_block(text: str, label: str) -> bool:
|
|
72
|
+
return bool(_block_re(label).search(text))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def upsert(text: str, host: SshHost) -> str:
|
|
76
|
+
"""Replace the label's block in place, or append it. Idempotent."""
|
|
77
|
+
block = host.render()
|
|
78
|
+
pattern = _block_re(host.label)
|
|
79
|
+
if pattern.search(text):
|
|
80
|
+
return pattern.sub(lambda _m: block, text, count=1)
|
|
81
|
+
if text and not text.endswith("\n"):
|
|
82
|
+
text += "\n"
|
|
83
|
+
sep = "\n" if text.strip() else ""
|
|
84
|
+
return text + sep + block # appended so user-written entries keep first-match precedence
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def remove(text: str, label: str) -> str:
|
|
88
|
+
return _block_re(label).sub("", text, count=1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def defined_hosts(text: str) -> set[str]:
|
|
92
|
+
"""Literal Host aliases (no wildcards/negations) the user can pass to `ltsbu discover`."""
|
|
93
|
+
found: set[str] = set()
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
parts = line.split()
|
|
96
|
+
if len(parts) > 1 and parts[0].lower() == "host":
|
|
97
|
+
found |= {p for p in parts[1:] if not any(ch in p for ch in "*?!")}
|
|
98
|
+
return found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def lint(text: str) -> list[str]:
|
|
102
|
+
"""Report unbalanced or duplicated ltsbu tags."""
|
|
103
|
+
problems: list[str] = []
|
|
104
|
+
begins = re.findall(r"^# >>> ltsbu:(\S+) >>>$", text, re.MULTILINE)
|
|
105
|
+
ends = re.findall(r"^# <<< ltsbu:(\S+) <<<$", text, re.MULTILINE)
|
|
106
|
+
for lbl in sorted(set(begins) | set(ends)):
|
|
107
|
+
b, e = begins.count(lbl), ends.count(lbl)
|
|
108
|
+
if b != 1 or e != 1:
|
|
109
|
+
problems.append(f"ssh config block ltsbu:{lbl} has {b} begin / {e} end tags")
|
|
110
|
+
return problems
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- I/O -------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def default_path() -> Path:
|
|
117
|
+
# ssh reads ~/.ssh/config from the passwd home, not $HOME (differs under `sudo -E`, in tests...)
|
|
118
|
+
return Path(pwd.getpwuid(os.getuid()).pw_dir) / ".ssh" / "config"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read(path: Path) -> str:
|
|
122
|
+
return path.read_text() if path.exists() else ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write(path: Path, text: str) -> Path | None:
|
|
126
|
+
"""Atomic write with a timestamped backup of the previous file. Returns the backup path."""
|
|
127
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
128
|
+
backup: Path | None = None
|
|
129
|
+
if path.exists():
|
|
130
|
+
backup = path.with_name(f"{path.name}.ltsbu-{time.strftime('%Y%m%dT%H%M%S')}.bak")
|
|
131
|
+
shutil.copy2(path, backup)
|
|
132
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".config.ltsbu-")
|
|
133
|
+
try:
|
|
134
|
+
with os.fdopen(fd, "w") as fh:
|
|
135
|
+
fh.write(text)
|
|
136
|
+
os.chmod(tmp, 0o600)
|
|
137
|
+
os.replace(tmp, path)
|
|
138
|
+
except BaseException:
|
|
139
|
+
if os.path.exists(tmp):
|
|
140
|
+
os.unlink(tmp)
|
|
141
|
+
raise
|
|
142
|
+
return backup
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Input validation and sanitisation at the trust boundary. Pure functions; raise ValidationError."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ValidationError(ValueError):
|
|
11
|
+
"""Raised when user-supplied input is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_LABEL_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
|
15
|
+
_HOST_LABEL_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
|
|
16
|
+
_DB_RE = re.compile(r"^[A-Za-z0-9_$]{1,64}$")
|
|
17
|
+
_UNIX_USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}\$?$")
|
|
18
|
+
_SNAPSHOT_RE = re.compile(r"^(latest|[0-9a-f]{8,64})$")
|
|
19
|
+
_DB_USER_RE = re.compile(r"^[A-Za-z0-9_.$-]{1,80}$")
|
|
20
|
+
_DB_HOST_RE = re.compile(r"^[A-Za-z0-9.%_:/-]{1,255}$") # name, IP, '%' / '_' wildcards, netmask
|
|
21
|
+
_HASH_RE = re.compile(r"^\*[0-9A-F]{40}$") # mysql_native_password
|
|
22
|
+
DB_PRIVILEGES = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "REFERENCES", "INDEX", "ALTER",
|
|
25
|
+
"CREATE TEMPORARY TABLES", "LOCK TABLES", "CREATE VIEW", "SHOW VIEW", "CREATE ROUTINE",
|
|
26
|
+
"ALTER ROUTINE", "EXECUTE", "EVENT", "TRIGGER",
|
|
27
|
+
}
|
|
28
|
+
) # fmt: skip
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def label(value: str) -> str:
|
|
32
|
+
"""Repo label: lowercase, starts alnum, [a-z0-9_-], 1..63 chars."""
|
|
33
|
+
v = value.strip().lower()
|
|
34
|
+
if not _LABEL_RE.match(v):
|
|
35
|
+
raise ValidationError(
|
|
36
|
+
f"invalid label {value!r}: use 1-63 chars of a-z, 0-9, '-', '_' starting with a-z/0-9"
|
|
37
|
+
)
|
|
38
|
+
return v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hostname(value: str) -> str:
|
|
42
|
+
"""Hostname (RFC 1123) or IP address."""
|
|
43
|
+
v = value.strip()
|
|
44
|
+
try:
|
|
45
|
+
ipaddress.ip_address(v)
|
|
46
|
+
return v
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
if not v or len(v) > 253 or not all(_HOST_LABEL_RE.match(p) for p in v.rstrip(".").split(".")):
|
|
50
|
+
raise ValidationError(f"invalid hostname {value!r}")
|
|
51
|
+
return v.lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def port(value: int | str) -> int:
|
|
55
|
+
try:
|
|
56
|
+
p = int(value)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
raise ValidationError(f"invalid port {value!r}") from None
|
|
59
|
+
if not 1 <= p <= 65535:
|
|
60
|
+
raise ValidationError(f"port out of range 1-65535: {p}")
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def unix_user(value: str) -> str:
|
|
65
|
+
v = value.strip()
|
|
66
|
+
if not _UNIX_USER_RE.match(v):
|
|
67
|
+
raise ValidationError(f"invalid unix user {value!r}")
|
|
68
|
+
return v
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def db_name(value: str) -> str:
|
|
72
|
+
"""MariaDB database identifier (unquoted-safe subset)."""
|
|
73
|
+
v = value.strip()
|
|
74
|
+
if not _DB_RE.match(v):
|
|
75
|
+
raise ValidationError(f"invalid database name {value!r}: use [A-Za-z0-9_$], max 64 chars")
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def db_user(value: str) -> str:
|
|
80
|
+
"""MariaDB account name; quote/backslash-free so it is safe inside '...' in SQL."""
|
|
81
|
+
if not isinstance(value, str) or not _DB_USER_RE.match(value):
|
|
82
|
+
raise ValidationError(f"invalid database user {value!r}: use [A-Za-z0-9_.$-], max 80 chars")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def db_host(value: str) -> str:
|
|
87
|
+
if not isinstance(value, str) or not _DB_HOST_RE.match(value):
|
|
88
|
+
raise ValidationError(f"invalid database user host {value!r}")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def password_hash(value: str) -> str:
|
|
93
|
+
if not isinstance(value, str) or not _HASH_RE.match(value):
|
|
94
|
+
raise ValidationError("invalid password hash: expected mysql_native_password '*' + 40 hex")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def privileges(values: list[str]) -> list[str]:
|
|
99
|
+
"""Database-level privileges from an allowlist; never empty."""
|
|
100
|
+
if not isinstance(values, list) or not values:
|
|
101
|
+
raise ValidationError("a database user needs at least one privilege")
|
|
102
|
+
bad = [p for p in values if p not in DB_PRIVILEGES]
|
|
103
|
+
if bad:
|
|
104
|
+
raise ValidationError(f"invalid database privilege(s): {', '.join(map(repr, bad))}")
|
|
105
|
+
return list(dict.fromkeys(values))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def abs_path(value: str, must_exist: bool = False, kind: str = "any") -> str:
|
|
109
|
+
"""Absolute, normalised path; optionally checks existence and kind ('dir'|'file'|'any')."""
|
|
110
|
+
v = value.strip()
|
|
111
|
+
if not v or "\x00" in v or "\n" in v:
|
|
112
|
+
raise ValidationError(f"invalid path {value!r}")
|
|
113
|
+
p = Path(v).expanduser()
|
|
114
|
+
if not p.is_absolute():
|
|
115
|
+
raise ValidationError(f"path must be absolute: {value!r}")
|
|
116
|
+
if must_exist:
|
|
117
|
+
if not p.exists():
|
|
118
|
+
raise ValidationError(f"path does not exist: {p}")
|
|
119
|
+
if kind == "dir" and not p.is_dir():
|
|
120
|
+
raise ValidationError(f"not a directory: {p}")
|
|
121
|
+
if kind == "file" and not p.is_file():
|
|
122
|
+
raise ValidationError(f"not a file: {p}")
|
|
123
|
+
return str(Path(*p.parts)) # normalises duplicate slashes without resolving symlinks
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def remote_path(value: str) -> str:
|
|
127
|
+
"""Repository path on the SFTP host: absolute, no whitespace/control chars."""
|
|
128
|
+
v = value.strip()
|
|
129
|
+
if not v.startswith("/") or any(c.isspace() or ord(c) < 32 for c in v):
|
|
130
|
+
raise ValidationError(f"invalid remote repository path {value!r}: absolute, no spaces")
|
|
131
|
+
return v.rstrip("/") or "/"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def snapshot_id(value: str) -> str:
|
|
135
|
+
v = value.strip().lower()
|
|
136
|
+
if not _SNAPSHOT_RE.match(v):
|
|
137
|
+
raise ValidationError(f"invalid snapshot id {value!r}: 'latest' or 8-64 hex chars")
|
|
138
|
+
return v
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def keep(value: int | None) -> int | None:
|
|
142
|
+
if value is None:
|
|
143
|
+
return None
|
|
144
|
+
if value < 0:
|
|
145
|
+
raise ValidationError(f"keep value must be >= 0, got {value}")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") # systemd OnCalendar names
|
|
150
|
+
REPO_KINDS = ("vhost", "db", "folder")
|
|
151
|
+
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def every_hours(value: int | str) -> int:
|
|
155
|
+
try:
|
|
156
|
+
h = int(value)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
raise ValidationError(f"invalid interval {value!r}") from None
|
|
159
|
+
if not 1 <= h <= 24:
|
|
160
|
+
raise ValidationError(f"backup interval must be 1-24 hours, got {h}")
|
|
161
|
+
return h
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def hhmm(value: str) -> str:
|
|
165
|
+
m = _HHMM_RE.match(value.strip())
|
|
166
|
+
if not m:
|
|
167
|
+
raise ValidationError(f"invalid time {value!r}: use HH:MM (00:00-23:59)")
|
|
168
|
+
return f"{int(m[1]):02d}:{m[2]}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def weekday(value: str) -> str:
|
|
172
|
+
v = value.strip().capitalize()
|
|
173
|
+
if v not in WEEKDAYS:
|
|
174
|
+
raise ValidationError(f"invalid weekday {value!r}: use one of {', '.join(WEEKDAYS)}")
|
|
175
|
+
return v
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def repo_kind(value: str) -> str:
|
|
179
|
+
if value not in REPO_KINDS:
|
|
180
|
+
raise ValidationError(f"invalid repo kind {value!r}: use one of {', '.join(REPO_KINDS)}")
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def remote_abs_path(value: str) -> str:
|
|
185
|
+
"""Absolute path on a remote server; normalised textually, never checked locally."""
|
|
186
|
+
v = value.strip()
|
|
187
|
+
if not v.startswith("/") or any(c.isspace() or ord(c) < 32 for c in v):
|
|
188
|
+
raise ValidationError(f"invalid remote path {value!r}: absolute, no spaces")
|
|
189
|
+
return "/" + "/".join(p for p in v.split("/") if p)
|