normalize.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import logging
  2. import os
  3. def normalize_sections(config_filename, config):
  4. '''
  5. Given a configuration filename and a configuration dict of its loaded contents, airlift any
  6. options out of sections ("location:", etc.) to the global scope and delete those sections.
  7. Return any log message warnings produced based on the normalization performed.
  8. Raise ValueError if the "prefix" option is set in both "location" and "consistency" sections.
  9. '''
  10. try:
  11. location = config.get('location') or {}
  12. except AttributeError:
  13. raise ValueError('Configuration does not contain any options')
  14. storage = config.get('storage') or {}
  15. consistency = config.get('consistency') or {}
  16. hooks = config.get('hooks') or {}
  17. if (
  18. location.get('prefix')
  19. and consistency.get('prefix')
  20. and location.get('prefix') != consistency.get('prefix')
  21. ):
  22. raise ValueError(
  23. 'The retention prefix and the consistency prefix cannot have different values (unless one is not set).'
  24. )
  25. if storage.get('umask') and hooks.get('umask') and storage.get('umask') != hooks.get('umask'):
  26. raise ValueError(
  27. 'The storage umask and the hooks umask cannot have different values (unless one is not set).'
  28. )
  29. any_section_upgraded = False
  30. # Move any options from deprecated sections into the global scope.
  31. for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'):
  32. section_config = config.get(section_name)
  33. if section_config is not None:
  34. any_section_upgraded = True
  35. del config[section_name]
  36. config.update(section_config)
  37. if any_section_upgraded:
  38. return [
  39. logging.makeLogRecord(
  40. dict(
  41. levelno=logging.WARNING,
  42. levelname='WARNING',
  43. msg=f'{config_filename}: Configuration sections (like location:, storage:, retention:, consistency:, and hooks:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.',
  44. )
  45. )
  46. ]
  47. return []
  48. def make_command_hook_deprecation_log(config_filename, option_name):
  49. '''
  50. Given a configuration filename and the name of a configuration option, return a deprecation
  51. warning log for it.
  52. '''
  53. return logging.makeLogRecord(
  54. dict(
  55. levelno=logging.WARNING,
  56. levelname='WARNING',
  57. msg=f'{config_filename}: {option_name} is deprecated and support will be removed from a future release. Use commands: instead.',
  58. )
  59. )
  60. def normalize_commands(config_filename, config):
  61. '''
  62. Given a configuration filename and a configuration dict, transform any "before_*"- and
  63. "after_*"-style command hooks into "commands:".
  64. '''
  65. logs = []
  66. # Normalize "before_actions" and "after_actions".
  67. for preposition in ('before', 'after'):
  68. option_name = f'{preposition}_actions'
  69. commands = config.pop(option_name, None)
  70. if commands:
  71. logs.append(make_command_hook_deprecation_log(config_filename, option_name))
  72. config.setdefault('commands', []).append(
  73. {
  74. preposition: 'repository',
  75. 'run': commands,
  76. }
  77. )
  78. # Normalize "before_backup", "before_prune", "after_backup", "after_prune", etc.
  79. for action_name in ('create', 'prune', 'compact', 'check', 'extract'):
  80. for preposition in ('before', 'after'):
  81. option_name = f'{preposition}_{"backup" if action_name == "create" else action_name}'
  82. commands = config.pop(option_name, None)
  83. if not commands:
  84. continue
  85. logs.append(make_command_hook_deprecation_log(config_filename, option_name))
  86. config.setdefault('commands', []).append(
  87. {
  88. preposition: 'action',
  89. 'when': [action_name],
  90. 'run': commands,
  91. }
  92. )
  93. # Normalize "on_error".
  94. commands = config.pop('on_error', None)
  95. if commands:
  96. logs.append(make_command_hook_deprecation_log(config_filename, 'on_error'))
  97. config.setdefault('commands', []).append(
  98. {
  99. 'after': 'error',
  100. 'when': ['create', 'prune', 'compact', 'check'],
  101. 'run': commands,
  102. }
  103. )
  104. # Normalize "before_everything" and "after_everything".
  105. for preposition in ('before', 'after'):
  106. option_name = f'{preposition}_everything'
  107. commands = config.pop(option_name, None)
  108. if commands:
  109. logs.append(make_command_hook_deprecation_log(config_filename, option_name))
  110. config.setdefault('commands', []).append(
  111. {
  112. preposition: 'everything',
  113. 'when': ['create'],
  114. 'run': commands,
  115. }
  116. )
  117. return logs
  118. def normalize(config_filename, config):
  119. '''
  120. Given a configuration filename and a configuration dict of its loaded contents, apply particular
  121. hard-coded rules to normalize the configuration to adhere to the current schema. Return any log
  122. message warnings produced based on the normalization performed.
  123. Raise ValueError the configuration cannot be normalized.
  124. '''
  125. logs = normalize_sections(config_filename, config)
  126. logs += normalize_commands(config_filename, config)
  127. if config.get('borgmatic_source_directory'):
  128. logs.append(
  129. logging.makeLogRecord(
  130. dict(
  131. levelno=logging.WARNING,
  132. levelname='WARNING',
  133. msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
  134. )
  135. )
  136. )
  137. # Upgrade exclude_if_present from a string to a list.
  138. exclude_if_present = config.get('exclude_if_present')
  139. if isinstance(exclude_if_present, str):
  140. logs.append(
  141. logging.makeLogRecord(
  142. dict(
  143. levelno=logging.WARNING,
  144. levelname='WARNING',
  145. msg=f'{config_filename}: The exclude_if_present option now expects a list value. String values for this option are deprecated and support will be removed from a future release.',
  146. )
  147. )
  148. )
  149. config['exclude_if_present'] = [exclude_if_present]
  150. # Unconditionally set the bootstrap hook so that it's enabled by default and config files get
  151. # stored in each Borg archive.
  152. config.setdefault('bootstrap', {})
  153. # Move store_config_files from the global scope to the bootstrap hook.
  154. store_config_files = config.get('store_config_files')
  155. if store_config_files is not None:
  156. logs.append(
  157. logging.makeLogRecord(
  158. dict(
  159. levelno=logging.WARNING,
  160. levelname='WARNING',
  161. msg=f'{config_filename}: The store_config_files option has moved under the bootstrap hook. Specifying store_config_files at the global scope is deprecated and support will be removed from a future release.',
  162. )
  163. )
  164. )
  165. del config['store_config_files']
  166. config['bootstrap']['store_config_files'] = store_config_files
  167. # Upgrade various monitoring hooks from a string to a dict.
  168. healthchecks = config.get('healthchecks')
  169. if isinstance(healthchecks, str):
  170. logs.append(
  171. logging.makeLogRecord(
  172. dict(
  173. levelno=logging.WARNING,
  174. levelname='WARNING',
  175. msg=f'{config_filename}: The healthchecks hook now expects a key/value pair with "ping_url" as a key. String values for this option are deprecated and support will be removed from a future release.',
  176. )
  177. )
  178. )
  179. config['healthchecks'] = {'ping_url': healthchecks}
  180. cronitor = config.get('cronitor')
  181. if isinstance(cronitor, str):
  182. logs.append(
  183. logging.makeLogRecord(
  184. dict(
  185. levelno=logging.WARNING,
  186. levelname='WARNING',
  187. msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
  188. )
  189. )
  190. )
  191. config['cronitor'] = {'ping_url': cronitor}
  192. pagerduty = config.get('pagerduty')
  193. if isinstance(pagerduty, str):
  194. logs.append(
  195. logging.makeLogRecord(
  196. dict(
  197. levelno=logging.WARNING,
  198. levelname='WARNING',
  199. msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
  200. )
  201. )
  202. )
  203. config['pagerduty'] = {'integration_key': pagerduty}
  204. cronhub = config.get('cronhub')
  205. if isinstance(cronhub, str):
  206. logs.append(
  207. logging.makeLogRecord(
  208. dict(
  209. levelno=logging.WARNING,
  210. levelname='WARNING',
  211. msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
  212. )
  213. )
  214. )
  215. config['cronhub'] = {'ping_url': cronhub}
  216. # Upgrade consistency checks from a list of strings to a list of dicts.
  217. checks = config.get('checks')
  218. if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
  219. logs.append(
  220. logging.makeLogRecord(
  221. dict(
  222. levelno=logging.WARNING,
  223. levelname='WARNING',
  224. msg=f'{config_filename}: The checks option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
  225. )
  226. )
  227. )
  228. config['checks'] = [{'name': check_type} for check_type in checks]
  229. # Rename various configuration options.
  230. numeric_owner = config.pop('numeric_owner', None)
  231. if numeric_owner is not None:
  232. logs.append(
  233. logging.makeLogRecord(
  234. dict(
  235. levelno=logging.WARNING,
  236. levelname='WARNING',
  237. msg=f'{config_filename}: The numeric_owner option has been renamed to numeric_ids. numeric_owner is deprecated and support will be removed from a future release.',
  238. )
  239. )
  240. )
  241. config['numeric_ids'] = numeric_owner
  242. bsd_flags = config.pop('bsd_flags', None)
  243. if bsd_flags is not None:
  244. logs.append(
  245. logging.makeLogRecord(
  246. dict(
  247. levelno=logging.WARNING,
  248. levelname='WARNING',
  249. msg=f'{config_filename}: The bsd_flags option has been renamed to flags. bsd_flags is deprecated and support will be removed from a future release.',
  250. )
  251. )
  252. )
  253. config['flags'] = bsd_flags
  254. remote_rate_limit = config.pop('remote_rate_limit', None)
  255. if remote_rate_limit is not None:
  256. logs.append(
  257. logging.makeLogRecord(
  258. dict(
  259. levelno=logging.WARNING,
  260. levelname='WARNING',
  261. msg=f'{config_filename}: The remote_rate_limit option has been renamed to upload_rate_limit. remote_rate_limit is deprecated and support will be removed from a future release.',
  262. )
  263. )
  264. )
  265. config['upload_rate_limit'] = remote_rate_limit
  266. # Upgrade remote repositories to ssh:// syntax, required in Borg 2.
  267. repositories = config.get('repositories')
  268. if repositories:
  269. if any(isinstance(repository, str) for repository in repositories):
  270. logs.append(
  271. logging.makeLogRecord(
  272. dict(
  273. levelno=logging.WARNING,
  274. levelname='WARNING',
  275. msg=f'{config_filename}: The repositories option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
  276. )
  277. )
  278. )
  279. config['repositories'] = [
  280. {'path': repository} if isinstance(repository, str) else repository
  281. for repository in repositories
  282. ]
  283. repositories = config['repositories']
  284. config['repositories'] = []
  285. for repository_dict in repositories:
  286. repository_path = repository_dict['path']
  287. if '~' in repository_path:
  288. logs.append(
  289. logging.makeLogRecord(
  290. dict(
  291. levelno=logging.WARNING,
  292. levelname='WARNING',
  293. msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and support will be removed from a future release.',
  294. )
  295. )
  296. )
  297. if ':' in repository_path:
  298. if repository_path.startswith('file://'):
  299. updated_repository_path = os.path.abspath(
  300. repository_path.partition('file://')[-1]
  301. )
  302. config['repositories'].append(
  303. dict(
  304. repository_dict,
  305. path=updated_repository_path,
  306. )
  307. )
  308. elif (
  309. repository_path.startswith('ssh://')
  310. or repository_path.startswith('sftp://')
  311. or repository_path.startswith('rclone:')
  312. ):
  313. config['repositories'].append(repository_dict)
  314. else:
  315. rewritten_repository_path = f"ssh://{repository_path.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
  316. logs.append(
  317. logging.makeLogRecord(
  318. dict(
  319. levelno=logging.WARNING,
  320. levelname='WARNING',
  321. msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
  322. )
  323. )
  324. )
  325. config['repositories'].append(
  326. dict(
  327. repository_dict,
  328. path=rewritten_repository_path,
  329. )
  330. )
  331. else:
  332. config['repositories'].append(repository_dict)
  333. if config.get('prefix'):
  334. logs.append(
  335. logging.makeLogRecord(
  336. dict(
  337. levelno=logging.WARNING,
  338. levelname='WARNING',
  339. msg=f'{config_filename}: The prefix option is deprecated and support will be removed from a future release. Use archive_name_format or match_archives instead.',
  340. )
  341. )
  342. )
  343. return logs