""" Rational -------- I have a small number of machines I administer. Including a couple VPS machines, a small local server, my kids computers, etc. I use this to centralize all the various scripts and configurations so I don't have to worry about making a change on 5 different machines if I change one of my standard scripts and so I can easily have it all in a VCS. Fabric w/ this fabfile fills that need perfectly. Overview -------- Used with fabric and typically a VCS to maintain and deploy the configuration and control scripts for a small number of systems (possibly including itself). It's fabric commands are modeled after a simple subset of common distributed VCS commands. It is organized as a set of directories with this fabfile living at the root. Each system gets a subdirectory that contains all the files for it. The subdirectory is named for the host name of the system which is used in all the remote commands. The files within this directory are laid out like a skeleton of that machine's root filesystem. All commands are recursive and work as locally as possible except status which will either restrict itself to one machine or check all machines. For example if you run 'fab diff' in the directory '[machine]/etc/postfix/' it would diff the files in only that directory and its sub-directories. Note that files on the remote host not present locally are ignored as the local files are meant to be a skeleton version of just the files you want to deal with. It supports using the sticky bit on files/dirs as a way to indicate you want to skip that file (for temporary or in progress things). It handles sym-links intelligently, preserving links inside the machine filesystem hierarchy while pulling the linked file for links that point outside the tree (rsync --copy-unsafe-links). Files and directories that are unreadable or unwritable by the user will automatically use sudo to gain access. No attemtps at preserving ownership is attempted, all files pushed using sudo will be owned by root. Example ------- I'll use a personal example to help illustrate. My kids both have Ubuntu machines I've cobbled together from spare parts. I have a standard backup script I put on all my machines along with a cron job to run it. I keep the script and the cron job in a separate area and link it into the machine hierarchy. So I have the root of my VCS in a directory admin/ with the machines in admin/machines/. The backup script and cron are in admin/backup/. So for this area what I have looks like:: admin/ backup/ backup backup-cron machines/ foo/ etc/cron.daily/backup-cron usr/local/sbin/backup bar/ etc/cron.daily/backup-cron usr/local/sbin/backup With this setup anytime I make a change to admin/backup/backup I can just go into either of the 2 directories and push to update the machines. I can run diff to see the changes and status will show me a summary of the files changed, added or removed. Command Summary --------------- status Shows list of new or modified files. New files exist only locally while modified files could have been changed locally or remotely. diff Shows diff between local and remote file(s). push Push local changes to remote file(s). pull Pull remote changes to local file(s). Requirements ------------ - fabric 9.2 (porting to 1.0 is planned) - rsync (3.x recommended) - ssh access as running user to all machines TODO ---- - when files exist, use pull to check and update existing files from remote - fabric 1.x compatibility evaluation (waiting on fabric 1.x entering debian) - a way to restart remote processes - command line wrapper (for better argument handling mostly) - fix push to work better with mac os x (mac cp supports neither -d nor -u -- maybe use tar, cpio or pax) - add file permission syncing (stat -c "%a" file) - change status to work locally to directory (like other commands) - sym-link to directory fails Command Rules ------------- (rules for myself when writing new rules) - context for all commands should be cwd (faking if necessary) """ from __future__ import print_function import os, stat, sys import functools as FT import contextlib as CT import fabric.api as fab import fabric.colors as C #blue, cyan, green, magenta, red, white, yellow from pygments import highlight as _highlight from pygments.lexers import DiffLexer as _DiffLexer from pygments.formatters import TerminalFormatter as _TerminalFormatter # global fab settings fab.env.shell = '/bin/sh -l -c' fab.output.status = False # no Done or Disconnecting messages # constant BASEDIR = os.path.dirname(fab.env.real_fabfile) REMOTE_CACHE = "${HOME}/.fab-cache" with fab.settings(fab.hide('everything'), warn_only=True): LOCALHOST = fab.local('hostname -s') ######################################################################## ### fab callable ######################################################################## def push(localpath=''): """ Push files to remote machine. Pushes a file or recursively if given a directory. Defaults to current directory if no parameter is passed. Only updates files that are different (or new). """ machines = _machines() machine = machines.pop() assert not(machines), "Must run within specific machine directory." fab.env.host_string = machine if _offline(machine): return with _path_setup(localpath) as (localpath, remotepath): with fab.settings(fab.hide('everything')): out = _diff("-q", remotepath) if out.return_code: src_dst = [(f[3],f[1]) for f in [o.split(' ') for o in out.split('\n') if not o.startswith('Only in')]] cachepath = _get_remote_cache() with fab.cd(cachepath): for (src, dst) in src_dst: cmd = fab.run if _user_writable(dst) else fab.sudo print('cp -Pu %s %s' % (src, dst)) cmd('mkdir -p %s && cp -P %s %s' % (os.path.dirname(dst), src, dst)) else: print('Files do not differ') def status(): """ Check machine for possible updates. """ for machine in _machines(): if _offline(machine): continue print('--', machine) for a in _added(machine): print(C.red(' A'), a) for u in _updated(machine): print(C.blue(' M'), u) def diff(localpath=''): """ Local vs remote file diff. Compare 1 or more files. Basically 'diff -u(r) localpath remotepath' Recursive diff of all current machine files if no parameter is passed. """ machines = _machines() machine = machines.pop() assert not(machines), "Must run within specific machine directory." fab.env.host_string = machine if _offline(machine): return with _path_setup(localpath) as (localpath, remotepath): with fab.settings(fab.hide('everything')): out = _diff("-u", remotepath) if out.return_code: out = '\n'.join(o for o in out.split('\n') if not o.startswith('Only in')) if sys.stdout.isatty(): out = _highlight(out, _DiffLexer(), _TerminalFormatter()) print(out) else: print('Files do not differ') def pull(localpath=''): """ Pull new file/directory from remote machine to repository. Useful for adding new things to the repo or pulling remote changes. """ machines = _machines() machine = machines.pop() assert not(machines), "Must run within specific machine directory." fab.env.host_string = machine if _offline(machine): return with _path_setup(localpath) as (localpath, remotepath): with fab.settings(fab.hide('everything')): assert _test('-e', remotepath), \ "Remotepath does not exist: %s" % remotepath localdir = localpath if _test('-f', remotepath): localdir = os.path.dirname(localpath) if localpath == localdir: # localpath is a dir files = [''] if _ltest('-e', localdir): files = fab.local(sh_find % localpath).split('\n') if files == ['']: files = [localpath] for lf in files: rf = os.path.join(os.path.dirname(remotepath), lf) _get(machine, rf, lf) else: _get(machine, remotepath, localpath) ######################################################################## ### Internals ######################################################################## def _machines(): """ Get yield list of machines/hosts. """ machines = [] cwd = os.environ.get('PWD') or os.getcwd() for filename in os.listdir(BASEDIR): filepath = os.path.join(BASEDIR, filename) if not os.path.isdir(filepath): continue if _disabled(filepath): continue if filepath in cwd: # test for specific machine dir return [filename] else: machines.append(filename) return machines def _updated(machine): """ Check for files that have been updated (are different). The files listed could have been changed locally or on the server. """ with _localdir(os.path.join(BASEDIR, machine)): with fab.settings(fab.hide('everything'), host_string=machine): cachepath = _get_remote_cache() with fab.cd(cachepath): result = _quiet(fab.run)( sh_find0 % '*' + ( '| xargs -n 1 -0 -I{} diff -q /{} {} ' '| cut -d " " -f 4')) if 'permission denied' in result.stdout.lower(): result = _quiet(fab.sudo)( sh_find0 % '*' + ( '| xargs -n 1 -0 -I{} diff -q /{} {} 2> /dev/null ' '| cut -d " " -f 4')) updated = result.replace('\r','').split('\n') updated = [i for i in updated if not i.endswith('No such file or directory')] return updated if updated != [''] else [] def _added(machine): """ Check for files that were added locally (not on machine yet). """ with _localdir(os.path.join(BASEDIR, machine)): with fab.settings(fab.hide('everything'), host_string=machine): cachepath = _get_remote_cache() with fab.cd(cachepath): files = fab.run(sh_find % '*').replace('\r','').split('\n') result = _quiet(fab.run)( sh_find0 % '*' + '| xargs -0 -I{} ls /{}') if result.failed and 'permission' in result.stdout.lower(): result = _quiet(fab.sudo)( sh_find0 % '*' + '| xargs -0 -I{} ls /{} 2> /dev/null') existing = result.replace('\r','').split('\n') new = [f for f in files if f not in [e[1:] for e in existing]] return new def _diff(flags, path): """ Compare files w/ diff on remote machine. Use sudo if not readable by standard user. """ flags += ' --unidirectional-new-file' with fab.settings(fab.hide('warnings'), warn_only=True): cachepath = _get_remote_cache() with fab.cd(cachepath): path = (path[1:] if path[0] == '/' else path) or '*' result = _quiet(fab.run)( sh_find0 % path + ' | xargs -0 -n 1 -I{} diff %s /{} {}' % flags) if result.failed and 'permission denied' in result.stdout.lower(): result = _quiet(fab.sudo)( sh_find0 % path + ' | xargs -0 -n 1 -I{} diff %s /{} {}' % flags) return result class FileNotExists(str): return_code = 2 return FileNotExists("File does not exist") def _get(machine, remotepath, localpath): """ Pull file or recursively pull files in directory to local store. """ if _test('-f', remotepath): assert _ltest('! -e', localpath) or _ltest('-f', localpath), \ "Can't overwrite file with non-file: %s" % localpath print('Getting file %s to %s' % (remotepath, localpath)) if _test('-r', remotepath): return _rsync("%s:%s" % (machine, remotepath), localpath) else: with _readable_path(remotepath) as tmppath: return _rsync("%s:%s" % (machine, tmppath), localpath) elif _test('-d', remotepath): assert _ltest('! -e', localpath) or _ltest('-d', localpath), \ "Can't sync directory to file: %s" % localpath if not remotepath.endswith('/'): remotepath += '/' if localpath.endswith('/'): localpath = localpath[:-1] print("Syncing directory '%s:%s' to '%s'" % (machine, remotepath, localpath)) result = _quiet(_rsync)("%s:%s" % (machine, remotepath), localpath) if result.return_code == 23: # should be a permission issue with _readable_path(remotepath) as tmppath: result = _rsync("%s:%s" % (machine, tmppath), localpath) print(result) else: raise RuntimeError("Bad remote file path: %s" % remotepath) ######################################################################## ### Shell script templates sh_find = ("find %s \( -type f -o -type l \) " "! -perm -a=t ! -name '*.swp' ! -name '*~'") sh_find0 = sh_find + " -print0 " ######################################################################## ### Tests def _disabled(machine): """ This machine directory not disabled. Disable a machine/directory by setting the sticky bit. """ with _localdir(BASEDIR): if os.stat(machine)[0] & stat.S_ISVTX: return True return False def _offline(machine): """ Is machine reachable on the network? """ if _quiet(fab.local)("ping -c 1 -W 1 %s" % machine).failed: print(C.yellow('%s is Offline' % machine)) return True return False def _user_writable(dest): """ Does the normal (non-root) user have access. Used to test to see if we need to use sudo. """ if _test('-e', dest): return _test('-w', dest) else: remotedir = os.path.dirname(dest) while remotedir != '/': if _test('-e', remotedir): return _test('-w', remotedir) remotedir = os.path.dirname(remotedir) return False def _test(flags, path): """ Run arbitrary 'test' command on remote machine. """ return _quiet(fab.run)('test %s %s' % (flags, path)).return_code == 0 def _ltest(flags, path): """ Run arbitrary 'test' command on local machine. """ return _quiet(fab.local)('test %s %s' % (flags, path)).return_code == 0 ######################################################################## ### File moving def _get_remote_cache(path='', _memoize=[]): """ Update remote cache, restrict to path if provided. Returns path to remote cached file. """ host = fab.env.host_string if host == LOCALHOST: cwd = os.getcwd() cache_path = cwd[:cwd.find(host)+len(host)+1] return os.path.join(cache_path, path) if path: cwd = os.getcwd() path = os.path.join(cwd[cwd.find(host)+len(host)+1:], path) cache_path = os.path.join(remote_cache(), path) if path else remote_cache() _token = (host, path) if _token in _memoize: return cache_path else: _memoize.append(_token) # necessary in case we are rsyncing a sub-section to the cache # if I change caching to be just everything each time I can get # rid of this if not _test('-e', cache_path): if _ltest('-d', path): fab.run('mkdir -p %s' % cache_path) else: fab.run('mkdir -p %s' % os.path.dirname(cache_path)) path = os.path.normpath(path if path else "./") with _localdir(BASEDIR): _rsync(os.path.join(host, path), "%s:%s" % (host, cache_path)) return cache_path def remote_cache(): """ Determin remote cache location. """ if not _test('-d', REMOTE_CACHE): fab.run("mkdir -p %s" % REMOTE_CACHE) return fab.run("cd %s && pwd" % REMOTE_CACHE) def _clear_remote_cache(): """ Clear remote cache. """ fab.run('rm -rf %s' % remote_cache()) def _rsync(src, dest): """ Standard rsync args. """ if _ltest('-d', src) and not src.endswith('/'): src += '/' if dest.endswith('/'): dest = dest[:-1] with _excludes_file(src) as excludes_file: result = fab.local( # consider adding AX when rsync 2.x support is no longer needed "rsync -vaxH --delete --delete-excluded " "--exclude '*.swp' --exclude '*~' " "--copy-unsafe-links --exclude-from=%s %s %s" % (excludes_file, src, dest)) return result ######################################################################## ### Context Managers & Function Wrappers def _quiet(fun): """ Ignore errors and just return result. """ return _contextualize(fun, fab.settings(fab.hide('everything'), warn_only=True)) def _contextualize(fun, cm): """ Wraps function (fun) in contextmanager (cm). """ @FT.wraps(fun) def newfun(*a, **kw): with cm: return fun(*a, **kw) return newfun @CT.contextmanager def _readable_path(remotepath): """ Copy file to tmp location and chown to running user. Allows read access while keeping file permission settings. """ basepath = '/tmp/fabtmp' fab.run('mkdir -m 700 %s' % basepath) # limit exposure filename = (os.path.basename(remotepath) if not remotepath.endswith('/') else os.path.basename(remotepath[:-1]) + '/') tmppath = os.path.join(basepath, filename) fab.sudo('cp -a %s %s' % (remotepath, tmppath)) fab.sudo('chown -R %s %s' % (fab.env.user, tmppath)) yield tmppath if basepath.startswith('/tmp'): fab.sudo('rm -rf %s' % basepath) else: raise RuntimeError('Bad tmp path: %s' % tmppath) @CT.contextmanager def _excludes_file(path): """ Create and return file with rsync excludes, remove when done. Exclude files with sticky bit. """ excludes_file = '/tmp/fab-excludes' with fab.settings(warn_only=True): fab.local("find %s -perm -a=t > %s" % (path, excludes_file)) # massage path to work with rsync fab.local("sed -i -e 's/%s//' %s" % (path.replace('/', '\/'), excludes_file)) yield excludes_file fab.local('rm %s' % excludes_file) @CT.contextmanager def _localdir(path): """ Contextmanager to set local directory and switch back after done. """ cwd = os.getcwd() os.chdir(path) yield os.chdir(cwd) @CT.contextmanager def _path_setup(localpath): """ Contextmanager to set the paths for local and remote files. Sets defaults to cwd if no path is passed in (if None or ''). """ orig_cwd = cwd = os.getcwd() machine = fab.env.host_string mdir = cwd[cwd.find(machine)+len(machine):] or '/' # if no path, use cwd as path to check if not localpath and mdir != '/': cwd = os.path.dirname(cwd) localpath = os.path.basename(mdir) mdir = os.path.dirname(mdir) remotepath = os.path.join(mdir, localpath) fab.local('cd %s' % cwd) yield (localpath, remotepath) fab.local('cd %s' % orig_cwd)