mergerfs.balance 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. #!/usr/bin/env python3
  2. # Copyright (c) 2016, Antonio SJ Musumeci <trapexit@spawn.link>
  3. #
  4. # Permission to use, copy, modify, and/or distribute this software for any
  5. # purpose with or without fee is hereby granted, provided that the above
  6. # copyright notice and this permission notice appear in all copies.
  7. #
  8. # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  9. # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  10. # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
  11. # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  12. # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  13. # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  14. # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  15. import argparse
  16. import ctypes
  17. import errno
  18. import fnmatch
  19. import io
  20. import os
  21. import shlex
  22. import subprocess
  23. import sys
  24. _libc = ctypes.CDLL("libc.so.6",use_errno=True)
  25. _lgetxattr = _libc.lgetxattr
  26. _lgetxattr.argtypes = [ctypes.c_char_p,ctypes.c_char_p,ctypes.c_void_p,ctypes.c_size_t]
  27. def lgetxattr(path,name):
  28. if type(path) == str:
  29. path = path.encode(errors='backslashreplace')
  30. if type(name) == str:
  31. name = name.encode(errors='backslashreplace')
  32. length = 64
  33. while True:
  34. buf = ctypes.create_string_buffer(length)
  35. res = _lgetxattr(path,name,buf,ctypes.c_size_t(length))
  36. if res >= 0:
  37. return buf.raw[0:res].decode(errors='backslashreplace')
  38. else:
  39. err = ctypes.get_errno()
  40. if err == errno.ERANGE:
  41. length *= 2
  42. elif err == errno.ENODATA:
  43. return None
  44. else:
  45. raise IOError(err,os.strerror(err),path)
  46. def ismergerfs(path):
  47. try:
  48. lgetxattr(path,'user.mergerfs.version')
  49. return True
  50. except IOError as e:
  51. return False
  52. def mergerfs_control_file(basedir):
  53. if basedir == '/':
  54. return None
  55. ctrlfile = os.path.join(basedir,'.mergerfs')
  56. if os.path.exists(ctrlfile):
  57. return ctrlfile
  58. else:
  59. dirname = os.path.dirname(basedir)
  60. return mergerfs_control_file(dirname)
  61. def mergerfs_srcmounts(ctrlfile):
  62. srcmounts = lgetxattr(ctrlfile,'user.mergerfs.srcmounts')
  63. srcmounts = srcmounts.split(':')
  64. return srcmounts
  65. def match(filename,matches):
  66. for match in matches:
  67. if fnmatch.fnmatch(filename,match):
  68. return True
  69. return False
  70. def exclude_by_size(filepath,exclude_lt,exclude_gt):
  71. try:
  72. st = os.lstat(filepath)
  73. if exclude_lt and st.st_size < exclude_lt:
  74. return True
  75. if exclude_gt and st.st_size > exclude_gt:
  76. return True
  77. return False
  78. except:
  79. return False
  80. def find_a_file(src,
  81. relpath,
  82. file_includes,file_excludes,
  83. path_includes,path_excludes,
  84. exclude_lt,exclude_gt):
  85. basepath = os.path.join(src,relpath)
  86. for (dirpath,dirnames,filenames) in os.walk(basepath):
  87. for filename in filenames:
  88. filepath = os.path.join(dirpath,filename)
  89. if match(filename,file_excludes):
  90. continue
  91. if match(filepath,path_excludes):
  92. continue
  93. if not match(filename,file_includes):
  94. continue
  95. if not match(filepath,path_includes):
  96. continue
  97. if exclude_by_size(filepath,exclude_lt,exclude_gt):
  98. continue
  99. return os.path.relpath(filepath,src)
  100. return None
  101. def execute(args):
  102. return subprocess.call(args)
  103. def print_args(args):
  104. quoted = [shlex.quote(arg) for arg in args]
  105. print(' '.join(quoted))
  106. def build_move_file(src,dst,relfile):
  107. frompath = os.path.join(src,'./',relfile)
  108. topath = dst+'/'
  109. args = ['rsync',
  110. '-avlHAXWE',
  111. '--relative',
  112. '--progress',
  113. '--remove-source-files',
  114. frompath,
  115. topath]
  116. return args
  117. def freespace_percentage(srcmounts):
  118. lfsp = []
  119. for srcmount in srcmounts:
  120. vfs = os.statvfs(srcmount)
  121. avail = vfs.f_bavail * vfs.f_frsize
  122. total = vfs.f_blocks * vfs.f_frsize
  123. per = avail / total
  124. lfsp.append((srcmount,per))
  125. return sorted(lfsp, key=lambda x: x[1])
  126. def all_within_range(l,n):
  127. if len(l) == 0 or len(l) == 1:
  128. return True
  129. return (abs(l[0][1] - l[-1][1]) <= n)
  130. def human_to_bytes(s):
  131. m = s[-1]
  132. if m == 'K':
  133. i = int(s[0:-1]) * 1024
  134. elif m == 'M':
  135. i = int(s[0:-1]) * 1024 * 1024
  136. elif m == 'G':
  137. i = int(s[0:-1]) * 1024 * 1024 * 1024
  138. elif m == 'T':
  139. i = int(s[0:-1]) * 1024 * 1024 * 1024 * 1024
  140. else:
  141. i = int(s)
  142. return i
  143. def buildargparser():
  144. parser = argparse.ArgumentParser(description='balance files on a mergerfs mount based on percentage drive filled')
  145. parser.add_argument('dir',
  146. type=str,
  147. help='starting directory')
  148. parser.add_argument('-p',
  149. dest='percentage',
  150. type=float,
  151. default=2.0,
  152. help='percentage range of freespace (default 2.0)')
  153. parser.add_argument('-i','--include',
  154. dest='include',
  155. type=str,
  156. action='append',
  157. default=[],
  158. help='fnmatch compatible file filter (can use multiple times)')
  159. parser.add_argument('-e','--exclude',
  160. dest='exclude',
  161. type=str,
  162. action='append',
  163. default=[],
  164. help='fnmatch compatible file filter (can use multiple times)')
  165. parser.add_argument('-I','--include-path',
  166. dest='includepath',
  167. type=str,
  168. action='append',
  169. default=[],
  170. help='fnmatch compatible path filter (can use multiple times)')
  171. parser.add_argument('-E','--exclude-path',
  172. dest='excludepath',
  173. type=str,
  174. action='append',
  175. default=[],
  176. help='fnmatch compatible path filter (can use multiple times)')
  177. parser.add_argument('-s',
  178. dest='excludelt',
  179. type=str,
  180. default='0',
  181. help='exclude files smaller than <int>[KMGT] bytes')
  182. parser.add_argument('-S',
  183. dest='excludegt',
  184. type=str,
  185. default='0',
  186. help='exclude files larger than <int>[KMGT] bytes')
  187. return parser
  188. def main():
  189. sys.stdout = io.TextIOWrapper(sys.stdout.buffer,
  190. encoding='utf8',
  191. errors="backslashreplace",
  192. line_buffering=True)
  193. sys.stderr = io.TextIOWrapper(sys.stderr.buffer,
  194. encoding='utf8',
  195. errors="backslashreplace",
  196. line_buffering=True)
  197. parser = buildargparser()
  198. args = parser.parse_args()
  199. args.dir = os.path.realpath(args.dir)
  200. ctrlfile = mergerfs_control_file(args.dir)
  201. if not ismergerfs(ctrlfile):
  202. print("%s is not a mergerfs mount" % args.dir)
  203. sys.exit(1)
  204. relpath = ''
  205. mntpoint = os.path.dirname(ctrlfile)
  206. if args.dir != mntpoint:
  207. relpath = os.path.relpath(args.dir,mntpoint)
  208. file_includes = ['*'] if not args.include else args.include
  209. file_excludes = args.exclude
  210. path_includes = ['*'] if not args.includepath else args.includepath
  211. path_excludes = args.excludepath
  212. exclude_lt = human_to_bytes(args.excludelt)
  213. exclude_gt = human_to_bytes(args.excludegt)
  214. srcmounts = mergerfs_srcmounts(ctrlfile)
  215. percentage = args.percentage / 100
  216. try:
  217. l = freespace_percentage(srcmounts)
  218. while not all_within_range(l,percentage):
  219. todrive = l[-1][0]
  220. relfilepath = None
  221. while not relfilepath and len(l):
  222. fromdrive = l[0][0]
  223. del l[0]
  224. relfilepath = find_a_file(fromdrive,
  225. relpath,
  226. file_includes,file_excludes,
  227. path_includes,path_excludes,
  228. exclude_lt,exclude_gt)
  229. if len(l) == 0:
  230. print('Could not find file to transfer: exiting...')
  231. break
  232. if fromdrive == todrive:
  233. print('Source drive == target drive: exiting...')
  234. break
  235. args = build_move_file(fromdrive,todrive,relfilepath)
  236. print('file: {}\nfrom: {}\nto: {}'.format(relfilepath,fromdrive,todrive))
  237. print_args(args)
  238. rv = execute(args)
  239. if rv:
  240. print('ERROR - exited with exit code: {}'.format(rv))
  241. break
  242. l = freespace_percentage(srcmounts)
  243. print('Branches within {:.1%} range: '.format(percentage))
  244. for (branch,percentage) in l:
  245. print(' * {}: {:.2%} free'.format(branch,percentage))
  246. except KeyboardInterrupt:
  247. print("exiting: CTRL-C pressed")
  248. sys.exit(0)
  249. if __name__ == "__main__":
  250. main()