btrfs.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import collections
  2. import glob
  3. import logging
  4. import os
  5. import shutil
  6. import subprocess
  7. import borgmatic.config.paths
  8. import borgmatic.execute
  9. import borgmatic.hooks.data_source.snapshot
  10. logger = logging.getLogger(__name__)
  11. def use_streaming(hook_config, config, log_prefix): # pragma: no cover
  12. '''
  13. Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
  14. '''
  15. return False
  16. def get_filesystem_mount_points(findmnt_command):
  17. '''
  18. Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
  19. '''
  20. findmnt_output = borgmatic.execute.execute_command_and_capture_output(
  21. tuple(findmnt_command.split(' '))
  22. + (
  23. '-n', # No headings.
  24. '-t', # Filesystem type.
  25. 'btrfs',
  26. )
  27. )
  28. return tuple(line.rstrip().split(' ')[0] for line in findmnt_output.splitlines())
  29. def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
  30. '''
  31. Given a Btrfs command to run and a Btrfs filesystem mount point, get the sorted subvolumes for
  32. that filesystem. Include the filesystem itself.
  33. '''
  34. btrfs_output = borgmatic.execute.execute_command_and_capture_output(
  35. tuple(btrfs_command.split(' '))
  36. + (
  37. 'subvolume',
  38. 'list',
  39. filesystem_mount_point,
  40. )
  41. )
  42. if not filesystem_mount_point.strip():
  43. return ()
  44. return (filesystem_mount_point,) + tuple(
  45. sorted(
  46. subvolume_path
  47. for line in btrfs_output.splitlines()
  48. for subvolume_subpath in (line.rstrip().split(' ')[-1],)
  49. for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
  50. if subvolume_subpath.strip()
  51. )
  52. )
  53. Subvolume = collections.namedtuple(
  54. 'Subvolume', ('path', 'contained_source_directories'), defaults=((),)
  55. )
  56. def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
  57. '''
  58. Given a Btrfs command to run and a sequence of configured source directories, find the
  59. intersection between the current Btrfs filesystem and subvolume mount points and the configured
  60. borgmatic source directories. The idea is that these are the requested subvolumes to snapshot.
  61. If the source directories is None, then return all subvolumes, sorted by path.
  62. Return the result as a sequence of matching subvolume mount points.
  63. '''
  64. candidate_source_directories = set(source_directories or ())
  65. subvolumes = []
  66. # For each filesystem mount point, find its subvolumes and match them again the given source
  67. # directories to find the subvolumes to backup. And within this loop, sort the subvolumes from
  68. # longest to shortest mount points, so longer mount points get a whack at the candidate source
  69. # directory piñata before their parents do. (Source directories are consumed during this
  70. # process, so no two datasets get the same contained source directories.)
  71. for mount_point in get_filesystem_mount_points(findmnt_command):
  72. subvolumes.extend(
  73. Subvolume(subvolume_path, contained_source_directories)
  74. for subvolume_path in reversed(
  75. get_subvolumes_for_filesystem(btrfs_command, mount_point)
  76. )
  77. for contained_source_directories in (
  78. borgmatic.hooks.data_source.snapshot.get_contained_directories(
  79. subvolume_path, candidate_source_directories
  80. ),
  81. )
  82. if source_directories is None or contained_source_directories
  83. )
  84. return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
  85. BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
  86. def make_snapshot_path(subvolume_path): # pragma: no cover
  87. '''
  88. Given the path to a subvolume, make a corresponding snapshot path for it.
  89. '''
  90. return os.path.join(
  91. subvolume_path,
  92. f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
  93. # Included so that the snapshot ends up in the Borg archive at the "original" subvolume
  94. # path.
  95. subvolume_path.lstrip(os.path.sep),
  96. )
  97. def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
  98. '''
  99. Given the path to a subvolume, make a corresponding exclude path for its embedded snapshot path.
  100. This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory of a
  101. subvolume, then the snapshot's own initial directory component shows up as an empty directory
  102. within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and make a
  103. snapshot of it at:
  104. /mnt/.borgmatic-snapshot-1234/mnt
  105. ... then the snapshot itself will have an empty directory at:
  106. /mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
  107. So to prevent that from ending up in the Borg archive, this function produces its path for
  108. exclusion.
  109. '''
  110. snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
  111. return os.path.join(
  112. subvolume_path,
  113. snapshot_directory,
  114. subvolume_path.lstrip(os.path.sep),
  115. snapshot_directory,
  116. )
  117. def make_borg_source_directory_path(subvolume_path, source_directory): # pragma: no cover
  118. '''
  119. Given the path to a subvolume and a source directory inside it, make a corresponding path for
  120. the source directory within a snapshot path intended for giving to Borg.
  121. '''
  122. return os.path.join(
  123. subvolume_path,
  124. f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
  125. '.', # Borg 1.4+ "slashdot" hack.
  126. # Included so that the source directory ends up in the Borg archive at its "original" path.
  127. source_directory.lstrip(os.path.sep),
  128. )
  129. def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
  130. '''
  131. Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
  132. Btrfs snapshot of the subvolume.
  133. '''
  134. os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
  135. borgmatic.execute.execute_command(
  136. tuple(btrfs_command.split(' '))
  137. + (
  138. 'subvolume',
  139. 'snapshot',
  140. '-r', # Read-only,
  141. subvolume_path,
  142. snapshot_path,
  143. ),
  144. output_log_level=logging.DEBUG,
  145. )
  146. def dump_data_sources(
  147. hook_config,
  148. config,
  149. log_prefix,
  150. config_paths,
  151. borgmatic_runtime_directory,
  152. source_directories,
  153. dry_run,
  154. ):
  155. '''
  156. Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic
  157. configuration file paths, the borgmatic runtime directory, the configured source directories,
  158. and whether this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed
  159. in the given source directories. Also update those source directories, replacing subvolume mount
  160. points with corresponding snapshot directories so they get stored in the Borg archive instead.
  161. Use the log prefix in any log entries.
  162. Return an empty sequence, since there are no ongoing dump processes from this hook.
  163. If this is a dry run, then don't actually snapshot anything.
  164. '''
  165. dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
  166. logger.info(f'{log_prefix}: Snapshotting Btrfs subvolumes{dry_run_label}')
  167. # Based on the configured source directories, determine Btrfs subvolumes to backup.
  168. btrfs_command = hook_config.get('btrfs_command', 'btrfs')
  169. findmnt_command = hook_config.get('findmnt_command', 'findmnt')
  170. subvolumes = get_subvolumes(btrfs_command, findmnt_command, source_directories)
  171. if not subvolumes:
  172. logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
  173. # Snapshot each subvolume, rewriting source directories to use their snapshot paths.
  174. for subvolume in subvolumes:
  175. logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
  176. snapshot_path = make_snapshot_path(subvolume.path)
  177. if dry_run:
  178. continue
  179. snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
  180. for source_directory in subvolume.contained_source_directories:
  181. try:
  182. source_directories.remove(source_directory)
  183. except ValueError:
  184. pass
  185. source_directories.append(
  186. make_borg_source_directory_path(subvolume.path, source_directory)
  187. )
  188. config.setdefault('exclude_patterns', []).append(make_snapshot_exclude_path(subvolume.path))
  189. return []
  190. def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
  191. '''
  192. Given a Btrfs command to run and the name of a snapshot path, delete it.
  193. '''
  194. borgmatic.execute.execute_command(
  195. tuple(btrfs_command.split(' '))
  196. + (
  197. 'subvolume',
  198. 'delete',
  199. snapshot_path,
  200. ),
  201. output_log_level=logging.DEBUG,
  202. )
  203. def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
  204. '''
  205. Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic runtime
  206. directory, and whether this is a dry run, delete any Btrfs snapshots created by borgmatic. Use
  207. the log prefix in any log entries. If this is a dry run, then don't actually remove anything.
  208. '''
  209. dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
  210. btrfs_command = hook_config.get('btrfs_command', 'btrfs')
  211. findmnt_command = hook_config.get('findmnt_command', 'findmnt')
  212. try:
  213. all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
  214. except FileNotFoundError as error:
  215. logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
  216. return
  217. except subprocess.CalledProcessError as error:
  218. logger.debug(f'{log_prefix}: {error}')
  219. return
  220. # Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
  221. # subvolumes before the shorter mount point paths of parent subvolumes.
  222. for subvolume in reversed(all_subvolumes):
  223. subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
  224. os.path.normpath(make_snapshot_path(subvolume.path)),
  225. temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
  226. )
  227. logger.debug(
  228. f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
  229. )
  230. for snapshot_path in glob.glob(subvolume_snapshots_glob):
  231. if not os.path.isdir(snapshot_path):
  232. continue
  233. logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
  234. if dry_run:
  235. continue
  236. try:
  237. delete_snapshot(btrfs_command, snapshot_path)
  238. except FileNotFoundError:
  239. logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
  240. return
  241. except subprocess.CalledProcessError as error:
  242. logger.debug(f'{log_prefix}: {error}')
  243. return
  244. # Strip off the subvolume path from the end of the snapshot path and then delete the
  245. # resulting directory.
  246. shutil.rmtree(snapshot_path.rsplit(subvolume.path, 1)[0])
  247. def make_data_source_dump_patterns(
  248. hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
  249. ): # pragma: no cover
  250. '''
  251. Restores aren't implemented, because stored files can be extracted directly with "extract".
  252. '''
  253. return ()
  254. def restore_data_source_dump(
  255. hook_config,
  256. config,
  257. log_prefix,
  258. data_source,
  259. dry_run,
  260. extract_process,
  261. connection_params,
  262. borgmatic_runtime_directory,
  263. ): # pragma: no cover
  264. '''
  265. Restores aren't implemented, because stored files can be extracted directly with "extract".
  266. '''
  267. raise NotImplementedError()