diff options
author | Lars Wirzenius <lars.wirzenius@codethink.co.uk> | 2014-03-25 15:11:24 +0000 |
---|---|---|
committer | Lars Wirzenius <lars.wirzenius@codethink.co.uk> | 2014-03-25 15:11:24 +0000 |
commit | 4d80caf31d05a98fb4e884ccc083a9535c691596 (patch) | |
tree | 2c9756b5add2112752d9e8236cb061b9067b6ddc | |
parent | bc518bff343c5df24707b9e13c8e711605a9e28c (diff) | |
download | lorry-controller-4d80caf31d05a98fb4e884ccc083a9535c691596.tar.gz |
Move the rest into modules in package
-rwxr-xr-x | lorry-controller-webapp | 868 | ||||
-rw-r--r-- | lorrycontroller/__init__.py | 17 | ||||
-rw-r--r-- | lorrycontroller/givemejob.py | 75 | ||||
-rw-r--r-- | lorrycontroller/jobupdate.py | 50 | ||||
-rw-r--r-- | lorrycontroller/listjobs.py | 58 | ||||
-rw-r--r-- | lorrycontroller/listqueue.py | 31 | ||||
-rw-r--r-- | lorrycontroller/listrunningjobs.py | 32 | ||||
-rw-r--r-- | lorrycontroller/lstroves.py | 200 | ||||
-rw-r--r-- | lorrycontroller/movetopbottom.py | 56 | ||||
-rw-r--r-- | lorrycontroller/readconf.py | 194 | ||||
-rw-r--r-- | lorrycontroller/removejob.py | 42 | ||||
-rw-r--r-- | lorrycontroller/showjob.py | 48 | ||||
-rw-r--r-- | lorrycontroller/showlorry.py | 75 | ||||
-rw-r--r-- | lorrycontroller/startstopqueue.py | 53 | ||||
-rw-r--r-- | lorrycontroller/static.py | 34 | ||||
-rw-r--r-- | lorrycontroller/status.py | 154 | ||||
-rw-r--r-- | lorrycontroller/stopjob.py | 39 |
17 files changed, 1160 insertions, 866 deletions
diff --git a/lorry-controller-webapp b/lorry-controller-webapp index 64b53a1..f63390e 100755 --- a/lorry-controller-webapp +++ b/lorry-controller-webapp @@ -16,7 +16,6 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -import collections import errno import glob import logging @@ -35,870 +34,6 @@ from flup.server.fcgi import WSGIServer import lorrycontroller -class StatusRenderer(object): - - '''Helper class for rendering service status as JSON/HTML''' - - def get_status_as_dict(self, statedb, work_directory): - quotes = [ - "Never get drunk unless you're willing to pay for it - " - "the next day.", - "I'm giving her all she's got, Captain!", - ] - import random - status = { - 'quote': '%s' % random.choice(quotes), - 'running_queue': statedb.get_running_queue(), - 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), - 'run_queue': self.get_run_queue(statedb), - 'troves': self.get_troves(statedb), - 'warning_msg': '', - } - status.update(self.get_free_disk_space(work_directory)) - return status - - def render_status_as_html(self, template, status): - return bottle.template(template, **status) - - def write_status_as_html(self, template, status, filename): - html = self.render_status_as_html(template, status) - try: - with open(filename, 'w') as f: - f.write(html) - except (OSError, IOError) as e: - status['warning_msg'] = ( - 'ERROR WRITING STATUS HTML TO DISK: %s' % str(e)) - - def get_free_disk_space(self, dirname): - result = os.statvfs(dirname) - free_bytes = result.f_bavail * result.f_bsize - return { - 'disk_free': free_bytes, - 'disk_free_mib': free_bytes / 1024**2, - 'disk_free_gib': free_bytes / 1024**3, - } - - def get_run_queue(self, statedb): - lorries = statedb.get_all_lorries_info() - now = time.time() - for lorry in lorries: - due = lorry['last_run'] + lorry['interval'] - lorry['interval_nice'] = self.format_secs_nicely(lorry['interval']) - lorry['due_nice'] = self.format_due_nicely(due) - return lorries - - def format_due_nicely(self, due): - now = int(time.time()) - if due <= now: - return 'now' - else: - nice = self.format_secs_nicely(due - now) - return 'in %s' % nice - - def format_secs_nicely(self, secs): - if secs <= 0: - return 'now' - - result = [] - - hours = secs / 3600 - secs %= 3600 - mins = secs / 60 - secs %= 60 - - if hours > 0: - result.append('%d h' % hours) - if mins > 0: - result.append('%d min' % mins) - elif mins > 0: - result.append('%d min' % mins) - if secs > 0: - result.append('%d s' % secs) - else: - result.append('%d s' % secs) - - return ' '.join(result) - - def get_troves(self, statedb): - troves = [] - for trovehost in statedb.get_troves(): - trove_info = statedb.get_trove_info(trovehost) - - trove_info['ls_interval_nice'] = self.format_secs_nicely( - trove_info['ls_interval']) - - ls_due = trove_info['ls_last_run'] + trove_info['ls_interval'] - now = int(time.time()) - trove_info['ls_due_nice'] = self.format_due_nicely(ls_due) - - troves.append(trove_info) - return troves - - -class Status(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/status' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - renderer = StatusRenderer() - statedb = self.open_statedb() - status = renderer.get_status_as_dict( - statedb, self.app_settings['statedb']) - renderer.write_status_as_html( - self._templates['status'], - status, - self.app_settings['status-html']) - return status - - -class StatusHTML(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/status-html' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - renderer = StatusRenderer() - statedb = self.open_statedb() - status = renderer.get_status_as_dict( - statedb, self.app_settings['statedb']) - renderer.write_status_as_html( - self._templates['status'], - status, - self.app_settings['status-html']) - return renderer.render_status_as_html( - self._templates['status'], status) - - -class ReadConfiguration(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/read-configuration' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - self.get_confgit() - conf_obj = self.read_config_file() - self.fix_up_parsed_fields(conf_obj) - - statedb = self.open_statedb() - with statedb: - existing_lorries = set(statedb.get_lorries_paths()) - existing_troves = set(statedb.get_troves()) - - for section in conf_obj: - if section['type'] == 'lorries': - added = self.add_matching_lorries_to_statedb( - statedb, section) - existing_lorries = existing_lorries.difference(added) - elif section['type'] in ('trove', 'troves'): - self.add_trove(statedb, section) - if section['trovehost'] in existing_troves: - existing_troves.remove(section['trovehost']) - existing_lorries = self.without_lorries_for_trovehost( - statedb, existing_lorries, section['trovehost']) - else: - logging.warning( - 'Unknown section in configuration: %r', section) - - for path in existing_lorries: - statedb.remove_lorry(path) - - for trovehost in existing_troves: - statedb.remove_trove(trovehost) - statedb.remove_lorries_for_trovehost(trovehost) - - - if 'redirect' in bottle.request.forms: - bottle.redirect(bottle.request.forms.redirect) - - return 'Configuration has been updated.' - - def without_lorries_for_trovehost(self, statedb, lorries, trovehost): - for_trovehost = statedb.get_lorries_for_trove(trovehost) - return set(x for x in lorries if x not in for_trovehost) - - def get_confgit(self): - if self.app_settings['debug-real-confgit']: - confdir = self.app_settings['configuration-directory'] - if not os.path.exists(confdir): - self.git_clone_confgit(confdir) - else: - self.git_pull_confgit(confdir) - - def git_clone_confgit(self, confdir): - url = self.app_settings['confgit-url'] - branch = self.app_settings['confgit-branch'] - cliapp.runcmd(['git', 'clone', '-b', branch, url, confdir]) - - def git_pull_confgit(self, confdir): - cliapp.runcmd(['git', 'pull'], cwd=confdir) - - @property - def config_file_name(self): - return os.path.join( - self.app_settings['configuration-directory'], - 'lorry-controller.conf') - - def read_config_file(self): - '''Read the configuration file, return as Python object.''' - - filename = self.config_file_name - - try: - with open(filename) as f: - return json.load(f) - except IOError as e: - if e.errno == errno.ENOENT: - # File doesn't exist. Return an empty configuration. - return [] - bottle.abort(500, 'Error reading %s: %s' % (filename, e)) - - def fix_up_parsed_fields(self, obj): - for item in obj: - item['interval'] = self.fix_up_interval(item.get('interval')) - item['ls-interval'] = self.fix_up_interval(item.get('ls-interval')) - - def fix_up_interval(self, value): - default_interval = 86400 # 1 day - if not value: - return default_interval - m = re.match('(\d+)\s*(s|m|h|d)?', value, re.I) - if not m: - return default_value - - number, factor = m.groups() - factors = { - 's': 1, - 'm': 60, - 'h': 60*60, - 'd': 60*60*24, - } - if factor is None: - factor = 's' - factor = factors.get(factor.lower(), 1) - return int(number) * factor - - def add_matching_lorries_to_statedb(self, statedb, section): - added_paths = set() - - filenames = self.find_lorry_files_for_section(section) - lorry_specs = [] - for filename in sorted(filenames): - for lorry_spec in self.get_lorry_specs(filename): - self.add_refspecs_if_missing(lorry_spec) - lorry_specs.append(lorry_spec) - - for lorry_spec in sorted(lorry_specs): - path = self.deduce_repo_path(section, lorry_spec) - text = self.serialise_lorry_spec(lorry_spec) - interval = section['interval'] - - try: - old_lorry_info = statedb.get_lorry_info(path) - except lorrycontroller.LorryNotFoundError: - old_lorry_info = None - - statedb.add_to_lorries( - path=path, text=text, from_trovehost='', - interval=interval) - - added_paths.add(path) - - return added_paths - - def find_lorry_files_for_section(self, section): - result = [] - dirname = os.path.dirname(self.config_file_name) - for base_pattern in section['globs']: - pattern = os.path.join(dirname, base_pattern) - result.extend(glob.glob(pattern)) - return result - - def get_lorry_specs(self, filename): - with open(filename) as f: - obj = json.load(f) - return obj.items() - - def add_refspecs_if_missing(self, lorry_spec): - base_path, details = lorry_spec - if 'refspecs' not in details: - details['refspecs'] = [ - '+refs/heads/*', - '+refs/tags/*', - ] - - def deduce_repo_path(self, section, lorry_spec): - base_path, details = lorry_spec - return '%s/%s' % (section['prefix'], base_path) - - def serialise_lorry_spec(self, lorry_spec): - key, details = lorry_spec - obj = { key: details } - return json.dumps(obj, indent=4) - - def add_trove(self, statedb, section): - statedb.add_trove( - trovehost=section['trovehost'], - lorry_interval=section['interval'], - ls_interval=section['ls-interval'], - prefixmap=json.dumps(section['prefixmap']), - ignore=json.dumps(section['ignore'])) - - -class ListQueue(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/list-queue' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - return { - 'queue': - [spec['path'] for spec in statedb.get_all_lorries_info()], - } - - -class ShowLorry(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/lorry/<path:path>' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - try: - return statedb.get_lorry_info(kwargs['path']) - except lorrycontroller.LorryNotFoundError as e: - bottle.abort(404, str(e)) - - -class ShowLorryHTML(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/lorry-html/<path:path>' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - try: - lorry_info = statedb.get_lorry_info(kwargs['path']) - except lorrycontroller.LorryNotFoundError as e: - bottle.abort(404, str(e)) - - renderer = StatusRenderer() - - lorry_obj = json.loads(lorry_info['text']).values()[0] - lorry_info['url'] = lorry_obj['url'] - - lorry_info['interval_nice'] = renderer.format_secs_nicely( - lorry_info['interval']) - - lorry_info['last_run_nice'] = time.strftime( - '%Y-%m-%d %H:%M:%S UTC', - time.gmtime(lorry_info['last_run'])) - - due = lorry_info['last_run'] + lorry_info['interval'] - lorry_info['due_nice'] = renderer.format_due_nicely(due) - - timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()) - - parts = urlparse.urlparse(bottle.request.url) - host, port = parts.netloc.split(':', 1) - http_server_root = urlparse.urlunparse( - (parts.scheme, host, '', '', '', '')) - - return bottle.template( - self._templates['lorry'], - http_server_root=http_server_root, - lorry=lorry_info, - timestamp=timestamp) - - -class StartQueue(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/start-queue' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - with statedb: - statedb.set_running_queue(1) - - if 'redirect' in bottle.request.forms: - bottle.redirect(bottle.request.forms.redirect) - - return 'Queue set to run' - - -class StopQueue(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/stop-queue' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - with statedb: - statedb.set_running_queue(0) - - if 'redirect' in bottle.request.forms: - bottle.redirect(bottle.request.forms.redirect) - - return 'Queue set to not run' - - -class GiveMeJob(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/give-me-job' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - readdb = self.open_statedb() - if readdb.get_running_queue(): - statedb = self.open_statedb() - with statedb: - lorry_infos = statedb.get_all_lorries_info() - for lorry_info in lorry_infos: - if self.ready_to_run(lorry_info): - self.create_repository_in_local_trove(lorry_info) - self.give_job_to_minion(statedb, lorry_info) - logging.info( - 'Giving job %s to lorry %s to MINION %s:%s', - lorry_info['job_id'], - lorry_info['path'], - bottle.request.forms.host, - bottle.request.forms.pid) - return lorry_info - - logging.info('No job to give MINION') - return { 'job_id': None } - - def ready_to_run(self, lorry_info): - due = lorry_info['last_run'] + lorry_info['interval'] - return (lorry_info['running_job'] is None and due <= time.time()) - - def create_repository_in_local_trove(self, lorry_info): - # Create repository on local Trove. If it fails, assume - # it failed because the repository already existed, and - # ignore the failure (but log message). - exit, stdout, stderr = cliapp.runcmd_unchecked( - ['ssh', 'git@localhost', 'create', lorry_info['path']]) - if exit: - logging.debug( - 'Ignoring error creating %s on local Trove:\n%s\n%s', - lorry_info['path'], stdout, stderr) - else: - logging.info('Created %s on local repo', lorry_info['path']) - - def give_job_to_minion(self, statedb, lorry_info): - path = lorry_info['path'] - minion_host = bottle.request.forms.host - minion_pid = bottle.request.forms.pid - running_job = statedb.get_next_job_id() - statedb.set_running_job(path, running_job) - statedb.add_new_job(running_job, minion_host, minion_pid, path) - lorry_info['job_id'] = running_job - return lorry_info - - -class JobUpdate(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/job-update' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - job_id = int(bottle.request.forms.job_id) - exit = bottle.request.forms.exit - stdout = bottle.request.forms.stdout - stderr = bottle.request.forms.stderr - - logging.info('Job %s updated (exit=%s)', job_id, exit) - - statedb = self.open_statedb() - with statedb: - if stdout: - statedb.append_to_job_output(job_id, stdout) - if stderr: - statedb.append_to_job_output(job_id, stderr) - - path = statedb.find_lorry_running_job(job_id) - if exit is not None and exit != 'no': - lorry_info = statedb.get_lorry_info(path) - statedb.set_lorry_last_run(path, int(time.time())) - statedb.set_running_job(path, None) - statedb.set_job_exit(job_id, exit) - return statedb.get_lorry_info(path) - - -class ListRunningJobs(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/list-running-jobs' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - statedb = self.open_statedb() - job_ids = statedb.get_running_jobs() - return { - 'running_jobs': job_ids, - } - - -class MoveToTop(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/move-to-top' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - path = bottle.request.forms.path - statedb = self.open_statedb() - with statedb: - lorry_infos = statedb.get_all_lorries_info() - if lorry_infos: - topmost = lorry_infos[0] - timestamp = min(0, topmost['last_run'] - 1) - statedb.set_lorry_last_run(path, timestamp) - return 'Lorry %s moved to top of run-queue' % path - - -class MoveToBottom(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/move-to-bottom' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - path = bottle.request.forms.path - statedb = self.open_statedb() - with statedb: - lorry_infos = statedb.get_all_lorries_info() - if lorry_infos: - bottommost = lorry_infos[-1] - timestamp = ( - bottommost['last_run'] + bottommost['interval'] + 1) - statedb.set_lorry_last_run(path, timestamp) - return 'Lorry %s moved to bototm of run-queue' % path - - -class StopJob(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/stop-job' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - with statedb: - job_id = bottle.request.forms.job_id - try: - path = statedb.find_lorry_running_job(job_id) - except lorrycontroller.WrongNumberLorriesRunningJob: - logging.warning( - "Tried to kill job %s which isn't running" % job_id) - bottle.abort(409, 'Job is not currently running') - statedb.set_kill_job(path, True) - return statedb.get_lorry_info(path) - - -class ListAllJobs(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/list-jobs' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - statedb = self.open_statedb() - return { 'job_ids': statedb.get_job_ids() } - - -class ListAllJobsHTML(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/list-jobs-html' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - statedb = self.open_statedb() - values = { - 'job_infos': self.get_jobs(statedb), - 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), - } - return bottle.template(self._templates['list-jobs'], **values) - - def get_jobs(self, statedb): - jobs = [] - for job_id in statedb.get_job_ids(): - exit = statedb.get_job_exit(job_id) - job = { - 'job_id': job_id, - 'exit': 'no' if exit is None else str(exit), - 'path': statedb.get_job_path(job_id), - } - jobs.append(job) - return jobs - - -class ShowJob(lorrycontroller.LorryControllerRoute): - - http_method = 'GET' - path = '/1.0/job/<job_id:int>' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - job_id = int(kwargs['job_id']) - - statedb = self.open_statedb() - - path = statedb.get_job_path(job_id) - exit = statedb.get_job_exit(job_id) - output = statedb.get_job_output(job_id) - - variables = { - 'job_id': job_id, - 'host': statedb.get_job_minion_host(job_id), - 'pid': statedb.get_job_minion_pid(job_id), - 'path': statedb.get_job_path(job_id), - 'exit': 'no' if exit is None else exit, - 'output': output, - 'timestamp': - time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), - } - - return bottle.template(self._templates['job'], **variables) - - -class RemoveJob(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/remove-job' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - job_id = bottle.request.forms.job_id - - statedb = self.open_statedb() - with statedb: - try: - statedb.find_lorry_running_job(job_id) - except lorrycontroller.WrongNumberLorriesRunningJob: - pass - else: - return { 'job_id': None, 'reason': 'still running' } - - statedb.remove_job(job_id) - return { 'job_id': job_id } - - -class GitanoLsError(Exception): - - def __init__(self, trovehost, output): - Exception.__init__( - self, - 'Failed to get list of git repositories ' - 'on remote host %s:\n%s' % (trovehost, output)) - self.trovehost = trovehost - - -class TroveRepositoryLister(object): - - def __init__(self, app_settings): - self.app_settings = app_settings - - def list_trove_into_statedb(self, statedb, trove_info): - remote_paths = self.ls(trove_info) - remote_paths = self.skip_ignored_repos(trove_info, remote_paths) - repo_map = self.map_remote_repos_to_local_ones( - trove_info, remote_paths) - - with statedb: - self.update_lorries_for_trove(statedb, trove_info, repo_map) - now = int(time.time()) - statedb.set_trove_ls_last_run(trove_info['trovehost'], now) - - def ls(self, trove_info): - if self.app_settings['debug-fake-trove']: - repo_paths = self.get_fake_ls_output(trove_info) - else: - repo_paths = self.get_real_ls_output(trove_info) - - return repo_paths - - def get_fake_ls_output(self, trove_info): - trovehost = trove_info['trovehost'] - for item in self.app_settings['debug-fake-trove']: - host, path = item.split('=', 1) - if host == trovehost: - with open(path) as f: - obj = json.load(f) - return obj['ls-output'] - return None - - def get_real_ls_output(self, trove_info): - trovehost = trove_info['trovehost'] - exit, stdout, stderr = cliapp.runcmd_unchecked( - ['ssh', 'git@%s' % trovehost, 'ls']) - - if exit != 0: - logging.error( - 'Failed to run "gitano ls" for %s:\n%s', - trove_info['trovehost'], stdout + stderr) - raise GitanoLsError(trovehost, stdout + stderr) - - return self.parse_ls_output(stdout) - - def parse_ls_output(self, ls_output): - repo_paths = [] - for line in ls_output.splitlines(): - words = line.split() - if words[0].startswith('R') and len(words) == 2: - repo_paths.append(words[1]) - return repo_paths - - def skip_ignored_repos(self, trovehost, repo_paths): - ignored_paths = json.loads(trovehost['ignore']) - return [x for x in repo_paths if x not in ignored_paths] - - def map_remote_repos_to_local_ones(self, trove_info, remote_paths): - '''Return a dict that maps each remote repo path to a local one.''' - prefixmap = self.parse_prefixmap(trove_info['prefixmap']) - repo_map = {} - for remote_path in remote_paths: - local_path = self.map_one_remote_repo_to_local_one( - remote_path, prefixmap) - if local_path: - repo_map[remote_path] = local_path - else: - logging.debug('Remote repo %r not in prefixmap', remote_path) - return repo_map - - def parse_prefixmap(self, prefixmap_string): - return json.loads(prefixmap_string) - - def map_one_remote_repo_to_local_one(self, remote_path, prefixmap): - for remote_prefix in prefixmap: - if self.path_starts_with_prefix(remote_path, remote_prefix): - local_prefix = prefixmap[remote_prefix] - relative_path = remote_path[len(remote_prefix):] - local_path = local_prefix + relative_path - return local_path - return None - - def path_starts_with_prefix(self, path, prefix): - return path.startswith(prefix) and path[len(prefix):].startswith('/') - - def update_lorries_for_trove(self, statedb, trove_info, repo_map): - trovehost = trove_info['trovehost'] - for remote_path, local_path in repo_map.items(): - lorry = { - local_path: { - 'type': 'git', - 'url': 'ssh://git@%s/%s' % (trovehost, remote_path), - 'refspecs': [ - "+refs/heads/*", - "+refs/tags/*", - ], - } - } - statedb.add_to_lorries( - path=local_path, - text=json.dumps(lorry, indent=4), - from_trovehost=trovehost, - interval=trove_info['lorry_interval']) - - all_local_paths = set(statedb.get_lorries_for_trove(trovehost)) - wanted_local_paths = set(repo_map.values()) - delete_local_paths = all_local_paths.difference(wanted_local_paths) - for local_path in delete_local_paths: - statedb.remove_lorry(local_path) - - -class ForceLsTrove(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/force-ls-trove' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - trovehost = bottle.request.forms.trovehost - - statedb = self.open_statedb() - lister = TroveRepositoryLister(self.app_settings) - trove_info = statedb.get_trove_info(trovehost) - try: - updated = lister.list_trove_into_statedb(statedb, trove_info) - except GitanoLsError as e: - raise bottle.abort(500, str(e)) - - return { 'updated-troves': updated } - - -class LsTroves(lorrycontroller.LorryControllerRoute): - - http_method = 'POST' - path = '/1.0/ls-troves' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - - statedb = self.open_statedb() - lister = TroveRepositoryLister(self.app_settings) - - trove_infos = self.get_due_troves(statedb) - for trove_info in trove_infos: - logging.info('Trove %r is due an ls', trove_info['trovehost']) - try: - lister.list_trove_into_statedb(statedb, trove_info) - except GitanoLsError as e: - bottle.abort(500, str(e)) - - return { - 'updated-troves': [trove_info['trovehost'] for trove_info in trove_infos], - } - - def get_due_troves(self, statedb): - trove_infos = [ - statedb.get_trove_info(trovehost) - for trovehost in statedb.get_troves()] - return [ - trove_info - for trove_info in trove_infos - if self.is_due(trove_info)] - - def is_due(self, trove_info): - ls_due = trove_info['ls_last_run'] + trove_info['ls_interval'] - return ls_due <= time.time() - - -class StaticFile(lorrycontroller.LorryControllerRoute): - - # Note that the path below must match what lighttpd (running on a - # different port than us) would accept. - - http_method = 'GET' - path = '/lc-static/<filename>' - - def run(self, **kwargs): - logging.info('%s %s called', self.http_method, self.path) - return bottle.static_file( - kwargs['filename'], - self.app_settings['static-files']) - - class WEBAPP(cliapp.Application): def add_settings(self): @@ -994,7 +129,8 @@ class WEBAPP(cliapp.Application): # subclasses of our superclass (no duck typing here), but ARE # NOT the superclass itself. - for x in globals().values(): + for name in dir(lorrycontroller): + x = getattr(lorrycontroller, name) is_route = ( type(x) == type and # it must be class, for issubclass issubclass(x, lorrycontroller.LorryControllerRoute) and diff --git a/lorrycontroller/__init__.py b/lorrycontroller/__init__.py index 2e20c59..ac0aa6b 100644 --- a/lorrycontroller/__init__.py +++ b/lorrycontroller/__init__.py @@ -20,3 +20,20 @@ from statedb import ( WrongNumberLorriesRunningJob, TroveNotFoundError) from route import LorryControllerRoute +from status import Status, StatusHTML +from listqueue import ListQueue +from showlorry import ShowLorry, ShowLorryHTML +from startstopqueue import StartQueue, StopQueue +from givemejob import GiveMeJob +from jobupdate import JobUpdate +from listrunningjobs import ListRunningJobs +from movetopbottom import MoveToTop, MoveToBottom +from stopjob import StopJob +from listjobs import ListAllJobs, ListAllJobsHTML +from showjob import ShowJob +from removejob import RemoveJob +from lstroves import LsTroves, ForceLsTrove +from static import StaticFile + + +__all__ = locals() diff --git a/lorrycontroller/givemejob.py b/lorrycontroller/givemejob.py new file mode 100644 index 0000000..818ccd1 --- /dev/null +++ b/lorrycontroller/givemejob.py @@ -0,0 +1,75 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class GiveMeJob(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/give-me-job' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + readdb = self.open_statedb() + if readdb.get_running_queue(): + statedb = self.open_statedb() + with statedb: + lorry_infos = statedb.get_all_lorries_info() + for lorry_info in lorry_infos: + if self.ready_to_run(lorry_info): + self.create_repository_in_local_trove(lorry_info) + self.give_job_to_minion(statedb, lorry_info) + logging.info( + 'Giving job %s to lorry %s to MINION %s:%s', + lorry_info['job_id'], + lorry_info['path'], + bottle.request.forms.host, + bottle.request.forms.pid) + return lorry_info + + logging.info('No job to give MINION') + return { 'job_id': None } + + def ready_to_run(self, lorry_info): + due = lorry_info['last_run'] + lorry_info['interval'] + return (lorry_info['running_job'] is None and due <= time.time()) + + def create_repository_in_local_trove(self, lorry_info): + # Create repository on local Trove. If it fails, assume + # it failed because the repository already existed, and + # ignore the failure (but log message). + exit, stdout, stderr = cliapp.runcmd_unchecked( + ['ssh', 'git@localhost', 'create', lorry_info['path']]) + if exit: + logging.debug( + 'Ignoring error creating %s on local Trove:\n%s\n%s', + lorry_info['path'], stdout, stderr) + else: + logging.info('Created %s on local repo', lorry_info['path']) + + def give_job_to_minion(self, statedb, lorry_info): + path = lorry_info['path'] + minion_host = bottle.request.forms.host + minion_pid = bottle.request.forms.pid + running_job = statedb.get_next_job_id() + statedb.set_running_job(path, running_job) + statedb.add_new_job(running_job, minion_host, minion_pid, path) + lorry_info['job_id'] = running_job + return lorry_info diff --git a/lorrycontroller/jobupdate.py b/lorrycontroller/jobupdate.py new file mode 100644 index 0000000..cbb8d44 --- /dev/null +++ b/lorrycontroller/jobupdate.py @@ -0,0 +1,50 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class JobUpdate(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/job-update' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + job_id = int(bottle.request.forms.job_id) + exit = bottle.request.forms.exit + stdout = bottle.request.forms.stdout + stderr = bottle.request.forms.stderr + + logging.info('Job %s updated (exit=%s)', job_id, exit) + + statedb = self.open_statedb() + with statedb: + if stdout: + statedb.append_to_job_output(job_id, stdout) + if stderr: + statedb.append_to_job_output(job_id, stderr) + + path = statedb.find_lorry_running_job(job_id) + if exit is not None and exit != 'no': + lorry_info = statedb.get_lorry_info(path) + statedb.set_lorry_last_run(path, int(time.time())) + statedb.set_running_job(path, None) + statedb.set_job_exit(job_id, exit) + return statedb.get_lorry_info(path) diff --git a/lorrycontroller/listjobs.py b/lorrycontroller/listjobs.py new file mode 100644 index 0000000..119b394 --- /dev/null +++ b/lorrycontroller/listjobs.py @@ -0,0 +1,58 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class ListAllJobs(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/list-jobs' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + statedb = self.open_statedb() + return { 'job_ids': statedb.get_job_ids() } + + +class ListAllJobsHTML(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/list-jobs-html' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + values = { + 'job_infos': self.get_jobs(statedb), + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), + } + return bottle.template(self._templates['list-jobs'], **values) + + def get_jobs(self, statedb): + jobs = [] + for job_id in statedb.get_job_ids(): + exit = statedb.get_job_exit(job_id) + job = { + 'job_id': job_id, + 'exit': 'no' if exit is None else str(exit), + 'path': statedb.get_job_path(job_id), + } + jobs.append(job) + return jobs diff --git a/lorrycontroller/listqueue.py b/lorrycontroller/listqueue.py new file mode 100644 index 0000000..36c87ab --- /dev/null +++ b/lorrycontroller/listqueue.py @@ -0,0 +1,31 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import lorrycontroller + + +class ListQueue(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/list-queue' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + return { + 'queue': + [spec['path'] for spec in statedb.get_all_lorries_info()], + } diff --git a/lorrycontroller/listrunningjobs.py b/lorrycontroller/listrunningjobs.py new file mode 100644 index 0000000..9462fb1 --- /dev/null +++ b/lorrycontroller/listrunningjobs.py @@ -0,0 +1,32 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import lorrycontroller + + +class ListRunningJobs(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/list-running-jobs' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + statedb = self.open_statedb() + job_ids = statedb.get_running_jobs() + return { + 'running_jobs': job_ids, + } diff --git a/lorrycontroller/lstroves.py b/lorrycontroller/lstroves.py new file mode 100644 index 0000000..69363aa --- /dev/null +++ b/lorrycontroller/lstroves.py @@ -0,0 +1,200 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class GitanoLsError(Exception): + + def __init__(self, trovehost, output): + Exception.__init__( + self, + 'Failed to get list of git repositories ' + 'on remote host %s:\n%s' % (trovehost, output)) + self.trovehost = trovehost + + +class TroveRepositoryLister(object): + + def __init__(self, app_settings): + self.app_settings = app_settings + + def list_trove_into_statedb(self, statedb, trove_info): + remote_paths = self.ls(trove_info) + remote_paths = self.skip_ignored_repos(trove_info, remote_paths) + repo_map = self.map_remote_repos_to_local_ones( + trove_info, remote_paths) + + with statedb: + self.update_lorries_for_trove(statedb, trove_info, repo_map) + now = int(time.time()) + statedb.set_trove_ls_last_run(trove_info['trovehost'], now) + + def ls(self, trove_info): + if self.app_settings['debug-fake-trove']: + repo_paths = self.get_fake_ls_output(trove_info) + else: + repo_paths = self.get_real_ls_output(trove_info) + + return repo_paths + + def get_fake_ls_output(self, trove_info): + trovehost = trove_info['trovehost'] + for item in self.app_settings['debug-fake-trove']: + host, path = item.split('=', 1) + if host == trovehost: + with open(path) as f: + obj = json.load(f) + return obj['ls-output'] + return None + + def get_real_ls_output(self, trove_info): + trovehost = trove_info['trovehost'] + exit, stdout, stderr = cliapp.runcmd_unchecked( + ['ssh', 'git@%s' % trovehost, 'ls']) + + if exit != 0: + logging.error( + 'Failed to run "gitano ls" for %s:\n%s', + trove_info['trovehost'], stdout + stderr) + raise GitanoLsError(trovehost, stdout + stderr) + + return self.parse_ls_output(stdout) + + def parse_ls_output(self, ls_output): + repo_paths = [] + for line in ls_output.splitlines(): + words = line.split() + if words[0].startswith('R') and len(words) == 2: + repo_paths.append(words[1]) + return repo_paths + + def skip_ignored_repos(self, trovehost, repo_paths): + ignored_paths = json.loads(trovehost['ignore']) + return [x for x in repo_paths if x not in ignored_paths] + + def map_remote_repos_to_local_ones(self, trove_info, remote_paths): + '''Return a dict that maps each remote repo path to a local one.''' + prefixmap = self.parse_prefixmap(trove_info['prefixmap']) + repo_map = {} + for remote_path in remote_paths: + local_path = self.map_one_remote_repo_to_local_one( + remote_path, prefixmap) + if local_path: + repo_map[remote_path] = local_path + else: + logging.debug('Remote repo %r not in prefixmap', remote_path) + return repo_map + + def parse_prefixmap(self, prefixmap_string): + return json.loads(prefixmap_string) + + def map_one_remote_repo_to_local_one(self, remote_path, prefixmap): + for remote_prefix in prefixmap: + if self.path_starts_with_prefix(remote_path, remote_prefix): + local_prefix = prefixmap[remote_prefix] + relative_path = remote_path[len(remote_prefix):] + local_path = local_prefix + relative_path + return local_path + return None + + def path_starts_with_prefix(self, path, prefix): + return path.startswith(prefix) and path[len(prefix):].startswith('/') + + def update_lorries_for_trove(self, statedb, trove_info, repo_map): + trovehost = trove_info['trovehost'] + for remote_path, local_path in repo_map.items(): + lorry = { + local_path: { + 'type': 'git', + 'url': 'ssh://git@%s/%s' % (trovehost, remote_path), + 'refspecs': [ + "+refs/heads/*", + "+refs/tags/*", + ], + } + } + statedb.add_to_lorries( + path=local_path, + text=json.dumps(lorry, indent=4), + from_trovehost=trovehost, + interval=trove_info['lorry_interval']) + + all_local_paths = set(statedb.get_lorries_for_trove(trovehost)) + wanted_local_paths = set(repo_map.values()) + delete_local_paths = all_local_paths.difference(wanted_local_paths) + for local_path in delete_local_paths: + statedb.remove_lorry(local_path) + + +class ForceLsTrove(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/force-ls-trove' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + trovehost = bottle.request.forms.trovehost + + statedb = self.open_statedb() + lister = TroveRepositoryLister(self.app_settings) + trove_info = statedb.get_trove_info(trovehost) + try: + updated = lister.list_trove_into_statedb(statedb, trove_info) + except GitanoLsError as e: + raise bottle.abort(500, str(e)) + + return { 'updated-troves': updated } + + +class LsTroves(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/ls-troves' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + statedb = self.open_statedb() + lister = TroveRepositoryLister(self.app_settings) + + trove_infos = self.get_due_troves(statedb) + for trove_info in trove_infos: + logging.info('Trove %r is due an ls', trove_info['trovehost']) + try: + lister.list_trove_into_statedb(statedb, trove_info) + except GitanoLsError as e: + bottle.abort(500, str(e)) + + return { + 'updated-troves': [trove_info['trovehost'] for trove_info in trove_infos], + } + + def get_due_troves(self, statedb): + trove_infos = [ + statedb.get_trove_info(trovehost) + for trovehost in statedb.get_troves()] + return [ + trove_info + for trove_info in trove_infos + if self.is_due(trove_info)] + + def is_due(self, trove_info): + ls_due = trove_info['ls_last_run'] + trove_info['ls_interval'] + return ls_due <= time.time() diff --git a/lorrycontroller/movetopbottom.py b/lorrycontroller/movetopbottom.py new file mode 100644 index 0000000..c4d3191 --- /dev/null +++ b/lorrycontroller/movetopbottom.py @@ -0,0 +1,56 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class MoveToTop(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/move-to-top' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + path = bottle.request.forms.path + statedb = self.open_statedb() + with statedb: + lorry_infos = statedb.get_all_lorries_info() + if lorry_infos: + topmost = lorry_infos[0] + timestamp = min(0, topmost['last_run'] - 1) + statedb.set_lorry_last_run(path, timestamp) + return 'Lorry %s moved to top of run-queue' % path + + +class MoveToBottom(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/move-to-bottom' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + path = bottle.request.forms.path + statedb = self.open_statedb() + with statedb: + lorry_infos = statedb.get_all_lorries_info() + if lorry_infos: + bottommost = lorry_infos[-1] + timestamp = ( + bottommost['last_run'] + bottommost['interval'] + 1) + statedb.set_lorry_last_run(path, timestamp) + return 'Lorry %s moved to bototm of run-queue' % path diff --git a/lorrycontroller/readconf.py b/lorrycontroller/readconf.py new file mode 100644 index 0000000..6895462 --- /dev/null +++ b/lorrycontroller/readconf.py @@ -0,0 +1,194 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import lorrycontroller + + +class ReadConfiguration(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/read-configuration' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + self.get_confgit() + conf_obj = self.read_config_file() + self.fix_up_parsed_fields(conf_obj) + + statedb = self.open_statedb() + with statedb: + existing_lorries = set(statedb.get_lorries_paths()) + existing_troves = set(statedb.get_troves()) + + for section in conf_obj: + if section['type'] == 'lorries': + added = self.add_matching_lorries_to_statedb( + statedb, section) + existing_lorries = existing_lorries.difference(added) + elif section['type'] in ('trove', 'troves'): + self.add_trove(statedb, section) + if section['trovehost'] in existing_troves: + existing_troves.remove(section['trovehost']) + existing_lorries = self.without_lorries_for_trovehost( + statedb, existing_lorries, section['trovehost']) + else: + logging.warning( + 'Unknown section in configuration: %r', section) + + for path in existing_lorries: + statedb.remove_lorry(path) + + for trovehost in existing_troves: + statedb.remove_trove(trovehost) + statedb.remove_lorries_for_trovehost(trovehost) + + + if 'redirect' in bottle.request.forms: + bottle.redirect(bottle.request.forms.redirect) + + return 'Configuration has been updated.' + + def without_lorries_for_trovehost(self, statedb, lorries, trovehost): + for_trovehost = statedb.get_lorries_for_trove(trovehost) + return set(x for x in lorries if x not in for_trovehost) + + def get_confgit(self): + if self.app_settings['debug-real-confgit']: + confdir = self.app_settings['configuration-directory'] + if not os.path.exists(confdir): + self.git_clone_confgit(confdir) + else: + self.git_pull_confgit(confdir) + + def git_clone_confgit(self, confdir): + url = self.app_settings['confgit-url'] + branch = self.app_settings['confgit-branch'] + cliapp.runcmd(['git', 'clone', '-b', branch, url, confdir]) + + def git_pull_confgit(self, confdir): + cliapp.runcmd(['git', 'pull'], cwd=confdir) + + @property + def config_file_name(self): + return os.path.join( + self.app_settings['configuration-directory'], + 'lorry-controller.conf') + + def read_config_file(self): + '''Read the configuration file, return as Python object.''' + + filename = self.config_file_name + + try: + with open(filename) as f: + return json.load(f) + except IOError as e: + if e.errno == errno.ENOENT: + # File doesn't exist. Return an empty configuration. + return [] + bottle.abort(500, 'Error reading %s: %s' % (filename, e)) + + def fix_up_parsed_fields(self, obj): + for item in obj: + item['interval'] = self.fix_up_interval(item.get('interval')) + item['ls-interval'] = self.fix_up_interval(item.get('ls-interval')) + + def fix_up_interval(self, value): + default_interval = 86400 # 1 day + if not value: + return default_interval + m = re.match('(\d+)\s*(s|m|h|d)?', value, re.I) + if not m: + return default_value + + number, factor = m.groups() + factors = { + 's': 1, + 'm': 60, + 'h': 60*60, + 'd': 60*60*24, + } + if factor is None: + factor = 's' + factor = factors.get(factor.lower(), 1) + return int(number) * factor + + def add_matching_lorries_to_statedb(self, statedb, section): + added_paths = set() + + filenames = self.find_lorry_files_for_section(section) + lorry_specs = [] + for filename in sorted(filenames): + for lorry_spec in self.get_lorry_specs(filename): + self.add_refspecs_if_missing(lorry_spec) + lorry_specs.append(lorry_spec) + + for lorry_spec in sorted(lorry_specs): + path = self.deduce_repo_path(section, lorry_spec) + text = self.serialise_lorry_spec(lorry_spec) + interval = section['interval'] + + try: + old_lorry_info = statedb.get_lorry_info(path) + except lorrycontroller.LorryNotFoundError: + old_lorry_info = None + + statedb.add_to_lorries( + path=path, text=text, from_trovehost='', + interval=interval) + + added_paths.add(path) + + return added_paths + + def find_lorry_files_for_section(self, section): + result = [] + dirname = os.path.dirname(self.config_file_name) + for base_pattern in section['globs']: + pattern = os.path.join(dirname, base_pattern) + result.extend(glob.glob(pattern)) + return result + + def get_lorry_specs(self, filename): + with open(filename) as f: + obj = json.load(f) + return obj.items() + + def add_refspecs_if_missing(self, lorry_spec): + base_path, details = lorry_spec + if 'refspecs' not in details: + details['refspecs'] = [ + '+refs/heads/*', + '+refs/tags/*', + ] + + def deduce_repo_path(self, section, lorry_spec): + base_path, details = lorry_spec + return '%s/%s' % (section['prefix'], base_path) + + def serialise_lorry_spec(self, lorry_spec): + key, details = lorry_spec + obj = { key: details } + return json.dumps(obj, indent=4) + + def add_trove(self, statedb, section): + statedb.add_trove( + trovehost=section['trovehost'], + lorry_interval=section['interval'], + ls_interval=section['ls-interval'], + prefixmap=json.dumps(section['prefixmap']), + ignore=json.dumps(section['ignore'])) diff --git a/lorrycontroller/removejob.py b/lorrycontroller/removejob.py new file mode 100644 index 0000000..6eef029 --- /dev/null +++ b/lorrycontroller/removejob.py @@ -0,0 +1,42 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class RemoveJob(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/remove-job' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + + job_id = bottle.request.forms.job_id + + statedb = self.open_statedb() + with statedb: + try: + statedb.find_lorry_running_job(job_id) + except lorrycontroller.WrongNumberLorriesRunningJob: + pass + else: + return { 'job_id': None, 'reason': 'still running' } + + statedb.remove_job(job_id) + return { 'job_id': job_id } diff --git a/lorrycontroller/showjob.py b/lorrycontroller/showjob.py new file mode 100644 index 0000000..6901faf --- /dev/null +++ b/lorrycontroller/showjob.py @@ -0,0 +1,48 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class ShowJob(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/job/<job_id:int>' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + job_id = int(kwargs['job_id']) + + statedb = self.open_statedb() + + path = statedb.get_job_path(job_id) + exit = statedb.get_job_exit(job_id) + output = statedb.get_job_output(job_id) + + variables = { + 'job_id': job_id, + 'host': statedb.get_job_minion_host(job_id), + 'pid': statedb.get_job_minion_pid(job_id), + 'path': statedb.get_job_path(job_id), + 'exit': 'no' if exit is None else exit, + 'output': output, + 'timestamp': + time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), + } + + return bottle.template(self._templates['job'], **variables) diff --git a/lorrycontroller/showlorry.py b/lorrycontroller/showlorry.py new file mode 100644 index 0000000..1941534 --- /dev/null +++ b/lorrycontroller/showlorry.py @@ -0,0 +1,75 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class ShowLorry(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/lorry/<path:path>' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + try: + return statedb.get_lorry_info(kwargs['path']) + except lorrycontroller.LorryNotFoundError as e: + bottle.abort(404, str(e)) + + +class ShowLorryHTML(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/lorry-html/<path:path>' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + try: + lorry_info = statedb.get_lorry_info(kwargs['path']) + except lorrycontroller.LorryNotFoundError as e: + bottle.abort(404, str(e)) + + renderer = StatusRenderer() + + lorry_obj = json.loads(lorry_info['text']).values()[0] + lorry_info['url'] = lorry_obj['url'] + + lorry_info['interval_nice'] = renderer.format_secs_nicely( + lorry_info['interval']) + + lorry_info['last_run_nice'] = time.strftime( + '%Y-%m-%d %H:%M:%S UTC', + time.gmtime(lorry_info['last_run'])) + + due = lorry_info['last_run'] + lorry_info['interval'] + lorry_info['due_nice'] = renderer.format_due_nicely(due) + + timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()) + + parts = urlparse.urlparse(bottle.request.url) + host, port = parts.netloc.split(':', 1) + http_server_root = urlparse.urlunparse( + (parts.scheme, host, '', '', '', '')) + + return bottle.template( + self._templates['lorry'], + http_server_root=http_server_root, + lorry=lorry_info, + timestamp=timestamp) diff --git a/lorrycontroller/startstopqueue.py b/lorrycontroller/startstopqueue.py new file mode 100644 index 0000000..6869728 --- /dev/null +++ b/lorrycontroller/startstopqueue.py @@ -0,0 +1,53 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class StartQueue(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/start-queue' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + with statedb: + statedb.set_running_queue(1) + + if 'redirect' in bottle.request.forms: + bottle.redirect(bottle.request.forms.redirect) + + return 'Queue set to run' + + +class StopQueue(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/stop-queue' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + with statedb: + statedb.set_running_queue(0) + + if 'redirect' in bottle.request.forms: + bottle.redirect(bottle.request.forms.redirect) + + return 'Queue set to not run' diff --git a/lorrycontroller/static.py b/lorrycontroller/static.py new file mode 100644 index 0000000..261a1da --- /dev/null +++ b/lorrycontroller/static.py @@ -0,0 +1,34 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class StaticFile(lorrycontroller.LorryControllerRoute): + + # Note that the path below must match what lighttpd (running on a + # different port than us) would accept. + + http_method = 'GET' + path = '/lc-static/<filename>' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + return bottle.static_file( + kwargs['filename'], + self.app_settings['static-files']) diff --git a/lorrycontroller/status.py b/lorrycontroller/status.py new file mode 100644 index 0000000..9f42bef --- /dev/null +++ b/lorrycontroller/status.py @@ -0,0 +1,154 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import lorrycontroller + + +class StatusRenderer(object): + + '''Helper class for rendering service status as JSON/HTML''' + + def get_status_as_dict(self, statedb, work_directory): + quotes = [ + "Never get drunk unless you're willing to pay for it - " + "the next day.", + "I'm giving her all she's got, Captain!", + ] + import random + status = { + 'quote': '%s' % random.choice(quotes), + 'running_queue': statedb.get_running_queue(), + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime()), + 'run_queue': self.get_run_queue(statedb), + 'troves': self.get_troves(statedb), + 'warning_msg': '', + } + status.update(self.get_free_disk_space(work_directory)) + return status + + def render_status_as_html(self, template, status): + return bottle.template(template, **status) + + def write_status_as_html(self, template, status, filename): + html = self.render_status_as_html(template, status) + try: + with open(filename, 'w') as f: + f.write(html) + except (OSError, IOError) as e: + status['warning_msg'] = ( + 'ERROR WRITING STATUS HTML TO DISK: %s' % str(e)) + + def get_free_disk_space(self, dirname): + result = os.statvfs(dirname) + free_bytes = result.f_bavail * result.f_bsize + return { + 'disk_free': free_bytes, + 'disk_free_mib': free_bytes / 1024**2, + 'disk_free_gib': free_bytes / 1024**3, + } + + def get_run_queue(self, statedb): + lorries = statedb.get_all_lorries_info() + now = time.time() + for lorry in lorries: + due = lorry['last_run'] + lorry['interval'] + lorry['interval_nice'] = self.format_secs_nicely(lorry['interval']) + lorry['due_nice'] = self.format_due_nicely(due) + return lorries + + def format_due_nicely(self, due): + now = int(time.time()) + if due <= now: + return 'now' + else: + nice = self.format_secs_nicely(due - now) + return 'in %s' % nice + + def format_secs_nicely(self, secs): + if secs <= 0: + return 'now' + + result = [] + + hours = secs / 3600 + secs %= 3600 + mins = secs / 60 + secs %= 60 + + if hours > 0: + result.append('%d h' % hours) + if mins > 0: + result.append('%d min' % mins) + elif mins > 0: + result.append('%d min' % mins) + if secs > 0: + result.append('%d s' % secs) + else: + result.append('%d s' % secs) + + return ' '.join(result) + + def get_troves(self, statedb): + troves = [] + for trovehost in statedb.get_troves(): + trove_info = statedb.get_trove_info(trovehost) + + trove_info['ls_interval_nice'] = self.format_secs_nicely( + trove_info['ls_interval']) + + ls_due = trove_info['ls_last_run'] + trove_info['ls_interval'] + now = int(time.time()) + trove_info['ls_due_nice'] = self.format_due_nicely(ls_due) + + troves.append(trove_info) + return troves + + +class Status(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/status' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + renderer = StatusRenderer() + statedb = self.open_statedb() + status = renderer.get_status_as_dict( + statedb, self.app_settings['statedb']) + renderer.write_status_as_html( + self._templates['status'], + status, + self.app_settings['status-html']) + return status + + +class StatusHTML(lorrycontroller.LorryControllerRoute): + + http_method = 'GET' + path = '/1.0/status-html' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + renderer = StatusRenderer() + statedb = self.open_statedb() + status = renderer.get_status_as_dict( + statedb, self.app_settings['statedb']) + renderer.write_status_as_html( + self._templates['status'], + status, + self.app_settings['status-html']) + return renderer.render_status_as_html( + self._templates['status'], status) diff --git a/lorrycontroller/stopjob.py b/lorrycontroller/stopjob.py new file mode 100644 index 0000000..f6aa61e --- /dev/null +++ b/lorrycontroller/stopjob.py @@ -0,0 +1,39 @@ +# Copyright (C) 2014 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +import bottle + +import lorrycontroller + + +class StopJob(lorrycontroller.LorryControllerRoute): + + http_method = 'POST' + path = '/1.0/stop-job' + + def run(self, **kwargs): + logging.info('%s %s called', self.http_method, self.path) + statedb = self.open_statedb() + with statedb: + job_id = bottle.request.forms.job_id + try: + path = statedb.find_lorry_running_job(job_id) + except lorrycontroller.WrongNumberLorriesRunningJob: + logging.warning( + "Tried to kill job %s which isn't running" % job_id) + bottle.abort(409, 'Job is not currently running') + statedb.set_kill_job(path, True) + return statedb.get_lorry_info(path) |