Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | #-*- coding: utf-8 -*- |
| 3 | |
| 4 | |
| 5 | import datetime |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 6 | import logging |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 7 | import os |
Max Rasskazov | e99d837 | 2015-01-13 20:07:01 +0300 | [diff] [blame] | 8 | import re |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 9 | import subprocess |
| 10 | import tempfile |
| 11 | |
| 12 | |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 13 | logging.basicConfig(level=logging.INFO) |
| 14 | logger = logging.getLogger('rsync_staging') |
| 15 | |
Max Rasskazov | 3fe4271 | 2015-01-15 15:34:22 +0300 | [diff] [blame] | 16 | now = datetime.datetime.utcnow() |
| 17 | staging_snapshot_stamp_format = r'%Y-%m-%d-%H%M%S' |
| 18 | staging_snapshot_stamp_regexp = r'[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' |
| 19 | staging_snapshot_stamp = now.strftime(staging_snapshot_stamp_format) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 20 | |
| 21 | |
| 22 | class RemoteRsyncStaging(object): |
| 23 | def __init__(self, |
| 24 | mirror_name, |
| 25 | host, |
| 26 | module='mirror-sync', |
| 27 | root_path='fwm', |
| 28 | files_dir='files', |
| 29 | save_last_days=61, |
| 30 | rsync_extra_params='-v', |
| 31 | staging_postfix='staging'): |
| 32 | self.mirror_name = mirror_name |
| 33 | self.host = host |
| 34 | self.module = module |
| 35 | self.root_path = root_path |
| 36 | self.files_dir = files_dir |
| 37 | self.save_last_days = save_last_days |
| 38 | self.rsync_extra_params = rsync_extra_params |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 39 | self.staging_postfix = staging_postfix |
Max Rasskazov | 3fe4271 | 2015-01-15 15:34:22 +0300 | [diff] [blame] | 40 | self.staging_snapshot_stamp = staging_snapshot_stamp |
| 41 | self.staging_snapshot_stamp_format = staging_snapshot_stamp_format |
| 42 | if re.match(staging_snapshot_stamp_regexp, |
| 43 | self.staging_snapshot_stamp) \ |
| 44 | is not None: |
| 45 | self.staging_snapshot_stamp_regexp = staging_snapshot_stamp_regexp |
| 46 | else: |
| 47 | raise RuntimeError('Wrong regexp for staging_snapshot_stamp\n' |
| 48 | 'staging_snapshot_stamp = "{}"\n' |
| 49 | 'staging_snapshot_stamp_regexp = "{}"'. |
| 50 | format(staging_snapshot_stamp, |
| 51 | staging_snapshot_stamp_regexp) |
| 52 | ) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 53 | |
| 54 | @property |
| 55 | def url(self): |
| 56 | return '{}::{}'.format(self.host, self.module) |
| 57 | |
| 58 | @property |
| 59 | def root_url(self): |
| 60 | return '{}/{}'.format(self.url, self.root_path) |
| 61 | |
| 62 | @property |
| 63 | def files_path(self): |
| 64 | return '{}/{}'.format(self.root_path, self.files_dir) |
| 65 | |
| 66 | @property |
| 67 | def files_url(self): |
| 68 | return '{}/{}'.format(self.root_url, self.files_dir) |
| 69 | |
| 70 | def http_url(self, path): |
| 71 | return 'http://{}/{}'.format(self.host, path) |
| 72 | |
| 73 | def html_link(self, path, link_name): |
| 74 | return '<a href="{}">{}</a>'.format(self.http_url(path), link_name) |
| 75 | |
| 76 | @property |
| 77 | def staging_dir(self): |
| 78 | return '{}-{}'.format(self.mirror_name, self.staging_snapshot_stamp) |
| 79 | |
| 80 | @property |
| 81 | def staging_dir_path(self): |
| 82 | return '{}/{}'.format(self.files_path, self.staging_dir) |
| 83 | |
| 84 | @property |
| 85 | def staging_dir_url(self): |
| 86 | return '{}/{}'.format(self.url, self.staging_dir_path) |
| 87 | |
| 88 | @property |
| 89 | def staging_link(self): |
| 90 | return '{}-{}'.format(self.mirror_name, self.staging_postfix) |
| 91 | |
| 92 | @property |
| 93 | def staging_link_path(self): |
| 94 | return '{}/{}'.format(self.files_path, self.staging_link) |
| 95 | |
| 96 | @property |
| 97 | def staging_link_url(self): |
| 98 | return '{}/{}'.format(self.url, self.staging_link_path) |
| 99 | |
| 100 | @property |
| 101 | def empty_dir(self): |
| 102 | if self.__dict__.get('_empty_dir') is None: |
| 103 | self._empty_dir = tempfile.mkdtemp() |
| 104 | return self._empty_dir |
| 105 | |
| 106 | def symlink_to(self, target): |
| 107 | linkname = tempfile.mktemp() |
| 108 | os.symlink(target, linkname) |
| 109 | return linkname |
| 110 | |
| 111 | def _shell(self, cmd, raise_error=True): |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 112 | logger.info(cmd) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 113 | process = subprocess.Popen(cmd, |
| 114 | stdin=subprocess.PIPE, |
| 115 | stdout=subprocess.PIPE, |
| 116 | stderr=subprocess.PIPE, |
| 117 | shell=True) |
| 118 | out, err = process.communicate() |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 119 | logger.debug(out) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 120 | exitcode = process.returncode |
| 121 | if process.returncode != 0 and raise_error: |
| 122 | msg = '"{cmd}" failed. Exit code == {exitcode}'\ |
| 123 | '\n\nSTDOUT: \n{out}'\ |
| 124 | '\n\nSTDERR: \n{err}'\ |
| 125 | .format(**(locals())) |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 126 | logger.error(msg) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 127 | raise RuntimeError(msg) |
| 128 | return exitcode, out, err |
| 129 | |
| 130 | def _do_rsync(self, source='', dest=None, opts='', extra=None): |
| 131 | if extra is None: |
| 132 | extra = self.rsync_extra_params |
| 133 | cmd = 'rsync {opts} {extra} {source} {dest}'.format(**(locals())) |
| 134 | return self._shell(cmd) |
| 135 | |
Max Rasskazov | e99d837 | 2015-01-13 20:07:01 +0300 | [diff] [blame] | 136 | def _rsync_ls(self, dirname=None, pattern=r'.*', opts=''): |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 137 | if dirname is None: |
| 138 | dirname = '{}/'.format(self.root_path) |
| 139 | dest = '{}/{}'.format(self.url, dirname) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 140 | extra = self.rsync_extra_params + ' --no-v' |
| 141 | exitcode, out, err = self._do_rsync(dest=dest, opts=opts, extra=extra) |
Max Rasskazov | e99d837 | 2015-01-13 20:07:01 +0300 | [diff] [blame] | 142 | regexp = re.compile(pattern) |
| 143 | out = [_ for _ in out.splitlines() |
| 144 | if (_.split()[-1] != '.') and |
| 145 | (regexp.match(_.split()[-1]) is not None)] |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 146 | return exitcode, out, err |
| 147 | |
Max Rasskazov | e99d837 | 2015-01-13 20:07:01 +0300 | [diff] [blame] | 148 | def rsync_ls(self, dirname, pattern=r'.*'): |
| 149 | exitcode, out, err = self._rsync_ls(dirname, pattern=pattern) |
| 150 | out = [_.split()[-1] for _ in out] |
| 151 | return exitcode, out, err |
| 152 | |
| 153 | def rsync_ls_dirs(self, dirname, pattern=r'.*'): |
| 154 | exitcode, out, err = self._rsync_ls(dirname, pattern=pattern) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 155 | out = [_.split()[-1] for _ in out if _.startswith('d')] |
| 156 | return exitcode, out, err |
| 157 | |
Max Rasskazov | e99d837 | 2015-01-13 20:07:01 +0300 | [diff] [blame] | 158 | def rsync_ls_symlinks(self, dirname, pattern=r'.*'): |
| 159 | exitcode, out, err = self._rsync_ls(dirname, |
| 160 | pattern=pattern, |
| 161 | opts='-l') |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 162 | out = [_.split()[-3:] for _ in out if _.startswith('l')] |
| 163 | out = [[_[0], _[-1]] for _ in out] |
| 164 | return exitcode, out, err |
| 165 | |
| 166 | def rsync_delete_file(self, filename): |
| 167 | dirname, filename = os.path.split(filename) |
| 168 | source = '{}/'.format(self.empty_dir) |
| 169 | dest = '{}/{}/'.format(self.url, dirname) |
| 170 | opts = "-r --delete --include={} '--exclude=*'".format(filename) |
| 171 | return self._do_rsync(source=source, dest=dest, opts=opts) |
| 172 | |
| 173 | def rsync_delete_dir(self, dirname): |
| 174 | source = '{}/'.format(self.empty_dir) |
| 175 | dest = '{}/{}/'.format(self.url, dirname) |
| 176 | opts = "-a --delete" |
| 177 | exitcode, out, err = self._do_rsync(source=source, |
| 178 | dest=dest, |
| 179 | opts=opts) |
| 180 | return self.rsync_delete_file(dirname) |
| 181 | |
| 182 | def rsync_staging_transfer(self, source, tgt_symlink_name=None): |
| 183 | if tgt_symlink_name is None: |
| 184 | tgt_symlink_name = self.mirror_name |
| 185 | opts = '--archive --force --ignore-errors '\ |
| 186 | '--delete-excluded --no-owner --no-group --delete '\ |
| 187 | '--link-dest=/{}'.format(self.staging_link_path) |
| 188 | try: |
| 189 | exitcode, out, err = self._do_rsync(source=source, |
| 190 | dest=self.staging_dir_url, |
| 191 | opts=opts) |
| 192 | self.rsync_delete_file(self.staging_link_path) |
| 193 | self._do_rsync(source=self.symlink_to(self.staging_dir), |
| 194 | dest=self.staging_link_url, |
| 195 | opts='-l') |
| 196 | # cleaning of old snapshots |
Max Rasskazov | 3fe4271 | 2015-01-15 15:34:22 +0300 | [diff] [blame] | 197 | self._remove_old_snapshots() |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 198 | return exitcode, out, err |
| 199 | except RuntimeError as e: |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 200 | logger.error(e.message) |
Max Rasskazov | 7f3c53c | 2014-12-09 14:19:17 +0300 | [diff] [blame] | 201 | self.rsync_delete_dir(self.staging_dir_path) |
| 202 | raise |
Max Rasskazov | 3fe4271 | 2015-01-15 15:34:22 +0300 | [diff] [blame] | 203 | |
| 204 | def _remove_old_snapshots(self, save_last_days=None): |
| 205 | if save_last_days is None: |
| 206 | save_last_days = self.save_last_days |
| 207 | if save_last_days is None \ |
| 208 | or save_last_days is False \ |
| 209 | or save_last_days == 0: |
| 210 | # skipping deletion if save_last_days == None or False or 0 |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 211 | logger.info('Skip deletion of old snapshots because of ' |
| 212 | 'save_last_days == {}'.format(save_last_days)) |
Max Rasskazov | 3fe4271 | 2015-01-15 15:34:22 +0300 | [diff] [blame] | 213 | return |
| 214 | warn_date = now - datetime.timedelta(days=save_last_days) |
| 215 | warn_date = datetime.datetime.combine(warn_date, datetime.time(0)) |
| 216 | dirs = self.rsync_ls_dirs( |
| 217 | '{}/'.format(self.files_path), |
| 218 | pattern='^{}-{}'.format(self.mirror_name, |
| 219 | self.staging_snapshot_stamp_regexp) |
| 220 | )[1] |
| 221 | links = self.rsync_ls_symlinks('{}/'.format(self.root_path))[1] |
| 222 | links += self.rsync_ls_symlinks('{}/'.format(self.files_path))[1] |
| 223 | for d in dirs: |
| 224 | dir_date = datetime.datetime.strptime( |
| 225 | d, |
| 226 | '{}-{}'.format(self.mirror_name, |
| 227 | self.staging_snapshot_stamp_format) |
| 228 | ) |
| 229 | dir_date = datetime.datetime.combine(dir_date, datetime.time(0)) |
| 230 | dir_path = '{}/{}'.format(self.files_path, d) |
| 231 | if dir_date < warn_date: |
| 232 | dir_links = [_[0] for _ in links |
| 233 | if _[1] == d |
| 234 | or _[1].endswith('/{}'.format(d)) |
| 235 | ] |
| 236 | if not dir_links: |
| 237 | self.rsync_delete_dir(dir_path) |
| 238 | else: |
Max Rasskazov | 11653ab | 2015-01-15 15:45:16 +0300 | [diff] [blame^] | 239 | logger.info('Skip deletion of "{}" because there are ' |
| 240 | 'symlinks found: {}'.format(d, dir_links)) |