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.
@@ -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)
@@ -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
@@ -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"))