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
lts_backup_cli/wizard.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Interactive `discover` flow. Prompts go through an Asker so answers can be preset or scripted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Protocol
|
|
8
|
+
|
|
9
|
+
from . import apache, sshconfig
|
|
10
|
+
from . import config as c
|
|
11
|
+
from . import schedule as sch
|
|
12
|
+
from . import validation as v
|
|
13
|
+
from .services import DiscoverRequest, DiscoverService, Echo
|
|
14
|
+
|
|
15
|
+
Validator = Callable[[str], Any]
|
|
16
|
+
|
|
17
|
+
NONE = "(none - use ssh defaults)"
|
|
18
|
+
ALL = "(all)" # checkbox preset: every choice offered
|
|
19
|
+
OTHER = "(other path...)"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WizardError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Asker(Protocol):
|
|
27
|
+
def text(
|
|
28
|
+
self, key: str, message: str, default: str = "", validate: Validator | None = None
|
|
29
|
+
) -> str: ...
|
|
30
|
+
def password(self, key: str, message: str) -> str: ...
|
|
31
|
+
def confirm(self, key: str, message: str, default: bool = True) -> bool: ...
|
|
32
|
+
def select(
|
|
33
|
+
self, key: str, message: str, choices: Sequence[str], default: str | None = None
|
|
34
|
+
) -> str: ...
|
|
35
|
+
def checkbox(
|
|
36
|
+
self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
|
|
37
|
+
) -> list[str]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _checked(validate: Validator | None, value: str) -> str:
|
|
41
|
+
return str(validate(value)) if validate else value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScriptedAsker:
|
|
45
|
+
"""Answers from a dict; falls back to defaults. Used by --non-interactive and tests."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, answers: Mapping[str, Any]) -> None:
|
|
48
|
+
self.answers = dict(answers)
|
|
49
|
+
|
|
50
|
+
def _get(self, key: str, default: Any) -> Any:
|
|
51
|
+
if key in self.answers:
|
|
52
|
+
return self.answers[key]
|
|
53
|
+
wildcard = "*." + key.split(".", 1)[1] if "." in key else None
|
|
54
|
+
if wildcard in self.answers: # "*.every_hours" answers it for every repo
|
|
55
|
+
return self.answers[wildcard]
|
|
56
|
+
if default is None or default == "":
|
|
57
|
+
raise WizardError(f"missing value for '{key}' (required in non-interactive mode)")
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
def text(
|
|
61
|
+
self, key: str, message: str, default: str = "", validate: Validator | None = None
|
|
62
|
+
) -> str:
|
|
63
|
+
return _checked(validate, str(self._get(key, default)))
|
|
64
|
+
|
|
65
|
+
def password(self, key: str, message: str) -> str:
|
|
66
|
+
return str(self._get(key, None))
|
|
67
|
+
|
|
68
|
+
def confirm(self, key: str, message: str, default: bool = True) -> bool:
|
|
69
|
+
return bool(self._get(key, default))
|
|
70
|
+
|
|
71
|
+
def select(
|
|
72
|
+
self, key: str, message: str, choices: Sequence[str], default: str | None = None
|
|
73
|
+
) -> str:
|
|
74
|
+
return str(self._get(key, default))
|
|
75
|
+
|
|
76
|
+
def checkbox(
|
|
77
|
+
self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
raw = self.answers.get(key, defaults)
|
|
80
|
+
picked = list(choices) if raw == ALL else list(raw)
|
|
81
|
+
unknown = [x for x in picked if x not in choices]
|
|
82
|
+
if unknown:
|
|
83
|
+
raise WizardError(
|
|
84
|
+
f"{key}: unknown choice(s) {', '.join(unknown)} (have: {', '.join(choices)})"
|
|
85
|
+
)
|
|
86
|
+
return picked
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class QuestionaryAsker:
|
|
90
|
+
"""Interactive prompts; any preset answer (from CLI options) skips its prompt."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, presets: Mapping[str, Any] | None = None) -> None:
|
|
93
|
+
self.presets = ScriptedAsker(presets or {})
|
|
94
|
+
|
|
95
|
+
def _has(self, key: str) -> bool:
|
|
96
|
+
wildcard = "*." + key.split(".", 1)[1] if "." in key else None
|
|
97
|
+
return key in self.presets.answers or wildcard in self.presets.answers
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _ask(question: Any) -> Any:
|
|
101
|
+
ans = question.ask()
|
|
102
|
+
if ans is None: # Ctrl-C / Ctrl-D
|
|
103
|
+
raise KeyboardInterrupt
|
|
104
|
+
return ans
|
|
105
|
+
|
|
106
|
+
def text(
|
|
107
|
+
self, key: str, message: str, default: str = "", validate: Validator | None = None
|
|
108
|
+
) -> str:
|
|
109
|
+
if self._has(key):
|
|
110
|
+
return self.presets.text(key, message, default, validate)
|
|
111
|
+
import questionary
|
|
112
|
+
|
|
113
|
+
def qv(s: str) -> Any:
|
|
114
|
+
if not validate:
|
|
115
|
+
return True
|
|
116
|
+
try:
|
|
117
|
+
validate(s)
|
|
118
|
+
return True
|
|
119
|
+
except v.ValidationError as e:
|
|
120
|
+
return str(e)
|
|
121
|
+
|
|
122
|
+
return _checked(
|
|
123
|
+
validate, self._ask(questionary.text(message, default=default, validate=qv))
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def password(self, key: str, message: str) -> str:
|
|
127
|
+
if self._has(key):
|
|
128
|
+
return self.presets.password(key, message)
|
|
129
|
+
import questionary
|
|
130
|
+
|
|
131
|
+
while True:
|
|
132
|
+
first = self._ask(questionary.password(message))
|
|
133
|
+
if not first:
|
|
134
|
+
print(" password must not be empty")
|
|
135
|
+
continue
|
|
136
|
+
if self._ask(questionary.password("Repeat password:")) == first:
|
|
137
|
+
return str(first)
|
|
138
|
+
print(" passwords do not match, try again")
|
|
139
|
+
|
|
140
|
+
def confirm(self, key: str, message: str, default: bool = True) -> bool:
|
|
141
|
+
if self._has(key):
|
|
142
|
+
return self.presets.confirm(key, message, default)
|
|
143
|
+
import questionary
|
|
144
|
+
|
|
145
|
+
return bool(self._ask(questionary.confirm(message, default=default)))
|
|
146
|
+
|
|
147
|
+
def select(
|
|
148
|
+
self, key: str, message: str, choices: Sequence[str], default: str | None = None
|
|
149
|
+
) -> str:
|
|
150
|
+
if self._has(key):
|
|
151
|
+
return self.presets.select(key, message, choices, default)
|
|
152
|
+
import questionary
|
|
153
|
+
|
|
154
|
+
return str(self._ask(questionary.select(message, choices=list(choices), default=default)))
|
|
155
|
+
|
|
156
|
+
def checkbox(
|
|
157
|
+
self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
|
|
158
|
+
) -> list[str]:
|
|
159
|
+
if self._has(key):
|
|
160
|
+
return self.presets.checkbox(key, message, choices, defaults)
|
|
161
|
+
import questionary
|
|
162
|
+
|
|
163
|
+
qchoices = [
|
|
164
|
+
questionary.Choice(title, value=val, checked=val in defaults)
|
|
165
|
+
for val, title in choices.items()
|
|
166
|
+
]
|
|
167
|
+
return list(self._ask(questionary.checkbox(message, choices=qchoices)))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _int_ge0(s: str) -> int:
|
|
171
|
+
try:
|
|
172
|
+
n = int(s)
|
|
173
|
+
except ValueError:
|
|
174
|
+
raise v.ValidationError(f"not a number: {s!r}") from None
|
|
175
|
+
v.keep(n)
|
|
176
|
+
return n
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _identity(ask: Asker, key: str, default: str | None) -> str | None:
|
|
180
|
+
keys = sorted(
|
|
181
|
+
str(p) for p in (Path.home() / ".ssh").glob("id_*") if p.suffix != ".pub" and p.is_file()
|
|
182
|
+
)
|
|
183
|
+
choice = ask.select(
|
|
184
|
+
key,
|
|
185
|
+
"SSH private key (admin box -> server):",
|
|
186
|
+
[*keys, NONE, OTHER],
|
|
187
|
+
default=default if default in keys else (keys[0] if keys else NONE),
|
|
188
|
+
)
|
|
189
|
+
if choice == OTHER:
|
|
190
|
+
choice = ask.text(f"{key}_path", "Path to private key:")
|
|
191
|
+
return None if choice == NONE else v.abs_path(choice, must_exist=True, kind="file")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _connection(
|
|
195
|
+
ask: Asker, host: str, label: str, old: c.ServerConfig | None, ssh_text: str
|
|
196
|
+
) -> c.ServerConfig:
|
|
197
|
+
storage_old = old.storage if old else None
|
|
198
|
+
alias = host in sshconfig.defined_hosts(ssh_text) or (old is not None and old.use_ssh_config)
|
|
199
|
+
use_alias = host == "localhost" or (
|
|
200
|
+
alias
|
|
201
|
+
and ask.confirm("use_ssh_config", f"Use your ~/.ssh/config entry '{host}' as is?", True)
|
|
202
|
+
)
|
|
203
|
+
user, port, identity = None, 22, None
|
|
204
|
+
if not use_alias:
|
|
205
|
+
user = ask.text(
|
|
206
|
+
"ssh_user", "SSH user on the server:", (old and old.ssh_user) or "", v.unix_user
|
|
207
|
+
)
|
|
208
|
+
port = int(ask.text("ssh_port", "SSH port:", str(old.ssh_port if old else 22), v.port))
|
|
209
|
+
identity = _identity(ask, "identity", old.ssh_identity if old else None)
|
|
210
|
+
st_identity = ask.text(
|
|
211
|
+
"storage_identity",
|
|
212
|
+
"Private key ON THE SERVER for the storage host (empty = server's default keys):",
|
|
213
|
+
(storage_old and storage_old.identity) or "-",
|
|
214
|
+
).strip()
|
|
215
|
+
storage = c.StorageConfig(
|
|
216
|
+
host=ask.text("storage_host", "Storage (SFTP) host, as seen from the server:",
|
|
217
|
+
storage_old.host if storage_old else "", v.hostname),
|
|
218
|
+
user=ask.text("storage_user", "Storage SSH user:",
|
|
219
|
+
storage_old.user if storage_old else "", v.unix_user),
|
|
220
|
+
port=int(ask.text("storage_port", "Storage SSH port:",
|
|
221
|
+
str(storage_old.port if storage_old else 22), v.port)),
|
|
222
|
+
base_path=ask.text("storage_path", "Base path on the storage host:",
|
|
223
|
+
storage_old.base_path if storage_old else "/srv/restic", v.remote_path),
|
|
224
|
+
identity=None if st_identity in ("", "-") else v.remote_abs_path(st_identity),
|
|
225
|
+
) # fmt: skip
|
|
226
|
+
return c.ServerConfig(
|
|
227
|
+
label=label,
|
|
228
|
+
ssh_host=host,
|
|
229
|
+
ssh_user=user,
|
|
230
|
+
use_ssh_config=use_alias and host != "localhost",
|
|
231
|
+
ssh_port=port,
|
|
232
|
+
ssh_identity=identity,
|
|
233
|
+
secrets_dir=old.secrets_dir if old else "/", # set from the probed home below
|
|
234
|
+
storage=storage,
|
|
235
|
+
db_login=old.db_login if old else False,
|
|
236
|
+
repos=dict(old.repos) if old else {},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _db_login(
|
|
241
|
+
ask: Asker, svc: DiscoverService, runner: Any, server: c.ServerConfig, echo: Echo
|
|
242
|
+
) -> list[str] | None:
|
|
243
|
+
if not ask.confirm(
|
|
244
|
+
"db_login",
|
|
245
|
+
f"No working MariaDB login on {server.label}. Enter the admin (sa) login now?",
|
|
246
|
+
True,
|
|
247
|
+
):
|
|
248
|
+
return None
|
|
249
|
+
for _ in range(3):
|
|
250
|
+
user = ask.text("db_user", "MariaDB admin user:", "root", v.db_user)
|
|
251
|
+
if svc.set_db_login(runner, server, user, ask.password("db_password", "MariaDB password:")):
|
|
252
|
+
echo(f"MariaDB login verified and saved to {server.label}:{server.mycnf_path} (0600)")
|
|
253
|
+
return svc.databases(runner, server)[0]
|
|
254
|
+
echo(" MariaDB login failed, try again")
|
|
255
|
+
echo(f"warning: no working MariaDB login; database backups skipped for {server.label}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _folder_name(path: str) -> str:
|
|
260
|
+
return apache.repo_name_for(path.strip("/").replace("/", "-") or "root")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _tune(ask: Asker, repo: c.RepoConfig) -> None:
|
|
264
|
+
n, s, k = repo.name, repo.schedule, repo.keep
|
|
265
|
+
s.every_hours = int(
|
|
266
|
+
ask.text(
|
|
267
|
+
f"{n}.every_hours", f"[{n}] backup every N hours:", str(s.every_hours), v.every_hours
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
s.weekly_day = ask.text(f"{n}.weekly_day", f"[{n}] weekly run day:", s.weekly_day, v.weekday)
|
|
271
|
+
s.weekly_time = ask.text(
|
|
272
|
+
f"{n}.weekly_time", f"[{n}] weekly run time (HH:MM):", s.weekly_time, v.hhmm
|
|
273
|
+
)
|
|
274
|
+
for attr in ("hourly", "daily", "weekly", "monthly"):
|
|
275
|
+
cur = getattr(k, attr)
|
|
276
|
+
val = ask.text(
|
|
277
|
+
f"{n}.keep_{attr}",
|
|
278
|
+
f"[{n}] keep {attr} snapshots:",
|
|
279
|
+
"" if cur is None else str(cur),
|
|
280
|
+
_int_ge0,
|
|
281
|
+
)
|
|
282
|
+
setattr(k, attr, int(val))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run(
|
|
286
|
+
ask: Asker, svc: DiscoverService, cfg: c.Config, host: str, label: str, echo: Echo
|
|
287
|
+
) -> DiscoverRequest:
|
|
288
|
+
host = v.hostname(host)
|
|
289
|
+
known = next((s.label for s in cfg.servers.values() if s.ssh_host == host), None)
|
|
290
|
+
label = label or ask.text(
|
|
291
|
+
"server", "Server label:", known or v.label(host.split(".")[0]), v.label
|
|
292
|
+
)
|
|
293
|
+
old = cfg.servers.get(label)
|
|
294
|
+
echo(f"{'editing' if old else 'new'} server {label} ({host})")
|
|
295
|
+
server = _connection(ask, host, label, old, sshconfig.read(svc.ssh_config_path))
|
|
296
|
+
|
|
297
|
+
runner = svc.connect(server)
|
|
298
|
+
found = svc.probe(runner, server)
|
|
299
|
+
if not old:
|
|
300
|
+
server.secrets_dir = f"{found.home}/.config/ltsbu"
|
|
301
|
+
databases = (
|
|
302
|
+
found.databases
|
|
303
|
+
if found.databases is not None
|
|
304
|
+
else _db_login(ask, svc, runner, server, echo)
|
|
305
|
+
)
|
|
306
|
+
for name in found.skipped_databases:
|
|
307
|
+
echo(f"warning: database {name!r} has characters ltsbu does not handle; skipped")
|
|
308
|
+
|
|
309
|
+
existing = {r.name: r for r in server.repos.values()}
|
|
310
|
+
wanted: dict[str, c.RepoConfig] = {}
|
|
311
|
+
pw_dir = c.config_dir() / "servers" / label
|
|
312
|
+
|
|
313
|
+
def new_repo(name: str, kind: str, **kw: Any) -> c.RepoConfig:
|
|
314
|
+
return existing.get(name) or c.RepoConfig(
|
|
315
|
+
name=name, kind=kind, password_file=str(pw_dir / f"{name}.pass"), **kw
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# --- vhosts ---
|
|
319
|
+
vhosts = {apache.repo_name_for(h.server_name): h for h in found.vhosts}
|
|
320
|
+
choices = {n: f"{h.server_name} ({h.document_root})" for n, h in vhosts.items()}
|
|
321
|
+
choices |= {
|
|
322
|
+
n: f"{r.vhost} ({', '.join(r.paths)}) [configured]"
|
|
323
|
+
for n, r in existing.items()
|
|
324
|
+
if r.kind == "vhost" and n not in choices
|
|
325
|
+
}
|
|
326
|
+
defaults = [n for n in choices if n in existing] if old else list(choices)
|
|
327
|
+
for n in ask.checkbox(
|
|
328
|
+
"vhosts", "Apache/httpd vhosts to back up (one repo each):", choices, defaults
|
|
329
|
+
):
|
|
330
|
+
h = vhosts.get(n)
|
|
331
|
+
wanted[n] = new_repo(
|
|
332
|
+
n, "vhost", vhost=h.server_name if h else None, paths=[h.document_root] if h else []
|
|
333
|
+
)
|
|
334
|
+
if h: # docroot may have moved since last discover
|
|
335
|
+
wanted[n].paths = [h.document_root]
|
|
336
|
+
|
|
337
|
+
# --- databases (one repo per server) ---
|
|
338
|
+
if databases is not None:
|
|
339
|
+
current = existing["db"].databases if "db" in existing else []
|
|
340
|
+
db_choices = {d: d for d in sorted({*databases, *current})}
|
|
341
|
+
picked = ask.checkbox(
|
|
342
|
+
"databases",
|
|
343
|
+
"MariaDB databases to back up (repo 'db'):",
|
|
344
|
+
db_choices,
|
|
345
|
+
current if old else list(db_choices),
|
|
346
|
+
)
|
|
347
|
+
if picked:
|
|
348
|
+
wanted["db"] = new_repo("db", "db")
|
|
349
|
+
wanted["db"].databases = sorted(picked)
|
|
350
|
+
elif "db" in existing:
|
|
351
|
+
echo("warning: cannot list databases; keeping repo 'db' unchanged")
|
|
352
|
+
wanted["db"] = existing["db"]
|
|
353
|
+
|
|
354
|
+
# --- extra folders (one repo each) ---
|
|
355
|
+
folder_paths = [p for r in existing.values() if r.kind == "folder" for p in r.paths]
|
|
356
|
+
raw = ask.text(
|
|
357
|
+
"folders",
|
|
358
|
+
"Extra folders on the server (space separated, empty for none):",
|
|
359
|
+
" ".join(folder_paths) or "-",
|
|
360
|
+
)
|
|
361
|
+
for path in (p for p in raw.split() if p != "-"):
|
|
362
|
+
path = v.remote_abs_path(path)
|
|
363
|
+
n = _folder_name(path)
|
|
364
|
+
wanted[n] = new_repo(n, "folder", paths=[path])
|
|
365
|
+
|
|
366
|
+
# --- schedule & retention ---
|
|
367
|
+
taken = {
|
|
368
|
+
(r.schedule.weekly_day, r.schedule.weekly_time)
|
|
369
|
+
for s in cfg.servers.values()
|
|
370
|
+
if s.storage.host == server.storage.host and s.label != label
|
|
371
|
+
for r in s.repos.values()
|
|
372
|
+
} | {
|
|
373
|
+
(r.schedule.weekly_day, r.schedule.weekly_time)
|
|
374
|
+
for r in wanted.values()
|
|
375
|
+
if r.name in existing
|
|
376
|
+
}
|
|
377
|
+
for repo in sorted(wanted.values(), key=lambda r: r.name):
|
|
378
|
+
if repo.name not in existing:
|
|
379
|
+
repo.schedule = sch.default_schedule(label, repo.name, taken)
|
|
380
|
+
taken.add((repo.schedule.weekly_day, repo.schedule.weekly_time))
|
|
381
|
+
if wanted and ask.confirm(
|
|
382
|
+
"customize",
|
|
383
|
+
"Customise schedule/retention per repo? (default: every 2h + weekly, rotated)",
|
|
384
|
+
False,
|
|
385
|
+
):
|
|
386
|
+
for repo in sorted(wanted.values(), key=lambda r: r.name):
|
|
387
|
+
_tune(ask, repo)
|
|
388
|
+
|
|
389
|
+
# --- removals need an explicit yes; restic data is never deleted ---
|
|
390
|
+
removed = sorted(set(existing) - set(wanted))
|
|
391
|
+
if removed and not ask.confirm(
|
|
392
|
+
"confirm_remove", f"Stop backing up {', '.join(removed)}? (restic data is kept)", False
|
|
393
|
+
):
|
|
394
|
+
wanted |= {n: existing[n] for n in removed}
|
|
395
|
+
removed = []
|
|
396
|
+
server.repos = dict(sorted(wanted.items()))
|
|
397
|
+
|
|
398
|
+
echo(
|
|
399
|
+
f"\nserver {label}: {len(server.repos)} repo(s) -> {server.storage.host}:{server.storage.base_path}/{label}/"
|
|
400
|
+
)
|
|
401
|
+
for r in server.repos.values():
|
|
402
|
+
what = ", ".join(r.databases or r.paths)
|
|
403
|
+
echo(
|
|
404
|
+
f" {r.name:<24} {r.kind:<6} {what} [every {r.schedule.every_hours}h, weekly {r.schedule.weekly_day} {r.schedule.weekly_time}]"
|
|
405
|
+
)
|
|
406
|
+
for n in removed:
|
|
407
|
+
echo(f" {n:<24} REMOVE (timers off, restic data kept)")
|
|
408
|
+
actions = ask.checkbox(
|
|
409
|
+
"actions",
|
|
410
|
+
"Actions:",
|
|
411
|
+
{
|
|
412
|
+
"restic_init": "Initialise missing restic repositories",
|
|
413
|
+
"timers": "Install/refresh systemd timers",
|
|
414
|
+
},
|
|
415
|
+
defaults=["restic_init", "timers"],
|
|
416
|
+
)
|
|
417
|
+
if not ask.confirm("apply", "Apply?", True):
|
|
418
|
+
raise WizardError("aborted")
|
|
419
|
+
return DiscoverRequest(server, removed, "restic_init" in actions, "timers" in actions)
|