Coverage for bzfs_main / argparse_cli.py: 100%

171 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-29 12:49 +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"""Documentation, definition of input data and ArgumentParser used by the 'bzfs' CLI.""" 

16 

17from __future__ import ( 

18 annotations, 

19) 

20import argparse 

21import dataclasses 

22import itertools 

23from typing import ( 

24 Final, 

25) 

26 

27from bzfs_main.argparse_actions import ( 

28 CheckPercentRange, 

29 DatasetPairsAction, 

30 DeleteDstSnapshotsExceptPlanAction, 

31 FileOrLiteralAction, 

32 IncludeSnapshotPlanAction, 

33 NewSnapshotFilterGroupAction, 

34 NonEmptyStringAction, 

35 SafeDirectoryNameAction, 

36 SafeFileNameAction, 

37 SSHConfigFileNameAction, 

38 TimeRangeAndRankRangeAction, 

39) 

40from bzfs_main.detect import ( 

41 DISABLE_PRG, 

42 DUMMY_DATASET, 

43) 

44from bzfs_main.period_anchors import ( 

45 PeriodAnchors, 

46) 

47from bzfs_main.util.check_range import ( 

48 CheckRange, 

49) 

50from bzfs_main.util.utils import ( 

51 ENV_VAR_PREFIX, 

52 PROG_NAME, 

53 format_dict, 

54) 

55 

56# constants: 

57__version__: Final[str] = "1.21.0.dev0" 

58PROG_AUTHOR: Final[str] = "Wolfgang Hoschek" 

59EXCLUDE_DATASET_REGEXES_DEFAULT: Final[str] = r"(?:.*/)?[Tt][Ee]?[Mm][Pp][-_]?[0-9]*" # skip tmp datasets by default 

60LOG_DIR_DEFAULT: Final[str] = PROG_NAME + "-logs" 

61SKIP_ON_ERROR_DEFAULT: Final[str] = "dataset" 

62CMP_CHOICES_ITEMS: Final[tuple[str, str, str]] = ("src", "dst", "all") 

63ZFS_RECV_O: Final[str] = "zfs_recv_o" 

64ZFS_RECV_X: Final[str] = "zfs_recv_x" 

65ZFS_RECV_GROUPS: Final[dict[str, str]] = {ZFS_RECV_O: "-o", ZFS_RECV_X: "-x", "zfs_set": ""} 

66ZFS_RECV_O_INCLUDE_REGEX_DEFAULT: Final[str] = "|".join( 

67 [ 

68 "aclinherit", 

69 "aclmode", 

70 "acltype", 

71 "atime", 

72 "checksum", 

73 "compression", 

74 "copies", 

75 "logbias", 

76 "primarycache", 

77 "recordsize", 

78 "redundant_metadata", 

79 "relatime", 

80 "secondarycache", 

81 "snapdir", 

82 "sync", 

83 "xattr", 

84 ] 

85) 

86 

87 

88def argument_parser() -> argparse.ArgumentParser: 

89 """Returns the CLI parser used by bzfs.""" 

90 create_src_snapshots_plan_example1: str = str({"test": {"": {"adhoc": 1}}}).replace(" ", "") 

91 create_src_snapshots_plan_example2: str = str({"prod": {"us-west": {"minutely": 40, "hourly": 36, "daily": 31}}}) 

92 create_src_snapshots_plan_example2 = create_src_snapshots_plan_example2.replace(" ", "") 

93 delete_dst_snapshots_except_plan_example1: str = str( 

94 { 

95 "prod": { 

96 "us-west": { 

97 "secondly": 40, 

98 "minutely": 40, 

99 "hourly": 36, 

100 "daily": 31, 

101 "weekly": 12, 

102 "monthly": 18, 

103 "yearly": 5, 

104 } 

105 } 

106 } 

107 ).replace(" ", "") 

108 monitor_snapshots_example: str = str( 

109 { 

110 "prod": { 

111 "us-west": { 

112 "minutely": {"latest": {"warning": "30 seconds", "critical": "300 seconds"}}, 

113 "hourly": {"latest": {"warning": "30 minutes", "critical": "300 minutes"}}, 

114 "daily": {"latest": {"warning": "4 hours", "critical": "8 hours"}}, 

115 }, 

116 }, 

117 } 

118 ).replace(" ", "") 

119 

120 # fmt: off 

121 parser: argparse.ArgumentParser = argparse.ArgumentParser( 

122 prog=PROG_NAME, 

123 allow_abbrev=False, 

124 formatter_class=argparse.RawTextHelpFormatter, 

125 description=f""" 

126On the first run, {PROG_NAME} replicates the source dataset and all its snapshots to the destination. 

127On subsequent runs, it sends only changes since the previous run by incrementally replicating snapshots 

128created on the source after that run. Source snapshots older than the most recent common snapshot 

129on the destination are skipped automatically. 

130 

131Unless {PROG_NAME} is told to create snapshots on the source, it treats the source as read-only. With 

132`--dryrun`, it also treats the destination as read-only. In normal operation, the destination is 

133append-only. Optional flags can delete destination snapshots and datasets if you want to manage storage 

134space consumption, reconcile divergence, restore production from backup, or resync backup from production. 

135 

136{PROG_NAME} supports include/exclude filters that can be combined to choose which datasets, 

137snapshots, and properties to create, replicate, delete, or compare. 

138 

139A common setup uses scheduled `cron` jobs: one to create and prune source snapshots, one to prune 

140destination snapshots, and one to replicate recently created snapshots from source to destination. 

141Schedules can run every N milliseconds, seconds, minutes, hours, days, weeks, months, or years. 

142 

143Snapshot creation, replication, pruning, monitoring, and comparison work with snapshots in any naming 

144format, including snapshots created by third-party tools or by manual zfs snapshot commands. 

145These functions can also be used independently. 

146 

147The source can push to the destination, and the destination can pull from the source. {PROG_NAME} 

148runs on the initiator host, which can be the source host (push mode), destination host (pull mode), 

149same host (local mode, no network, no ssh), or a third-party host that can SSH 

150into source and destination (pull-push mode). In pull-push mode, the source `zfs send` stream is 

151relayed by the initiator to the destination `zfs receive`, without storing anything locally. 

152For bulk data transfers, remote-to-remote mode (`--r2r=pull` or `--r2r=push`) can instead transfer the 

153stream directly between source and destination to avoid making the initiator a bandwidth bottleneck. In 

154this mode, {PROG_NAME} does not need to be installed on source or destination; only the `zfs` CLI is 

155required there. {PROG_NAME} can run as root or as a non-root user via sudo or delegated `zfs allow` 

156permissions. 

157 

158{PROG_NAME} is written in Python and continuously tested with unit and integration tests on old and 

159new ZFS versions, on multiple Linux and FreeBSD versions, and on all Python versions >= 3.9 (including 

160latest stable, currently python-3.14). 

161 

162{PROG_NAME} is a stand-alone program with zero required dependencies. It is meant to run in restricted 

163barebones server environments. No external Python packages are required; indeed no Python package 

164management at all is required. You can symlink the program wherever you like, such as /usr/local/bin, 

165and run it like a shell script or binary executable. 

166 

167{PROG_NAME} replicates snapshots for multiple datasets in parallel. It also deletes (or monitors or 

168compares) snapshots of multiple datasets in parallel. Atomic snapshots can be created as often as 

169every N milliseconds. 

170 

171Replication progress (e.g. throughput and ETA) is shown aggregated across parallel replication tasks. 

172Example console status line: 

173 

174`2025-01-17 01:23:04 [I] zfs sent 41.7 GiB 0:00:46 [963 MiB/s] [907 MiB/s] 80% ETA 0:00:04 ETA 01:23:08` 

175 

176{PROG_NAME} uses streaming algorithms to process millions of datasets with low memory usage and low latency. 

177It handles replication policies with multiple sources and multiple destinations per source. 

178 

179Optionally, {PROG_NAME} applies bandwidth rate limiting and progress monitoring (via `pv`) during 

180`zfs send/receive` transfers. Over the network, it can insert lightweight compression (via `zstd`) 

181and buffering (via `mbuffer`) between endpoints. If one of these tools is not installed, {PROG_NAME} 

182auto-detects that and continues without that auxiliary feature. 

183 

184# Periodic Jobs with bzfs_jobrunner 

185 

186The project also ships with [bzfs_jobrunner](README_bzfs_jobrunner.md), a companion program that wraps 

187`{PROG_NAME}` for periodic snapshot creation, replication, pruning, and monitoring across N source hosts 

188and M destination hosts, using one shared fleet-wide [jobconfig](bzfs_testbed/bzfs_job_testbed.py) 

189script. 

190 

191Typical use cases include geo-replicated backup where each destination host is in a different region 

192and receives replicas from the same set of source hosts, low-latency replication from a primary to a 

193secondary or to M read replicas, and backups to removable drives. 

194 

195# Quickstart 

196 

197* Create adhoc atomic snapshots without a schedule: 

198 

199```$ {PROG_NAME} tank1/foo/bar dummy --recursive --skip-replication --create-src-snapshots 

200--create-src-snapshots-plan "{create_src_snapshots_plan_example1}"``` 

201 

202```$ zfs list -t snapshot tank1/foo/bar 

203 

204tank1/foo/bar@test_2024-11-06_08:30:05_adhoc``` 

205 

206* Create periodic atomic snapshots on a schedule, every minute, every hour and every day, by launching this from a periodic `cron` job: 

207 

208```$ {PROG_NAME} tank1/foo/bar dummy --recursive --skip-replication --create-src-snapshots 

209--create-src-snapshots-plan "{create_src_snapshots_plan_example2}"``` 

210 

211```$ zfs list -t snapshot tank1/foo/bar 

212 

213tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_daily 

214 

215tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_hourly 

216 

217tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_minutely``` 

218 

219Note: A periodic snapshot is created if it is due per the schedule indicated by its suffix (e.g. `_daily` or `_hourly` 

220or `_minutely` or `_2secondly` or `_100millisecondly`), or if the --create-src-snapshots-even-if-not-due flag is specified, 

221or if the most recent scheduled snapshot is somehow missing. In the latter case {PROG_NAME} immediately creates a snapshot 

222(named with the current time, not backdated to the missed time), and then resumes the original schedule. If the suffix is 

223`_adhoc` or not a known period then a snapshot is considered non-periodic and is thus created immediately regardless of the 

224creation time of any existing snapshot. 

225 

226* Replication example in local mode (no network, no ssh), to replicate ZFS dataset tank1/foo/bar to tank2/boo/bar: 

227 

228```$ {PROG_NAME} tank1/foo/bar tank2/boo/bar``` 

229 

230```$ zfs list -t snapshot tank1/foo/bar 

231 

232tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_daily 

233 

234tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_hourly 

235 

236tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_minutely``` 

237 

238```$ zfs list -t snapshot tank2/boo/bar 

239 

240tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_daily 

241 

242tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_hourly 

243 

244tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_minutely``` 

245 

246* Same example in pull mode: 

247 

248```$ {PROG_NAME} root@host1.example.com:tank1/foo/bar tank2/boo/bar``` 

249 

250* Same example in push mode: 

251 

252```$ {PROG_NAME} tank1/foo/bar root@host2.example.com:tank2/boo/bar``` 

253 

254* Same example in pull-push mode: 

255 

256```$ {PROG_NAME} root@host1:tank1/foo/bar root@host2:tank2/boo/bar``` 

257 

258* Same example in pull-push mode with remote-to-remote transfer via `--r2r=pull`: 

259 

260```$ {PROG_NAME} --r2r=pull root@host1:tank1/foo/bar root@host2:tank2/boo/bar``` 

261 

262* Same example in pull-push mode with remote-to-remote transfer via `--r2r=push`: 

263 

264```$ {PROG_NAME} --r2r=push root@host1:tank1/foo/bar root@host2:tank2/boo/bar``` 

265 

266* Example in local mode (no network, no ssh) to recursively replicate ZFS dataset tank1/foo/bar and its descendant 

267datasets to tank2/boo/bar: 

268 

269```$ {PROG_NAME} --recursive tank1/foo/bar tank2/boo/bar``` 

270 

271```$ zfs list -t snapshot -r tank1/foo/bar 

272 

273tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_daily 

274 

275tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_hourly 

276 

277tank1/foo/bar@prod_us-west_2024-11-06_08:30:05_minutely 

278 

279tank1/foo/bar/baz@prod_us-west_2024-11-06_08:40:00_daily 

280 

281tank1/foo/bar/baz@prod_us-west_2024-11-06_08:40:00_hourly 

282 

283tank1/foo/bar/baz@prod_us-west_2024-11-06_08:40:00_minutely``` 

284 

285```$ zfs list -t snapshot -r tank2/boo/bar 

286 

287tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_daily 

288 

289tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_hourly 

290 

291tank2/boo/bar@prod_us-west_2024-11-06_08:30:05_minutely 

292 

293tank2/boo/bar/baz@prod_us-west_2024-11-06_08:40:00_daily 

294 

295tank2/boo/bar/baz@prod_us-west_2024-11-06_08:40:00_hourly 

296 

297tank2/boo/bar/baz@prod_us-west_2024-11-06_08:40:00_minutely``` 

298 

299* Replicate all daily snapshots created during the last 31 days, and at the same time ensure that the latest 31 daily 

300snapshots (per dataset) are replicated regardless of creation time. Same for 40 minutely snapshots, and 36 hourly 

301snapshots: 

302 

303```$ {PROG_NAME} tank1/foo/bar tank2/boo/bar --recursive --include-snapshot-plan "{create_src_snapshots_plan_example2}"``` 

304 

305Note: The example above compares the specified times against the standard ZFS 'creation' time property of the snapshots 

306(which is a UTC Unix time in integer seconds), rather than against a timestamp that may be part of the snapshot name. 

307 

308* Retain all secondly snapshots that were created less than 40 seconds ago, and ensure that the latest 40 

309secondly snapshots (per dataset) are retained regardless of creation time. Same for 40 minutely snapshots, 36 hourly 

310snapshots, 31 daily snapshots, 12 weekly snapshots, 18 monthly snapshots, and 5 yearly snapshots: 

311 

312```$ {PROG_NAME} {DUMMY_DATASET} tank2/boo/bar --dryrun --recursive --skip-replication --delete-dst-snapshots 

313--delete-dst-snapshots-except-plan "{delete_dst_snapshots_except_plan_example1}"``` 

314 

315Note: This also prints how many GB of disk space in total would be freed if the command were to be run for real without 

316the --dryrun flag. 

317 

318* Compare source and destination dataset trees recursively, for example to check if all recently taken snapshots have 

319been successfully replicated by a periodic job. List snapshots only contained in src (tagged with 'src'), 

320only contained in dst (tagged with 'dst'), and contained in both src and dst (tagged with 'all'), restricted to hourly 

321and daily snapshots taken within the last 7 days, excluding the last 4 hours (to allow for some slack/stragglers), 

322excluding temporary datasets: 

323 

324```$ {PROG_NAME} tank1/foo/bar tank2/boo/bar --skip-replication --compare-snapshot-lists --recursive 

325--include-snapshot-regex '.*_(hourly|daily)' --include-snapshot-times-and-ranks '7 days ago..4 hours ago' 

326--exclude-dataset-regex '(.*/)?tmp.*'``` 

327 

328If the resulting TSV output file contains zero lines starting with the prefix 'src' and zero lines starting with the 

329prefix 'dst' then no source snapshots are missing on the destination, and no destination snapshots are missing 

330on the source, indicating that the periodic replication and pruning jobs perform as expected. The TSV output is sorted 

331by dataset, and by ZFS creation time within each dataset - the first and last line prefixed with 'all' contains the 

332metadata of the oldest and latest common snapshot, respectively. The --compare-snapshot-lists option also directly 

333logs [various summary stats](https://github.com/whoschek/bzfs/blob/main/bzfs_docs/compare-snapshot-lists-example.log), 

334such as the metadata of the latest common snapshot, latest snapshots and oldest snapshots, as well as the time diff 

335between the latest common snapshot and latest snapshot only in src (and only in dst), as well as how many src snapshots 

336and how many GB of data are missing on dst, etc. 

337 

338* Alert the user if the ZFS 'creation' time property of the latest source or destination snapshot for any specified 

339snapshot name pattern within the selected datasets is too old wrt. the specified age limit. The purpose is to check if 

340snapshots are successfully created and replicated on schedule. 

341Process exit code is 0, 1, 2 on OK, WARNING, CRITICAL, respectively. 

342The example alerts the user if the *latest* source or destination snapshot named `prod_us-west_<timestamp>_hourly` is 

343more than 30 minutes late (i.e. more than 30+60=90 minutes old) [warning], or more than 300 minutes late (i.e. more 

344than 300+60=360 minutes old) [critical]. Analog for minutely and daily snapshots: 

345 

346```$ {PROG_NAME} tank1/foo/bar tank2/boo/bar --recursive --skip-replication -v --monitor-snapshots \ 

347"{monitor_snapshots_example}"``` 

348 

349* Example that makes destination identical to source even if the two have drastically diverged: 

350 

351```$ {PROG_NAME} tank1/foo/bar tank2/boo/bar --dryrun --recursive --force --delete-dst-datasets --delete-dst-snapshots``` 

352 

353""") 

354 

355 parser.add_argument( 

356 "--no-argument-file", action="store_true", 

357 # help="Disable support for reading the names of datasets and snapshots from a file.\n\n") 

358 help=argparse.SUPPRESS) 

359 parser.add_argument( 

360 "root_dataset_pairs", nargs="+", action=DatasetPairsAction, metavar="SRC_DATASET DST_DATASET", 

361 help="SRC_DATASET: " 

362 "Source ZFS dataset (and its descendants) that will be replicated. Can be a ZFS filesystem or ZFS volume. " 

363 "Format is [[user@]host:]dataset. The host name can also be an IPv4 address (or an IPv6 address where " 

364 "each ':' colon character must be replaced with a '|' pipe character for disambiguation). If the " 

365 "host name is '-', the dataset will be on the local host, and the corresponding SSH leg will be omitted. " 

366 "The same is true if the host is omitted and the dataset does not contain a ':' colon at the same time. " 

367 "Local dataset examples: `tank1/foo/bar`, `tank1`, `-:tank1/foo/bar:baz:boo` " 

368 "Remote dataset examples: `host:tank1/foo/bar`, `host.example.com:tank1/foo/bar`, " 

369 "`root@host:tank`, `root@host.example.com:tank1/foo/bar`, `user@127.0.0.1:tank1/foo/bar:baz:boo`, " 

370 "`user@||1:tank1/foo/bar:baz:boo`. " 

371 "The first component of the ZFS dataset name is the ZFS pool name, here `tank1`. " 

372 "If the option starts with a `+` prefix then dataset names are read from the UTF-8 text file given " 

373 "after the `+` prefix, with each line in the file containing a SRC_DATASET and a DST_DATASET, " 

374 "separated by a tab character. The basename must contain the substring 'bzfs_argument_file'. " 

375 "Example: `+root_dataset_names_bzfs_argument_file.txt`, " 

376 "`+/path/to/root_dataset_names_bzfs_argument_file.txt`\n\n" 

377 "DST_DATASET: " 

378 "Destination ZFS dataset for replication and deletion. Has same naming format as SRC_DATASET. During " 

379 "replication, destination datasets that do not yet exist are created as necessary, along with their " 

380 "parent and ancestors.\n\n" 

381 f"*Performance Note:* {PROG_NAME} automatically replicates multiple datasets in parallel. It replicates " 

382 "snapshots in parallel across datasets and serially within a dataset. All child datasets of a dataset " 

383 "may be processed in parallel. For consistency, processing of a dataset only starts after processing of " 

384 "all its ancestor datasets has completed. Further, when a thread is ready to start processing another " 

385 "dataset, it chooses the next dataset wrt. lexicographical sort order from the datasets that are " 

386 "currently available for start of processing. Initially, only the roots of the selected dataset subtrees " 

387 "are available for start of processing. The degree of parallelism is configurable with the --threads " 

388 "option (see below).\n\n") 

389 parser.add_argument( 

390 "--recursive", "-r", action="store_true", 

391 help="During snapshot creation, replication, deletion and comparison, also consider descendant datasets, i.e. " 

392 "datasets within the dataset tree, including children, and children of children, etc.\n\n") 

393 parser.add_argument( 

394 "--include-dataset", action=FileOrLiteralAction, nargs="+", default=[], metavar="DATASET", 

395 help="During snapshot creation, replication, deletion and comparison, select any ZFS dataset (and its descendants) " 

396 "that is contained within SRC_DATASET (DST_DATASET in case of deletion) if its dataset name is one of the " 

397 "given include dataset names but none of the exclude dataset names. If a dataset is excluded its descendants " 

398 "are automatically excluded too, and this decision is never reconsidered even for the descendants because " 

399 "exclude takes precedence over include.\n\n" 

400 "A dataset name is absolute if the specified dataset is prefixed by `/`, e.g. `/tank/baz/tmp`. " 

401 "Otherwise the dataset name is relative wrt. source and destination, e.g. `baz/tmp` if the source " 

402 "is `tank`.\n\n" 

403 "This option is automatically translated to an --include-dataset-regex (see below) and can be " 

404 "specified multiple times.\n\n" 

405 "If the option starts with a `+` prefix then dataset names are read from the newline-separated " 

406 "UTF-8 text file given after the `+` prefix, one dataset per line inside of the text file. The basename " 

407 "must contain the substring 'bzfs_argument_file'.\n\n" 

408 "Examples: `/tank/baz/tmp` (absolute), `baz/tmp` (relative), " 

409 "`+dataset_names_bzfs_argument_file.txt`, `+/path/to/dataset_names_bzfs_argument_file.txt`\n\n") 

410 parser.add_argument( 

411 "--exclude-dataset", action=FileOrLiteralAction, nargs="+", default=[], metavar="DATASET", 

412 help="Same syntax as --include-dataset (see above) except that the option is automatically translated to an " 

413 "--exclude-dataset-regex (see below).\n\n") 

414 parser.add_argument( 

415 "--include-dataset-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

416 help="During snapshot creation, replication (and deletion) and comparison, select any ZFS dataset (and its " 

417 "descendants) that is contained within SRC_DATASET (DST_DATASET in case of deletion) if its relative dataset " 

418 "path (e.g. `baz/tmp`) wrt. SRC_DATASET (DST_DATASET in case of deletion) matches at least one of the given " 

419 "include regular expressions but none of the exclude regular expressions. " 

420 "If a dataset is excluded its descendants are automatically excluded too, and this decision is never " 

421 "reconsidered even for the descendants because exclude takes precedence over include.\n\n" 

422 "This option can be specified multiple times. " 

423 "A leading `!` character indicates logical negation, i.e. the regex matches if the regex with the " 

424 "leading `!` character removed does not match.\n\n" 

425 "If the option starts with a `+` prefix then regex names are read from the newline-separated " 

426 "UTF-8 text file given after the `+` prefix, one regex per line inside of the text file. The basename " 

427 "must contain the substring 'bzfs_argument_file'.\n\n" 

428 "Default: `.*` (include all datasets).\n\n" 

429 "Examples: `baz/tmp`, `(.*/)?doc[^/]*/(private|confidential).*`, `!public`, " 

430 "`+dataset_regexes_bzfs_argument_file.txt`, `+/path/to/dataset_regexes_bzfs_argument_file.txt`\n\n") 

431 parser.add_argument( 

432 "--exclude-dataset-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

433 help="Same syntax as --include-dataset-regex (see above) except that the default is " 

434 f"`{EXCLUDE_DATASET_REGEXES_DEFAULT}` (exclude tmp datasets). Example: `!.*` (exclude no dataset)\n\n") 

435 parser.add_argument( 

436 "--exclude-dataset-property", default=None, action=NonEmptyStringAction, metavar="STRING", 

437 help="The name of a ZFS dataset user property (optional). If this option is specified, the effective value " 

438 "(potentially inherited) of that user property is read via 'zfs list' for each selected source dataset " 

439 "to determine whether the dataset will be included or excluded, as follows:\n\n" 

440 "a) Value is 'true' or '-' or empty string or the property is missing: Include the dataset.\n\n" 

441 "b) Value is 'false': Exclude the dataset and its descendants.\n\n" 

442 "c) Value is a comma-separated list of host names (no spaces, for example: " 

443 "'store001,store002'): Include the dataset if the host name of " 

444 f"the host executing {PROG_NAME} is contained in the list, otherwise exclude the dataset and its " 

445 "descendants.\n\n" 

446 "If a dataset is excluded its descendants are automatically excluded too, and the property values of the " 

447 "descendants are ignored because exclude takes precedence over include.\n\n" 

448 "Examples: 'syncoid:sync', 'com.example.eng.project.x:backup'\n\n" 

449 "*Note:* The use of --exclude-dataset-property is discouraged for most use cases. It is more flexible, " 

450 "more powerful, *and* more efficient to instead use a combination of --include/exclude-dataset-regex " 

451 "and/or --include/exclude-dataset to achieve the same or better outcome.\n\n") 

452 parser.add_argument( 

453 "--include-snapshot-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

454 help="During replication, deletion and comparison, select any source ZFS snapshot that has a name (i.e. the part " 

455 "after the '@') that matches at least one of the given include regular expressions but none of the " 

456 "exclude regular expressions. If a snapshot is excluded this decision is never reconsidered because " 

457 "exclude takes precedence over include.\n\n" 

458 "This option can be specified multiple times. " 

459 "A leading `!` character indicates logical negation, i.e. the regex matches if the regex with the " 

460 "leading `!` character removed does not match.\n\n" 

461 "Default: `.*` (include all snapshots). " 

462 "Examples: `test_.*`, `!prod_.*`, `.*_(hourly|frequent)`, `!.*_(weekly|daily)`\n\n" 

463 "*Note:* All --include/exclude-snapshot-* CLI option groups are combined into a mini filter pipeline. " 

464 "A filter pipeline is executed in the order given on the command line, left to right. For example if " 

465 "--include-snapshot-times-and-ranks (see below) is specified on the command line before " 

466 "--include/exclude-snapshot-regex, then --include-snapshot-times-and-ranks will be applied before " 

467 "--include/exclude-snapshot-regex. The pipeline results would not always be the same if the order were " 

468 "reversed. Order matters.\n\n" 

469 "*Note:* During replication, bookmarks are always retained aka selected in order to help find common " 

470 "snapshots between source and destination.\n\n") 

471 parser.add_argument( 

472 "--exclude-snapshot-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

473 help="Same syntax as --include-snapshot-regex (see above) except that the default is to exclude no " 

474 "snapshots.\n\n") 

475 parser.add_argument( 

476 "--include-snapshot-times-and-ranks", action=TimeRangeAndRankRangeAction, nargs="+", default=[], 

477 metavar=("TIMERANGE", "RANKRANGE"), 

478 help="This option takes as input parameters a time range filter and an optional rank range filter. It " 

479 "separately computes the results for each filter and selects the UNION of both results. " 

480 "To instead use a pure rank range filter (no UNION), or a pure time range filter (no UNION), simply " 

481 "use 'notime' aka '0..0' to indicate an empty time range, or omit the rank range, respectively. " 

482 "This option can be specified multiple times.\n\n" 

483 "<b>*Replication Example (UNION):* </b>\n\n" 

484 "Specify to replicate all daily snapshots created during the last 7 days, " 

485 "and at the same time ensure that the latest 7 daily snapshots (per dataset) are replicated regardless " 

486 "of creation time, like so: " 

487 "`--include-snapshot-regex '.*_daily' --include-snapshot-times-and-ranks '7 days ago..anytime' 'latest 7'`\n\n" 

488 "<b>*Deletion Example (no UNION):* </b>\n\n" 

489 "Specify to delete all daily snapshots older than 7 days, but ensure that the " 

490 "latest 7 daily snapshots (per dataset) are retained regardless of creation time, like so: " 

491 "`--include-snapshot-regex '.*_daily' --include-snapshot-times-and-ranks notime 'all except latest 7' " 

492 "--include-snapshot-times-and-ranks 'anytime..7 days ago'`" 

493 "\n\n" 

494 "This helps to safely cope with irregular scenarios where no snapshots were created or received within " 

495 "the last 7 days, or where more than 7 daily snapshots were created within the last 7 days. It can also " 

496 "help to avoid accidental pruning of the last snapshot that source and destination have in common.\n\n" 

497 "" 

498 "<b>*TIMERANGE:* </b>\n\n" 

499 "The ZFS 'creation' time of a snapshot (and bookmark) must fall into this time range in order for the " 

500 "snapshot to be included. The time range consists of a 'start' time, followed by a '..' separator, " 

501 "followed by an 'end' time. For example '2024-01-01..2024-04-01', or 'anytime..anytime' aka `*..*` aka all " 

502 "times, or 'notime' aka '0..0' aka empty time range. Only snapshots (and bookmarks) in the half-open time " 

503 "range [start, end) are included; other snapshots (and bookmarks) are excluded. If a snapshot is excluded " 

504 "this decision is never reconsidered because exclude takes precedence over include. Each of the two specified " 

505 "times can take any of the following forms:\n\n" 

506 "* a) `anytime` aka `*` wildcard; represents negative or positive infinity.\n\n" 

507 "* b) a non-negative integer representing a UTC Unix time in seconds. Example: 1728109805\n\n" 

508 "* c) an ISO 8601 datetime string with or without timezone. Examples: '2024-10-05', " 

509 "'2024-10-05T14:48:55', '2024-10-05T14:48:55+02', '2024-10-05T14:48:55-04:30'. If the datetime string " 

510 "does not contain time zone info then it is assumed to be in the local time zone. Timezone string support " 

511 "requires Python ≥ 3.11.\n\n" 

512 "* d) a duration that indicates how long ago from the current time, using the following syntax: " 

513 "a non-negative integer, followed by an optional space, followed by a duration unit that is " 

514 "*one* of 'seconds', 'secs', 'minutes', 'mins', 'hours', 'days', 'weeks', 'months', 'years', " 

515 "followed by an optional space, followed by the word 'ago'. " 

516 "Examples: '0secs ago', '40 mins ago', '36hours ago', '90days ago', '12weeksago'.\n\n" 

517 "* Note: This option compares the specified time against the standard ZFS 'creation' time property of the " 

518 "snapshot (which is a UTC Unix time in integer seconds), rather than against a timestamp that may be " 

519 "part of the snapshot name. You can list the ZFS creation time of snapshots and bookmarks as follows: " 

520 "`zfs list -t snapshot,bookmark -o name,creation -s creation -d 1 $SRC_DATASET` (optionally add " 

521 "the -p flag to display UTC Unix time in integer seconds).\n\n" 

522 "*Note:* During replication, bookmarks are always retained aka selected in order to help find common " 

523 "snapshots between source and destination.\n\n" 

524 "" 

525 "<b>*RANKRANGE:* </b>\n\n" 

526 "Specifies to include the N (or N%%) oldest snapshots or latest snapshots, and exclude all other " 

527 "snapshots (default: include no snapshots). Snapshots are sorted by creation time (actually, by the " 

528 "'createtxg' ZFS property, which serves the same purpose but is more precise). The rank position of a " 

529 "snapshot is the zero-based integer position of the snapshot within that sorted list. A rank consists of the " 

530 "optional words 'all except' (followed by an optional space), followed by the word 'oldest' or 'latest', " 

531 "followed by a non-negative integer, followed by an optional '%%' percent sign. A rank range consists of a " 

532 "lower rank, followed by a '..' separator, followed by a higher rank. " 

533 "If the optional lower rank is missing it is assumed to be 0. Examples:\n\n" 

534 "* 'oldest 10%%' aka 'oldest 0..oldest 10%%' (include the oldest 10%% of all snapshots)\n\n" 

535 "* 'latest 10%%' aka 'latest 0..latest 10%%' (include the latest 10%% of all snapshots)\n\n" 

536 "* 'all except latest 10%%' aka 'oldest 90%%' aka 'oldest 0..oldest 90%%' (include all snapshots except the " 

537 "latest 10%% of all snapshots)\n\n" 

538 "* 'oldest 90' aka 'oldest 0..oldest 90' (include the oldest 90 snapshots)\n\n" 

539 "* 'latest 90' aka 'latest 0..latest 90' (include the latest 90 snapshots)\n\n" 

540 "* 'all except oldest 90' aka 'oldest 90..oldest 100%%' (include all snapshots except the oldest 90 snapshots)" 

541 "\n\n" 

542 "* 'all except latest 90' aka 'latest 90..latest 100%%' (include all snapshots except the latest 90 snapshots)" 

543 "\n\n" 

544 "* 'latest 1' aka 'latest 0..latest 1' (include the latest snapshot)\n\n" 

545 "* 'all except latest 1' aka 'latest 1..latest 100%%' (include all snapshots except the latest snapshot)\n\n" 

546 "* 'oldest 2' aka 'oldest 0..oldest 2' (include the oldest 2 snapshots)\n\n" 

547 "* 'all except oldest 2' aka 'oldest 2..oldest 100%%' (include all snapshots except the oldest 2 snapshots)\n\n" 

548 "* 'oldest 100%%' aka 'oldest 0..oldest 100%%' (include all snapshots)\n\n" 

549 "* 'oldest 0%%' aka 'oldest 0..oldest 0%%' (include no snapshots)\n\n" 

550 "* 'oldest 0' aka 'oldest 0..oldest 0' (include no snapshots)\n\n" 

551 "*Note for multiple RANKRANGEs:* `--include-snapshot-times-and-ranks TIMERANGE RANKRANGE1 RANKRANGE2` is " 

552 "equivalent to `--include-snapshot-times-and-ranks TIMERANGE RANKRANGE1 --include-snapshot-times-and-ranks " 

553 "TIMERANGE RANKRANGE2`.\n\n" 

554 "*Note:* Percentage calculations are not based on the number of snapshots " 

555 "contained in the dataset on disk, but rather based on the number of snapshots arriving at the filter. " 

556 "For example, if only two daily snapshots arrive at the filter because a prior filter excludes hourly " 

557 "snapshots, then 'latest 10' will only include these two daily snapshots, and 'latest 50%%' will only " 

558 "include one of these two daily snapshots.\n\n" 

559 "*Note:* During replication, bookmarks are always retained aka selected in order to help find common " 

560 "snapshots between source and destination. Bookmarks do not count towards N or N%% wrt. rank.\n\n" 

561 "*Note:* If a snapshot is excluded this decision is never reconsidered because exclude takes precedence " 

562 "over include.\n\n") 

563 

564 src_snapshot_plan_example = { 

565 "prod": { 

566 "onsite": {"secondly": 40, "minutely": 40, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5}, 

567 "us-west": {"secondly": 0, "minutely": 0, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5}, 

568 "eu-west": {"secondly": 0, "minutely": 0, "hourly": 36, "daily": 31, "weekly": 12, "monthly": 18, "yearly": 5}, 

569 }, 

570 "test": { 

571 "offsite": {"12hourly": 42, "weekly": 12}, 

572 "onsite": {"100millisecondly": 42}, 

573 }, 

574 } 

575 parser.add_argument( 

576 "--include-snapshot-plan", action=IncludeSnapshotPlanAction, default=None, metavar="DICT_STRING", 

577 help="Replication periods to be used if replicating snapshots within the selected destination datasets. " 

578 "Has the same format as --create-src-snapshots-plan and --delete-dst-snapshots-except-plan (see below). " 

579 "Snapshots that do not match a period will not be replicated. To avoid unexpected surprises, make sure to " 

580 "carefully specify ALL snapshot names and periods that shall be replicated, in combination with --dryrun.\n\n" 

581 f"Example: `{format_dict(src_snapshot_plan_example)}`. This example will, for the organization 'prod' and the " 

582 "intended logical target 'onsite', replicate secondly snapshots that were created less than 40 seconds ago, " 

583 "yet replicate the latest 40 secondly snapshots regardless of creation time. Analog for the latest 40 minutely " 

584 "snapshots, latest 36 hourly snapshots, etc. " 

585 "Note: A zero within a period (e.g. 'hourly': 0) indicates that no snapshots shall be replicated for the given " 

586 "period.\n\n" 

587 "Note: --include-snapshot-plan is a convenience option that auto-generates a series of the following other " 

588 "options: --new-snapshot-filter-group, --include-snapshot-regex, --include-snapshot-times-and-ranks\n\n") 

589 parser.add_argument( 

590 "--new-snapshot-filter-group", action=NewSnapshotFilterGroupAction, nargs=0, 

591 help="Starts a new snapshot filter group containing separate --{include|exclude}-snapshot-* filter options. The " 

592 "program separately computes the results for each filter group and selects the UNION of all results. " 

593 "This option can be specified multiple times and serves as a separator between groups. Example:\n\n" 

594 "Delete all minutely snapshots older than 40 minutes, but ensure that the latest 40 minutely snapshots (per " 

595 "dataset) are retained regardless of creation time. Additionally, delete all hourly snapshots older than 36 " 

596 "hours, but ensure that the latest 36 hourly snapshots (per dataset) are retained regardless of creation time. " 

597 "Additionally, delete all daily snapshots older than 31 days, but ensure that the latest 31 daily snapshots " 

598 "(per dataset) are retained regardless of creation time: " 

599 f"`{PROG_NAME} {DUMMY_DATASET} tank2/boo/bar --dryrun --recursive --skip-replication --delete-dst-snapshots " 

600 "--include-snapshot-regex '.*_minutely' --include-snapshot-times-and-ranks notime 'all except latest 40' " 

601 "--include-snapshot-times-and-ranks 'anytime..40 minutes ago' " 

602 "--new-snapshot-filter-group " 

603 "--include-snapshot-regex '.*_hourly' --include-snapshot-times-and-ranks notime 'all except latest 36' " 

604 "--include-snapshot-times-and-ranks 'anytime..36 hours ago' " 

605 "--new-snapshot-filter-group " 

606 "--include-snapshot-regex '.*_daily' --include-snapshot-times-and-ranks notime 'all except latest 31' " 

607 "--include-snapshot-times-and-ranks 'anytime..31 days ago'`\n\n") 

608 parser.add_argument( 

609 "--create-src-snapshots", action="store_true", 

610 help="Do nothing if the --create-src-snapshots flag is missing. Otherwise, before the replication step (see below), " 

611 "atomically create new snapshots of the source datasets selected via --{include|exclude}-dataset* policy. " 

612 "The names of the snapshots can be configured via --create-src-snapshots-* suboptions (see below). " 

613 "To create snapshots only, without any other processing such as replication, etc, consider using this flag " 

614 "together with the --skip-replication flag.\n\n" 

615 "A periodic snapshot is created if it is due per the schedule indicated by --create-src-snapshots-plan " 

616 "(for example '_daily' or '_hourly' or _'10minutely' or '_2secondly' or '_100millisecondly'), or if the " 

617 "--create-src-snapshots-even-if-not-due flag is specified, or if the most recent scheduled snapshot " 

618 f"is somehow missing. In the latter case {PROG_NAME} immediately creates a snapshot (tagged with the current " 

619 "time, not backdated to the missed time), and then resumes the original schedule.\n\n" 

620 "If the snapshot suffix is '_adhoc' or not a known period then a snapshot is considered " 

621 "non-periodic and is thus created immediately regardless of the creation time of any existing snapshot.\n\n" 

622 "The implementation attempts to fit as many datasets as possible into a single (atomic) 'zfs snapshot' command " 

623 "line, using lexicographical sort order, and using 'zfs snapshot -r' to the extent that this is compatible " 

624 "with the actual results of the schedule and the actual results of the --{include|exclude}-dataset* pruning " 

625 "policy. The snapshots of all datasets that fit " 

626 "within the same single 'zfs snapshot' CLI invocation will be taken within the same ZFS transaction group, and " 

627 "correspondingly have identical 'createtxg' ZFS property (but not necessarily identical 'creation' ZFS time " 

628 "property as ZFS actually provides no such guarantee), and thus be consistent. Dataset names that can't fit " 

629 "into a single command line are spread over multiple command line invocations, respecting the limits that the " 

630 "operating system places on the maximum length of a single command line, per `getconf ARG_MAX`.\n\n" 

631 f"Note: All {PROG_NAME} functions including snapshot creation, replication, deletion, monitoring, comparison, " 

632 "etc. happily work with any snapshots in any format, even created or managed by third party ZFS snapshot " 

633 "management tools, including manual zfs snapshot/destroy.\n\n") 

634 parser.add_argument( 

635 "--create-src-snapshots-plan", default=None, type=str, metavar="DICT_STRING", 

636 help="Creation periods that specify a schedule for when new snapshots shall be created on src within the selected " 

637 "datasets. Has the same format as --delete-dst-snapshots-except-plan.\n\n" 

638 f"Example: `{format_dict(src_snapshot_plan_example)}`. This example will, for the organization 'prod' and " 

639 "the intended logical target 'onsite', create 'secondly' snapshots every second, 'minutely' snapshots every " 

640 "minute, hourly snapshots every hour, and so on. " 

641 "It will also create snapshots for the targets 'us-west' and 'eu-west' within the 'prod' organization. " 

642 "In addition, it will create snapshots every 12 hours and every week for the 'test' organization, " 

643 "and name them as being intended for the 'offsite' replication target. Analog for snapshots that are taken " 

644 "every 100 milliseconds within the 'test' organization.\n\n" 

645 "The example creates ZFS snapshots with names like " 

646 "`prod_onsite_<timestamp>_secondly`, `prod_onsite_<timestamp>_minutely`, " 

647 "`prod_us-west_<timestamp>_hourly`, `prod_us-west_<timestamp>_daily`, " 

648 "`prod_eu-west_<timestamp>_hourly`, `prod_eu-west_<timestamp>_daily`, " 

649 "`test_offsite_<timestamp>_12hourly`, `test_offsite_<timestamp>_weekly`, and so on.\n\n" 

650 "Note: A period name that is missing indicates that no snapshots shall be created for the given period.\n\n" 

651 "The period name can contain an optional positive integer immediately preceding the time period unit, for " 

652 "example `_2secondly` or `_10minutely` or `_100millisecondly` to indicate that snapshots are taken every 2 " 

653 "seconds, or every 10 minutes, or every 100 milliseconds, respectively.\n\n") 

654 

655 def argparser_escape(text: str) -> str: 

656 return text.replace("%", "%%") 

657 

658 parser.add_argument( 

659 "--create-src-snapshots-timeformat", default="%Y-%m-%d_%H:%M:%S", metavar="STRFTIME_SPEC", 

660 help="Default is `%(default)s`. For the strftime format, see " 

661 "https://docs.python.org/3.11/library/datetime.html#strftime-strptime-behavior. " 

662 f"Examples: `{argparser_escape('%Y-%m-%d_%H:%M:%S.%f')}` (adds microsecond resolution), " 

663 f"`{argparser_escape('%Y-%m-%d_%H:%M:%S%z')}` (adds timezone offset), " 

664 f"`{argparser_escape('%Y-%m-%dT%H-%M-%S')}` (no colons).\n\n" 

665 "The name of the snapshot created on the src is `$org_$target_strftime(--create-src-snapshots-time*)_$period`. " 

666 "Example: `tank/foo@prod_us-west_2024-09-03_12:26:15_daily`\n\n") 

667 parser.add_argument( 

668 "--create-src-snapshots-timezone", default="", type=str, metavar="TZ_SPEC", 

669 help=f"Default is the local timezone of the system running {PROG_NAME}. When creating a new snapshot on the source, " 

670 "fetch the current time in the specified timezone, and feed that time, and the value of " 

671 "--create-src-snapshots-timeformat, into the standard strftime() function to generate the timestamp portion " 

672 "of the snapshot name. The TZ_SPEC input parameter is of the form 'UTC' or '+HHMM' or '-HHMM' for fixed UTC " 

673 "offsets, or an IANA TZ identifier for auto-adjustment to daylight savings time, or the empty string to use " 

674 "the local timezone, for example '', 'UTC', '+0000', '+0530', '-0400', 'America/Los_Angeles', 'Europe/Vienna'. " 

675 "For a list of valid IANA TZ identifiers see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List" 

676 "\n\nTo change the timezone not only for snapshot name creation, but in all respects for the entire program, " 

677 "use the standard 'TZ' Unix environment variable, like so: `export TZ=UTC`.\n\n") 

678 parser.add_argument( 

679 "--create-src-snapshots-even-if-not-due", action="store_true", 

680 help="Take snapshots immediately regardless of the creation time of any existing snapshot, even if snapshots " 

681 "are periodic and not actually due per the schedule.\n\n") 

682 parser.add_argument( 

683 "--zfs-send-program-opts", type=str, default="--raw --compressed", metavar="STRING", 

684 help="Parameters to fine-tune 'zfs send' behaviour (optional); will be passed into 'zfs send' CLI. " 

685 "The value is split on runs of one or more whitespace characters. " 

686 "Default is '%(default)s'. To run `zfs send` without options, specify the empty " 

687 "string: `--zfs-send-program-opts=''`. " 

688 "See https://openzfs.github.io/openzfs-docs/man/master/8/zfs-send.8.html " 

689 "and https://github.com/openzfs/zfs/issues/13024\n\n") 

690 parser.add_argument( 

691 "--zfs-recv-program-opts", type=str, default="-u", metavar="STRING", 

692 help="Parameters to fine-tune 'zfs receive' behaviour (optional); will be passed into 'zfs receive' CLI. " 

693 "The value is split on runs of one or more whitespace characters. " 

694 "Default is '%(default)s'. To run `zfs receive` without options, specify the empty " 

695 "string: `--zfs-recv-program-opts=''`. " 

696 "Example: '-u -o canmount=noauto -o readonly=on -x keylocation -x keyformat -x encryption'. " 

697 "See https://openzfs.github.io/openzfs-docs/man/master/8/zfs-receive.8.html " 

698 "and https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html\n\n") 

699 parser.add_argument( 

700 "--zfs-recv-program-opt", action="append", default=[], metavar="STRING", 

701 help="Parameter to fine-tune 'zfs receive' behaviour (optional); will be passed into 'zfs receive' CLI. " 

702 "The value can contain spaces and is not split. This option can be specified multiple times. Example: `" 

703 "--zfs-recv-program-opt=-o " 

704 "--zfs-recv-program-opt='org.zfsbootmenu:commandline=ro debug zswap.enabled=1'`\n\n") 

705 parser.add_argument( 

706 "--preserve-properties", nargs="+", default=[], metavar="STRING", 

707 help="On replication, preserve the current value of ZFS properties with the given names on the destination " 

708 "datasets. The destination ignores the property value it 'zfs receive's from the source if the property name " 

709 "matches one of the given blacklist values. This prevents a compromised or untrusted source from overwriting " 

710 "security-critical properties on the destination. The default is to preserve none, i.e. an empty blacklist.\n\n" 

711 "Example blacklist that protects against dangerous overwrites: " 

712 "mountpoint overlay sharenfs sharesmb exec setuid devices encryption keyformat keylocation volsize\n\n" 

713 "See https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html and " 

714 "https://openzfs.github.io/openzfs-docs/man/master/8/zfs-receive.8.html#x\n\n" 

715 "Note: --preserve-properties uses the 'zfs recv -x' option and thus requires either OpenZFS ≥ 2.2.0 " 

716 "(see https://github.com/openzfs/zfs/commit/b0269cd8ced242e66afc4fa856d62be29bb5a4ff), or that " 

717 "'zfs send --props' is not used.\n\n") 

718 parser.add_argument( 

719 "--force-rollback-to-latest-snapshot", action="store_true", 

720 help="Before replication, rollback the destination dataset to its most recent destination snapshot (if there " 

721 "is one), via 'zfs rollback', just in case the destination dataset was modified since its most recent " 

722 "snapshot. This is much less invasive than the other --force* options (see below).\n\n") 

723 parser.add_argument( 

724 "--force-rollback-to-latest-common-snapshot", action="store_true", 

725 help="Before replication, delete destination ZFS snapshots that are more recent than the most recent common " 

726 "snapshot ('conflicting snapshots'), via 'zfs rollback'. Do no rollback if no common snapshot exists.\n\n") 

727 parser.add_argument( 

728 "--force", action="store_true", 

729 help="Same as --force-rollback-to-latest-common-snapshot (see above), except that additionally, if no common " 

730 "snapshot exists, then delete all destination snapshots before starting replication, and proceed " 

731 "without aborting. Without the --force* flags, the destination dataset is treated as append-only, hence " 

732 "no destination snapshot that already exists is deleted, and instead the operation is aborted with an " 

733 "error when encountering a conflicting snapshot.\n\n" 

734 "Analogy: --force-rollback-to-latest-snapshot is a tiny hammer, whereas " 

735 "--force-rollback-to-latest-common-snapshot is a medium sized hammer, --force is a large hammer, and " 

736 "--force-destroy-dependents is a very large hammer. " 

737 "Consider using the smallest hammer that can fix the problem. No hammer is ever used by default.\n\n") 

738 parser.add_argument( 

739 "--force-destroy-dependents", action="store_true", 

740 help="On destination, --force and --force-rollback-to-latest-common-snapshot and --delete-* will add the " 

741 "'-R' flag to their use of 'zfs rollback' and 'zfs destroy', causing them to delete dependents such as " 

742 "clones and bookmarks. This can be very destructive and is rarely advisable.\n\n") 

743 parser.add_argument( 

744 "--force-unmount", action="store_true", 

745 help="On destination, --force and --force-rollback-to-latest-common-snapshot will add the '-f' flag to their " 

746 "use of 'zfs rollback' and 'zfs destroy'.\n\n") 

747 parser.add_argument( 

748 "--force-once", "--f1", action="store_true", 

749 help="Use the --force option or --force-rollback-to-latest-common-snapshot option at most once to resolve a " 

750 "conflict, then abort with an error on any subsequent conflict. This helps to interactively resolve " 

751 "conflicts, one conflict at a time.\n\n") 

752 parser.add_argument( 

753 "--skip-parent", action="store_true", 

754 help="During replication and deletion, skip processing of the SRC_DATASET and DST_DATASET and only process " 

755 "their descendant datasets, i.e. children, and children of children, etc (with --recursive). No dataset " 

756 "is processed unless --recursive is also specified. " 

757 f"Analogy: `{PROG_NAME} --recursive --skip-parent src dst` is akin to Unix `cp -r src/* dst/` whereas " 

758 f" `{PROG_NAME} --recursive --skip-parent --skip-replication --delete-dst-datasets dummy dst` is akin to " 

759 "Unix `rm -r dst/*`\n\n") 

760 parser.add_argument( 

761 "--skip-missing-snapshots", choices=["fail", "dataset", "continue"], default="dataset", nargs="?", 

762 help="During replication, handle source datasets that select no snapshots (and no relevant bookmarks) " 

763 "as follows:\n\n" 

764 "a) 'fail': Abort with an error.\n\n" 

765 "b) 'dataset' (default): Skip the source dataset with a warning. Skip descendant datasets if " 

766 "--recursive and destination dataset does not exist. Otherwise skip to the next dataset.\n\n" 

767 "c) 'continue': Skip nothing. If destination snapshots exist, delete them (with --force) or abort " 

768 "with an error (without --force). If there is no such abort, continue processing with the next dataset. " 

769 "Eventually create empty destination dataset and ancestors if they do not yet exist and source dataset " 

770 "has at least one descendant that selects at least one snapshot.\n\n") 

771 parser.add_argument( 

772 "--retries", dest="max_retries", type=int, min=0, default=2, action=CheckRange, metavar="INT", 

773 help="The maximum number of times a retryable replication or deletion step shall be retried if it fails, for " 

774 "example because of network hiccups (default: %(default)s, min: %(min)s). " 

775 "Also consider this option if a periodic pruning script may simultaneously delete a dataset or " 

776 f"snapshot or bookmark while {PROG_NAME} is running and attempting to access it.\n\n") 

777 parser.add_argument( 

778 "--retry-min-sleep-secs", type=float, min=0, default=0, action=CheckRange, metavar="FLOAT", 

779 help="The minimum duration to sleep between retries (default: %(default)s).\n\n") 

780 parser.add_argument( 

781 "--retry-initial-max-sleep-secs", type=float, min=0, default=0.125, action=CheckRange, metavar="FLOAT", 

782 help="The initial maximum duration to sleep between retries (default: %(default)s).\n\n") 

783 parser.add_argument( 

784 "--retry-max-sleep-secs", type=float, min=0, default=5 * 60, action=CheckRange, metavar="FLOAT", 

785 help="The maximum duration to sleep between retries initially starts with --retry-initial-max-sleep-secs " 

786 "(see above), and doubles on each retry, up to the final maximum of --retry-max-sleep-secs " 

787 "(default: %(default)s). On each retry a random sleep time in the [--retry-min-sleep-secs, current max] range " 

788 "is picked. In a nutshell: retry-min-sleep-secs ≤ retry-initial-max-sleep-secs ≤ retry-max-sleep-secs. " 

789 "The timer resets after each operation.\n\n") 

790 parser.add_argument( 

791 "--retry-max-elapsed-secs", type=float, min=0, default=60 * 60, action=CheckRange, metavar="FLOAT", 

792 help="A single operation (e.g. 'zfs send/receive' of the current dataset, or deletion of a list of snapshots " 

793 "within the current dataset) will not be retried (or not retried anymore) once this much time has elapsed " 

794 "since the initial start of the operation, including retries (default: %(default)s). " 

795 "The timer resets after each operation completes or retries exhaust, such that subsequently failing " 

796 "operations can again be retried.\n\n") 

797 parser.add_argument( 

798 "--skip-on-error", choices=["fail", "tree", "dataset"], default=SKIP_ON_ERROR_DEFAULT, 

799 help="During replication and deletion, if an error is not retryable, or --retries has been exhausted, " 

800 "or --skip-missing-snapshots raises an error, proceed as follows:\n\n" 

801 "a) 'fail': Abort the program with an error. This mode is ideal for testing, clear " 

802 "error reporting, and situations where consistency trumps availability.\n\n" 

803 "b) 'tree': Log the error, skip the dataset tree rooted at the dataset for which the error " 

804 "occurred, and continue processing the next (sibling) dataset tree. " 

805 "Example: Assume datasets tank/user1/foo and tank/user2/bar and an error occurs while processing " 

806 "tank/user1. In this case processing skips tank/user1/foo and proceeds with tank/user2.\n\n" 

807 "c) 'dataset' (default): Same as 'tree' except if the destination dataset already exists, skip to " 

808 "the next dataset instead.\n\n" 

809 "Example: Assume datasets tank/user1/foo and tank/user2/bar and an error occurs while " 

810 "processing tank/user1. In this case processing skips tank/user1 and proceeds with tank/user1/foo " 

811 "if the destination already contains tank/user1. Otherwise processing continues with tank/user2. " 

812 "This mode is for production use cases that require timely forward progress even in the presence of " 

813 "partial failures. For example, assume the job is to backup the home directories or virtual machines " 

814 "of thousands of users across an organization. Even if replication of some of the datasets for some " 

815 "users fails due too conflicts, busy datasets, etc, the replication job will continue for the " 

816 "remaining datasets and the remaining users.\n\n") 

817 parser.add_argument( 

818 "--skip-replication", action="store_true", 

819 help="Skip replication step (see above) and proceed to the optional --delete-dst-datasets step " 

820 "immediately (see below).\n\n") 

821 parser.add_argument( 

822 "--delete-dst-datasets", action="store_true", 

823 help="Do nothing if the --delete-dst-datasets option is missing. Otherwise, after successful replication " 

824 "step, if any, delete existing destination datasets that are selected via --{include|exclude}-dataset* " 

825 "policy yet do not exist within SRC_DATASET (which can be an empty dataset, such as the hardcoded virtual " 

826 f"dataset named '{DUMMY_DATASET}'!). Do not recurse without --recursive. With --recursive, never delete " 

827 "non-selected dataset subtrees or their ancestors.\n\n" 

828 "For example, if the destination contains datasets h1,h2,h3,d1 whereas source only contains h3, " 

829 "and the include/exclude policy selects h1,h2,h3,d1, then delete datasets h1,h2,d1 on " 

830 "the destination to make it 'the same'. On the other hand, if the include/exclude policy " 

831 "only selects h1,h2,h3 then only delete datasets h1,h2 on the destination to make it 'the same'.\n\n" 

832 "Example to delete all tmp datasets within tank2/boo/bar: " 

833 f"`{PROG_NAME} {DUMMY_DATASET} tank2/boo/bar --dryrun --skip-replication --recursive " 

834 "--delete-dst-datasets --include-dataset-regex '(.*/)?tmp.*' --exclude-dataset-regex '!.*'`\n\n") 

835 parser.add_argument( 

836 "--delete-dst-snapshots", choices=["snapshots", "bookmarks"], default=None, const="snapshots", nargs="?", 

837 help="Do nothing if the --delete-dst-snapshots option is missing. Otherwise, after successful " 

838 "replication, and successful --delete-dst-datasets step, if any, delete existing destination snapshots " 

839 "whose GUID does not exist within the source dataset (which can be an empty dummy dataset!) if the " 

840 "destination snapshots are selected by the --include/exclude-snapshot-* policy, and the destination " 

841 "dataset is selected via --{include|exclude}-dataset* policy. Does not recurse without --recursive.\n\n" 

842 "For example, if the destination dataset contains snapshots h1,h2,h3,d1 (h=hourly, d=daily) whereas " 

843 "the source dataset only contains snapshot h3, and the include/exclude policy selects " 

844 "h1,h2,h3,d1, then delete snapshots h1,h2,d1 on the destination dataset to make it 'the same'. " 

845 "On the other hand, if the include/exclude policy only selects snapshots h1,h2,h3 then only " 

846 "delete snapshots h1,h2 on the destination dataset to make it 'the same'.\n\n" 

847 "*Note:* To delete snapshots regardless, consider using --delete-dst-snapshots in combination with a " 

848 f"source that is an empty dataset, such as the hardcoded virtual dataset named '{DUMMY_DATASET}', like so:" 

849 f" `{PROG_NAME} {DUMMY_DATASET} tank2/boo/bar --dryrun --skip-replication --delete-dst-snapshots " 

850 "--include-snapshot-regex '.*_daily' --recursive`\n\n" 

851 "*Note:* Use --delete-dst-snapshots=bookmarks to delete bookmarks instead of snapshots, in which " 

852 "case no snapshots are selected and the --{include|exclude}-snapshot-* filter options treat bookmarks as " 

853 "snapshots wrt. selecting.\n\n" 

854 "*Note:* Does not attempt to delete snapshots that carry a `zfs hold`; instead auto-skips them without " 

855 "failing.\n\n" 

856 "*Performance Note:* --delete-dst-snapshots operates on multiple datasets in parallel (and serially " 

857 f"within a dataset), using the same dataset order as {PROG_NAME} replication. " 

858 "The degree of parallelism is configurable with the --threads option (see below).\n\n") 

859 parser.add_argument( 

860 "--delete-dst-snapshots-no-crosscheck", action="store_true", 

861 help="This flag indicates that --delete-dst-snapshots=snapshots shall check the source dataset only for " 

862 "a snapshot with the same GUID, and ignore whether a bookmark with the same GUID is present in the " 

863 "source dataset. Similarly, it also indicates that --delete-dst-snapshots=bookmarks shall check the " 

864 "source dataset only for a bookmark with the same GUID, and ignore whether a snapshot with the same GUID " 

865 "is present in the source dataset.\n\n") 

866 parser.add_argument( 

867 "--delete-dst-snapshots-except", action="store_true", 

868 help="This flag indicates that the --include/exclude-snapshot-* options shall have inverted semantics for the " 

869 "--delete-dst-snapshots option, thus deleting all snapshots except for the selected snapshots (within the " 

870 "specified datasets), instead of deleting all selected snapshots (within the specified datasets). In other " 

871 "words, this flag enables to specify which snapshots to retain instead of which snapshots to delete.\n\n" 

872 "*Synchronization vs. Backup*: When a real (non-dummy) source dataset is specified in combination with " 

873 "--delete-dst-snapshots-except, then any destination snapshot retained by the rules above is actually only " 

874 "retained if it also exists in the source dataset - __all other destination snapshots are deleted__. This is " 

875 "great for synchronization use cases but should __NEVER BE USED FOR LONG-TERM ARCHIVAL__. Long-term archival " 

876 "use cases should instead specify the `dummy` source dataset as they require an independent retention policy " 

877 "that is not tied to the current contents of the source dataset.\n\n") 

878 parser.add_argument( 

879 "--delete-dst-snapshots-except-plan", action=DeleteDstSnapshotsExceptPlanAction, default=None, metavar="DICT_STRING", 

880 help="Retention periods to be used if pruning snapshots or bookmarks within the selected destination datasets via " 

881 "--delete-dst-snapshots. Has the same format as --create-src-snapshots-plan. " 

882 "Snapshots (--delete-dst-snapshots=snapshots) or bookmarks (with --delete-dst-snapshots=bookmarks) that " 

883 "do not match a period will be deleted. To avoid unexpected surprises, make sure to carefully specify ALL " 

884 "snapshot names and periods that shall be retained, in combination with --dryrun.\n\n" 

885 f"Example: `{format_dict(src_snapshot_plan_example)}`. This example will, for the organization 'prod' and " 

886 "the intended logical target 'onsite', retain secondly snapshots that were created less than 40 seconds ago, " 

887 "yet retain the latest 40 secondly snapshots regardless of creation time. Analog for the latest 40 minutely " 

888 "snapshots, latest 36 hourly snapshots, etc. " 

889 "It will also retain snapshots for the targets 'us-west' and 'eu-west' within the 'prod' organization. " 

890 "In addition, within the 'test' organization, it will retain snapshots that are created every 12 hours and " 

891 "every week as specified, and name them as being intended for the 'offsite' replication target. Analog for " 

892 "snapshots that are taken every 100 milliseconds within the 'test' organization. " 

893 "All other snapshots within the selected datasets will be deleted - you've been warned!\n\n" 

894 "The example scans the selected ZFS datasets for snapshots with names like " 

895 "`prod_onsite_<timestamp>_secondly`, `prod_onsite_<timestamp>_minutely`, " 

896 "`prod_us-west_<timestamp>_hourly`, `prod_us-west_<timestamp>_daily`, " 

897 "`prod_eu-west_<timestamp>_hourly`, `prod_eu-west_<timestamp>_daily`, " 

898 "`test_offsite_<timestamp>_12hourly`, `test_offsite_<timestamp>_weekly`, and so on, and deletes all snapshots " 

899 "that do not match a retention rule.\n\n" 

900 "Note: A zero within a period (e.g. 'hourly': 0) indicates that no snapshots shall be retained for the given " 

901 "period.\n\n" 

902 "Note: --delete-dst-snapshots-except-plan is a convenience option that auto-generates a series of the " 

903 "following other options: --delete-dst-snapshots-except, " 

904 "--new-snapshot-filter-group, --include-snapshot-regex, --include-snapshot-times-and-ranks\n\n") 

905 parser.add_argument( 

906 "--delete-empty-dst-datasets", choices=["snapshots", "snapshots+bookmarks"], default=None, 

907 const="snapshots+bookmarks", nargs="?", 

908 help="Do nothing if the --delete-empty-dst-datasets option is missing or --recursive is missing. Otherwise, " 

909 "after successful replication " 

910 "step and successful --delete-dst-datasets and successful --delete-dst-snapshots steps, if any, " 

911 "delete any selected destination dataset that has no snapshot and no bookmark if all descendants of " 

912 "that destination dataset are also selected and do not have a snapshot or bookmark either " 

913 "(again, only if the existing destination dataset is selected via --{include|exclude}-dataset* policy). " 

914 "Never delete non-selected dataset subtrees or their ancestors.\n\n" 

915 "For example, if the destination contains datasets h1,d1, and the include/exclude policy " 

916 "selects h1,d1, then check if h1,d1 can be deleted. " 

917 "On the other hand, if the include/exclude policy only selects h1 then only check if h1 can be deleted.\n\n" 

918 "*Note:* Use --delete-empty-dst-datasets=snapshots to delete snapshot-less datasets even if they still " 

919 "contain bookmarks.\n\n") 

920 monitor_snapshot_plan_example = { 

921 "prod": { 

922 "onsite": { 

923 "100millisecondly": {"latest": {"warning": "300 milliseconds", "critical": "2 seconds"}}, 

924 "secondly": {"latest": {"warning": "2 seconds", "critical": "14 seconds"}}, 

925 "minutely": {"latest": {"warning": "30 seconds", "critical": "300 seconds"}}, 

926 "hourly": {"latest": {"warning": "30 minutes", "critical": "300 minutes"}}, 

927 "daily": {"latest": {"warning": "4 hours", "critical": "8 hours"}}, 

928 "weekly": {"latest": {"warning": "2 days", "critical": "8 days"}}, 

929 "monthly": {"latest": {"warning": "2 days", "critical": "8 days"}}, 

930 "yearly": {"latest": {"warning": "5 days", "critical": "14 days"}}, 

931 "10minutely": {"latest": {"warning": "0 minutes", "critical": "0 minutes"}}, 

932 }, 

933 "": { 

934 "daily": {"latest": {"warning": "4 hours", "critical": "8 hours"}}, 

935 }, 

936 }, 

937 } 

938 monitor_snapshots_output_example: str = ( 

939 "`--monitor_snapshots: OK. Latest snapshot for tank/foo@prod_<timestamp>_daily is 4.18h old: @prod_2025-01-10_08:30:05_daily`\n\n" 

940 "`--monitor_snapshots: OK. Latest snapshot for tank/bar@prod_<timestamp>_daily is 4.18h old: @prod_2025-01-10_08:30:05_daily`\n\n" 

941 "`--monitor_snapshots: Latest snapshot for tank/baz@prod_<timestamp>_daily is 1.2d old but should be at most 1.1d old: @prod_2025-01-09_08:30:05_daily`\n\n" 

942 " ...\n\n" 

943 "`ERROR: Exiting bzfs with status code 2. Cause: --monitor_snapshots: Latest snapshot for tank/baz@prod_<timestamp>_daily is 1.2d old but should be at most 1.1d old: @prod_2025-01-09_08:30:05_daily`\n\n" 

944 ) 

945 parser.add_argument( 

946 "--monitor-snapshots", default="{}", type=str, metavar="DICT_STRING", 

947 help="Do nothing if the --monitor-snapshots flag is missing. Otherwise, after all other steps, " 

948 "alert the user if the ZFS 'creation' time property of the latest snapshot for any specified snapshot name " 

949 "pattern within the selected datasets is too old wrt. the specified age limit. The purpose is to check if " 

950 "snapshots are successfully taken on schedule, successfully replicated on schedule, and successfully pruned on " 

951 "schedule. Process exit code is 0, 1, 2 on OK, WARNING, CRITICAL, respectively. " 

952 f"Example DICT_STRING: `{format_dict(monitor_snapshot_plan_example)}`. " 

953 "This example alerts the user if the latest src or dst snapshot named `prod_onsite_<timestamp>_hourly` is more " 

954 "than 30 minutes late (i.e. more than 30+60=90 minutes old) [warning] or more than 300 minutes late (i.e. more " 

955 "than 300+60=360 minutes old) [critical]. " 

956 "Analog for the latest snapshot named `prod_<timestamp>_daily`, and so on.\n\n" 

957 "Note: A duration that is missing or zero (e.g. '0 minutes') indicates that no snapshots shall be checked for " 

958 "the given snapshot name pattern.\n\n" 

959 f"Example output with `--verbose`:\n\n{monitor_snapshots_output_example}\n\n") 

960 parser.add_argument( 

961 "--monitor-snapshots-dont-warn", action="store_true", 

962 help="Log a message for monitoring warnings but nonetheless exit with zero exit code.\n\n") 

963 parser.add_argument( 

964 "--monitor-snapshots-dont-crit", action="store_true", 

965 help="Log a message for monitoring criticals but nonetheless exit with zero exit code.\n\n") 

966 parser.add_argument( 

967 "--monitor-snapshots-no-latest-check", action="store_true", 

968 # help="Disable monitoring check of latest snapshot.\n\n") 

969 help=argparse.SUPPRESS) 

970 parser.add_argument( 

971 "--monitor-snapshots-no-oldest-check", action="store_true", 

972 # help="Disable monitoring check of oldest snapshot.\n\n") 

973 help=argparse.SUPPRESS) 

974 cmp_choices_dflt: str = "+".join(CMP_CHOICES_ITEMS) 

975 cmp_choices: list[str] = [] 

976 for i in range(len(CMP_CHOICES_ITEMS)): 

977 cmp_choices += ["+".join(c) for c in itertools.combinations(CMP_CHOICES_ITEMS, i + 1)] 

978 parser.add_argument( 

979 "--compare-snapshot-lists", choices=cmp_choices, default="", const=cmp_choices_dflt, nargs="?", 

980 help="Do nothing if the --compare-snapshot-lists option is missing. Otherwise, after successful replication " 

981 "step and successful --delete-dst-datasets, --delete-dst-snapshots steps and --delete-empty-dst-datasets " 

982 "steps, if any, proceed as follows:\n\n" 

983 "Compare source and destination dataset trees recursively wrt. snapshots, for example to check if all " 

984 "recently taken snapshots have been successfully replicated by a periodic job.\n\n" 

985 "Example: List snapshots only contained in source (tagged with 'src'), only contained in destination " 

986 "(tagged with 'dst'), and contained in both source and destination (tagged with 'all'), restricted to " 

987 "hourly and daily snapshots taken within the last 7 days, excluding the last 4 hours (to allow for some " 

988 "slack/stragglers), excluding temporary datasets: " 

989 f"`{PROG_NAME} tank1/foo/bar tank2/boo/bar --skip-replication " 

990 "--compare-snapshot-lists=src+dst+all --recursive --include-snapshot-regex '.*_(hourly|daily)' " 

991 "--include-snapshot-times-and-ranks '7 days ago..4 hours ago' --exclude-dataset-regex 'tmp.*'`\n\n" 

992 "This outputs a TSV file containing the following columns:\n\n" 

993 "`location creation_iso createtxg rel_name guid root_dataset rel_dataset name creation written`\n\n" 

994 "Example output row:\n\n" 

995 "`src 2024-11-06_08:30:05 17435050 /foo@test_2024-11-06_08:30:05_daily 2406491805272097867 tank1/src " 

996 "/foo tank1/src/foo@test_2024-10-06_08:30:04_daily 1730878205 24576`\n\n" 

997 "If the TSV output file contains zero lines starting with the prefix 'src' and zero lines starting with " 

998 "the prefix 'dst' then no source snapshots are missing on the destination, and no destination " 

999 "snapshots are missing on the source, indicating that the periodic replication and pruning jobs perform " 

1000 "as expected. The TSV output is sorted by rel_dataset, and by ZFS creation time within each rel_dataset " 

1001 "- the first and last line prefixed with 'all' contains the metadata of the oldest and latest common " 

1002 "snapshot, respectively. Third party tools can use this info for post-processing, for example using " 

1003 "custom scripts using 'csplit' or duckdb analytics queries.\n\n" 

1004 "The --compare-snapshot-lists option also directly logs [various summary stats]" 

1005 "(https://github.com/whoschek/bzfs/blob/main/bzfs_docs/compare-snapshot-lists-example.log), " 

1006 "such as the metadata of the latest common snapshot, latest snapshots and oldest snapshots, as well as the " 

1007 "time diff between the latest common snapshot and latest snapshot only in src (and only in dst), as well as " 

1008 "how many src snapshots and how many GB of data are missing on dst, etc.\n\n" 

1009 "*Note*: Consider omitting the 'all' flag to reduce noise and instead focus on missing snapshots only, " 

1010 "like so: --compare-snapshot-lists=src+dst \n\n" 

1011 "*Note*: The source can also be an empty dataset, such as the hardcoded virtual dataset named " 

1012 f"'{DUMMY_DATASET}'.\n\n" 

1013 "*Note*: --compare-snapshot-lists is typically *much* faster than standard 'zfs list -t snapshot' CLI " 

1014 "usage because the former issues requests with a higher degree of parallelism than the latter. The " 

1015 "degree is configurable with the --threads option (see below).\n\n") 

1016 parser.add_argument( 

1017 "--cache-snapshots", action="store_true", 

1018 help="If --cache-snapshots is specified, maintain a persistent local cache of recent snapshot creation times, " 

1019 "recent successful replication times, and recent monitoring times, and compare them to a quick " 

1020 "'zfs list -t filesystem,volume -p -o snapshots_changed' to help determine if a new snapshot shall be created " 

1021 "on the src, and if there are any changes that need to be replicated or monitored. Enabling the cache " 

1022 "improves performance if --create-src-snapshots and/or replication and/or --monitor-snapshots is invoked " 

1023 "frequently (e.g. every minute via cron) over a large number of datasets, with each dataset containing a large " 

1024 "number of snapshots, yet it is seldom for a new src snapshot to actually be created, or there are seldom any " 

1025 "changes to replicate or monitor (e.g. a snapshot is only created every day and/or deleted every day).\n\n" 

1026 "*Note:* This flag only has an effect on OpenZFS ≥ 2.2.\n\n" 

1027 "*Note:* This flag is only relevant for snapshot creation on the src if --create-src-snapshots-even-if-not-due " 

1028 "is not specified.\n\n") 

1029 parser.add_argument( 

1030 "--dryrun", "-n", choices=["recv", "send"], default=None, const="send", nargs="?", 

1031 help="Do a dry run (aka 'no-op') to print what operations would happen if the command were to be executed " 

1032 "for real (optional). This option treats both the ZFS source and destination as read-only. " 

1033 "Accepts an optional argument for fine tuning that is handled as follows:\n\n" 

1034 "a) 'recv': Send snapshot data via 'zfs send' to the destination host and receive it there via " 

1035 "'zfs receive -n', which discards the received data there.\n\n" 

1036 "b) 'send': Do not execute 'zfs send' and do not execute 'zfs receive'. This is a less 'realistic' form " 

1037 "of dry run, but much faster, especially for large snapshots and slow networks/disks, as no snapshot is " 

1038 "actually transferred between source and destination. This is the default when specifying --dryrun.\n\n" 

1039 "Examples: --dryrun, --dryrun=send, --dryrun=recv\n\n") 

1040 parser.add_argument( 

1041 "--verbose", "-v", action="count", default=0, 

1042 help="Print verbose information. This option can be specified multiple times to increase the level of " 

1043 "verbosity. To print what ZFS/SSH operation exactly is happening (or would happen), add the `-v -v -v` " 

1044 "flag, maybe along with --dryrun. All ZFS and SSH commands (even with --dryrun) are logged such that " 

1045 "they can be inspected, copy-and-pasted into a terminal shell and run manually to help anticipate or " 

1046 "diagnose issues. ERROR, WARN, INFO, DEBUG, TRACE output lines are identified by [E], [W], [I], [D], [T] " 

1047 "prefixes, respectively.\n\n") 

1048 parser.add_argument( 

1049 "--quiet", "-q", action="store_true", 

1050 help="Suppress non-error, info, debug, and trace output.\n\n") 

1051 parser.add_argument( 

1052 "--no-privilege-elevation", "-p", action="store_true", 

1053 help="Do not attempt to run state changing ZFS operations 'zfs create/rollback/destroy/send/receive/snapshot' as " 

1054 "root (via 'sudo -u root' elevation granted by administrators appending the following to /etc/sudoers: " 

1055 "`<NON_ROOT_USER_NAME> ALL=NOPASSWD:/path/to/zfs`\n\n" 

1056 "Instead, the --no-privilege-elevation flag is for non-root users that have been granted corresponding " 

1057 "ZFS permissions by administrators via 'zfs allow' delegation mechanism, like so: " 

1058 "sudo zfs allow -u $SRC_NON_ROOT_USER_NAME snapshot,destroy,send,bookmark,hold $SRC_DATASET; " 

1059 "sudo zfs allow -u $DST_NON_ROOT_USER_NAME mount,create,receive,rollback,destroy $DST_DATASET_OR_POOL.\n\n" 

1060 "If you do not plan to use the --force* flags and --delete-* CLI options then ZFS permissions " 

1061 "'rollback,destroy' can be omitted, arriving at the absolutely minimal set of required destination " 

1062 "permissions: `mount,create,receive`.\n\n" 

1063 "For extra security $SRC_NON_ROOT_USER_NAME should be different than $DST_NON_ROOT_USER_NAME, i.e. the " 

1064 "sending Unix user on the source and the receiving Unix user at the destination should be separate Unix " 

1065 "user accounts with separate private keys even if both accounts reside on the same machine, per the " 

1066 "principle of least privilege.\n\n" 

1067 "Also see https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html#EXAMPLES and " 

1068 "https://tinyurl.com/9h97kh8n and " 

1069 "https://youtu.be/o_jr13Z9f1k?si=7shzmIQJpzNJV6cq\n\n") 

1070 parser.add_argument( 

1071 "--no-stream", action="store_true", 

1072 help="During replication, only replicate the most recent selected source snapshot of a dataset (using -i " 

1073 "incrementals instead of -I incrementals), hence skip all intermediate source snapshots that may exist " 

1074 "between that and the most recent common snapshot. If there is no common snapshot also skip all other " 

1075 "source snapshots for the dataset, except for the most recent selected source snapshot. This option helps " 

1076 "the destination to 'catch up' with the source ASAP, consuming a minimum of disk space, at the expense " 

1077 "of reducing reliable options for rolling back to intermediate snapshots in the future.\n\n") 

1078 parser.add_argument( 

1079 "--no-resume-recv", action="store_true", 

1080 help="Replication of snapshots via 'zfs send/receive' can be interrupted by intermittent network hiccups, " 

1081 "reboots, hardware issues, etc. Interrupted 'zfs send/receive' operations are retried if the --retries " 

1082 f"and --retry-* options enable it (see above). In normal operation {PROG_NAME} automatically retries " 

1083 "such that only the portion of the snapshot is transmitted that has not yet been fully received on the " 

1084 "destination. For example, this helps to progressively transfer a large individual snapshot over a " 

1085 "wireless network in a timely manner despite frequent intermittent network hiccups. This optimization is " 

1086 "called 'resume receive' and uses the 'zfs receive -s' and 'zfs send -t' feature.\n\n" 

1087 "The --no-resume-recv option disables this optimization such that a retry now retransmits the entire " 

1088 "snapshot from scratch, which could slow down or even prohibit progress in case of frequent network " 

1089 f"hiccups. {PROG_NAME} automatically falls back to using the --no-resume-recv option if it is " 

1090 "auto-detected that the ZFS pool does not reliably support the 'resume receive' optimization.\n\n" 

1091 "*Note:* Snapshots that have already been fully transferred as part of the current 'zfs send/receive' " 

1092 "operation need not be retransmitted regardless of the --no-resume-recv flag. For example, assume " 

1093 "a single 'zfs send/receive' operation is transferring incremental snapshots 1 through 10 via " 

1094 "'zfs send -I', but the operation fails while transferring snapshot 10, then snapshots 1 through 9 " 

1095 "need not be retransmitted regardless of the --no-resume-recv flag, as these snapshots have already " 

1096 "been successfully received at the destination either way.\n\n") 

1097 parser.add_argument( 

1098 "--create-bookmarks", choices=["all", "hourly", "minutely", "secondly", "none"], default="all", 

1099 help=f"For increased safety, {PROG_NAME} replication behaves as follows wrt. ZFS bookmark creation, if it is " 

1100 "autodetected that the source ZFS pool supports bookmarks:\n\n" 

1101 "* `all` (default): Whenever it has successfully completed a 'zfs send' operation, " 

1102 f"{PROG_NAME} creates a ZFS bookmark of each source snapshot that was sent during that 'zfs send' operation, " 

1103 "and attaches it to the source dataset. This increases safety at the expense of a little performance.\n\n" 

1104 "* `hourly`: Whenever it has successfully completed replication of the most recent source snapshot, " 

1105 f"{PROG_NAME} creates a ZFS bookmark of that snapshot, and attaches it to the source dataset. In addition, " 

1106 f"whenever it has successfully completed a 'zfs send' operation, {PROG_NAME} creates a ZFS bookmark of each " 

1107 f"hourly, daily, weekly, monthly and yearly source snapshot that was sent during that 'zfs send' operation, " 

1108 "and attaches it to the source dataset.\n\n" 

1109 "* `minutely` and `secondly`: Same as `hourly` except that it also creates ZFS bookmarks for minutely and " 

1110 "secondly snapshots, respectively.\n\n" 

1111 "* `none`: No bookmark is created.\n\n" 

1112 "Bookmarks exist so an incremental stream can continue to be sent from the source dataset without having " 

1113 "to keep the already replicated snapshot around on the source dataset until the next upcoming snapshot " 

1114 "has been successfully replicated. This way you can send the snapshot from the source dataset to another " 

1115 "host, then bookmark the snapshot on the source dataset, then delete the snapshot from the source " 

1116 "dataset to save disk space, and then still incrementally send the next upcoming snapshot from the " 

1117 "source dataset to the other host by referring to the bookmark.\n\n" 

1118 "The --create-bookmarks=none option disables this safety feature but is discouraged, because bookmarks " 

1119 "are tiny and relatively cheap and help to ensure that ZFS replication can continue even if source and " 

1120 "destination dataset somehow have no common snapshot anymore. " 

1121 "For example, if a pruning script has accidentally deleted too many (or even all) snapshots on the " 

1122 "source dataset in an effort to reclaim disk space, replication can still proceed because it can use " 

1123 "the info in the bookmark (the bookmark must still exist in the source dataset) instead of the info in " 

1124 "the metadata of the (now missing) source snapshot.\n\n" 

1125 "A ZFS bookmark is a tiny bit of metadata extracted from a ZFS snapshot by the 'zfs bookmark' CLI, and " 

1126 "attached to a dataset, much like a ZFS snapshot. Note that a ZFS bookmark does not contain user data; " 

1127 "instead a ZFS bookmark is essentially a tiny pointer in the form of the GUID of the snapshot and 64-bit " 

1128 "transaction group number of the snapshot and creation time of the snapshot, which is sufficient to tell " 

1129 "the destination ZFS pool how to find the destination snapshot corresponding to the source bookmark " 

1130 "and (potentially already deleted) source snapshot. A bookmark can be fed into 'zfs send' as the " 

1131 "source of an incremental send. Note that while a bookmark allows for its snapshot " 

1132 "to be deleted on the source after successful replication, it still requires that its snapshot is not " 

1133 "somehow deleted prematurely on the destination dataset, so be mindful of that. " 

1134 f"By convention, a bookmark created by {PROG_NAME} has the same name as its corresponding " 

1135 "snapshot, the only difference being the leading '#' separator instead of the leading '@' separator. " 

1136 "Also see https://www.youtube.com/watch?v=LaNgoAZeTww&t=316s.\n\n" 

1137 "You can list bookmarks, like so: " 

1138 "`zfs list -t bookmark -o name,guid,createtxg,creation -d 1 $SRC_DATASET`, and you can (and should) " 

1139 "periodically prune obsolete bookmarks just like snapshots, like so: " 

1140 "`zfs destroy $SRC_DATASET#$BOOKMARK`. Typically, bookmarks should be pruned less aggressively " 

1141 "than snapshots, and destination snapshots should be pruned less aggressively than source snapshots. " 

1142 "As an example starting point, here is a command that deletes all bookmarks older than " 

1143 "90 days, but retains the latest 200 bookmarks (per dataset) regardless of creation time: " 

1144 f"`{PROG_NAME} {DUMMY_DATASET} tank2/boo/bar --dryrun --recursive --skip-replication " 

1145 "--delete-dst-snapshots=bookmarks --include-snapshot-times-and-ranks notime 'all except latest 200' " 

1146 "--include-snapshot-times-and-ranks 'anytime..90 days ago'`\n\n") 

1147 parser.add_argument( 

1148 "--no-use-bookmark", action="store_true", 

1149 help=f"For increased safety, in normal replication operation {PROG_NAME} replication also looks for bookmarks " 

1150 "(in addition to snapshots) on the source dataset in order to find the most recent common snapshot wrt. the " 

1151 "destination dataset, if it is auto-detected that the source ZFS pool support bookmarks. " 

1152 "The --no-use-bookmark option disables this safety feature but is discouraged, because bookmarks help " 

1153 "to ensure that ZFS replication can continue even if source and destination dataset somehow have no " 

1154 "common snapshot anymore.\n\n" 

1155 f"Note that it does not matter whether a bookmark was created by {PROG_NAME} or a third party script, " 

1156 "as only the GUID of the bookmark and the GUID of the snapshot is considered for comparison, and ZFS " 

1157 "guarantees that any bookmark of a given snapshot automatically has the same GUID, transaction group " 

1158 "number and creation time as the snapshot. Also note that you can create, delete and prune bookmarks " 

1159 f"any way you like, as {PROG_NAME} (without --no-use-bookmark) will happily work with whatever " 

1160 "bookmarks currently exist, if any.\n\n") 

1161 

1162 ssh_cipher_default = "^aes256-gcm@openssh.com" 

1163 # ^aes256-gcm@openssh.com cipher: for speed with confidentiality and integrity 

1164 # measure cipher perf like so: count=5000; for i in $(seq 1 3); do echo "iteration $i:"; for cipher in $(ssh -Q cipher); do dd if=/dev/zero bs=1M count=$count 2> /dev/null | ssh -c $cipher -p 40999 127.0.0.1 "(time -p cat) > /dev/null" 2>&1 | grep real | awk -v count=$count -v cipher=$cipher '{print cipher ": " count / $2 " MB/s"}'; done; done 

1165 # see https://web.archive.org/web/20251011105141if_/https://gbe0.com/posts/linux/server/benchmark-ssh-ciphers/ 

1166 # and https://crypto.stackexchange.com/questions/43287/what-are-the-differences-between-these-aes-ciphers 

1167 parser.add_argument( 

1168 "--ssh-cipher", type=str, default=ssh_cipher_default, metavar="STRING", 

1169 help="SSH cipher specification for encrypting the session (optional); will be passed into ssh -c CLI. " 

1170 "--ssh-cipher is a comma-separated list of ciphers listed in order of preference. See the 'Ciphers' " 

1171 "keyword in ssh_config(5) for more information: " 

1172 "https://manpages.ubuntu.com/manpages/man5/ssh_config.5.html. Default: `%(default)s`\n\n") 

1173 

1174 locations = ["src", "dst"] 

1175 for loc in locations: 

1176 parser.add_argument( 

1177 f"--ssh-{loc}-user", type=str, metavar="STRING", 

1178 help=f"Remote SSH username on {loc} host to connect to (optional). Overrides username given in " 

1179 f"{loc.upper()}_DATASET.\n\n") 

1180 for loc in locations: 

1181 parser.add_argument( 

1182 f"--ssh-{loc}-host", type=str, metavar="STRING", 

1183 help=f"Remote SSH hostname of {loc} host to connect to (optional). Can also be an IPv4 or IPv6 address. " 

1184 f"Overrides hostname given in {loc.upper()}_DATASET.\n\n") 

1185 for loc in locations: 

1186 parser.add_argument( 

1187 f"--ssh-{loc}-port", type=int, min=1, max=65535, action=CheckRange, metavar="INT", 

1188 help=f"Remote SSH port on {loc} host to connect to (optional).\n\n") 

1189 for loc in locations: 

1190 parser.add_argument( 

1191 f"--ssh-{loc}-config-file", type=str, action=SSHConfigFileNameAction, metavar="FILE", 

1192 help=f"Path to SSH ssh_config(5) file to connect to {loc} (optional); will be passed into ssh -F CLI. " 

1193 "The basename must contain the substring 'bzfs_ssh_config'.\n\n") 

1194 control_persist_secs_dflt: int = 600 

1195 parser.add_argument( 

1196 "--ssh-exit-on-shutdown", action="store_true", 

1197 # help="On process shutdown, ask the SSH ControlMaster to exit immediately via 'ssh -O exit'. By default, masters " 

1198 # f"persist for {control_persist_secs_dflt} idle seconds and are reused across {PROG_NAME} processes to improve " 

1199 # f"startup latency when safe. A master is never used simultaneously by multiple {PROG_NAME} processes.") 

1200 help=argparse.SUPPRESS) 

1201 parser.add_argument( 

1202 "--ssh-control-persist-secs", type=int, min=1, default=control_persist_secs_dflt, action=CheckRange, metavar="INT", 

1203 help="The number of seconds an idle SSH connection will stay alive to improve latency on subsequent reuse (default: " 

1204 "%(default)s, min: %(min)s).\n\n") 

1205 parser.add_argument( 

1206 "--timeout", default=None, metavar="DURATION", 

1207 # help="Exit the program (or current task with non-zero --daemon-lifetime) with an error after this much time has " 

1208 # "elapsed. Default is to never timeout. Examples: '600 seconds', '90 minutes', '10years'\n\n") 

1209 help=argparse.SUPPRESS) 

1210 threads_default = 100 # percent 

1211 parser.add_argument( 

1212 "--threads", min=1, max=1600, default=(threads_default, True), action=CheckPercentRange, metavar="INT[%]", 

1213 help="The maximum number of threads to use for parallel operations; can be given as a positive integer, " 

1214 f"optionally followed by the %% percent character (min: %(min)s, default: {threads_default}%%). Percentages " 

1215 "are relative to the number of CPU cores on the machine. Example: 200%% uses twice as many threads as " 

1216 "there are cores on the machine; 75%% uses num_threads = num_cores * 0.75. Currently this option only " 

1217 "applies to dataset and snapshot replication, --create-src-snapshots, --delete-dst-snapshots, " 

1218 "--delete-empty-dst-datasets, --monitor-snapshots and --compare-snapshot-lists. The ideal value for this " 

1219 "parameter depends on the use case and its performance requirements, as well as the number of available CPU " 

1220 "cores and the parallelism offered by SSDs vs. HDDs, ZFS topology and configuration, as well as the network " 

1221 "bandwidth and other workloads simultaneously running on the system. The current default is geared towards a " 

1222 "high degree of parallelism, and as such may perform poorly on HDDs. Examples: 1, 4, 75%%, 150%%\n\n") 

1223 parser.add_argument( 

1224 "--max-concurrent-ssh-sessions-per-tcp-connection", type=int, min=1, default=8, action=CheckRange, metavar="INT", 

1225 help=f"For best throughput, {PROG_NAME} uses multiple SSH TCP connections in parallel, as indicated by " 

1226 "--threads (see above). For best startup latency, each such parallel TCP connection can carry a " 

1227 "maximum of S concurrent SSH sessions, where " 

1228 "S=--max-concurrent-ssh-sessions-per-tcp-connection (default: %(default)s, min: %(min)s). " 

1229 "Concurrent SSH sessions are mostly used for metadata operations such as listing ZFS datasets and their " 

1230 "snapshots. This client-side max sessions parameter must not be higher than the server-side " 

1231 "sshd_config(5) MaxSessions parameter (which defaults to 10, see " 

1232 "https://manpages.ubuntu.com/manpages/man5/sshd_config.5.html).\n\n" 

1233 f"*Note:* For better throughput, {PROG_NAME} uses one dedicated TCP connection per ZFS " 

1234 "send/receive operation such that the dedicated connection is never used by any other " 

1235 "concurrent SSH session, effectively ignoring the value of the " 

1236 "--max-concurrent-ssh-sessions-per-tcp-connection parameter in the ZFS send/receive case.\n\n") 

1237 parser.add_argument( 

1238 "--r2r", choices=["off", "pull", "push"], default="off", 

1239 help="For remote-to-remote replication, controls whether the `zfs send` stream is relayed by the initiator " 

1240 "or transferred directly between source and destination in order to improve the throughput of bulk data " 

1241 "transfers (default: %(default)s). This option has no effect unless both replication endpoints are remote.\n\n" 

1242 "* `off`: Keeps existing pull-push behavior where the initiator acts as an intermediary that relays the " 

1243 "`zfs send` stream between source and destination, which can become a central bandwidth bottleneck.\n\n" 

1244 "Example: ssh alice@srchost 'zfs send ...' | ssh bob@dsthost 'zfs receive ...'\n\n" 

1245 "* `pull`: Tells the destination host to pull the `zfs send` stream directly from the source host. Requires " 

1246 "`sh` and `ssh` on the destination host, plus SSH setup such that the destination user@host can `ssh` into " 

1247 "the source host.\n\n" 

1248 "Example: ssh bob@dsthost \"sh -c 'ssh alice@srchost zfs send ... | zfs receive ...'\"\n\n" 

1249 "* `push`: Tells the source host to push the `zfs send` stream directly to the destination host. Requires " 

1250 "`sh` and `ssh` on the source host, plus SSH setup such that the source user@host can `ssh` into the " 

1251 "destination host.\n\n" 

1252 "Example: ssh alice@srchost \"sh -c 'zfs send ... | ssh bob@dsthost zfs receive ...'\"\n\n" 

1253 "*Note:* Orchestration still runs on the initiator; only the bulk data path changes. It is recommended to " 

1254 "also set `--ssh-src-user` and `--ssh-dst-user` explicitly in order to avoid potential confusion about which " 

1255 "SSH user account performs the nested remote-to-remote operation.\n\n" 

1256 f"*Note:* If the required nested `sh`/`ssh` do not exist, {PROG_NAME} falls back to `--r2r=off`. " 

1257 "`--r2r=pull` falls back to `--r2r=off` if `--ssh-src-config-file` is set to a non-empty value other than " 

1258 "`none`, and `--r2r=push` falls back to `--r2r=off` if `--ssh-dst-config-file` is set to a non-empty value " 

1259 "other than `none`.\n\n" 

1260 "*Note:* `--pv*` progress reporting options have no effect in r2r modes; progress reporting is disabled.\n\n") 

1261 parser.add_argument( 

1262 "--bwlimit", default=None, action=NonEmptyStringAction, metavar="STRING", 

1263 help="Sets `pv` and `mbuffer` bandwidth rate limit for zfs send/receive data transfer (optional). " 

1264 "Example: `100m` to cap throughput at 100 MB/sec. Default is unlimited. Also see " 

1265 "https://manpages.ubuntu.com/manpages/man1/pv.1.html\n\n") 

1266 parser.add_argument( 

1267 "--daemon-lifetime", default="0 seconds", metavar="DURATION", 

1268 # help="Exit the daemon after this much time has elapsed. Default is '0 seconds', i.e. no daemon mode. " 

1269 # "Examples: '600 seconds', '86400 seconds', '1000years'\n\n") 

1270 help=argparse.SUPPRESS) 

1271 parser.add_argument( 

1272 "--daemon-frequency", default="minutely", metavar="STRING", 

1273 # help="Run a daemon iteration every N time units. Default is '%(default)s'. " 

1274 # "Examples: '100 millisecondly', '10secondly, 'minutely' to request the daemon to run every 100 milliseconds, " 

1275 # "or every 10 seconds, or every minute, respectively. Only has an effect if --daemon-lifetime is nonzero.\n\n") 

1276 help=argparse.SUPPRESS) 

1277 parser.add_argument( 

1278 "--daemon-remote-conf-cache-ttl", default="300 seconds", metavar="DURATION", 

1279 # help="The Time-To-Live for the remote host configuration cache, which stores available programs and " 

1280 # f"ZFS features. After this duration, {prog_name} will re-detect the remote environment. Set to '0 seconds' " 

1281 # "to re-detect on every daemon iteration. Default: %(default)s.\n\n") 

1282 help=argparse.SUPPRESS) 

1283 parser.add_argument( 

1284 "--no-estimate-send-size", action="store_true", 

1285 help="Skip 'zfs send -n -v'. This can improve performance if replicating small snapshots at high frequency.\n\n") 

1286 

1287 def hlp(program: str) -> str: 

1288 return f"The name of the '{program}' executable (optional). Default is '{program}'. " 

1289 

1290 msg: str = f"Use '{DISABLE_PRG}' to disable the use of this program.\n\n" 

1291 parser.add_argument( 

1292 "--compression-program", default="zstd", choices=["zstd", "lz4", "pzstd", "pigz", "gzip", DISABLE_PRG], 

1293 help=hlp("zstd") + msg.rstrip() + " The use is auto-disabled if data is transferred locally instead of via the " 

1294 "network. This option is about transparent compression-on-the-wire, not about " 

1295 "compression-at-rest.\n\n") 

1296 parser.add_argument( 

1297 "--compression-program-opts", default="-1", metavar="STRING", 

1298 help="The options to be passed to the compression program on the compression step (optional). " 

1299 "Default is '%(default)s' (fastest).\n\n") 

1300 parser.add_argument( 

1301 "--mbuffer-program", default="mbuffer", choices=["mbuffer", DISABLE_PRG], 

1302 help=hlp("mbuffer") + msg.rstrip() + " The use is auto-disabled if data is transferred locally " 

1303 "instead of via the network. This tool is used to smooth out the rate " 

1304 "of data flow and prevent bottlenecks caused by network latency or " 

1305 "speed fluctuation.\n\n") 

1306 parser.add_argument( 

1307 "--mbuffer-program-opts", default="-q -m 128M", metavar="STRING", 

1308 help="Options to be passed to 'mbuffer' program (optional). Default: '%(default)s'.\n\n") 

1309 parser.add_argument( 

1310 "--ps-program", default="ps", choices=["ps", DISABLE_PRG], 

1311 help=hlp("ps") + msg) 

1312 parser.add_argument( 

1313 "--pv-program", default="pv", choices=["pv", DISABLE_PRG], 

1314 help=hlp("pv") + msg.rstrip() + " This is used for bandwidth rate-limiting and progress monitoring.\n\n") 

1315 parser.add_argument( 

1316 "--pv-program-opts", metavar="STRING", 

1317 default="--progress --timer --eta --fineta --rate --average-rate --bytes --interval=1 --width=120 --buffer-size=2M", 

1318 help="The options to be passed to the 'pv' program (optional). Default: '%(default)s'.\n\n") 

1319 parser.add_argument( 

1320 "--shell-program", default="sh", choices=["sh", DISABLE_PRG], 

1321 help=hlp("sh") + msg) 

1322 parser.add_argument( 

1323 "--ssh-program", default="ssh", choices=["ssh", "hpnssh", DISABLE_PRG], 

1324 help=hlp("ssh") + msg) 

1325 parser.add_argument( 

1326 "--sudo-program", default="sudo", choices=["sudo", "doas", DISABLE_PRG], 

1327 help=hlp("sudo") + msg) 

1328 parser.add_argument( 

1329 "--zpool-program", default="zpool", choices=["zpool", DISABLE_PRG], 

1330 help=hlp("zpool") + msg) 

1331 parser.add_argument( 

1332 "--log-dir", type=str, action=SafeDirectoryNameAction, metavar="DIR", 

1333 help=f"Path to the log output directory on local host (optional). Default: $HOME/{LOG_DIR_DEFAULT}. The logger " 

1334 "that is used by default writes log files there, in addition to the console. The basename of --log-dir must " 

1335 f"contain the substring '{LOG_DIR_DEFAULT}' as this helps prevent accidents. The current.dir symlink " 

1336 "always points to the subdirectory containing the most recent log file. The current.log symlink " 

1337 "always points to the most recent log file. The current.pv symlink always points to the most recent " 

1338 "data transfer monitoring log. Run `tail --follow=name --max-unchanged-stats=1` on both symlinks to " 

1339 "follow what's currently going on. Parallel replication generates a separate .pv file per thread. To " 

1340 "monitor these, run something like " 

1341 "`while true; do clear; for f in $(realpath $HOME/bzfs-logs/current/current.pv)*; " 

1342 "do tac -s $(printf '\\r') $f | tr '\\r' '\\n' | grep -m1 -v '^$'; done; sleep 1; done`\n\n") 

1343 h_fix = ("The path name of the log file on local host is " 

1344 "`${--log-dir}/${--log-file-prefix}<timestamp>${--log-file-infix}${--log-file-suffix}-<random>.log`. " 

1345 "Example: `--log-file-prefix=zrun_us-west_ --log-file-suffix=_daily` will generate log " 

1346 "file names such as `zrun_us-west_2024-09-03_12:26:15_daily-bl4i1fth.log`\n\n") 

1347 parser.add_argument( 

1348 "--log-file-prefix", default="zrun_", action=SafeFileNameAction, metavar="STRING", 

1349 help="Default is %(default)s. " + h_fix) 

1350 parser.add_argument( 

1351 "--log-file-infix", default="", action=SafeFileNameAction, metavar="STRING", 

1352 help="Default is the empty string. " + h_fix) 

1353 parser.add_argument( 

1354 "--log-file-suffix", default="", action=SafeFileNameAction, metavar="STRING", 

1355 help="Default is the empty string. " + h_fix) 

1356 parser.add_argument( 

1357 "--log-subdir", choices=["daily", "hourly", "minutely"], default="daily", 

1358 help="Make a new subdirectory in --log-dir every day, hour or minute; write log files there. " 

1359 "Default is '%(default)s'.") 

1360 parser.add_argument( 

1361 "--log-syslog-address", default=None, action=NonEmptyStringAction, metavar="STRING", 

1362 help="Host:port of the syslog machine to send messages to (e.g. 'foo.example.com:514' or '127.0.0.1:514'), or " 

1363 "the file system path to the syslog socket file on localhost (e.g. '/dev/log'). The default is no " 

1364 "address, i.e. do not log anything to syslog by default. See " 

1365 "https://docs.python.org/3/library/logging.handlers.html#sysloghandler\n\n") 

1366 parser.add_argument( 

1367 "--log-syslog-socktype", choices=["UDP", "TCP"], default="UDP", 

1368 help="The socket type to use to connect if no local socket file system path is used. Default is '%(default)s'.\n\n") 

1369 parser.add_argument( 

1370 "--log-syslog-facility", type=int, min=0, max=7, default=1, action=CheckRange, metavar="INT", 

1371 help="The local facility aka category that identifies msg sources in syslog " 

1372 "(default: %(default)s, min=%(min)s, max=%(max)s).\n\n") 

1373 parser.add_argument( 

1374 "--log-syslog-prefix", default=PROG_NAME, action=NonEmptyStringAction, metavar="STRING", 

1375 help=f"The name to prepend to each message that is sent to syslog; identifies {PROG_NAME} messages as opposed " 

1376 "to messages from other sources. Default is '%(default)s'.\n\n") 

1377 parser.add_argument( 

1378 "--log-syslog-level", choices=["CRITICAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"], 

1379 default="ERROR", 

1380 help="Only send messages with equal or higher priority than this log level to syslog. Default is '%(default)s'.\n\n") 

1381 parser.add_argument( 

1382 "--include-envvar-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

1383 help="On program startup, unset all Unix environment variables for which the full environment variable " 

1384 "name matches at least one of the excludes but none of the includes. If an environment variable is " 

1385 "included this decision is never reconsidered because include takes precedence over exclude. " 

1386 "The purpose is to tighten security and help guard against accidental inheritance or malicious " 

1387 "injection of environment variable values that may have unintended effects.\n\n" 

1388 "This option can be specified multiple times. " 

1389 "A leading `!` character indicates logical negation, i.e. the regex matches if the regex with the " 

1390 "leading `!` character removed does not match. " 

1391 "The default is to include no environment variables, i.e. to make no exceptions to --exclude-envvar-regex. " 

1392 "Example that retains at least these two env vars: " 

1393 "`--include-envvar-regex PATH " 

1394 f"--include-envvar-regex {ENV_VAR_PREFIX}min_pipe_transfer_size`. " 

1395 "Example that retains all environment variables without tightened security: `'.*'`\n\n") 

1396 parser.add_argument( 

1397 "--exclude-envvar-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

1398 help="Same syntax as --include-envvar-regex (see above) except that the default is to exclude no " 

1399 f"environment variables. Example: `{ENV_VAR_PREFIX}.*`\n\n") 

1400 

1401 for period, label in {"yearly": "years", "monthly": "months", "weekly": "weeks", "daily": "days", "hourly": "hours", 

1402 "minutely": "minutes", "secondly": "seconds", "millisecondly": "milliseconds"}.items(): 

1403 anchor_group = parser.add_argument_group( 

1404 f"{period.title()} period anchors", "Use these options to customize when snapshots that happen " 

1405 f"every N {label} are scheduled to be created on the source by the --create-src-snapshots option.") 

1406 for f in [f for f in dataclasses.fields(PeriodAnchors) if f.name.startswith(period + "_")]: 

1407 min_ = f.metadata.get("min") 

1408 max_ = f.metadata.get("max") 

1409 anchor_group.add_argument( 

1410 "--" + f.name, type=int, min=min_, max=max_, default=f.default, action=CheckRange, metavar="INT", 

1411 help=f"{f.metadata.get('help')} ({min_} ≤ x ≤ {max_}, default: %(default)s).\n\n") 

1412 

1413 for option_name, flag in ZFS_RECV_GROUPS.items(): 

1414 grup: str = option_name.replace("_", "-") # one of zfs_recv_o, zfs_recv_x 

1415 flag = "'" + flag + "'" # one of -o or -x 

1416 

1417 def h(text: str, option_name: str=option_name) -> str: 

1418 return argparse.SUPPRESS if option_name not in (ZFS_RECV_O, ZFS_RECV_X) else text 

1419 

1420 argument_group = parser.add_argument_group( 

1421 grup, 

1422 description=h(f"The following group of parameters specifies additional zfs receive {flag} options that " 

1423 "can be used to configure copying of ZFS dataset properties from the source dataset to " 

1424 "its corresponding destination dataset. The 'zfs-recv-o' group of parameters is applied " 

1425 "before the 'zfs-recv-x' group.")) 

1426 target_choices = ["full", "incremental", "full+incremental"] 

1427 target_choices_default = "full+incremental" if option_name == ZFS_RECV_X else "full" 

1428 qq = "'" 

1429 argument_group.add_argument( 

1430 f"--{grup}-targets", choices=target_choices, default=target_choices_default, 

1431 help=h(f"The zfs send phase or phases during which the extra {flag} options are passed to 'zfs receive'. " 

1432 "This can be one of the following choices: " 

1433 f"{', '.join([f'{qq}{x}{qq}' for x in target_choices])}. " 

1434 "Default is '%(default)s'. " 

1435 "A 'full' send is sometimes also known as an 'initial' send.\n\n")) 

1436 msg = "Thus, -x opts do not benefit from source != 'local' (which is the default already)." \ 

1437 if flag == "'-x'" else "" 

1438 argument_group.add_argument( 

1439 f"--{grup}-sources", action=NonEmptyStringAction, default="local", metavar="STRING", 

1440 help=h("The ZFS sources to provide to the 'zfs get -s' CLI in order to fetch the ZFS dataset properties " 

1441 f"that will be fed into the --{grup}-include/exclude-regex filter (see below). The sources are in " 

1442 "the form of a comma-separated list (no spaces) containing one or more of the following choices: " 

1443 "'local', 'default', 'inherited', 'temporary', 'received', 'none', with the default being '%(default)s'. " 

1444 f"Uses 'zfs get -p -s ${grup}-sources all $SRC_DATASET' to fetch the " 

1445 "properties to copy - https://openzfs.github.io/openzfs-docs/man/master/8/zfs-get.8.html. P.S: Note " 

1446 "that the existing 'zfs send --props' option does not filter and that --props only reads properties " 

1447 f"from the 'local' ZFS property source (https://github.com/openzfs/zfs/issues/13024). {msg}\n\n")) 

1448 if option_name == ZFS_RECV_O: 

1449 group_include_regex_default_help: str = f"The default regex is '{ZFS_RECV_O_INCLUDE_REGEX_DEFAULT}'." 

1450 else: 

1451 group_include_regex_default_help = ("The default is to include no properties, thus by default no extra " 

1452 f"{flag} option is appended. ") 

1453 argument_group.add_argument( 

1454 f"--{grup}-include-regex", action=FileOrLiteralAction, default=None, const=[], nargs="*", metavar="REGEX", 

1455 help=h(f"Take the output properties of --{grup}-sources (see above) and filter them such that we only " 

1456 "retain the properties whose name matches at least one of the --include regexes but none of the " 

1457 "--exclude regexes. If a property is excluded this decision is never reconsidered because exclude " 

1458 f"takes precedence over include. Append each retained property to the list of {flag} options in " 

1459 "--zfs-recv-program-opt(s), unless another '-o' or '-x' option with the same name already exists " 

1460 "therein. In other words, --zfs-recv-program-opt(s) takes precedence.\n\n" 

1461 f"Zero or more regexes can be specified. Specify zero regexes to append no extra {flag} option. " 

1462 "A leading `!` character indicates logical negation, i.e. the regex matches if the regex with the " 

1463 "leading `!` character removed does not match. " 

1464 "If the option starts with a `+` prefix then regexes are read from the newline-separated " 

1465 "UTF-8 text file given after the `+` prefix, one regex per line inside of the text file. The basename " 

1466 "must contain the substring 'bzfs_argument_file'.\n\n" 

1467 f"{group_include_regex_default_help} " 

1468 f"Example: `--{grup}-include-regex compression recordsize`. " 

1469 "More examples: `.*` (include all properties), `foo bar myapp:.*` (include three regexes) " 

1470 f"`+{grup}_regexes_bzfs_argument_file.txt`, `+/path/to/{grup}_regexes_bzfs_argument_file.txt`\n\n" 

1471 "See https://openzfs.github.io/openzfs-docs/man/master/7/zfsprops.7.html\n\n")) 

1472 argument_group.add_argument( 

1473 f"--{grup}-exclude-regex", action=FileOrLiteralAction, nargs="+", default=[], metavar="REGEX", 

1474 help=h(f"Same syntax as --{grup}-include-regex (see above), and the default is to exclude no properties. " 

1475 f"Example: --{grup}-exclude-regex encryptionroot keystatus origin volblocksize volsize\n\n")) 

1476 parser.add_argument( 

1477 "--version", action="version", version=f"{PROG_NAME}-{__version__}, by {PROG_AUTHOR}", 

1478 help="Display version information and exit.\n\n") 

1479 parser.add_argument( 

1480 "--help, -h", action="help", # trick to ensure both --help and -h are shown in the help msg 

1481 help="Show this help message and exit.\n\n") 

1482 return parser 

1483 # fmt: on