Coverage for bzfs_main/bzfs_jobrunner.py: 99%
675 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-21 12:39 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-21 12:39 +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.
15# Inline script metadata conforming to https://packaging.python.org/specifications/inline-script-metadata
16# /// script
17# requires-python = ">=3.9"
18# dependencies = []
19# ///
20#
21"""
22* High-level orchestrator that calls `bzfs` as part of complex, periodic workflows to manage backup, replication, and
23 pruning jobs across a fleet of multiple source and destination hosts; driven by a fleet-wide job config file (e.g.,
24 `bzfs_job_testbed.py`).
25* Overview of the bzfs_jobrunner.py codebase:
26* The codebase starts with docs, definition of input data and associated argument parsing of CLI options/parameters.
27* Control flow starts in main(), far below ..., which kicks off a "Job".
28* A Job creates zero or more "subjobs" for each local or remote host, via run_main().
29* It executes the subjobs, serially or in parallel, via run_subjobs(), which in turn delegates parallel job coordination to
30 bzfs.process_datasets_in_parallel_and_fault_tolerant().
31* README_bzfs_jobrunner.md is mostly auto-generated from the ArgumentParser help texts as the source of "truth", via
32 update_readme.sh. Simply run that script whenever you change or add ArgumentParser help text.
33"""
35from __future__ import (
36 annotations,
37)
38import argparse
39import contextlib
40import os
41import platform
42import pwd
43import random
44import socket
45import subprocess
46import sys
47import threading
48import time
49import uuid
50from ast import (
51 literal_eval,
52)
53from collections.abc import (
54 Iterable,
55)
56from logging import (
57 Logger,
58)
59from subprocess import (
60 DEVNULL,
61 PIPE,
62)
63from typing import (
64 Any,
65 Final,
66 NoReturn,
67 TypeVar,
68 Union,
69 final,
70)
72import bzfs_main.argparse_actions
73from bzfs_main import (
74 bzfs,
75)
76from bzfs_main.argparse_cli import (
77 PROG_AUTHOR,
78)
79from bzfs_main.detect import (
80 DUMMY_DATASET,
81)
82from bzfs_main.loggers import (
83 get_simple_logger,
84 reset_logger,
85 set_logging_runtime_defaults,
86)
87from bzfs_main.util import (
88 check_range,
89 utils,
90)
91from bzfs_main.util.parallel_tasktree import (
92 BARRIER_CHAR,
93)
94from bzfs_main.util.parallel_tasktree_policy import (
95 process_datasets_in_parallel_and_fault_tolerant,
96)
97from bzfs_main.util.utils import (
98 DIE_STATUS,
99 LOG_TRACE,
100 UMASK,
101 UNIX_TIME_INFINITY_SECS,
102 JobStats,
103 Subprocesses,
104 TaskTiming,
105 dry,
106 format_dict,
107 format_obj,
108 getenv_bool,
109 human_readable_duration,
110 percent,
111 shuffle_dict,
112 terminate_process_subtree,
113 termination_signal_handler,
114 validate_dataset_name,
115)
116from bzfs_main.util.utils import PROG_NAME as BZFS_PROG_NAME
118# constants:
119PROG_NAME: Final[str] = "bzfs_jobrunner"
120SRC_MAGIC_SUBSTITUTION_TOKEN: Final[str] = "^SRC_HOST" # noqa: S105
121DST_MAGIC_SUBSTITUTION_TOKEN: Final[str] = "^DST_HOST" # noqa: S105
122SEP: Final[str] = ","
123POSIX_END_OF_OPTIONS_MARKER: Final[str] = "--" # args following -- are treated as operands, even if they begin with a hyphen
126def argument_parser() -> argparse.ArgumentParser:
127 """Returns the CLI parser used by bzfs_jobrunner."""
128 # fmt: off
129 parser = argparse.ArgumentParser(
130 prog=PROG_NAME,
131 allow_abbrev=False,
132 formatter_class=argparse.RawTextHelpFormatter,
133 description=f"""
134This companion program wraps [bzfs](README.md) for periodic snapshot creation, replication, pruning, and monitoring across
135N source hosts and M destination hosts, using one shared fleet-wide [jobconfig](bzfs_testbed/bzfs_job_testbed.py) script.
137Typical use cases include geo-replicated backup where each destination host is in a different region and receives replicas
138from the same set of source hosts, low-latency replication from a primary to a secondary or to M read replicas, and backups
139to removable drives.
141This program can be used to efficiently replicate ...
143a) within a single machine (local mode), or
145b) from a single source host to one or more destination hosts (pull or push or pull-push mode), or
147c) from multiple source hosts to a single destination host (pull or push or pull-push mode), or
149d) from N source hosts to M destination hosts (pull or push or pull-push mode, N and M can be large, M=2 or M=3 are typical
150geo-replication factors)
152You can run this program on a single third-party host and have it talk to all source and destination hosts. That setup is
153convenient for basic use cases and testing, and efficient with `--r2r=pull` or `--r2r=push`.
154In many deployments, a cron job on each source host runs `{PROG_NAME}` periodically to create new snapshots (via
155--create-src-snapshots) and prune outdated snapshots and bookmarks on the source (via --prune-src-snapshots and
156--prune-src-bookmarks), whereas another cron job on each destination host runs `{PROG_NAME}` periodically to prune
157outdated destination snapshots (via --prune-dst-snapshots), and to replicate the recently created snapshots from the source
158to the destination (via --replicate).
159A separate cron job on each source host and each destination host runs `{PROG_NAME}` periodically to alert the user if the
160latest or oldest snapshot is somehow too old (via --monitor-src-snapshots and --monitor-dst-snapshots).
161The frequency of each activity can differ. Typical intervals range from N milliseconds to years.
163Edit the jobconfig script in a central place (e.g. versioned in a git repo), then copy the (very same) shared file onto all
164source hosts and all destination hosts, and add crontab entries (or systemd timers or Monit entries or similar), along these
165lines:
167* crontab on source hosts:
169`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --create-src-snapshots --prune-src-snapshots --prune-src-bookmarks`
171`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --monitor-src-snapshots`
173* crontab on destination hosts:
175`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --dst-host="$(hostname)" --replicate --prune-dst-snapshots`
177`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --dst-host="$(hostname)" --monitor-dst-snapshots`
179Some deployments choose to move monitoring to one centralized management host, like so:
181`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --monitor-src-snapshots --monitor-dst-snapshots`
183### Applying Actions to a Subset of Hosts
185`--src-host` and `--dst-host` let you run actions on only a subset of source and destination hosts. For example, you can
186replicate from selected source hosts to selected destination hosts.
187Each `{PROG_NAME}` invocation runs all enabled actions for the final effective values of `--src-hosts` and `--dst-hosts`,
188after applying the `--src-host` and `--dst-host` filters.
191### High Frequency Replication (Experimental Feature)
193Taking snapshots and/or replicating every N milliseconds to roughly every 10 seconds is considered high frequency. Consider
194that `zfs list -t snapshot` performance degrades as snapshot counts grow within the selected datasets. Keep the active
195snapshot count small, and prune at a cadence that matches your snapshot creation rate. Consider using `--skip-parent` and
196`--exclude-dataset*` filters so only datasets that need this frequency are selected.
198To reduce startup overhead, forward `--daemon-lifetime` to `bzfs`, use the `--daemon-*` options, and split the
199crontab entry (or, preferably, a high-frequency systemd timer) into multiple processes, from one source host to one
200destination host, along these lines:
202* crontab on source hosts:
204`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --dst-host="foo" --create-src-snapshots`
206`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --dst-host="foo" --prune-src-snapshots`
208`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --dst-host="foo" --prune-src-bookmarks`
210`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="$(hostname)" --dst-host="foo" --monitor-src-snapshots`
213* crontab on destination hosts:
215`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="bar" --dst-host="$(hostname)" --replicate`
217`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="bar" --dst-host="$(hostname)" --prune-dst-snapshots`
219`* * * * * testuser /bzfs/bzfs_testbed/bzfs_job_testbed.py --src-host="bar" --dst-host="$(hostname)" --monitor-dst-snapshots`
221The daemon processes work like non-daemon processes except that they loop, handle time events, and sleep between events.
222They exit after the interval specified by `--daemon-lifetime` (for example, 86400 seconds). They are then restarted by
223`cron`, or earlier if they fail. While an existing daemon is still running, `cron` may attempt to start another one.
224This is harmless because that extra process exits immediately with a message like this:
225"Exiting as same previous periodic job is still running without completion yet"
226""")
228 # commands:
229 parser.add_argument(
230 "--create-src-snapshots", action="store_true",
231 help="Take snapshots on the selected source hosts as necessary. Typically, this command should be called by a "
232 "program (or cron job) running on each src host.\n\n")
233 parser.add_argument(
234 "--replicate", action="store_true",
235 help="Replicate snapshots from the selected source hosts to the selected destinations hosts as necessary. For pull "
236 "mode (recommended), this command should be called by a program (or cron job) running on each dst "
237 "host; for push mode, on the src host; for pull-push mode on a third-party host, maybe with `--r2r=pull` "
238 "or `--r2r=push`.\n\n")
239 parser.add_argument(
240 "--prune-src-snapshots", action="store_true",
241 help="Prune snapshots on the selected source hosts as necessary. Typically, this command should be called by a "
242 "program (or cron job) running on each src host.\n\n")
243 parser.add_argument(
244 "--prune-src-bookmarks", action="store_true",
245 help="Prune bookmarks on the selected source hosts as necessary. Typically, this command should be called by a "
246 "program (or cron job) running on each src host.\n\n")
247 parser.add_argument(
248 "--prune-dst-snapshots", action="store_true",
249 help="Prune snapshots on the selected destination hosts as necessary. Typically, this command should be called by a "
250 "program (or cron job) running on each dst host.\n\n")
251 parser.add_argument(
252 "--monitor-src-snapshots", action="store_true",
253 help="Alert the user if snapshots on the selected source hosts are too old, using --monitor-snapshot-plan (see "
254 "below). Typically, this command should be called by a program (or cron job) running on each src host.\n\n")
255 parser.add_argument(
256 "--monitor-dst-snapshots", action="store_true",
257 help="Alert the user if snapshots on the selected destination hosts are too old, using --monitor-snapshot-plan (see "
258 "below). Typically, this command should be called by a program (or cron job) running on each dst host.\n\n")
260 # options:
261 parser.add_argument(
262 "--localhost", default=None, action=bzfs_main.argparse_actions.NonEmptyStringAction, metavar="STRING",
263 help="Hostname of localhost. Default is the hostname without the domain name, querying the Operating System.\n\n")
264 parser.add_argument(
265 "--src-hosts", default=None, metavar="LIST_STRING",
266 help="Hostnames of the sources to operate on. Specify a Python list literal such as "
267 "`\"['src1', 'src2']\"`. If omitted, reads the same list literal from stdin.\n\n")
268 parser.add_argument(
269 "--src-host", default=None, action="append", metavar="STRING",
270 help="For subsetting --src-hosts; Can be specified multiple times; Indicates to only use the --src-hosts that are "
271 "contained in the specified --src-host values (optional).\n\n"
272 "Example: `--src-host=src1 --src-host=src2 --src-hosts=\"['src1', 'src2', 'src3', 'src4']\"` indicates to "
273 "effectively only use `--src-hosts=\"['src1', 'src2']\"`.\n\n")
274 dst_hosts_example = {"nas": ["onsite"], "bak-us-west": ["us-west"],
275 "bak-eu-west": ["eu-west"], "archive": ["offsite"]}
276 parser.add_argument(
277 "--dst-hosts", default="{}", metavar="DICT_STRING",
278 help="Dictionary that maps each destination hostname to a list of zero or more logical replication target names "
279 "(the infix portion of snapshot name). As hostname use the real output of the `hostname` CLI. "
280 "The target is an arbitrary user-defined name that serves as an abstraction of the destination hostnames for "
281 "a group of snapshots, like target 'onsite', 'offsite', 'hotspare', a geographically independent datacenter like "
282 "'us-west', or similar. Rather than the snapshot name embedding (i.e. hardcoding) a list of destination "
283 "hostnames where it should be sent to, the snapshot name embeds the user-defined target name, which is later "
284 "mapped by this jobconfig to a list of destination hostnames.\n\n"
285 f"Example: `{format_dict(dst_hosts_example)}`.\n\n"
286 "With this, given a snapshot name, we can find the destination hostnames to which the snapshot shall be "
287 "replicated. Also, given a snapshot name and its own name, a destination host can determine if it shall "
288 "replicate the given snapshot from the source host, or if the snapshot is intended for another destination "
289 "host, in which case it skips the snapshot. A destination host will receive replicas of snapshots for all "
290 "targets that map to that destination host.\n\n"
291 "Removing a mapping can be used to temporarily suspend replication to a given destination host.\n\n")
292 parser.add_argument(
293 "--dst-host", default=None, action="append", metavar="STRING",
294 help="For subsetting --dst-hosts; Can be specified multiple times; Indicates to only use the --dst-hosts keys that "
295 "are contained in the specified --dst-host values (optional).\n\n"
296 "Example: "
297 "`--dst-host=dst1 --dst-host=dst2 --dst-hosts=\"{'dst1': ..., 'dst2': ..., 'dst3': ..., 'dst4': ...}\"` "
298 "indicates to effectively only use `--dst-hosts=\"{'dst1': ..., 'dst2': ...}\"`.\n\n")
299 parser.add_argument(
300 "--retain-dst-targets", default="{}", metavar="DICT_STRING",
301 help="Dictionary that maps each destination hostname to a list of zero or more logical replication target names "
302 "(the infix portion of snapshot name).\n\n"
303 f"Example: `{format_dict(dst_hosts_example)}`. Has same format as --dst-hosts.\n\n"
304 "As part of --prune-dst-snapshots, a destination host will delete any snapshot it has stored whose target has "
305 "no mapping to that destination host in this dictionary. Do not remove a mapping here unless you are sure it's "
306 "ok to delete all those snapshots on that destination host! If in doubt, use --dryrun mode first.\n\n")
307 dst_root_datasets_example = {
308 "nas": "tank2/bak",
309 "bak-us-west": "backups/bak001",
310 "bak-eu-west": "backups/bak999",
311 "archive": f"archives/zoo/{SRC_MAGIC_SUBSTITUTION_TOKEN}",
312 "hotspare": "",
313 }
314 parser.add_argument(
315 "--dst-root-datasets", default="{}", metavar="DICT_STRING",
316 help="Dictionary that maps each destination hostname to a root dataset located on that destination host. The root "
317 "dataset name is an (optional) prefix that will be prepended to each dataset that is replicated to that "
318 "destination host. For backup use cases, this is the backup ZFS pool or a ZFS dataset path within that pool, "
319 "whereas for cloning, master slave replication, or replication from a primary to a secondary, this can also be "
320 "the empty string.\n\n"
321 f"`{SRC_MAGIC_SUBSTITUTION_TOKEN}` and `{DST_MAGIC_SUBSTITUTION_TOKEN}` are optional magic substitution tokens "
322 "that will be auto-replaced at runtime with the actual hostname. This can be used to force the use of a "
323 "separate destination root dataset per source host or per destination host.\n\n"
324 f"Example: `{format_dict(dst_root_datasets_example)}`\n\n")
325 src_snapshot_plan_example = {
326 "prod": {
327 "onsite": {"secondly": 40, "minutely": 40, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5},
328 "us-west": {"secondly": 0, "minutely": 0, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5},
329 "eu-west": {"secondly": 0, "minutely": 0, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5},
330 },
331 "test": {
332 "offsite": {"12hourly": 42, "weekly": 12},
333 },
334 }
335 parser.add_argument(
336 "--src-snapshot-plan", default="{}", metavar="DICT_STRING",
337 help="Retention periods for snapshots to be used if pruning src, and when creating new snapshots on src. "
338 "Snapshots that do not match a retention period will be deleted. A zero or missing retention period indicates "
339 "that no snapshots shall be retained (or even be created) for the given period.\n\n"
340 f"Example: `{format_dict(src_snapshot_plan_example)}`. This example will, for the organization 'prod' and "
341 "the intended logical target 'onsite', create and then retain secondly snapshots that were created less "
342 "than 40 seconds ago, yet retain the latest 40 secondly snapshots regardless of creation time. Analog for "
343 "the latest 40 minutely snapshots, 36 hourly snapshots, etc. "
344 "It will also create and retain snapshots for the targets 'us-west' and 'eu-west' within the 'prod' "
345 "organization. "
346 "In addition, it will create and retain snapshots every 12 hours and every week for the 'test' organization, "
347 "and name them as being intended for the 'offsite' replication target. "
348 "The example creates snapshots with names like "
349 "`prod_onsite_<timestamp>_secondly`, `prod_onsite_<timestamp>_minutely`, "
350 "`prod_us-west_<timestamp>_hourly`, `prod_us-west_<timestamp>_daily`, "
351 "`prod_eu-west_<timestamp>_hourly`, `prod_eu-west_<timestamp>_daily`, "
352 "`test_offsite_<timestamp>_12hourly`, `test_offsite_<timestamp>_weekly`, and so on.\n\n")
353 parser.add_argument(
354 "--src-bookmark-plan", default="{}", metavar="DICT_STRING",
355 help="Retention periods for bookmarks to be used if pruning src. Has same format as --src-snapshot-plan.\n\n")
356 parser.add_argument(
357 "--dst-snapshot-plan", default="{}", metavar="DICT_STRING",
358 help="Retention periods for snapshots to be used if pruning dst. Has same format as --src-snapshot-plan.\n\n")
359 monitor_snapshot_plan_example = {
360 "prod": {
361 "onsite": {
362 "100millisecondly": {"warning": "650 milliseconds", "critical": "2 seconds"},
363 "secondly": {"warning": "2 seconds", "critical": "14 seconds"},
364 "minutely": {"warning": "30 seconds", "critical": "300 seconds"},
365 "hourly": {"warning": "30 minutes", "critical": "300 minutes"},
366 "daily": {"warning": "4 hours", "critical": "8 hours"},
367 "weekly": {"warning": "2 days", "critical": "8 days"},
368 "monthly": {"warning": "2 days", "critical": "8 days"},
369 "yearly": {"warning": "5 days", "critical": "14 days"},
370 "10minutely": {"warning": "0 minutes", "critical": "0 minutes"},
371 },
372 "": {
373 "daily": {"warning": "4 hours", "critical": "8 hours"},
374 },
375 },
376 }
377 parser.add_argument(
378 "--monitor-snapshot-plan", default="{}", metavar="DICT_STRING",
379 help="Alert the user if the ZFS 'creation' time property of the latest or oldest snapshot for any specified "
380 "snapshot pattern within the selected datasets is too old wrt. the specified age limit. The purpose is to "
381 "check if snapshots are successfully taken on schedule, successfully replicated on schedule, and successfully "
382 "pruned on schedule. "
383 "Process exit code is 0, 1, 2 on OK, WARNING, CRITICAL, respectively.\n\n"
384 f"Example DICT_STRING: `{format_dict(monitor_snapshot_plan_example)}`. "
385 "This example alerts the user if the *latest* src or dst snapshot named `prod_onsite_<timestamp>_hourly` is "
386 "more than 30 minutes late (i.e. more than 30+60=90 minutes old) [warning] or more than 300 minutes late (i.e. "
387 "more than 300+60=360 minutes old) [critical]. In addition, the example alerts the user if the *oldest* src or "
388 "dst snapshot named `prod_onsite_<timestamp>_hourly` is more than 30 + 60x36 minutes old [warning] or more "
389 "than 300 + 60x36 minutes old [critical], where 36 is the number of period cycles specified in "
390 "`src_snapshot_plan` or `dst_snapshot_plan`, respectively. "
391 "Analog for the latest snapshot named `prod_<timestamp>_daily`, and so on.\n\n"
392 "Note: A duration that is missing or zero (e.g. '0 minutes') indicates that no snapshots shall be checked for "
393 "the given snapshot name pattern.\n\n")
394 locations = ["src", "dst"]
395 for loc in locations:
396 parser.add_argument(
397 f"--ssh-{loc}-user", default="", metavar="STRING",
398 help=f"Remote SSH username on {loc} hosts to connect to (optional). Examples: 'root', 'alice'.\n\n")
399 for loc in locations:
400 parser.add_argument(
401 f"--ssh-{loc}-port", type=int, min=1, max=65535, action=check_range.CheckRange, metavar="INT",
402 help=f"Remote SSH port on {loc} host to connect to (optional).\n\n")
403 for loc in locations:
404 parser.add_argument(
405 f"--ssh-{loc}-config-file", type=str, action=bzfs_main.argparse_actions.SSHConfigFileNameAction, metavar="FILE",
406 help=f"Path to SSH ssh_config(5) file to connect to {loc} (optional); will be passed into ssh -F CLI. "
407 "The basename must contain the substring 'bzfs_ssh_config'.\n\n")
408 parser.add_argument(
409 "--job-id", required=True, action=bzfs_main.argparse_actions.NonEmptyStringAction, metavar="STRING",
410 help="The identifier that remains constant across all runs of this particular job; will be included in the log file "
411 "name infix. Example: mytestjob\n\n")
412 parser.add_argument(
413 "--job-run", default="", action=bzfs_main.argparse_actions.NonEmptyStringAction, metavar="STRING",
414 help="The identifier of this particular run of the overall job; will be included in the log file name suffix. "
415 "Default is a hex UUID. Example: 0badc0f003a011f0a94aef02ac16083c\n\n")
416 workers_default = 100 # percent
417 parser.add_argument(
418 "--workers", min=1, default=(workers_default, True), action=bzfs_main.argparse_actions.CheckPercentRange,
419 metavar="INT[%]",
420 help="The maximum number of jobs to run in parallel at any time; can be given as a positive integer, "
421 f"optionally followed by the %% percent character (min: %(min)s, default: {workers_default}%%). Percentages "
422 "are relative to the number of CPU cores on the machine. Example: 200%% uses twice as many parallel jobs as "
423 "there are cores on the machine; 75%% uses num_procs = num_cores * 0.75. Examples: 1, 4, 75%%, 150%%\n\n")
424 parser.add_argument(
425 "--work-period-seconds", type=float, min=0, default=0, action=check_range.CheckRange, metavar="FLOAT",
426 help="Reduces bandwidth spikes by spreading out the start of worker jobs over this much time; "
427 "0 disables this feature (default: %(default)s). Examples: 0, 60, 86400\n\n")
428 parser.add_argument(
429 "--jitter", action="store_true",
430 help="Randomize job start time and host order to avoid potential thundering herd problems in large distributed "
431 "systems (optional). Randomizing job start time is only relevant if --work-period-seconds > 0.\n\n")
432 parser.add_argument(
433 "--worker-timeout-seconds", type=float, min=0.001, default=None, action=check_range.CheckRange, metavar="FLOAT",
434 help="If this much time has passed after a worker process has started executing, kill the straggling worker "
435 "(optional). Other workers remain unaffected. Examples: 60, 3600\n\n")
436 parser.add_argument(
437 "--repeat-if-took-more-than-seconds", type=float, min=0.001, default=UNIX_TIME_INFINITY_SECS,
438 action=check_range.CheckRange, metavar="FLOAT",
439 help="Repeat the entire workflow if it took longer than this much time and was successful. Use this (with the POSIX "
440 "`timeout` CLI) before migrating VM storage to converge replication and reduce cutover downtime. Default is "
441 "infinity, i.e. never repeat the workflow. Examples: 1, 0.1\n\n")
442 parser.add_argument(
443 "--spawn-process-per-job", action="store_true",
444 help="Spawn a Python process per subjob instead of a Python thread per subjob (optional). The former is only "
445 "recommended for a job operating in parallel on a large number of hosts as it helps avoid exceeding "
446 "per-process limits such as the default max number of open file descriptors, at the expense of increased "
447 "startup latency.\n\n")
448 parser.add_argument(
449 "--jobrunner-dryrun", action="store_true",
450 help="Do a dry run (aka 'no-op') to print what operations would happen if the command were to be executed "
451 "for real (optional). This option treats both the ZFS source and destination as read-only. Can also be used to "
452 "check if the configuration options are valid.\n\n")
453 parser.add_argument(
454 "--jobrunner-log-level", choices=["CRITICAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"], default="INFO",
455 help="Only emit jobrunner messages with equal or higher priority than this log level. Default is '%(default)s'.\n\n")
456 parser.add_argument(
457 "--daemon-replication-frequency", default="minutely", metavar="STRING",
458 help="Specifies how often the bzfs daemon shall replicate from src to dst if --daemon-lifetime is nonzero.\n\n")
459 parser.add_argument(
460 "--daemon-prune-src-frequency", default="minutely", metavar="STRING",
461 help="Specifies how often the bzfs daemon shall prune src if --daemon-lifetime is nonzero.\n\n")
462 parser.add_argument(
463 "--daemon-prune-dst-frequency", default="minutely", metavar="STRING",
464 help="Specifies how often the bzfs daemon shall prune dst if --daemon-lifetime is nonzero.\n\n")
465 parser.add_argument(
466 "--daemon-monitor-snapshots-frequency", default="minutely", metavar="STRING",
467 help="Specifies how often the bzfs daemon shall monitor snapshot age if --daemon-lifetime is nonzero.\n\n")
468 bad_opts = ["--daemon-frequency", "--include-snapshot-plan", "--create-src-snapshots-plan", "--skip-replication",
469 "--include-snapshot-regex", "--exclude-snapshot-regex", "--include-snapshot-times-and-ranks",
470 "--log-file-prefix", "--log-file-infix", "--log-file-suffix",
471 "--delete-dst-datasets", "--delete-dst-snapshots", "--delete-dst-snapshots-except",
472 "--delete-dst-snapshots-except-plan", "--delete-empty-dst-datasets",
473 "--monitor-snapshots", "--timeout"]
474 for loc in locations:
475 bad_opts += [f"--ssh-{loc}-host"] # reject this arg as jobrunner will auto-generate it
476 for bad_opt in bad_opts:
477 parser.add_argument(bad_opt, action=RejectArgumentAction, nargs=0, help=argparse.SUPPRESS)
478 parser.add_argument(
479 "--version", action="version", version=f"{PROG_NAME}-{bzfs_main.argparse_cli.__version__}, by {PROG_AUTHOR}",
480 help="Display version information and exit.\n\n")
481 parser.add_argument(
482 "--root-dataset-pairs", required=True, nargs="+", action=bzfs_main.argparse_actions.DatasetPairsAction,
483 metavar="SRC_DATASET DST_DATASET",
484 help="Source and destination dataset pairs (excluding usernames and excluding hostnames, which will all be "
485 "auto-appended later).\n\n")
486 return parser
487 # fmt: on
490#############################################################################
491def main() -> None:
492 """API for command line clients."""
493 prev_umask: int = os.umask(UMASK)
494 try:
495 set_logging_runtime_defaults()
496 # On CTRL-C and SIGTERM, send signal to all descendant processes to terminate them
497 termination_event: threading.Event = threading.Event()
498 with termination_signal_handler(termination_events=[termination_event]):
499 Job(log=None, termination_event=termination_event).run_main(sys_argv=sys.argv)
500 finally:
501 os.umask(prev_umask) # restore prior global state
504#############################################################################
505@final
506class Job:
507 """Coordinates subjobs per the CLI flags; Each subjob handles one host pair and may run in its own process or thread."""
509 def __init__(self, log: Logger | None, termination_event: threading.Event) -> None:
510 # immutable variables:
511 self.log_was_None: Final[bool] = log is None
512 self.log: Final[Logger] = get_simple_logger(PROG_NAME) if log is None else log
513 self.termination_event: Final[threading.Event] = termination_event
514 self.timing: Final[TaskTiming] = TaskTiming.make_from(self.termination_event)
515 self.subprocesses: Final[Subprocesses] = Subprocesses(termination_event.is_set)
516 self.jobrunner_dryrun: bool = False
517 self.spawn_process_per_job: bool = False
518 self.loopback_address: Final[str] = _detect_loopback_address()
520 # mutable variables:
521 self.first_exception: int | None = None
522 self.worst_exception: int | None = None
523 self.stats: JobStats = JobStats(jobs_all=0)
524 self.cache_existing_dst_pools: set[str] = set()
525 self.cache_known_dst_pools: set[str] = set()
527 self.is_test_mode: bool = False # for testing only
529 def run_main(self, sys_argv: list[str]) -> None:
530 """API for Python clients; visible for testing; may become a public API eventually."""
531 try:
532 self._run_main(sys_argv)
533 finally:
534 if self.log_was_None: # reset Logger unless it's a Logger outside of our control 534 ↛ exitline 534 didn't return from function 'run_main' because the condition on line 534 was always true
535 reset_logger(self.log)
537 def _run_main(self, sys_argv: list[str]) -> None:
538 self.first_exception = None
539 self.worst_exception = None
540 log: Logger = self.log
541 log.info("CLI arguments: %s", " ".join(sys_argv))
542 nsp = argparse.Namespace(no_argument_file=True) # disable --root-dataset-pairs='+file' option in DatasetPairsAction
543 args, unknown_args = argument_parser().parse_known_args(sys_argv[1:], nsp) # forward all unknown args to `bzfs`
544 log.setLevel(args.jobrunner_log_level)
545 self.jobrunner_dryrun = args.jobrunner_dryrun
546 assert len(args.root_dataset_pairs) > 0
547 src_snapshot_plan: dict = self.validate_snapshot_plan(literal_eval(args.src_snapshot_plan), "--src-snapshot-plan")
548 src_bookmark_plan: dict = self.validate_snapshot_plan(literal_eval(args.src_bookmark_plan), "--src-bookmark-plan")
549 dst_snapshot_plan: dict = self.validate_snapshot_plan(literal_eval(args.dst_snapshot_plan), "--dst-snapshot-plan")
550 monitor_snapshot_plan: dict = self.validate_monitor_snapshot_plan(literal_eval(args.monitor_snapshot_plan))
551 localhostname: str = args.localhost if args.localhost else socket.gethostname()
552 self.validate_host_name(localhostname, "--localhost")
553 log.debug("localhostname: %s", localhostname)
554 src_hosts: list[str] = self.validate_src_hosts(self.parse_src_hosts_from_cli_or_stdin(args.src_hosts))
555 basis_src_hosts: list[str] = src_hosts
556 nb_src_hosts: int = len(basis_src_hosts)
557 log.debug("src_hosts before subsetting: %s", src_hosts)
558 if args.src_host is not None: # retain only the src hosts that are also contained in args.src_host
559 assert isinstance(args.src_host, list)
560 retain_src_hosts: set[str] = set(args.src_host)
561 self.validate_is_subset(retain_src_hosts, src_hosts, "--src-host", "--src-hosts")
562 src_hosts = [host for host in src_hosts if host in retain_src_hosts]
563 dst_hosts: dict[str, list[str]] = self.validate_dst_hosts(literal_eval(args.dst_hosts))
564 nb_dst_hosts: int = len(dst_hosts)
565 if args.dst_host is not None: # retain only the dst hosts that are also contained in args.dst_host
566 assert isinstance(args.dst_host, list)
567 retain_dst_hosts: set[str] = set(args.dst_host)
568 self.validate_is_subset(retain_dst_hosts, dst_hosts.keys(), "--dst-host", "--dst-hosts.keys")
569 dst_hosts = {dst_host: lst for dst_host, lst in dst_hosts.items() if dst_host in retain_dst_hosts}
570 retain_dst_targets: dict[str, list[str]] = self.validate_dst_hosts(literal_eval(args.retain_dst_targets))
571 self.validate_is_subset(dst_hosts.keys(), retain_dst_targets.keys(), "--dst-hosts.keys", "--retain-dst-targets.keys")
572 dst_root_datasets: dict[str, str] = self.validate_dst_root_datasets(literal_eval(args.dst_root_datasets))
573 self.validate_is_subset(
574 dst_root_datasets.keys(), retain_dst_targets.keys(), "--dst-root-dataset.keys", "--retain-dst-targets.keys"
575 )
576 self.validate_is_subset(dst_hosts.keys(), dst_root_datasets.keys(), "--dst-hosts.keys", "--dst-root-dataset.keys")
577 bad_root_datasets: dict[str, str] = {
578 dst_host: root_dataset
579 for dst_host in sorted(dst_hosts.keys())
580 if SRC_MAGIC_SUBSTITUTION_TOKEN not in (root_dataset := dst_root_datasets[dst_host])
581 }
582 if len(src_hosts) > 1 and len(bad_root_datasets) > 0:
583 self.die(
584 "Cowardly refusing to proceed as multiple source hosts must not be configured to write to the same "
585 "destination dataset. "
586 f"Problematic subset of --dst-root-datasets: {bad_root_datasets} for src_hosts: {sorted(src_hosts)}"
587 )
588 bad_root_datasets = {
589 dst_host: root_dataset
590 for dst_host, root_dataset in sorted(dst_root_datasets.items())
591 if root_dataset and SRC_MAGIC_SUBSTITUTION_TOKEN not in root_dataset
592 }
593 if len(basis_src_hosts) > 1 and len(bad_root_datasets) > 0:
594 self.die(
595 "Cowardly refusing to proceed as multiple source hosts are defined in the configuration, but "
596 f"not all non-empty root datasets in --dst-root-datasets contain the '{SRC_MAGIC_SUBSTITUTION_TOKEN}' "
597 "substitution token to prevent collisions on writing destination datasets. "
598 f"Problematic subset of --dst-root-datasets: {bad_root_datasets} for src_hosts: {sorted(basis_src_hosts)}"
599 )
600 if args.jitter: # randomize host order to avoid potential thundering herd problems in large distributed systems
601 random.SystemRandom().shuffle(src_hosts)
602 dst_hosts = shuffle_dict(dst_hosts)
603 ssh_src_user: str = args.ssh_src_user
604 ssh_dst_user: str = args.ssh_dst_user
605 ssh_src_port: int | None = args.ssh_src_port
606 ssh_dst_port: int | None = args.ssh_dst_port
607 ssh_src_config_file: str | None = args.ssh_src_config_file
608 ssh_dst_config_file: str | None = args.ssh_dst_config_file
609 job_id: str = _sanitize(args.job_id)
610 job_run: str = _sanitize(args.job_run) if args.job_run else uuid.uuid1().hex
611 workers, workers_is_percent = args.workers
612 max_workers: int = max(1, round((os.cpu_count() or 1) * workers / 100.0) if workers_is_percent else round(workers))
613 worker_timeout_seconds: int = args.worker_timeout_seconds
614 repeat_if_took_more_than_nanos: int = int(args.repeat_if_took_more_than_seconds * 1_000_000_000)
615 self.spawn_process_per_job = args.spawn_process_per_job
616 username: str = pwd.getpwuid(os.getuid()).pw_name
617 assert username
618 loopback_ids: set[str] = {"localhost", "127.0.0.1", "::1", socket.gethostname()} # ::1 is IPv6 loopback address
619 loopback_ids.update(self.get_localhost_ips()) # union
620 loopback_ids.add(localhostname)
621 loopback_ids = set() if getenv_bool("disable_loopback", False) else loopback_ids
622 log.log(LOG_TRACE, "loopback_ids: %s", sorted(loopback_ids))
624 def zero_pad(number: int, width: int = 6) -> str:
625 """Pads number with leading '0' chars to the given width."""
626 return f"{number:0{width}d}"
628 def jpad(jj: int, tag: str) -> str:
629 """Returns ``tag`` prefixed with slash and zero padded index."""
630 return "/" + zero_pad(jj) + tag
632 def runpad() -> str:
633 """Returns standardized subjob count suffix."""
634 return job_run + SEP + zero_pad(len(subjobs))
636 def update_subjob_name(tag: str) -> str:
637 """Derives next subjob name based on ``tag`` and index ``j``."""
638 if j <= 0:
639 return subjob_name
640 elif j == 1:
641 return subjob_name + jpad(j - 1, tag)
642 else:
643 return subjob_name + "/" + BARRIER_CHAR
645 def resolve_dataset(hostname: str, dataset: str, is_src: bool = True) -> str:
646 """Returns host:dataset string resolving IPv6 and localhost cases."""
647 assert hostname
648 assert dataset
649 ssh_user = ssh_src_user if is_src else ssh_dst_user
650 ssh_user = ssh_user if ssh_user else username
651 lb: str = self.loopback_address
652 loopbck_ids: set[str] = loopback_ids
653 hostname = hostname if hostname not in loopbck_ids else (lb if lb else hostname) if username != ssh_user else "-"
654 hostname = convert_ipv6(hostname)
655 return f"{hostname}:{dataset}"
657 def resolve_dst_dataset(dst_hostname: str, dst_dataset: str) -> str:
658 """Expands ``dst_dataset`` relative to ``dst_hostname`` roots."""
659 assert dst_hostname
660 assert dst_dataset
661 root_dataset: str | None = dst_root_datasets.get(dst_hostname)
662 assert root_dataset is not None, dst_hostname # f"Hostname '{dst_hostname}' missing in --dst-root-datasets"
663 root_dataset = root_dataset.replace(SRC_MAGIC_SUBSTITUTION_TOKEN, src_host)
664 root_dataset = root_dataset.replace(DST_MAGIC_SUBSTITUTION_TOKEN, dst_hostname)
665 resolved_dst_dataset: str = f"{root_dataset}/{dst_dataset}" if root_dataset else dst_dataset
666 validate_dataset_name(resolved_dst_dataset, dst_dataset)
667 return resolve_dataset(dst_hostname, resolved_dst_dataset, is_src=False)
669 for src_host in src_hosts:
670 assert src_host
671 for dst_hostname in dst_hosts:
672 assert dst_hostname
673 dummy: Final[str] = DUMMY_DATASET
674 lhn: Final[str] = localhostname
675 bzfs_prog_header: Final[list[str]] = [BZFS_PROG_NAME, "--no-argument-file"] + unknown_args
676 subjobs: dict[str, list[str]] = {}
677 for i, src_host in enumerate(src_hosts):
678 subjob_name: str = zero_pad(i) + "src-host"
679 src_log_suffix: str = _log_suffix(localhostname, src_host, "")
680 j: int = 0
681 opts: list[str]
683 if args.create_src_snapshots:
684 opts = ["--create-src-snapshots", f"--create-src-snapshots-plan={src_snapshot_plan}", "--skip-replication"]
685 self.add_log_file_opts(opts, "create-src-snapshots", job_id, runpad(), src_log_suffix)
686 self.add_ssh_opts(
687 opts, ssh_src_user=ssh_src_user, ssh_src_port=ssh_src_port, ssh_src_config_file=ssh_src_config_file
688 )
689 opts += [POSIX_END_OF_OPTIONS_MARKER]
690 opts += _flatten(_dedupe([(resolve_dataset(src_host, src), dummy) for src, dst in args.root_dataset_pairs]))
691 subjob_name += "/create-src-snapshots"
692 subjobs[subjob_name] = bzfs_prog_header + opts
694 if args.replicate:
695 j = 0
696 marker: str = "replicate"
697 for dst_hostname, targets in dst_hosts.items():
698 opts = self.replication_opts(
699 dst_snapshot_plan, set(targets), lhn, src_host, dst_hostname, marker, job_id, runpad()
700 )
701 if len(opts) > 0:
702 opts += [f"--daemon-frequency={args.daemon_replication_frequency}"]
703 self.add_ssh_opts(
704 opts,
705 ssh_src_user=ssh_src_user,
706 ssh_dst_user=ssh_dst_user,
707 ssh_src_port=ssh_src_port,
708 ssh_dst_port=ssh_dst_port,
709 ssh_src_config_file=ssh_src_config_file,
710 ssh_dst_config_file=ssh_dst_config_file,
711 )
712 opts += [POSIX_END_OF_OPTIONS_MARKER]
713 dataset_pairs: list[tuple[str, str]] = [
714 (resolve_dataset(src_host, src), resolve_dst_dataset(dst_hostname, dst))
715 for src, dst in args.root_dataset_pairs
716 ]
717 dataset_pairs = self.skip_nonexisting_local_dst_pools(dataset_pairs, worker_timeout_seconds)
718 if len(dataset_pairs) > 0:
719 subjobs[subjob_name + jpad(j, marker)] = bzfs_prog_header + opts + _flatten(dataset_pairs)
720 j += 1
721 subjob_name = update_subjob_name(marker)
723 def prune_src(
724 opts: list[str], retention_plan: dict, tag: str, src_host: str = src_host, logsuffix: str = src_log_suffix
725 ) -> None:
726 """Creates prune subjob options for ``tag`` using ``retention_plan``."""
727 opts += ["--skip-replication", f"--delete-dst-snapshots-except-plan={retention_plan}"]
728 opts += [f"--daemon-frequency={args.daemon_prune_src_frequency}"]
729 self.add_log_file_opts(opts, tag, job_id, runpad(), logsuffix)
730 self.add_ssh_opts( # i.e. dst=src, src=dummy
731 opts, ssh_dst_user=ssh_src_user, ssh_dst_port=ssh_src_port, ssh_dst_config_file=ssh_src_config_file
732 )
733 opts += [POSIX_END_OF_OPTIONS_MARKER]
734 opts += _flatten(_dedupe([(dummy, resolve_dataset(src_host, src)) for src, dst in args.root_dataset_pairs]))
735 nonlocal subjob_name
736 subjob_name += f"/{tag}"
737 subjobs[subjob_name] = bzfs_prog_header + opts
739 if args.prune_src_snapshots:
740 prune_src(["--delete-dst-snapshots"], src_snapshot_plan, tag="prune-src-snapshots")
742 if args.prune_src_bookmarks:
743 prune_src(["--delete-dst-snapshots=bookmarks"], src_bookmark_plan, tag="prune-src-bookmarks")
745 if args.prune_dst_snapshots:
746 self.validate_true(
747 retain_dst_targets, "--retain-dst-targets must not be empty. Cowardly refusing to delete all snapshots!"
748 )
749 j = 0
750 marker = "prune-dst-snapshots"
751 for dst_hostname, _ in dst_hosts.items():
752 curr_retain_targets: set[str] = set(retain_dst_targets[dst_hostname])
753 curr_dst_snapshot_plan = { # only retain targets that belong to the host
754 org: {target: periods for target, periods in target_periods.items() if target in curr_retain_targets}
755 for org, target_periods in dst_snapshot_plan.items()
756 }
757 opts = ["--delete-dst-snapshots", "--skip-replication"]
758 opts += [f"--delete-dst-snapshots-except-plan={curr_dst_snapshot_plan}"]
759 opts += [f"--daemon-frequency={args.daemon_prune_dst_frequency}"]
760 self.add_log_file_opts(opts, marker, job_id, runpad(), _log_suffix(lhn, src_host, dst_hostname))
761 self.add_ssh_opts(
762 opts, ssh_dst_user=ssh_dst_user, ssh_dst_port=ssh_dst_port, ssh_dst_config_file=ssh_dst_config_file
763 )
764 opts += [POSIX_END_OF_OPTIONS_MARKER]
765 dataset_pairs = [(dummy, resolve_dst_dataset(dst_hostname, dst)) for src, dst in args.root_dataset_pairs]
766 dataset_pairs = _dedupe(dataset_pairs)
767 dataset_pairs = self.skip_nonexisting_local_dst_pools(dataset_pairs, worker_timeout_seconds)
768 if len(dataset_pairs) > 0:
769 subjobs[subjob_name + jpad(j, marker)] = bzfs_prog_header + opts + _flatten(dataset_pairs)
770 j += 1
771 subjob_name = update_subjob_name(marker)
773 def monitor_snapshots_opts(tag: str, monitor_plan: dict, logsuffix: str) -> list[str]:
774 """Returns monitor subjob options for ``tag`` and ``monitor_plan``."""
775 opts = [f"--monitor-snapshots={monitor_plan}", "--skip-replication"]
776 opts += [f"--daemon-frequency={args.daemon_monitor_snapshots_frequency}"]
777 self.add_log_file_opts(opts, tag, job_id, runpad(), logsuffix)
778 return opts
780 def build_monitor_plan(monitor_plan: dict, snapshot_plan: dict, cycles_prefix: str) -> dict:
781 """Expands ``monitor_plan`` with cycle defaults from ``snapshot_plan``."""
783 def alert_dicts(alertdict: dict, cycles: int) -> dict:
784 """Returns alert dictionaries with explicit ``cycles`` value."""
785 latest_dict = alertdict.copy()
786 for prefix in ("src_snapshot_", "dst_snapshot_", ""):
787 latest_dict.pop(f"{prefix}cycles", None)
788 oldest_dict = latest_dict.copy()
789 oldest_dict["cycles"] = int(alertdict.get(f"{cycles_prefix}cycles", cycles))
790 latest_dict.pop("oldest_skip_holds", None)
791 return {"latest": latest_dict, "oldest": oldest_dict}
793 return {
794 org: {
795 target: {
796 periodunit: alert_dicts(alertdict, snapshot_plan.get(org, {}).get(target, {}).get(periodunit, 1))
797 for periodunit, alertdict in periods.items()
798 }
799 for target, periods in target_periods.items()
800 }
801 for org, target_periods in monitor_plan.items()
802 }
804 if args.monitor_src_snapshots:
805 marker = "monitor-src-snapshots"
806 monitor_plan = build_monitor_plan(monitor_snapshot_plan, src_snapshot_plan, "src_snapshot_")
807 opts = monitor_snapshots_opts(marker, monitor_plan, src_log_suffix)
808 self.add_ssh_opts( # i.e. dst=src, src=dummy
809 opts, ssh_dst_user=ssh_src_user, ssh_dst_port=ssh_src_port, ssh_dst_config_file=ssh_src_config_file
810 )
811 opts += [POSIX_END_OF_OPTIONS_MARKER]
812 opts += _flatten(_dedupe([(dummy, resolve_dataset(src_host, src)) for src, dst in args.root_dataset_pairs]))
813 subjob_name += "/" + marker
814 subjobs[subjob_name] = bzfs_prog_header + opts
816 if args.monitor_dst_snapshots:
817 j = 0
818 marker = "monitor-dst-snapshots"
819 for dst_hostname, targets in dst_hosts.items():
820 monitor_targets: set[str] = set(targets).intersection(set(retain_dst_targets[dst_hostname]))
821 monitor_plan = { # only retain targets that belong to the host
822 org: {target: periods for target, periods in target_periods.items() if target in monitor_targets}
823 for org, target_periods in monitor_snapshot_plan.items()
824 }
825 monitor_plan = build_monitor_plan(monitor_plan, dst_snapshot_plan, "dst_snapshot_")
826 opts = monitor_snapshots_opts(marker, monitor_plan, _log_suffix(lhn, src_host, dst_hostname))
827 self.add_ssh_opts(
828 opts, ssh_dst_user=ssh_dst_user, ssh_dst_port=ssh_dst_port, ssh_dst_config_file=ssh_dst_config_file
829 )
830 opts += [POSIX_END_OF_OPTIONS_MARKER]
831 dataset_pairs = [(dummy, resolve_dst_dataset(dst_hostname, dst)) for src, dst in args.root_dataset_pairs]
832 dataset_pairs = _dedupe(dataset_pairs)
833 dataset_pairs = self.skip_nonexisting_local_dst_pools(dataset_pairs, worker_timeout_seconds)
834 if len(dataset_pairs) > 0:
835 subjobs[subjob_name + jpad(j, marker)] = bzfs_prog_header + opts + _flatten(dataset_pairs)
836 j += 1
837 subjob_name = update_subjob_name(marker)
839 msg = f"Ready to run {len(subjobs)} subjobs using {len(src_hosts)}/{nb_src_hosts} src hosts: {src_hosts}, "
840 msg += f"{len(dst_hosts)}/{nb_dst_hosts} dst hosts: {list(dst_hosts.keys())}"
841 log.info("%s", dry(msg, is_dry_run=self.jobrunner_dryrun))
842 log.log(LOG_TRACE, "subjobs: \n%s", _pretty_print_formatter(subjobs))
843 while True:
844 start_time_nanos = time.monotonic_ns()
845 self.first_exception = None
846 self.worst_exception = None
847 self.run_subjobs(subjobs, max_workers, worker_timeout_seconds, args.work_period_seconds, args.jitter)
848 ex = self.worst_exception
849 if isinstance(ex, int):
850 assert ex != 0
851 sys.exit(ex)
852 assert ex is None, ex
853 if time.monotonic_ns() - start_time_nanos <= repeat_if_took_more_than_nanos:
854 break
855 log.info("Succeeded. Bye!")
857 def replication_opts(
858 self,
859 dst_snapshot_plan: dict[str, dict[str, dict[str, int]]],
860 targets: set[str],
861 localhostname: str,
862 src_hostname: str,
863 dst_hostname: str,
864 tag: str,
865 job_id: str,
866 job_run: str,
867 ) -> list[str]:
868 """Returns CLI options for one replication subjob."""
869 log = self.log
870 log.debug("%s", f"Replicating targets {sorted(targets)} from {src_hostname} to {dst_hostname} ...")
871 include_snapshot_plan = { # only replicate targets that belong to the destination host and are relevant
872 org: {
873 target: {
874 duration_unit: duration_amount
875 for duration_unit, duration_amount in periods.items()
876 if duration_amount > 0
877 }
878 for target, periods in target_periods.items()
879 if target in targets
880 }
881 for org, target_periods in dst_snapshot_plan.items()
882 }
883 include_snapshot_plan = { # only replicate orgs that have at least one relevant target_period
884 org: target_periods
885 for org, target_periods in include_snapshot_plan.items()
886 if any(len(periods) > 0 for target, periods in target_periods.items())
887 }
888 opts: list[str] = []
889 if len(include_snapshot_plan) > 0:
890 opts += [f"--include-snapshot-plan={include_snapshot_plan}"]
891 self.add_log_file_opts(opts, tag, job_id, job_run, _log_suffix(localhostname, src_hostname, dst_hostname))
892 return opts
894 def skip_nonexisting_local_dst_pools(
895 self, root_dataset_pairs: list[tuple[str, str]], timeout_secs: float | None = None
896 ) -> list[tuple[str, str]]:
897 """Skip datasets that point to removable destination drives that are not currently (locally) attached, if any."""
899 def zpool(dataset: str) -> str:
900 """Returns pool name portion of ``dataset``."""
901 return dataset.split("/", 1)[0]
903 assert len(root_dataset_pairs) > 0
904 unknown_dst_pools = {zpool(dst) for src, dst in root_dataset_pairs}
905 unknown_dst_pools = unknown_dst_pools.difference(self.cache_known_dst_pools)
907 # Here we treat a zpool as existing if the zpool isn't local, aka if it isn't prefixed with "-:". A remote host
908 # will raise an appropriate error if it turns out that the remote zpool doesn't actually exist.
909 unknown_local_dst_pools = {pool for pool in unknown_dst_pools if pool.startswith("-:")}
910 if len(unknown_local_dst_pools) > 0: # `zfs list` if local
911 existing_pools = {pool[len("-:") :] for pool in unknown_local_dst_pools}
912 cmd = "zfs list -t filesystem,volume -Hp -o name".split(" ") + sorted(existing_pools)
913 sp = subprocess.run(cmd, stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True, timeout=timeout_secs)
914 if sp.returncode not in (0, 1): # 1 means dataset not found
915 self.die(f"Unexpected error {sp.returncode} on checking for existing local dst pools: {sp.stderr.strip()}")
916 existing_pools = {"-:" + pool for pool in sp.stdout.splitlines() if pool}
917 self.cache_existing_dst_pools.update(existing_pools) # union
918 unknown_remote_dst_pools = unknown_dst_pools.difference(unknown_local_dst_pools)
919 self.cache_existing_dst_pools.update(unknown_remote_dst_pools) # union
920 self.cache_known_dst_pools.update(unknown_dst_pools) # union
921 results: list[tuple[str, str]] = []
922 for src, dst in root_dataset_pairs:
923 if zpool(dst) in self.cache_existing_dst_pools:
924 results.append((src, dst))
925 else:
926 self.log.warning("Skipping dst dataset for which local dst pool does not exist: %s", dst)
927 return results
929 @staticmethod
930 def add_ssh_opts(
931 opts: list[str],
932 *,
933 ssh_src_user: str | None = None,
934 ssh_dst_user: str | None = None,
935 ssh_src_port: int | None = None,
936 ssh_dst_port: int | None = None,
937 ssh_src_config_file: str | None = None,
938 ssh_dst_config_file: str | None = None,
939 ) -> None:
940 """Appends ssh related options to ``opts`` if specified."""
941 assert isinstance(opts, list)
942 opts += [f"--ssh-src-user={ssh_src_user}"] if ssh_src_user else []
943 opts += [f"--ssh-dst-user={ssh_dst_user}"] if ssh_dst_user else []
944 opts += [f"--ssh-src-port={ssh_src_port}"] if ssh_src_port is not None else []
945 opts += [f"--ssh-dst-port={ssh_dst_port}"] if ssh_dst_port is not None else []
946 opts += [f"--ssh-src-config-file={ssh_src_config_file}"] if ssh_src_config_file else []
947 opts += [f"--ssh-dst-config-file={ssh_dst_config_file}"] if ssh_dst_config_file else []
949 @staticmethod
950 def add_log_file_opts(opts: list[str], tag: str, job_id: str, job_run: str, logsuffix: str) -> None:
951 """Appends standard log-file CLI options to ``opts``."""
952 opts += [f"--log-file-prefix={PROG_NAME}{SEP}{tag}{SEP}"]
953 opts += [f"--log-file-infix={SEP}{job_id}"]
954 opts += [f"--log-file-suffix={SEP}{job_run}{logsuffix}{SEP}"]
956 def run_subjobs(
957 self,
958 subjobs: dict[str, list[str]],
959 max_workers: int,
960 timeout_secs: float | None,
961 work_period_seconds: float,
962 jitter: bool,
963 ) -> None:
964 """Executes subjobs sequentially or in parallel, respecting '~' barriers.
966 Design note on subjob failure, isolation and termination:
967 - On subjob failure the subjob's subtree is skipped.
968 - Subjob failures are converted to return codes (not exceptions) so the policy does not invoke a termination handler
969 on such failures and sibling subjobs continue unaffected. This preserves per-subjob isolation, both within a single
970 process (thread-per-subjob mode; default) as well as in process-per-subjob mode (``--spawn-process-per-job``).
971 - The first failure is retained for diagnostics.
972 - Process exit code precedence is: fatal/non-monitor failure > monitor CRITICAL/WARNING > STILL_RUNNING > success.
973 """
974 self.stats = JobStats(len(subjobs))
975 log = self.log
976 num_intervals = 1 + len(subjobs) if jitter else len(subjobs)
977 interval_nanos = 0 if len(subjobs) == 0 else round(1_000_000_000 * max(0.0, work_period_seconds) / num_intervals)
978 assert interval_nanos >= 0
979 if jitter: # randomize job start time to avoid potential thundering herd problems in large distributed systems
980 sleep_nanos = random.SystemRandom().randint(0, interval_nanos)
981 log.info("Jitter: Delaying job start time by sleeping for %s ...", human_readable_duration(sleep_nanos))
982 self.timing.sleep(sleep_nanos) # allows early wakeup on async termination
983 sorted_subjobs: list[str] = sorted(subjobs.keys())
984 spawn_process_per_job: bool = self.spawn_process_per_job
985 log.log(LOG_TRACE, "%s: %s", "spawn_process_per_job", spawn_process_per_job)
986 if process_datasets_in_parallel_and_fault_tolerant(
987 log=log,
988 datasets=sorted_subjobs,
989 process_dataset=lambda subjob, tid, retry: self.run_subjob(
990 subjobs[subjob], name=subjob, timeout_secs=timeout_secs, spawn_process_per_job=spawn_process_per_job
991 )
992 == 0,
993 priority=lambda dataset: dataset.count("/"), # start runnable jobs sorted by tree depth aka breadth-first order
994 skip_tree_on_error=lambda subjob: True,
995 skip_on_error="dataset",
996 max_workers=max_workers,
997 interval_nanos=lambda last_update_nanos, dataset, submit_count: interval_nanos,
998 timing=self.timing,
999 termination_handler=self.subprocesses.terminate_process_subtrees,
1000 task_name="Subjob",
1001 dry_run=False,
1002 is_test_mode=self.is_test_mode,
1003 ):
1004 self.first_exception = DIE_STATUS if self.first_exception is None else self.first_exception
1005 self.worst_exception = self.get_worst_exception(self.worst_exception, DIE_STATUS)
1006 stats = self.stats
1007 jobs_skipped = stats.jobs_all - stats.jobs_started
1008 msg = f"{stats}, skipped:" + percent(jobs_skipped, total=stats.jobs_all, print_total=True)
1009 log.info("Final Progress: %s", msg)
1010 assert stats.jobs_running == 0, msg
1011 assert stats.jobs_completed == stats.jobs_started, msg
1012 skipped_jobs_dict = {subjob: subjobs[subjob] for subjob in sorted_subjobs if subjob not in stats.started_job_names}
1013 if len(skipped_jobs_dict) > 0:
1014 log.debug("Skipped subjobs: \n%s", _pretty_print_formatter(skipped_jobs_dict))
1015 assert jobs_skipped == len(skipped_jobs_dict), msg
1017 def run_subjob(
1018 self, cmd: list[str], name: str, timeout_secs: float | None, spawn_process_per_job: bool
1019 ) -> int | None: # thread-safe
1020 """Executes one worker job and updates shared Stats."""
1021 start_time_nanos = time.monotonic_ns()
1022 returncode = None
1023 log = self.log
1024 cmd_str = " ".join(cmd)
1025 stats = self.stats
1026 try:
1027 msg: str = stats.submit_job(name)
1028 log.log(LOG_TRACE, "Starting worker job: %s", cmd_str)
1029 log.info("Progress: %s", msg)
1030 start_time_nanos = time.monotonic_ns()
1031 if spawn_process_per_job:
1032 returncode = self.run_worker_job_spawn_process_per_job(cmd, timeout_secs)
1033 else:
1034 returncode = self.run_worker_job_in_current_thread(cmd, timeout_secs)
1035 except BaseException as e:
1036 log.error("Worker job failed with unexpected exception: %s for command: %s", e, cmd_str)
1037 raise
1038 else:
1039 elapsed_human: str = human_readable_duration(time.monotonic_ns() - start_time_nanos)
1040 if returncode != 0:
1041 with stats.lock:
1042 if self.first_exception is None:
1043 self.first_exception = DIE_STATUS if returncode is None else returncode
1044 self.worst_exception = self.get_worst_exception(self.worst_exception, returncode)
1045 log.error("Worker job failed with exit code %s in %s: %s", returncode, elapsed_human, cmd_str)
1046 else:
1047 log.debug("Worker job succeeded in %s: %s", elapsed_human, cmd_str)
1048 return returncode
1049 finally:
1050 msg = stats.complete_job(failed=returncode != 0, elapsed_nanos=time.monotonic_ns() - start_time_nanos)
1051 log.info("Progress: %s", msg)
1053 def run_worker_job_in_current_thread(self, cmd: list[str], timeout_secs: float | None) -> int | None:
1054 """Runs ``bzfs`` in-process and return its exit code."""
1055 log = self.log
1056 if timeout_secs is not None:
1057 cmd = cmd[0:1] + [f"--timeout={round(1000 * timeout_secs)}milliseconds"] + cmd[1:]
1058 try:
1059 if not self.jobrunner_dryrun:
1060 self._bzfs_run_main(cmd)
1061 return 0
1062 except subprocess.CalledProcessError as e:
1063 return bzfs.normalize_called_process_error(e)
1064 except SystemExit as e:
1065 assert e.code is None or isinstance(e.code, int)
1066 return e.code
1067 except BaseException:
1068 log.exception("Worker job failed with unexpected exception for command: %s", " ".join(cmd))
1069 return DIE_STATUS
1071 def _bzfs_run_main(self, cmd: list[str]) -> None:
1072 """Delegates execution to :mod:`bzfs` using parsed arguments."""
1073 bzfs_job = bzfs.Job(termination_event=self.termination_event)
1074 bzfs_job.is_test_mode = self.is_test_mode
1075 bzfs_job.run_main(bzfs.argument_parser().parse_args(cmd[1:]), cmd)
1077 def run_worker_job_spawn_process_per_job(self, cmd: list[str], timeout_secs: float | None) -> int | None:
1078 """Spawns a subprocess for the worker job and waits for completion."""
1079 log = self.log
1080 if len(cmd) > 0 and cmd[0] == BZFS_PROG_NAME:
1081 cmd = [sys.executable, "-m", "bzfs_main." + cmd[0]] + cmd[1:]
1082 if self.jobrunner_dryrun:
1083 return 0
1085 with self.subprocesses.popen_and_track(cmd, stdin=subprocess.DEVNULL, text=True) as proc:
1086 try:
1087 if self.termination_event.is_set():
1088 timeout_secs = 1.0 if timeout_secs is None else timeout_secs
1089 raise subprocess.TimeoutExpired(cmd, timeout_secs) # do not wait for normal completion
1090 proc.communicate(timeout=timeout_secs) # Wait for the subprocess to complete and exit normally
1091 except subprocess.TimeoutExpired:
1092 cmd_str = " ".join(cmd)
1093 if self.termination_event.is_set():
1094 log.error("%s", f"Terminating worker job due to async termination request: {cmd_str}")
1095 else:
1096 log.error("%s", f"Terminating worker job as it failed to complete within {timeout_secs}s: {cmd_str}")
1097 proc.terminate() # Sends SIGTERM signal to job subprocess
1098 assert timeout_secs is not None
1099 timeout_secs = min(1.0, timeout_secs)
1100 try:
1101 proc.communicate(timeout=timeout_secs) # Wait for the subprocess to exit
1102 except subprocess.TimeoutExpired:
1103 log.error("%s", f"Killing worker job as it failed to terminate within {timeout_secs}s: {cmd_str}")
1104 terminate_process_subtree(root_pids=[proc.pid]) # Send SIGTERM to process subtree
1105 proc.kill() # Sends SIGKILL signal to job subprocess because SIGTERM wasn't enough
1106 timeout_secs = min(0.025, timeout_secs)
1107 with contextlib.suppress(subprocess.TimeoutExpired):
1108 proc.communicate(timeout=timeout_secs) # Wait for the subprocess to exit
1109 return proc.returncode
1111 @staticmethod
1112 def get_worst_exception(existing_code: int | None, new_code: int | None) -> int:
1113 """Process exit code precedence: fatal/non-monitor failure > monitor CRITICAL/WARNING > STILL_RUNNING > success."""
1114 new_code = DIE_STATUS if new_code is None else new_code
1115 if existing_code is None:
1116 return new_code
1117 assert existing_code is not None
1118 assert new_code is not None
1120 nonfatal: tuple[int, ...] = (0, bzfs.WARNING_STATUS, bzfs.CRITICAL_STATUS, bzfs.STILL_RUNNING_STATUS)
1121 existing_is_fatal = existing_code not in nonfatal
1122 new_is_fatal = new_code not in nonfatal
1123 if existing_is_fatal and new_is_fatal:
1124 return max(existing_code, new_code)
1125 if existing_is_fatal or new_is_fatal:
1126 return existing_code if existing_is_fatal else new_code
1128 monitor: tuple[int, ...] = (bzfs.WARNING_STATUS, bzfs.CRITICAL_STATUS)
1129 existing_is_monitor = existing_code in monitor
1130 new_is_monitor = new_code in monitor
1131 if existing_is_monitor and new_is_monitor:
1132 return max(existing_code, new_code)
1133 if existing_is_monitor or new_is_monitor:
1134 return existing_code if existing_is_monitor else new_code
1136 assert existing_code in (0, bzfs.STILL_RUNNING_STATUS), existing_code
1137 assert new_code in (0, bzfs.STILL_RUNNING_STATUS), new_code
1138 return max(existing_code, new_code)
1140 def validate_src_hosts(self, src_hosts: list[str]) -> list[str]:
1141 """Checks ``src_hosts`` contains valid hostnames."""
1142 context = "--src-hosts"
1143 self.validate_type(src_hosts, list, context)
1144 for src_hostname in src_hosts:
1145 self.validate_host_name(src_hostname, context)
1146 return src_hosts
1148 def validate_dst_hosts(self, dst_hosts: dict[str, list[str]]) -> dict[str, list[str]]:
1149 """Checks destination hosts dictionary."""
1150 context = "--dst-hosts"
1151 self.validate_type(dst_hosts, dict, context)
1152 for dst_hostname, targets in dst_hosts.items():
1153 self.validate_host_name(dst_hostname, context)
1154 self.validate_type(targets, list, f"{context} targets")
1155 for target in targets:
1156 self.validate_type(target, str, f"{context} target")
1157 return dst_hosts
1159 def validate_dst_root_datasets(self, dst_root_datasets: dict[str, str]) -> dict[str, str]:
1160 """Checks that each destination root dataset string is valid."""
1161 context = "--dst-root-datasets"
1162 self.validate_type(dst_root_datasets, dict, context)
1163 for dst_hostname, dst_root_dataset in dst_root_datasets.items():
1164 self.validate_host_name(dst_hostname, context)
1165 self.validate_type(dst_root_dataset, str, f"{context} root dataset")
1166 return dst_root_datasets
1168 def validate_snapshot_plan(
1169 self, snapshot_plan: dict[str, dict[str, dict[str, int]]], context: str
1170 ) -> dict[str, dict[str, dict[str, int]]]:
1171 """Checks snapshot plan structure and value types."""
1172 self.validate_type(snapshot_plan, dict, context)
1173 for org, target_periods in snapshot_plan.items():
1174 self.validate_type(org, str, f"{context} org")
1175 self.validate_type(target_periods, dict, f"{context} target_periods")
1176 for target, periods in target_periods.items():
1177 self.validate_type(target, str, f"{context} org/target")
1178 self.validate_type(periods, dict, f"{context} org/periods")
1179 for period_unit, period_amount in periods.items():
1180 self.validate_non_empty_string(period_unit, f"{context} org/target/period_unit")
1181 self.validate_non_negative_int(period_amount, f"{context} org/target/period_amount")
1182 return snapshot_plan
1184 def validate_monitor_snapshot_plan(
1185 self, monitor_snapshot_plan: dict[str, dict[str, dict[str, dict[str, str | int]]]]
1186 ) -> dict[str, dict[str, dict[str, dict[str, str | int]]]]:
1187 """Checks snapshot monitoring plan configuration."""
1188 context = "--monitor-snapshot-plan"
1189 self.validate_type(monitor_snapshot_plan, dict, context)
1190 for org, target_periods in monitor_snapshot_plan.items():
1191 self.validate_type(org, str, f"{context} org")
1192 self.validate_type(target_periods, dict, f"{context} target_periods")
1193 for target, periods in target_periods.items():
1194 self.validate_type(target, str, f"{context} org/target")
1195 self.validate_type(periods, dict, f"{context} org/periods")
1196 for period_unit, alert_dict in periods.items():
1197 self.validate_non_empty_string(period_unit, f"{context} org/target/period_unit")
1198 self.validate_type(alert_dict, dict, f"{context} org/target/alert_dict")
1199 for key, value in alert_dict.items():
1200 self.validate_non_empty_string(key, f"{context} org/target/alert_dict/key")
1201 self.validate_type(value, Union[str, int], f"{context} org/target/alert_dict/value")
1202 return monitor_snapshot_plan
1204 def validate_is_subset(self, x: Iterable[str], y: Iterable[str], x_name: str, y_name: str) -> None:
1205 """Raises error if ``x`` contains an item not present in ``y``."""
1206 if isinstance(x, str) or not isinstance(x, Iterable):
1207 self.die(f"{x_name} must be an Iterable")
1208 if isinstance(y, str) or not isinstance(y, Iterable):
1209 self.die(f"{y_name} must be an Iterable")
1210 if not set(x).issubset(set(y)):
1211 diff = sorted(set(x).difference(set(y)))
1212 self.die(f"{x_name} must be a subset of {y_name}. diff: {diff}, {x_name}: {sorted(x)}, {y_name}: {sorted(y)}")
1214 def validate_host_name(self, hostname: str, context: str) -> None:
1215 """Checks host name string."""
1216 self.validate_non_empty_string(hostname, f"{context} hostname")
1217 bzfs.validate_host_name(hostname, context)
1219 def validate_non_empty_string(self, value: str, name: str) -> None:
1220 """Checks that ``value`` is a non-empty string."""
1221 self.validate_type(value, str, name)
1222 if not value:
1223 self.die(f"{name} must not be empty!")
1225 def validate_non_negative_int(self, value: int, name: str) -> None:
1226 """Checks ``value`` is an int >= 0."""
1227 self.validate_type(value, int, name)
1228 if value < 0:
1229 self.die(f"{name} must be a non-negative integer: {value}")
1231 def validate_true(self, expr: Any, msg: str) -> None:
1232 """Raises error if ``expr`` evaluates to ``False``."""
1233 if not bool(expr):
1234 self.die(msg)
1236 def validate_type(self, value: Any, expected_type: Any, name: str) -> None:
1237 """Checks ``value`` is instance of ``expected_type`` or union thereof."""
1238 if hasattr(expected_type, "__origin__") and expected_type.__origin__ is Union: # for compat with python < 3.10
1239 union_types = expected_type.__args__
1240 for t in union_types:
1241 if isinstance(value, t):
1242 return
1243 type_msg = " or ".join([t.__name__ for t in union_types])
1244 self.die(f"{name} must be of type {type_msg} but got {type(value).__name__}: {value}")
1245 elif not isinstance(value, expected_type):
1246 self.die(f"{name} must be of type {expected_type.__name__} but got {type(value).__name__}: {value}")
1248 def parse_src_hosts_from_cli_or_stdin(self, raw_src_hosts: str | None) -> list:
1249 """Resolve --src-hosts from CLI or stdin with robust TTY/empty handling."""
1250 if raw_src_hosts is None:
1251 # If stdin is an interactive TTY, don't block waiting for input; fail clearly instead
1252 try:
1253 is_tty: bool = getattr(sys.stdin, "isatty", lambda: False)()
1254 except Exception:
1255 is_tty = False
1256 if is_tty:
1257 self.die("Missing --src-hosts and stdin is a TTY. Provide --src-hosts or pipe the list on stdin.")
1258 stdin_text: str = sys.stdin.read()
1259 if not stdin_text.strip(): # avoid literal_eval("") SyntaxError and provide a clear message
1260 self.die("Missing --src-hosts and stdin is empty. Provide --src-hosts or pipe a list on stdin.")
1261 raw_src_hosts = stdin_text
1262 try:
1263 value = literal_eval(raw_src_hosts)
1264 except Exception as e:
1265 self.die(f"Invalid --src-hosts format: {e} for input: {raw_src_hosts}")
1266 if not isinstance(value, list):
1267 example: str = format_obj(["hostname1", "hostname2"])
1268 self.die(f"Invalid --src-hosts: expected a Python list literal, e.g. {example} but got: {format_obj(value)}")
1269 return value
1271 def die(self, msg: str) -> NoReturn:
1272 """Log ``msg`` and exit the program."""
1273 self.log.error("%s", msg)
1274 utils.die(msg)
1276 def get_localhost_ips(self) -> set[str]:
1277 """Returns all network addresses of the local host, i.e. all configured addresses on all network interfaces, without
1278 depending on name resolution."""
1279 ips: set[str] = set()
1280 if platform.system() == "Linux":
1281 try:
1282 proc = subprocess.run(["hostname", "-I"], stdin=DEVNULL, stdout=PIPE, text=True, check=True) # noqa: S607
1283 except Exception as e:
1284 self.log.warning("Cannot run 'hostname -I' on localhost: %s", e)
1285 else:
1286 ips = {ip for ip in proc.stdout.strip().split() if ip}
1287 self.log.log(LOG_TRACE, "localhost_ips: %s", sorted(ips))
1288 return ips
1291#############################################################################
1292@final
1293class RejectArgumentAction(argparse.Action):
1294 """An argparse Action that immediately fails if it is ever triggered."""
1296 def __call__(
1297 self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Any, option_string: str | None = None
1298 ) -> None:
1299 """Abort argument parsing if a protected option is seen."""
1300 parser.error(f"Security: Overriding protected argument '{option_string}' is not allowed.")
1303#############################################################################
1304def _dedupe(root_dataset_pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
1305 """Returns a list with duplicate dataset pairs removed while preserving order."""
1306 return list(dict.fromkeys(root_dataset_pairs))
1309_T = TypeVar("_T")
1312def _flatten(root_dataset_pairs: Iterable[Iterable[_T]]) -> list[_T]:
1313 """Flattens an iterable of pairs into a single list."""
1314 return [item for pair in root_dataset_pairs for item in pair]
1317def _sanitize(filename: str) -> str:
1318 """Replaces potentially problematic characters in ``filename`` with '!'."""
1319 for s in (" ", "..", "/", "\\", SEP):
1320 filename = filename.replace(s, "!")
1321 return filename
1324def _log_suffix(localhostname: str, src_hostname: str, dst_hostname: str) -> str:
1325 """Returns a log file suffix in a format that contains the given hostnames."""
1326 return f"{SEP}{_sanitize(localhostname)}{SEP}{_sanitize(src_hostname)}{SEP}{_sanitize(dst_hostname)}"
1329def _pretty_print_formatter(dictionary: dict[str, Any]) -> Any:
1330 """Lazy JSON formatter used to avoid overhead in disabled log levels."""
1332 @final
1333 class PrettyPrintFormatter:
1334 """Wrapper returning formatted JSON on ``str`` conversion."""
1336 def __str__(self) -> str:
1337 import json
1339 return json.dumps(dictionary, indent=4, sort_keys=True)
1341 return PrettyPrintFormatter()
1344def _detect_loopback_address() -> str:
1345 """Detects if a loopback connection over IPv4 or IPv6 is possible."""
1346 try:
1347 addr = "127.0.0.1"
1348 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1349 s.bind((addr, 0))
1350 return addr
1351 except OSError:
1352 pass
1354 try:
1355 addr = "::1"
1356 with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
1357 s.bind((addr, 0))
1358 return addr
1359 except OSError:
1360 pass
1362 return ""
1365def convert_ipv6(hostname: str) -> str:
1366 """Supports IPv6 without getting confused by host:dataset colon separator and any colons that may be part of a (valid)
1367 ZFS dataset name."""
1368 return hostname.replace(":", "|") # Also see bzfs.convert_ipv6() for the reverse conversion
1371#############################################################################
1372if __name__ == "__main__": 1372 ↛ 1373line 1372 didn't jump to line 1373 because the condition on line 1372 was never true
1373 main()