| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 | #!/usr/bin/env python3# Copyright (c) 2016, Antonio SJ Musumeci <trapexit@spawn.link>## Permission to use, copy, modify, and/or distribute this software for any# purpose with or without fee is hereby granted, provided that the above# copyright notice and this permission notice appear in all copies.## THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.import argparseimport ctypesimport errnoimport fnmatchimport ioimport osimport shleximport subprocessimport sys_libc = ctypes.CDLL("libc.so.6",use_errno=True)_lgetxattr = _libc.lgetxattr_lgetxattr.argtypes = [ctypes.c_char_p,ctypes.c_char_p,ctypes.c_void_p,ctypes.c_size_t]def lgetxattr(path,name):    if type(path) == str:        path = path.encode(errors='backslashreplace')    if type(name) == str:        name = name.encode(errors='backslashreplace')    length = 64    while True:        buf = ctypes.create_string_buffer(length)        res = _lgetxattr(path,name,buf,ctypes.c_size_t(length))        if res >= 0:            return buf.raw[0:res].decode(errors='backslashreplace')        else:            err = ctypes.get_errno()            if err == errno.ERANGE:                length *= 2            elif err == errno.ENODATA:                return None            else:                raise IOError(err,os.strerror(err),path)def ismergerfs(path):    try:        lgetxattr(path,'user.mergerfs.version')        return True    except IOError as e:        return Falsedef mergerfs_control_file(basedir):    if basedir == '/':        return None    ctrlfile = os.path.join(basedir,'.mergerfs')    if os.path.exists(ctrlfile):        return ctrlfile    else:        dirname = os.path.dirname(basedir)        return mergerfs_control_file(dirname)def mergerfs_srcmounts(ctrlfile):    srcmounts = lgetxattr(ctrlfile,'user.mergerfs.srcmounts')    srcmounts = srcmounts.split(':')    return srcmountsdef match(filename,matches):    for match in matches:        if fnmatch.fnmatch(filename,match):            return True    return Falsedef exclude_by_size(filepath,exclude_lt,exclude_gt):    try:        st = os.lstat(filepath)        if exclude_lt and st.st_size < exclude_lt:            return True        if exclude_gt and st.st_size > exclude_gt:            return True        return False    except:        return Falsedef find_a_file(src,                relpath,                file_includes,file_excludes,                path_includes,path_excludes,                exclude_lt,exclude_gt):    basepath = os.path.join(src,relpath)    for (dirpath,dirnames,filenames) in os.walk(basepath):        for filename in filenames:            filepath = os.path.join(dirpath,filename)            if match(filename,file_excludes):                continue            if match(filepath,path_excludes):                continue            if not match(filename,file_includes):                continue            if not match(filepath,path_includes):                continue            if exclude_by_size(filepath,exclude_lt,exclude_gt):                continue            return os.path.relpath(filepath,src)    return Nonedef execute(args):    return subprocess.call(args)def print_args(args):    quoted = [shlex.quote(arg) for arg in args]    print(' '.join(quoted))def build_move_file(src,dst,relfile):    frompath = os.path.join(src,'./',relfile)    topath   = dst+'/'    args = ['rsync',            '-avlHAXWE',            '--relative',            '--progress',            '--remove-source-files',            frompath,            topath]    return argsdef freespace_percentage(srcmounts):    lfsp = []    for srcmount in srcmounts:        vfs = os.statvfs(srcmount)        avail = vfs.f_bavail * vfs.f_frsize        total = vfs.f_blocks * vfs.f_frsize        per = avail / total        lfsp.append((srcmount,per))    return sorted(lfsp, key=lambda x: x[1])def all_within_range(l,n):    if len(l) == 0 or len(l) == 1:        return True    return (abs(l[0][1] - l[-1][1]) <= n)def human_to_bytes(s):    m = s[-1]    if   m == 'K':        i = int(s[0:-1]) * 1024    elif m == 'M':        i = int(s[0:-1]) * 1024 * 1024    elif m == 'G':        i = int(s[0:-1]) * 1024 * 1024 * 1024    elif m == 'T':        i = int(s[0:-1]) * 1024 * 1024 * 1024 * 1024    else:        i = int(s)    return idef buildargparser():    parser = argparse.ArgumentParser(description='balance files on a mergerfs mount based on percentage drive filled')    parser.add_argument('dir',                        type=str,                        help='starting directory')    parser.add_argument('-p',                        dest='percentage',                        type=float,                        default=2.0,                        help='percentage range of freespace (default 2.0)')    parser.add_argument('-i','--include',                        dest='include',                        type=str,                         action='append',                        default=[],                        help='fnmatch compatible file filter (can use multiple times)')    parser.add_argument('-e','--exclude',                        dest='exclude',                        type=str,                        action='append',                        default=[],                        help='fnmatch compatible file filter (can use multiple times)')    parser.add_argument('-I','--include-path',                        dest='includepath',                        type=str,                        action='append',                        default=[],                        help='fnmatch compatible path filter (can use multiple times)')    parser.add_argument('-E','--exclude-path',                        dest='excludepath',                        type=str,                        action='append',                        default=[],                        help='fnmatch compatible path filter (can use multiple times)')    parser.add_argument('-s',                        dest='excludelt',                        type=str,                        default='0',                        help='exclude files smaller than <int>[KMGT] bytes')    parser.add_argument('-S',                        dest='excludegt',                        type=str,                        default='0',                        help='exclude files larger than <int>[KMGT] bytes')    return parserdef main():    sys.stdout = io.TextIOWrapper(sys.stdout.buffer,                                  encoding='utf8',                                  errors="backslashreplace",                                  line_buffering=True)    sys.stderr = io.TextIOWrapper(sys.stderr.buffer,                                  encoding='utf8',                                  errors="backslashreplace",                                  line_buffering=True)    parser = buildargparser()    args = parser.parse_args()    args.dir = os.path.realpath(args.dir)    ctrlfile = mergerfs_control_file(args.dir)    if not ismergerfs(ctrlfile):        print("%s is not a mergerfs mount" % args.dir)        sys.exit(1)    relpath = ''    mntpoint = os.path.dirname(ctrlfile)    if args.dir != mntpoint:        relpath = os.path.relpath(args.dir,mntpoint)    file_includes = ['*'] if not args.include else args.include    file_excludes = args.exclude    path_includes = ['*'] if not args.includepath else args.includepath    path_excludes = args.excludepath    exclude_lt    = human_to_bytes(args.excludelt)    exclude_gt    = human_to_bytes(args.excludegt)    srcmounts     = mergerfs_srcmounts(ctrlfile)    percentage    = args.percentage / 100    try:        l = freespace_percentage(srcmounts)        while not all_within_range(l,percentage):            todrive     = l[-1][0]            relfilepath = None            while not relfilepath and len(l):                fromdrive = l[0][0]                del l[0]                relfilepath = find_a_file(fromdrive,                                          relpath,                                          file_includes,file_excludes,                                          path_includes,path_excludes,                                          exclude_lt,exclude_gt)            if len(l) == 0:                print('Could not find file to transfer: exiting...')                break            if fromdrive == todrive:                print('Source drive == target drive: exiting...')                break            args = build_move_file(fromdrive,todrive,relfilepath)            print('file: {}\nfrom: {}\nto:   {}'.format(relfilepath,fromdrive,todrive))            print_args(args)            rv = execute(args)            if rv:                print('ERROR - exited with exit code: {}'.format(rv))                break            l = freespace_percentage(srcmounts)        print('Branches within {:.1%} range: '.format(percentage))        for (branch,percentage) in l:            print(' * {}: {:.2%} free'.format(branch,percentage))    except KeyboardInterrupt:        print("exiting: CTRL-C pressed")    sys.exit(0)if __name__ == "__main__":   main()
 |