Simple script to rotate out backup files and folders and stuffff.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

252 lines
6.1 KiB

  1. #!/usr/bin/env python3
  2. """
  3. Mike's Backup Rotator
  4. A simple script to help automatically rotate backup files
  5. Copyright 2019 Mike Peralta; All rights reserved
  6. Released under the GNU GENERAL PUBLIC LICENSE v3 (See LICENSE file for more)
  7. """
  8. import datetime
  9. import os
  10. import shutil
  11. import sys
  12. import syslog
  13. import yaml
  14. class BackupRotator:
  15. def __init__(self):
  16. self.__dry_run = False
  17. self.__configs = []
  18. self.__config_paths = []
  19. self.__calculated_actions = []
  20. def run(self):
  21. self.log("Begin")
  22. self.consume_arguments()
  23. self.consume_configs(self.__config_paths)
  24. # Rotate once per config
  25. for config_index in range(len(self.__configs)):
  26. #
  27. config = self.__configs[config_index]
  28. #
  29. self.log("Rotating for config " + str(config_index + 1) + " of " + str(len(self.__configs)), config["__path"])
  30. self.do_rotate(config)
  31. @staticmethod
  32. def current_time():
  33. now = datetime.datetime.now()
  34. now_s = now.strftime("%b-%d-%Y %I:%M%p")
  35. return str(now_s)
  36. def log(self, s, o=None):
  37. now = self.current_time()
  38. to_log = "[" + now + "][Backup Rotator] " + str(s)
  39. if o is not None:
  40. to_log += " " + str(o)
  41. syslog.syslog(to_log)
  42. print(to_log)
  43. def consume_arguments(self):
  44. self.__config_paths = []
  45. for i in range(1, len(sys.argv)):
  46. arg = sys.argv[i]
  47. if arg == "--config":
  48. i, one_path = self.consume_argument_companion(i)
  49. self.__config_paths.append(one_path)
  50. self.log("Found config path argument:", one_path)
  51. elif arg == "--dry-run":
  52. self.__dry_run = True
  53. self.log("Activating global dry-run mode")
  54. @staticmethod
  55. def consume_argument_companion(arg_index):
  56. companion_index = arg_index + 1
  57. if companion_index >= len(sys.argv):
  58. raise Exception("Expected argument after", sys.argv[arg_index])
  59. return companion_index, sys.argv[companion_index]
  60. def consume_configs(self, paths: list=None):
  61. if paths is None:
  62. raise Exception("Auto-finding of config file not implemented")
  63. # Use each config path
  64. for path in paths:
  65. # If this is a single path
  66. if os.path.isfile(path):
  67. self.consume_config(path)
  68. # If this is a directory
  69. elif os.path.isdir(path):
  70. # Iterate over each file inside
  71. for file_name in os.listdir(path):
  72. self.consume_config(os.path.join(path, file_name))
  73. def consume_config(self, path: str):
  74. # Open the file
  75. f = open(path)
  76. if not f:
  77. raise Exception("Unable to open config file: " + path)
  78. # Parse
  79. config = yaml.safe_load(f)
  80. # Add its own path
  81. config["__path"] = path
  82. # Consume to internal
  83. self.__configs.append(config)
  84. self.log("Consumed config from path:", path)
  85. def do_rotate(self, config):
  86. self.rotate_paths(config)
  87. def rotate_paths(self, config):
  88. self.log("Begin rotating " + str(len(config["paths"])) + " paths")
  89. for path in config["paths"]:
  90. self.rotate_path(config, path)
  91. def rotate_path(self, config, path):
  92. self.log("Rotating path", path)
  93. if "maximum-items" not in config:
  94. raise Exception("Please provide config key: \"maximum-items\"")
  95. max_items = config["maximum-items"]
  96. if not os.path.isdir(path):
  97. raise Exception("Path should be a directory:" + str(path))
  98. children = self.gather_rotation_candidates(config, path)
  99. # Do we need to rotate anything out?
  100. if len(children) <= max_items:
  101. self.log(
  102. "Path only has " + str(len(children)) + " items,"
  103. + " but needs " + str(max_items) + " for rotation"
  104. + "; Won't rotate this path."
  105. )
  106. return
  107. #
  108. purge_count = len(children) - max_items
  109. self.log(
  110. "Need to purge " + str(purge_count) + " items"
  111. )
  112. for purge_index in range(purge_count):
  113. #
  114. item_to_purge = self.pick_item_to_purge(config, children)
  115. children.remove(item_to_purge)
  116. #
  117. if os.path.isfile(item_to_purge):
  118. self.remove_file(config, item_to_purge)
  119. elif os.path.isdir(item_to_purge):
  120. self.remove_directory(config, item_to_purge)
  121. else:
  122. raise Exception("Don't know how to remove this item: " + str(item_to_purge))
  123. @staticmethod
  124. def gather_rotation_candidates(config, path):
  125. candidates = []
  126. if "target-type" not in config.keys():
  127. raise Exception("Please provide the configuration key: target-type")
  128. for item_name in os.listdir(path):
  129. item_path = os.path.join(path, item_name)
  130. if config["target-type"] == "file":
  131. if not os.path.isfile(item_path):
  132. continue
  133. elif config["target-type"] == "directory":
  134. if not os.path.isdir(item_path):
  135. continue
  136. else:
  137. raise Exception("Configuration key \"target-type\" must be \"file\" or \"directory\"")
  138. candidates.append(item_path)
  139. return candidates
  140. @staticmethod
  141. def pick_item_to_purge(config, items):
  142. if "date-detection" not in config.keys():
  143. raise Exception("Please provide config key: \"date-detection\"")
  144. detection = config["date-detection"]
  145. best_item = None
  146. best_ctime = None
  147. for item in items:
  148. if detection == "file":
  149. ctime = os.path.getctime(item)
  150. if best_ctime is None or ctime < best_ctime:
  151. best_ctime = ctime
  152. best_item = item
  153. else:
  154. raise Exception("Invalid value for \"date-detection\": " + str(detection))
  155. return best_item
  156. def remove_file(self, config, file_path):
  157. if not os.path.isfile(file_path):
  158. raise Exception("Tried to remove a file, but this path isn't a file: " + str(file_path))
  159. if self.__dry_run:
  160. self.log("Won't purge file during global-level dry run: ", file_path)
  161. elif "dry-run" in config.keys() and config["dry-run"] is True:
  162. self.log("Won't purge file during config-level dry run: ", file_path)
  163. else:
  164. self.log("Purging file:", file_path)
  165. os.remove(file_path)
  166. def remove_directory(self, config, dir_path):
  167. if not os.path.isdir(dir_path):
  168. raise Exception("Tried to remove a directory, but this path isn't a directory: " + str(dir_path))
  169. if self.__dry_run:
  170. self.log("Won't purge directory during global-level dry run: ", dir_path)
  171. elif "dry-run" in config.keys() and config["dry-run"] is True:
  172. self.log("Won't purge directory during config-level dry run: ", dir_path)
  173. else:
  174. self.log("Purging directory:", dir_path)
  175. shutil.rmtree(dir_path)