summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <lars.wirzenius@codethink.co.uk>2014-03-25 15:11:24 +0000
committerLars Wirzenius <lars.wirzenius@codethink.co.uk>2014-03-25 15:11:24 +0000
commit4d80caf31d05a98fb4e884ccc083a9535c691596 (patch)
tree2c9756b5add2112752d9e8236cb061b9067b6ddc
parentbc518bff343c5df24707b9e13c8e711605a9e28c (diff)
downloadlorry-controller-4d80caf31d05a98fb4e884ccc083a9535c691596.tar.gz
Move the rest into modules in package
-rwxr-xr-xlorry-controller-webapp868
-rw-r--r--lorrycontroller/__init__.py17
-rw-r--r--lorrycontroller/givemejob.py75
-rw-r--r--lorrycontroller/jobupdate.py50
-rw-r--r--lorrycontroller/listjobs.py58
-rw-r--r--lorrycontroller/listqueue.py31
-rw-r--r--lorrycontroller/listrunningjobs.py32
-rw-r--r--lorrycontroller/lstroves.py200
-rw-r--r--lorrycontroller/movetopbottom.py56
-rw-r--r--lorrycontroller/readconf.py194
-rw-r--r--lorrycontroller/removejob.py42
-rw-r--r--lorrycontroller/showjob.py48
-rw-r--r--lorrycontroller/showlorry.py75
-rw-r--r--lorrycontroller/startstopqueue.py53
-rw-r--r--lorrycontroller/static.py34
-rw-r--r--lorrycontroller/status.py154
-rw-r--r--lorrycontroller/stopjob.py39
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)