Coverage for bzfs_main/detect.py: 98%
223 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:02 +0000
1# Copyright 2024 Wolfgang Hoschek AT mac DOT com
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15"""Detection of ZFS features and system capabilities on local and remote hosts."""
17from __future__ import (
18 annotations,
19)
20import re
21import subprocess
22import sys
23import threading
24import time
25from dataclasses import (
26 dataclass,
27 field,
28)
29from subprocess import (
30 DEVNULL,
31 PIPE,
32)
33from typing import (
34 TYPE_CHECKING,
35 Final,
36 final,
37)
39from bzfs_main.util.connection import (
40 DEDICATED,
41 SHARED,
42 ConnectionPools,
43)
44from bzfs_main.util.utils import (
45 LOG_TRACE,
46 PROG_NAME,
47 SynchronousExecutor,
48 die,
49 drain,
50 list_formatter,
51 stderr_to_str,
52 xprint,
53)
55if TYPE_CHECKING: # pragma: no cover - for type hints only
56 from bzfs_main.bzfs import (
57 Job,
58 )
59 from bzfs_main.configuration import (
60 Params,
61 Remote,
62 )
64# constants:
65DISABLE_PRG: Final[str] = "-"
66DUMMY_DATASET: Final[str] = "dummy"
67POOL_GUID: Final[str] = "guid"
68ZFS_VERSION_IS_AT_LEAST_2_1_0: Final[str] = "zfs>=2.1.0"
69ZFS_VERSION_IS_AT_LEAST_2_2_0: Final[str] = "zfs>=2.2.0"
72#############################################################################
73@dataclass(frozen=True)
74@final
75class RemoteConfCacheItem:
76 """Caches detected programs, zpool features and connection pools, per remote."""
78 connection_pools: ConnectionPools
79 available_programs: dict[str, str]
80 zpool_features: dict[str, dict[str, str]]
81 timestamp_nanos: int = field(default_factory=time.monotonic_ns)
84def detect_available_programs(job: Job) -> None:
85 """Detects programs, zpool features and connection pools for local and remote hosts."""
86 p = params = job.params
87 log = p.log
88 available_programs: dict[str, dict[str, str]] = params.available_programs
89 if "local" not in available_programs:
90 cmd: list[str] = [p.shell_program_local, "-c", _find_available_programs(p)]
91 sp = job.subprocesses
92 proc = sp.subprocess_run(cmd, stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True, log=log)
93 xprint(log=log, value=stderr_to_str(proc.stderr), file=sys.stderr, end="")
94 stdout: str = proc.stdout
95 available_programs["local"] = dict.fromkeys(stdout.splitlines(), "")
96 cmd = [p.shell_program_local, "-c", "exit"]
97 proc = sp.subprocess_run(cmd, stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True, log=log)
98 xprint(log=log, value=stderr_to_str(proc.stderr), file=sys.stderr, end="")
99 if proc.returncode != 0:
100 _disable_program(p, "sh", ["local"])
102 todo: list[Remote] = []
103 for r in [p.dst, p.src]:
104 loc: str = r.location
105 remote_conf_cache_key: tuple = r.cache_key()
106 cache_item: RemoteConfCacheItem | None = job.remote_conf_cache.get(remote_conf_cache_key)
107 if cache_item is not None:
108 # startup perf: cache avoids ssh connect setup and feature detection roundtrips on revisits to same site
109 p.connection_pools[loc] = cache_item.connection_pools
110 p.available_programs[loc] = cache_item.available_programs
111 p.zpool_features[loc] = cache_item.zpool_features
112 if time.monotonic_ns() - cache_item.timestamp_nanos < p.remote_conf_cache_ttl_nanos:
113 if r.pool in cache_item.zpool_features:
114 continue # cache hit, skip remote detection
115 else:
116 p.zpool_features[loc] = {} # cache miss, invalidate features of zpools before refetching from remote
117 else:
118 p.connection_pools[loc] = ConnectionPools(
119 remote=r, capacities={SHARED: r.max_concurrent_ssh_sessions_per_tcp_connection, DEDICATED: 1}
120 )
121 todo.append(r)
123 lock: threading.Lock = threading.Lock()
125 def run_detect(r: Remote) -> None: # thread-safe
126 loc: str = r.location
127 remote_conf_cache_key: tuple = r.cache_key()
128 available_programs: dict[str, str] = _detect_available_programs_remote(job, r, r.ssh_user_host)
129 zpool_features: dict[str, str] = _detect_zpool_features(job, r, available_programs)
130 with lock:
131 r.params.available_programs[loc] = available_programs
132 r.params.zpool_features[loc][r.pool] = zpool_features
133 job.remote_conf_cache[remote_conf_cache_key] = RemoteConfCacheItem(
134 p.connection_pools[loc], available_programs, r.params.zpool_features[loc]
135 )
136 if r.use_zfs_delegation and zpool_features.get("delegation") == "off":
137 die(
138 f"Permission denied as ZFS delegation is disabled for {r.location} "
139 f"dataset: {r.basis_root_dataset}. Manually enable it via 'sudo zpool set delegation=on {r.pool}'"
140 )
142 with SynchronousExecutor.executor_for(max_workers=max(1, len(todo))) as executor:
143 drain(executor.map(run_detect, todo)) # detect ZFS features + system capabilities on src+dst in parallel
145 locations = ["src", "dst", "local"]
146 if params.compression_program == DISABLE_PRG:
147 _disable_program(p, "zstd", locations)
148 if params.mbuffer_program == DISABLE_PRG:
149 _disable_program(p, "mbuffer", locations)
150 if params.ps_program == DISABLE_PRG:
151 _disable_program(p, "ps", locations)
152 if params.pv_program == DISABLE_PRG:
153 _disable_program(p, "pv", locations)
154 if params.shell_program == DISABLE_PRG:
155 _disable_program(p, "sh", locations)
156 if params.sudo_program == DISABLE_PRG:
157 _disable_program(p, "sudo", locations)
158 if params.zpool_program == DISABLE_PRG:
159 _disable_program(p, "zpool", locations)
161 for key, programs in available_programs.items():
162 for program in list(programs.keys()):
163 if program.startswith("uname-"):
164 # uname-Linux foo 5.15.0-69-generic #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
165 # uname-FreeBSD freebsd 14.1-RELEASE FreeBSD 14.1-RELEASE releng/14.1-n267679-10e31f0946d8 GENERIC amd64
166 # uname-Darwin foo 23.6.0 Darwin Kernel Version 23.6.0: Mon Jul 29 21:13:04 PDT 2024; root:xnu-10063.141.2~1/RELEASE_ARM64_T6020 arm64
167 programs.pop(program)
168 uname: str = program[len("uname-") :]
169 programs["uname"] = uname
170 log.log(LOG_TRACE, f"available_programs[{key}][uname]: %s", uname)
171 programs["os"] = uname.split(" ", maxsplit=1)[0] # Linux|FreeBSD|Darwin
172 log.log(LOG_TRACE, f"available_programs[{key}][os]: %s", programs["os"])
173 elif program.startswith("default_shell-"):
174 programs.pop(program)
175 default_shell: str = program[len("default_shell-") :]
176 programs["default_shell"] = default_shell
177 log.log(LOG_TRACE, f"available_programs[{key}][default_shell]: %s", default_shell)
178 ssh_user_host = p.src.ssh_user_host if key == "src" else p.dst.ssh_user_host if key == "dst" else ""
179 if ssh_user_host:
180 _validate_default_shell(default_shell, key, ssh_user_host)
181 elif program.startswith("getconf_cpu_count-"):
182 programs.pop(program)
183 getconf_cpu_count: str = program[len("getconf_cpu_count-") :]
184 programs["getconf_cpu_count"] = getconf_cpu_count
185 log.log(LOG_TRACE, f"available_programs[{key}][getconf_cpu_count]: %s", getconf_cpu_count)
187 for key, programs in available_programs.items():
188 log.debug(f"available_programs[{key}]: %s", list_formatter(programs, separator=", "))
190 for r in [p.dst, p.src]:
191 if is_dummy(r):
192 continue
193 if r.sudo and not p.is_program_available("sudo", r.location):
194 die(f"{p.sudo_program} CLI is not available on {r.location} host: {r.ssh_user_host or 'localhost'}")
196 if (
197 len(p.args.preserve_properties) > 0
198 and any(prop in p.zfs_send_program_opts for prop in ["--props", "-p"])
199 and not p.is_program_available(ZFS_VERSION_IS_AT_LEAST_2_2_0, p.dst.location)
200 ):
201 die(
202 "Cowardly refusing to proceed as --preserve-properties is unreliable on destination ZFS < 2.2.0 when using "
203 "'zfs send --props'. Either upgrade destination ZFS, or remove '--props' from --zfs-send-program-opt(s)."
204 )
207def _disable_program(p: Params, program: str, locations: list[str]) -> None:
208 """Removes the given program from the available_programs mapping."""
209 for location in locations:
210 p.available_programs[location].pop(program, None)
213def _find_available_programs(p: Params) -> str:
214 """POSIX shell script that checks for the existence of various programs; It uses `if` statements instead of `&&` plus
215 `printf` instead of `echo` to ensure maximum compatibility across shells."""
216 cmds: list[str] = []
217 cmds.append("printf 'default_shell-%s\n' \"$SHELL\"")
218 cmds.append("if command -v echo > /dev/null; then printf 'echo\n'; fi")
219 cmds.append(f"if command -v {p.zpool_program} > /dev/null; then printf 'zpool\n'; fi")
220 cmds.append(f"if command -v {p.ssh_program} > /dev/null; then printf 'ssh\n'; fi")
221 cmds.append(f"if command -v {p.shell_program} > /dev/null; then printf 'sh\n'; fi")
222 cmds.append(f"if command -v {p.sudo_program} > /dev/null; then printf 'sudo\n'; fi")
223 cmds.append(f"if command -v {p.compression_program} > /dev/null; then printf 'zstd\n'; fi")
224 cmds.append(f"if command -v {p.mbuffer_program} > /dev/null; then printf 'mbuffer\n'; fi")
225 cmds.append(f"if command -v {p.pv_program} > /dev/null; then printf 'pv\n'; fi")
226 cmds.append(f"if command -v {p.ps_program} > /dev/null; then printf 'ps\n'; fi")
227 cmds.append(
228 f"if command -v {p.getconf_program} > /dev/null; then "
229 f"printf 'getconf_cpu_count-'; {p.getconf_program} _NPROCESSORS_ONLN; "
230 "fi"
231 )
232 cmds.append(f"if command -v {p.uname_program} > /dev/null; then printf 'uname-'; {p.uname_program} -a || true; fi")
233 return "; ".join(cmds)
236def _detect_available_programs_remote(job: Job, remote: Remote, ssh_user_host: str) -> dict[str, str]:
237 """Detects CLI tools available on ``remote`` and updates mapping correspondingly."""
238 p, log = job.params, job.params.log
239 location = remote.location
240 available_programs_minimum = {"sudo": ""}
241 available_programs: dict[str, str] = {}
242 if is_dummy(remote):
243 return available_programs
244 lines: str | None = None
245 try:
246 # on Linux, 'zfs --version' returns with zero status and prints the correct info
247 # on FreeBSD, 'zfs --version' always prints the same (correct) info as Linux, but nonetheless sometimes
248 # returns with non-zero status (sometimes = if the zfs kernel module is not loaded)
249 lines = job.run_ssh_command_with_retries(remote, LOG_TRACE, print_stderr=False, cmd=[p.zfs_program, "--version"])
250 assert lines
251 except (FileNotFoundError, PermissionError): # location is local and program file was not found
252 die(f"{p.zfs_program} CLI is not available on {location} host: {ssh_user_host or 'localhost'}")
253 except subprocess.CalledProcessError as e:
254 stderr: str = stderr_to_str(e.stderr)
255 stdout: str = stderr_to_str(e.stdout)
256 if "unrecognized command '--version'" in stderr and "run: zfs help" in stderr:
257 die(f"Unsupported ZFS platform: {stderr}") # solaris is unsupported
258 elif stderr.startswith("ssh: "):
259 assert e.returncode == 255, e.returncode # error within SSH itself (not during the remote command)
260 die(f"ssh exit code {e.returncode}: {stderr.rstrip()}")
261 elif not stdout.startswith("zfs"): 261 ↛ 264line 261 didn't jump to line 264 because the condition on line 261 was always true
262 die(f"{p.zfs_program} CLI is not available on {location} host: {ssh_user_host or 'localhost'}")
263 else:
264 lines = stdout # FreeBSD if the zfs kernel module is not loaded
265 assert lines
266 if lines: 266 ↛ 278line 266 didn't jump to line 278 because the condition on line 266 was always true
267 # Examples that should parse: "zfs-2.1.5~rc5-ubuntu3", "zfswin-2.2.3rc5"
268 first_line: str = lines.splitlines()[0] if lines.splitlines() else ""
269 match = re.search(r"(\d+)\.(\d+)\.(\d+)", first_line)
270 if not match:
271 die("Unparsable zfs version string: '" + first_line + "'")
272 version = ".".join(match.groups())
273 available_programs["zfs"] = version
274 if is_version_at_least(version, "2.1.0"):
275 available_programs[ZFS_VERSION_IS_AT_LEAST_2_1_0] = ""
276 if is_version_at_least(version, "2.2.0"):
277 available_programs[ZFS_VERSION_IS_AT_LEAST_2_2_0] = ""
278 log.log(LOG_TRACE, f"available_programs[{location}][zfs]: %s", available_programs["zfs"])
280 if p.shell_program != DISABLE_PRG:
281 try:
282 cmd: list[str] = [p.shell_program, "-c", _find_available_programs(p)]
283 stdout = job.run_ssh_command_with_retries(remote, LOG_TRACE, cmd=cmd)
284 available_programs.update(dict.fromkeys(stdout.splitlines(), ""))
285 return available_programs
286 except (FileNotFoundError, PermissionError) as e: # location is local and shell program file was not found
287 if e.filename != p.shell_program: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 raise
289 except subprocess.CalledProcessError:
290 pass
291 log.warning("%s", f"Failed to find {p.shell_program} on {location}. Continuing with minimal assumptions...")
292 available_programs.update(available_programs_minimum)
293 return available_programs
296def is_dummy(r: Remote) -> bool:
297 """Returns True if ``remote`` refers to the synthetic dummy dataset."""
298 return r.root_dataset == DUMMY_DATASET
301def _detect_zpool_features(job: Job, remote: Remote, available_programs: dict) -> dict[str, str]:
302 """Fills ``job.params.zpool_features`` with detected zpool capabilities."""
303 p = params = job.params
304 r, loc, log = remote, remote.location, p.log
305 lines: list[str] = []
306 features: dict[str, str] = {}
307 if is_dummy(r):
308 return {}
309 if params.zpool_program != DISABLE_PRG and (params.shell_program == DISABLE_PRG or "zpool" in available_programs):
310 cmd: list[str] = params.split_args(f"{params.zpool_program} get -Hp -o property,value all", r.pool)
311 try:
312 lines = job.run_ssh_command_with_retries(remote, LOG_TRACE, cmd=cmd).splitlines()
313 except (FileNotFoundError, PermissionError) as e:
314 if e.filename != params.zpool_program: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 raise
316 log.warning("%s", f"Failed to detect zpool features on {loc}: {r.pool}. Continuing with minimal assumptions ...")
317 except (subprocess.CalledProcessError, UnicodeDecodeError):
318 log.warning("%s", f"Failed to detect zpool features on {loc}: {r.pool}. Continuing with minimal assumptions ...")
319 else:
320 props: dict[str, str] = dict(line.split("\t", 1) for line in lines)
321 features = {k: v for k, v in props.items() if k.startswith("feature@") or k == "delegation" or k == POOL_GUID}
322 if len(lines) == 0:
323 cmd = p.split_args(f"{p.zfs_program} list -t filesystem -Hp -o name -s name", r.pool)
324 if job.try_ssh_command_with_retries(remote, LOG_TRACE, cmd=cmd) is None:
325 die(f"Pool does not exist for {loc} dataset: {r.basis_root_dataset}. Manually create the pool first!")
326 return features
329def is_zpool_feature_enabled_or_active(p: Params, remote: Remote, feature: str) -> bool:
330 """Returns True if the given zpool feature is active or enabled on ``remote``."""
331 return p.zpool_features[remote.location][remote.pool].get(feature) in ("active", "enabled")
334def are_bookmarks_enabled(p: Params, remote: Remote) -> bool:
335 """Checks if bookmark related features are enabled on ``remote``."""
336 return is_zpool_feature_enabled_or_active(p, remote, "feature@bookmark_v2") and is_zpool_feature_enabled_or_active(
337 p, remote, "feature@bookmark_written"
338 )
341def is_caching_snapshots(p: Params, remote: Remote) -> bool:
342 """Returns True if snapshot caching is supported and enabled on ``remote``."""
343 return p.is_caching_snapshots and p.is_program_available(ZFS_VERSION_IS_AT_LEAST_2_2_0, remote.location)
346def is_version_at_least(version_str: str, min_version_str: str) -> bool:
347 """Checks if the version string is at least the minimum version string."""
348 return tuple(map(int, version_str.split("."))) >= tuple(map(int, min_version_str.split(".")))
351def is_version_at_most(version_str: str, max_version_str: str) -> bool:
352 """Checks if the version string is at most the maximum version string."""
353 return tuple(map(int, version_str.split("."))) <= tuple(map(int, max_version_str.split(".")))
356def _validate_default_shell(path_to_default_shell: str, location: str, ssh_user_host: str) -> None:
357 """Fails for default shells that do not honor POSIX shell quoting."""
358 shell_name: str = path_to_default_shell.rsplit("/", maxsplit=1)[-1]
359 if shell_name in ("csh", "tcsh", "elvish", "fish", "nu", "nushell", "xonsh"):
360 # On some old FreeBSD systems the default shell is still csh. Also see https://www.grymoire.com/unix/CshTop10.txt
361 die(
362 f"Cowardly refusing to proceed because {PROG_NAME} requires POSIX shell quoting of special characters, "
363 f"but '{path_to_default_shell}' is incompatible. The safe workaround is to first manually set 'sh' "
364 f"instead of '{path_to_default_shell}' as the default shell of the Unix user on {location} host: "
365 f"{ssh_user_host or 'localhost'}, like so: "
366 "chsh -s /bin/sh <YOURUSERNAME>"
367 )