lvm.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import collections
  2. import glob
  3. import hashlib
  4. import json
  5. import logging
  6. import os
  7. import shutil
  8. import subprocess
  9. import borgmatic.borg.pattern
  10. import borgmatic.config.paths
  11. import borgmatic.execute
  12. import borgmatic.hooks.data_source.config
  13. import borgmatic.hooks.data_source.snapshot
  14. logger = logging.getLogger(__name__)
  15. def use_streaming(hook_config, config): # pragma: no cover
  16. '''
  17. Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
  18. '''
  19. return False
  20. BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
  21. Logical_volume = collections.namedtuple(
  22. 'Logical_volume',
  23. ('name', 'device_path', 'mount_point', 'contained_patterns'),
  24. )
  25. def get_logical_volumes(lsblk_command, patterns=None):
  26. '''
  27. Given an lsblk command to run and a sequence of configured patterns, find the intersection
  28. between the current LVM logical volume mount points and the paths of any patterns. The idea is
  29. that these pattern paths represent the requested logical volumes to snapshot.
  30. Only include logical volumes that contain at least one root pattern sourced from borgmatic
  31. configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, include
  32. all logical volume mounts points instead, not just those in patterns.
  33. Return the result as a sequence of Logical_volume instances.
  34. '''
  35. try:
  36. devices_info = json.loads(
  37. borgmatic.execute.execute_command_and_capture_output(
  38. # Use lsblk instead of lvs here because lvs can't show active mounts.
  39. (
  40. *lsblk_command.split(' '),
  41. '--output',
  42. 'name,path,mountpoint,type',
  43. '--json',
  44. '--list',
  45. ),
  46. close_fds=True,
  47. ),
  48. )
  49. except json.JSONDecodeError as error:
  50. raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
  51. candidate_patterns = set(patterns or ())
  52. try:
  53. # Sort from longest to shortest mount points, so longer mount points get a whack at the
  54. # candidate pattern piñata before their parents do. (Patterns are consumed below, so no two
  55. # logical volumes end up with the same contained patterns.)
  56. return tuple(
  57. Logical_volume(device['name'], device['path'], device['mountpoint'], contained_patterns)
  58. for device in sorted(
  59. devices_info['blockdevices'],
  60. key=lambda device: device['mountpoint'] or '',
  61. reverse=True,
  62. )
  63. if device['mountpoint'] and device['type'] == 'lvm'
  64. for contained_patterns in (
  65. borgmatic.hooks.data_source.snapshot.get_contained_patterns(
  66. device['mountpoint'],
  67. candidate_patterns,
  68. ),
  69. )
  70. if not patterns
  71. or any(
  72. pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
  73. and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
  74. for pattern in contained_patterns
  75. )
  76. )
  77. except KeyError as error:
  78. raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
  79. def snapshot_logical_volume(
  80. lvcreate_command,
  81. snapshot_name,
  82. logical_volume_device,
  83. snapshot_size,
  84. ):
  85. '''
  86. Given an lvcreate command to run, a snapshot name, the path to the logical volume device to
  87. snapshot, and a snapshot size string, create a new LVM snapshot.
  88. '''
  89. borgmatic.execute.execute_command(
  90. (
  91. *lvcreate_command.split(' '),
  92. '--snapshot',
  93. ('--extents' if '%' in snapshot_size else '--size'),
  94. snapshot_size,
  95. '--permission',
  96. 'rw', # Read-write in case an ext4 filesystem has orphaned files that need recovery.
  97. '--name',
  98. snapshot_name,
  99. logical_volume_device,
  100. ),
  101. output_log_level=logging.DEBUG,
  102. close_fds=True,
  103. )
  104. def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # pragma: no cover
  105. '''
  106. Given a mount command to run, the device path for an existing snapshot, and the path where the
  107. snapshot should be mounted, mount the snapshot as read-only (making any necessary directories
  108. first).
  109. '''
  110. os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
  111. borgmatic.execute.execute_command(
  112. (
  113. *mount_command.split(' '),
  114. '-o',
  115. 'ro',
  116. snapshot_device,
  117. snapshot_mount_path,
  118. ),
  119. output_log_level=logging.DEBUG,
  120. close_fds=True,
  121. )
  122. MOUNT_POINT_HASH_LENGTH = 10
  123. def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_directory):
  124. '''
  125. Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and a Logical_volume
  126. containing it, return a new Pattern with its path rewritten to be in a snapshot directory based
  127. on both the given runtime directory and the given Logical_volume's mount point.
  128. Move any initial caret in a regular expression pattern path to the beginning, so as not to break
  129. the regular expression.
  130. '''
  131. initial_caret = (
  132. '^'
  133. if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
  134. and pattern.path.startswith('^')
  135. else ''
  136. )
  137. rewritten_path = initial_caret + os.path.join(
  138. normalized_runtime_directory,
  139. 'lvm_snapshots',
  140. # Including this hash prevents conflicts between snapshot patterns for different logical
  141. # volumes. For instance, without this, snapshotting a logical volume at /var and another at
  142. # /var/spool would result in overlapping snapshot patterns and therefore colliding mount
  143. # attempts.
  144. hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
  145. MOUNT_POINT_HASH_LENGTH,
  146. ),
  147. # Use the Borg 1.4+ "slashdot" hack to prevent the snapshot path prefix from getting
  148. # included in the archive—but only if there's not already a slashdot hack present in the
  149. # pattern.
  150. ('' if f'{os.path.sep}.{os.path.sep}' in pattern.path else '.'),
  151. # Included so that the source directory ends up in the Borg archive at its "original" path.
  152. pattern.path.lstrip('^').lstrip(os.path.sep),
  153. )
  154. return borgmatic.borg.pattern.Pattern(
  155. rewritten_path,
  156. pattern.type,
  157. pattern.style,
  158. pattern.device,
  159. source=borgmatic.borg.pattern.Pattern_source.HOOK,
  160. )
  161. DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
  162. def dump_data_sources(
  163. hook_config,
  164. config,
  165. config_paths,
  166. borgmatic_runtime_directory,
  167. patterns,
  168. dry_run,
  169. ):
  170. '''
  171. Given an LVM configuration dict, a configuration dict, the borgmatic configuration file paths,
  172. the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
  173. auto-detect and snapshot any LVM logical volume mount points listed in the given patterns. Also
  174. update those patterns, replacing logical volume mount points with corresponding snapshot
  175. directories so they get stored in the Borg archive instead.
  176. Return an empty sequence, since there are no ongoing dump processes from this hook.
  177. If this is a dry run, then don't actually snapshot anything.
  178. '''
  179. dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
  180. logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
  181. # List logical volumes to get their mount points, but only consider those patterns that came
  182. # from actual user configuration (as opposed to, say, other hooks).
  183. lsblk_command = hook_config.get('lsblk_command', 'lsblk')
  184. requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
  185. # Snapshot each logical volume, rewriting source directories to use the snapshot paths.
  186. snapshot_suffix = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
  187. normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
  188. if not requested_logical_volumes:
  189. logger.warning(f'No LVM logical volumes found to snapshot{dry_run_label}')
  190. for logical_volume in requested_logical_volumes:
  191. snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
  192. logger.debug(
  193. f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}',
  194. )
  195. if not dry_run:
  196. snapshot_logical_volume(
  197. hook_config.get('lvcreate_command', 'lvcreate'),
  198. snapshot_name,
  199. logical_volume.device_path,
  200. hook_config.get('snapshot_size', DEFAULT_SNAPSHOT_SIZE),
  201. )
  202. # Get the device path for the snapshot we just created.
  203. if not dry_run:
  204. try:
  205. snapshot = get_snapshots(
  206. hook_config.get('lvs_command', 'lvs'),
  207. snapshot_name=snapshot_name,
  208. )[0]
  209. except IndexError:
  210. raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
  211. # Mount the snapshot into a particular named temporary directory so that the snapshot ends
  212. # up in the Borg archive at the "original" logical volume mount point path.
  213. snapshot_mount_path = os.path.join(
  214. normalized_runtime_directory,
  215. 'lvm_snapshots',
  216. hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
  217. MOUNT_POINT_HASH_LENGTH,
  218. ),
  219. logical_volume.mount_point.lstrip(os.path.sep),
  220. )
  221. logger.debug(
  222. f'Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}',
  223. )
  224. if dry_run:
  225. continue
  226. mount_snapshot(
  227. hook_config.get('mount_command', 'mount'),
  228. snapshot.device_path,
  229. snapshot_mount_path,
  230. )
  231. for pattern in logical_volume.contained_patterns:
  232. snapshot_pattern = make_borg_snapshot_pattern(
  233. pattern,
  234. logical_volume,
  235. normalized_runtime_directory,
  236. )
  237. borgmatic.hooks.data_source.config.replace_pattern(patterns, pattern, snapshot_pattern)
  238. return []
  239. def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
  240. '''
  241. Given a umount command to run and the mount path of a snapshot, unmount it.
  242. '''
  243. borgmatic.execute.execute_command(
  244. (*umount_command.split(' '), snapshot_mount_path),
  245. output_log_level=logging.DEBUG,
  246. close_fds=True,
  247. )
  248. def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
  249. '''
  250. Given an lvremove command to run and the device path of a snapshot, remove it it.
  251. '''
  252. borgmatic.execute.execute_command(
  253. (
  254. *lvremove_command.split(' '),
  255. '--force', # Suppress an interactive "are you sure?" type prompt.
  256. snapshot_device_path,
  257. ),
  258. output_log_level=logging.DEBUG,
  259. close_fds=True,
  260. )
  261. Snapshot = collections.namedtuple(
  262. 'Snapshot',
  263. ('name', 'device_path'),
  264. )
  265. def get_snapshots(lvs_command, snapshot_name=None):
  266. '''
  267. Given an lvs command to run, return all LVM snapshots as a sequence of Snapshot instances.
  268. If a snapshot name is given, filter the results to that snapshot.
  269. '''
  270. try:
  271. snapshot_info = json.loads(
  272. borgmatic.execute.execute_command_and_capture_output(
  273. # Use lvs instead of lsblk here because lsblk can't filter to just snapshots.
  274. (
  275. *lvs_command.split(' '),
  276. '--report-format',
  277. 'json',
  278. '--options',
  279. 'lv_name,lv_path',
  280. '--select',
  281. 'lv_attr =~ ^s', # Filter to just snapshots.
  282. ),
  283. close_fds=True,
  284. ),
  285. )
  286. except json.JSONDecodeError as error:
  287. raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
  288. try:
  289. return tuple(
  290. Snapshot(snapshot['lv_name'], snapshot['lv_path'])
  291. for snapshot in snapshot_info['report'][0]['lv']
  292. if snapshot_name is None or snapshot['lv_name'] == snapshot_name
  293. )
  294. except IndexError:
  295. raise ValueError(f'Invalid {lvs_command} output: Missing report data')
  296. except KeyError as error:
  297. raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
  298. def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, patterns, dry_run): # noqa: PLR0912
  299. '''
  300. Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, the
  301. configured patterns, and whether this is a dry run, unmount and delete any LVM snapshots created
  302. by borgmatic. If this is a dry run or LVM isn't configured in borgmatic's configuration, then
  303. don't actually remove anything.
  304. '''
  305. if hook_config is None:
  306. return
  307. dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
  308. # Unmount snapshots.
  309. try:
  310. logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
  311. except FileNotFoundError as error:
  312. logger.debug(f'Could not find "{error.filename}" command')
  313. return
  314. except subprocess.CalledProcessError as error:
  315. logger.debug(error)
  316. return
  317. snapshots_glob = os.path.join(
  318. borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
  319. os.path.normpath(borgmatic_runtime_directory),
  320. ),
  321. 'lvm_snapshots',
  322. '*',
  323. )
  324. logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
  325. umount_command = hook_config.get('umount_command', 'umount')
  326. for snapshots_directory in glob.glob(snapshots_glob):
  327. if not os.path.isdir(snapshots_directory):
  328. continue
  329. for logical_volume in logical_volumes:
  330. snapshot_mount_path = os.path.join(
  331. snapshots_directory,
  332. logical_volume.mount_point.lstrip(os.path.sep),
  333. )
  334. # If the snapshot mount path is empty, this is probably just a "shadow" of a nested
  335. # logical volume and therefore there's nothing to unmount.
  336. if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
  337. continue
  338. # This might fail if the directory is already mounted, but we swallow errors here since
  339. # we'll do another recursive delete below. The point of doing it here is that we don't
  340. # want to try to unmount a non-mounted directory (which *will* fail).
  341. if not dry_run:
  342. shutil.rmtree(snapshot_mount_path, ignore_errors=True)
  343. # If the delete was successful, that means there's nothing to unmount.
  344. if not os.path.isdir(snapshot_mount_path):
  345. continue
  346. logger.debug(f'Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}')
  347. if dry_run:
  348. continue
  349. try:
  350. unmount_snapshot(umount_command, snapshot_mount_path)
  351. except FileNotFoundError:
  352. logger.debug(f'Could not find "{umount_command}" command')
  353. return
  354. except subprocess.CalledProcessError as error:
  355. logger.debug(error)
  356. continue
  357. if not dry_run:
  358. shutil.rmtree(snapshots_directory, ignore_errors=True)
  359. # Delete snapshots.
  360. lvremove_command = hook_config.get('lvremove_command', 'lvremove')
  361. try:
  362. snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
  363. except FileNotFoundError as error:
  364. logger.debug(f'Could not find "{error.filename}" command')
  365. return
  366. except subprocess.CalledProcessError as error:
  367. logger.debug(error)
  368. return
  369. for snapshot in snapshots:
  370. # Only delete snapshots that borgmatic actually created!
  371. if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
  372. continue
  373. logger.debug(f'Deleting LVM snapshot {snapshot.name}{dry_run_label}')
  374. if not dry_run:
  375. remove_snapshot(lvremove_command, snapshot.device_path)
  376. def make_data_source_dump_patterns(
  377. hook_config,
  378. config,
  379. borgmatic_runtime_directory,
  380. name=None,
  381. ): # pragma: no cover
  382. '''
  383. Restores aren't implemented, because stored files can be extracted directly with "extract".
  384. '''
  385. return ()
  386. def restore_data_source_dump(
  387. hook_config,
  388. config,
  389. data_source,
  390. dry_run,
  391. extract_process,
  392. connection_params,
  393. borgmatic_runtime_directory,
  394. ): # pragma: no cover
  395. '''
  396. Restores aren't implemented, because stored files can be extracted directly with "extract".
  397. '''
  398. raise NotImplementedError()