Merge lp:~isoschiz/endroid/kermit-v2 into lp:endroid

Proposed by Phil Connell
Status: Needs review
Proposed branch: lp:~isoschiz/endroid/kermit-v2
Merge into: lp:endroid
Diff against target: 4395 lines (+4046/-37)
28 files modified
bin/kermit (+47/-0)
bin/kermit_queue_runner (+1123/-0)
bin/kermit_trigger (+56/-0)
debian/control (+14/-0)
debian/endroid-kermit-daemons.install (+3/-0)
debian/endroid-kermit.install (+19/-0)
debian/endroid-kermit.pyinstall (+1/-0)
debian/endroid.install (+1/-1)
debian/endroid.pyinstall (+3/-0)
etc/kermit.cfg (+157/-0)
resources/httpinterface/index.html (+1/-1)
resources/kermit/alert.css (+8/-0)
resources/kermit/alert.js (+31/-0)
resources/kermit/kermit.css (+57/-0)
resources/kermit/profile.css (+64/-0)
resources/kermit/profile.html (+147/-0)
resources/kermit/profile.js (+77/-0)
resources/kermit/queue.css (+169/-0)
resources/kermit/queue.html (+305/-0)
resources/kermit/queue.js (+497/-0)
resources/kermit/response.json (+5/-0)
resources/kermit/services.js (+39/-0)
src/endroid/database.py (+201/-29)
src/endroid/pluginmanager.py (+2/-2)
src/endroid/plugins/httpinterface.py (+11/-4)
src/endroid/plugins/kermit/__init__.py (+309/-0)
src/endroid/plugins/kermit/ssh.py (+301/-0)
src/endroid/plugins/kermit/web.py (+398/-0)
To merge this branch: bzr merge lp:~isoschiz/endroid/kermit-v2
Reviewer Review Type Date Requested Status
Phil Connell Needs Fixing
Review via email: mp+232939@code.launchpad.net

This proposal supersedes a proposal from 2014-07-18.

Description of the change

An additional set of features/bugfixes for kermit:

 - Bunch of GUI improvements
 - Add log file viewer via HTTP (with a really slow, non-streaming interface. Should be fixed)
 - Many bug fixes from later testing

To post a comment you must log in.
Revision history for this message
Phil Connell (pconnell) wrote : Posted in a previous version of this proposal

Again, planning to review just the core.

Would like to see the database changes merged with any markups from -v1.

(Happy to do this myself if given write perms on the branch).

Revision history for this message
Phil Connell (pconnell) wrote :

A few minor database things.

Not clear on whether the V1 branch needs to merge too (e.g. the usermanagement changes).

review: Needs Fixing

Unmerged revisions

91. By Martin Morrison

Fixed a bunch more bugs from Matt's testing, including making some core things more configurable, and removing dependency on the calling user's environment

90. By Martin Morrison

Make some small visual tweaks to the filter bar

89. By Martin Morrison

Make some small visual tweaks to the filter bar

88. By Martin Morrison

Minor tweaks following review from Matt

87. By Martin Morrison

Fix a bug with infinite query loop when a query doesn't return the latest entry. Add ability to restrict the filters by a single type.

86. By Martin Morrison

Fix links that weren't working; javascript error from angular; and add a watermark to the filter bar. Just cos.

85. By Martin Morrison

Replace the filter bar with a new, improved version.

84. By Martin Morrison

Add sftp access to log files (including new UI)

83. By Martin Morrison

Fix delivery of templates to the right directory

82. By Martin Morrison

Merge in changes to clean up log files

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'bin/kermit'
2--- bin/kermit 1970-01-01 00:00:00 +0000
3+++ bin/kermit 2014-09-01 20:21:07 +0000
4@@ -0,0 +1,47 @@
5+#!/usr/bin/env python
6+
7+import os
8+import pwd
9+import sys
10+import json
11+import urllib
12+import urllib2
13+
14+from argparse import ArgumentParser
15+
16+def main():
17+ parser = ArgumentParser()
18+ parser.add_argument("-c", "--commitdiff", required=True)
19+ parser.add_argument("-t", "--target", required=True)
20+ parser.add_argument("-b", "--buglist", required=True)
21+ parser.add_argument("-u", "--username",
22+ default=pwd.getpwuid(os.getuid())[0])
23+ parser.add_argument("-a", "--address",
24+ required="KERMIT_URL" not in os.environ,
25+ default=os.environ.get("KERMIT_URL", None))
26+
27+ args = parser.parse_args()
28+
29+ data = urllib.urlencode({
30+ 'username': args.username,
31+ 'commitdiff': args.commitdiff,
32+ 'target': args.target,
33+ 'buglist': args.buglist,
34+ 'commitmsg': '',
35+ })
36+
37+ req = urllib2.Request(args.address + '/kermit/submit', data)
38+ try:
39+ response = urllib2.urlopen(req)
40+ except urllib2.HTTPError as err:
41+ data = json.loads(err.read())
42+ sys.stderr.write("Failed to submit commit to queue: {}\n"
43+ .format(data['fail']))
44+ sys.exit(1)
45+ else:
46+ data = json.loads(response.read())
47+ sys.stdout.write("Submitted commit to queue as {}\n"
48+ .format(data['success']))
49+
50+if __name__ == "__main__":
51+ main()
52
53=== added file 'bin/kermit_queue_runner'
54--- bin/kermit_queue_runner 1970-01-01 00:00:00 +0000
55+++ bin/kermit_queue_runner 2014-09-01 20:21:07 +0000
56@@ -0,0 +1,1123 @@
57+#!/usr/bin/env python
58+
59+from __future__ import print_function
60+
61+import os
62+import pwd
63+import json
64+import time
65+import shlex
66+import shutil
67+import logging
68+import os.path
69+import cfgparser
70+import traceback
71+import collections
72+
73+from argparse import ArgumentParser
74+from StringIO import StringIO
75+
76+from twisted.python import log
77+from twisted.spread import pb
78+from twisted.internet import reactor, protocol, defer, task, error
79+from twisted.protocols import basic
80+from twisted.web.client import FileBodyProducer, Agent
81+from twisted.web.http_headers import Headers
82+
83+
84+_CFG_DEFAULTS = {
85+ 'filevars': '',
86+ 'attempts': '1',
87+ 'attempt_interval': '10',
88+ 'persist': 'no',
89+ 'environment': '',
90+ 'dependencies': '',
91+}
92+
93+"""
94+Utilities and types.
95+
96+"""
97+
98+_StageSpec = collections.namedtuple("_StageSpec", ("deps", "done"))
99+
100+class AbortedError(Exception): pass
101+
102+
103+def makedirs(dirs):
104+ """Make directories, but swallow any exceptions."""
105+ try:
106+ os.makedirs(dirs)
107+ except OSError:
108+ pass
109+
110+
111+def _makesockname(dirname):
112+ "Make the socket name for the inter-qr comms."
113+ return "\0kermit#{}#status".format(dirname)
114+
115+
116+def _send_update(qr, cfg, key, status):
117+ """
118+ Send an update to the kermit server for the given key.
119+
120+ """
121+ logging.debug("Updating {} (running: {})"
122+ .format(key, status.get('running', [])))
123+ statedir = os.path.join(qr.dir, "logs", key, ".kermit")
124+ with open(os.path.join(statedir, "progress"), "w") as file:
125+ file.write(json.dumps(status))
126+
127+ if not cfg.has_option("DEFAULT", "kermit_baseurl"):
128+ return defer.succeed(None)
129+
130+ d = Agent(reactor).request(
131+ "POST",
132+ cfg.get("DEFAULT", "kermit_baseurl") + "/kermit/update/{}".format(key),
133+ Headers({'Content-Type': ['text/json']}),
134+ FileBodyProducer(StringIO(json.dumps(status)))
135+ )
136+ class BodyReceiver(protocol.Protocol):
137+ def __init__(self, response, finished):
138+ self.finished = finished
139+ self.response = response
140+ self.data = []
141+ def dataReceived(self, bytes):
142+ self.data.append(bytes)
143+ def connectionLost(self, reason):
144+ d, self.finished = self.finished, None
145+ d.callback((self.response, self.data))
146+ def _handle(response):
147+ finished = defer.Deferred()
148+ response.deliverBody(BodyReceiver(response, finished))
149+ return finished
150+ d.addCallback(_handle)
151+ return d
152+
153+
154+"""
155+Command output logging between processes.
156+
157+Classes:
158+ - _LoggingProcess: base class for processes that log their output.
159+ - _LocalLoggingProcess: logs output to local files (used in primary ws).
160+ - _RemoteLoggingProcess: logs output over the LogfileProtocol (used in shared
161+ workspaces).
162+ - _RemoteLogfileProtocol: the remote (i.e. sending) side of the
163+ LogfileProtocol.
164+ - _RemoteLogfileFactory: ClientFactory subclass for opening remote logfiles.
165+ - _RemoteLogfile: wrapper around the transport to look file-like.
166+ - _LocalLogfileProtocol: the local (i.e. receiving) side of the
167+ LogfileProtocol.
168+ - _LocalLogfileFactory: server Factory for opening remote logfiles.
169+
170+"""
171+
172+class _LoggingProcess(protocol.ProcessProtocol):
173+ """
174+ Wraps processes so that their output is logged to files.
175+
176+ Maintains two files:
177+ - errlog: contains just the stderr content
178+ - alllog: contains all output (stdout and stderr interleaved)
179+
180+ Provides two deferreds:
181+ - ready: should be fired by subclasses when the log files are ready. When
182+ spawning, the actual spawn should be predicated on this deferred.
183+ - deferred: fires when the process completes, with either None, or the
184+ failure reason.
185+ Wraps processes so that their output is logged into the logs directory.
186+
187+ Maintains two logs. The first contains just the stderr content, and is
188+ suffixed with .err.log; the second contains both stdout and stderr and is
189+ suffixed with .log.
190+
191+ Callers should use the .deferred field to receive the result.
192+
193+ """
194+ def __init__(self, name, logdir, key):
195+ self.name = name
196+ self.logdir = logdir
197+ self.alllog = None
198+ self.errlog = None
199+ self.ready = defer.Deferred()
200+ self.deferred = defer.Deferred()
201+ def outReceived(self, data):
202+ self.alllog.write(data)
203+ def errReceived(self, data):
204+ self.alllog.write(data)
205+ self.errlog.write(data)
206+ def processEnded(self, reason):
207+ self.alllog.close()
208+ self.errlog.close()
209+ self.deferred, d = None, self.deferred
210+ logging.debug("Process {} complete: {}"
211+ .format(self.name, reason.value.exitCode))
212+ if reason.value.exitCode == 0:
213+ d.callback(None)
214+ else:
215+ d.errback(reason)
216+
217+class _LocalLoggingProcess(_LoggingProcess):
218+ """
219+ Logs to local files in the log directory.
220+
221+ alllog is given a .log suffix; errlog is given a .err.log suffix. There
222+ are no async events required to set up the logs, so this fires the
223+ ready callback on initialisation.
224+
225+ """
226+ def __init__(self, name, logdir, key):
227+ _LoggingProcess.__init__(self, name, logdir, key)
228+ makedirs(logdir)
229+ self.alllog = open(os.path.join(logdir, name + ".log"), "w")
230+ self.errlog = open(os.path.join(logdir, name + ".err.log"), "w")
231+ self.ready.callback(None)
232+
233+class _RemoteLoggingProcess(_LoggingProcess):
234+ """
235+ Logs to a remote file using a _RemoteLogfileFactory, requesting two files.
236+
237+ The first requested file is the alllog; the second is errlog. The ready
238+ deferred fires once the two files are open.
239+
240+ """
241+ def __init__(self, name, logdir, key):
242+ _LoggingProcess.__init__(self, name, logdir, key)
243+ f = _RemoteLogfileFactory(self.add_log_file, self._use_local)
244+ logsockpath = "\0kermit#{}#logs".format(key)
245+ reactor.connectUNIX(logsockpath, f)
246+ reactor.connectUNIX(logsockpath, f)
247+
248+ def _use_local(self, failure):
249+ failure.trap(error.ConnectionRefusedError)
250+ logging.warning("Couldn't log remotely. Logging locally instead")
251+ if self.alllog is None:
252+ self.alllog = open(os.path.join("logs", self.name + ".log"), "w")
253+ elif self.errlog is None:
254+ self.errlog = open(os.path.join("logs",
255+ self.name + ".err.log"), "w")
256+ d, self.ready = self.ready, None
257+ d.callback(None)
258+ else:
259+ raise ValueError("Didn't expect another remote logfile")
260+
261+ def add_log_file(self, proto):
262+ if self.alllog is None:
263+ proto.sendLine(self.name + ".log")
264+ self.alllog = _RemoteLogfile(proto.transport)
265+ elif self.errlog is None:
266+ proto.sendLine(self.name + ".err.log")
267+ self.errlog = _RemoteLogfile(proto.transport)
268+ d, self.ready = self.ready, None
269+ d.callback(None)
270+ else:
271+ raise ValueError("Didn't expect another remote logfile")
272+
273+
274+class _RemoteLogfileProtocol(basic.LineReceiver):
275+ def connectionMade(self):
276+ try:
277+ self.factory.add_log_file(self)
278+ except Exception as e:
279+ self.factory.clientConnectionFailed(None, e)
280+
281+class _RemoteLogfileFactory(protocol.ClientFactory):
282+ protocol = _RemoteLogfileProtocol
283+ def __init__(self, callback, errback):
284+ self.add_log_file = callback
285+ self.errback = errback
286+ def clientConnectionFailed(self, _, reason):
287+ logging.debug("Failed to connect to the logfile: {}".format(reason))
288+ # Two things:
289+ # - Should check if self.deferred is None (as we open 2 files and might
290+ # fail on the first)
291+ # - Should probably not fail, and instead just log locally
292+ self.errback(reason)
293+
294+class _RemoteLogfile(object):
295+ """
296+ Wrapper around _RemoteLogfileProtocol transport to make it file-like.
297+ """
298+ def __init__(self, transport):
299+ self._transport = transport
300+ def write(self, data):
301+ self._transport.write(data)
302+ def close(self):
303+ self._transport.loseConnection()
304+
305+class _LocalLogfileProtocol(basic.LineReceiver):
306+ def lineReceived(self, line):
307+ logging.debug("Opening a log file: {}".format(line))
308+ self._file = open(os.path.join(self.factory.op._cfg.get("DEFAULT",
309+ "logdir"),
310+ line.strip()), "w")
311+ self.setRawMode()
312+ def rawDataReceived(self, data):
313+ self._file.write(data)
314+ def connectionLost(self, reason):
315+ self._file.close()
316+
317+class _LocalLogfileFactory(protocol.Factory):
318+ protocol = _LocalLogfileProtocol
319+ def __init__(self, op):
320+ self.op = op
321+
322+
323+"""
324+Operation objects and helper classes.
325+
326+"""
327+
328+class _RemoteResults(pb.Referenceable):
329+ """
330+ Allows a shared operation to communicate back to the local operation that
331+ spawned it.
332+
333+ """
334+ def __init__(self, op, stages):
335+ self._op = op
336+ self._stages = set(stages)
337+
338+ def remote_running(self, stage):
339+ logging.debug("Our partner tells us {} has started running"
340+ .format(stage))
341+ self._op._status['running'].append(stage)
342+ self._op._send_update()
343+
344+ def remote_abort(self, stage):
345+ if stage in self._op._status['running']:
346+ self._op._status['running'].remove(stage)
347+ if stage in self._stages:
348+ logging.debug("Our partner tells us {} has aborted".format(stage))
349+ self._stages.remove(stage)
350+ self._op._not_doing(stage)
351+ self._op._send_update()
352+
353+ def remote_done(self, stage):
354+ if stage in self._op._status['running']:
355+ self._op._status['running'].remove(stage)
356+ if stage in self._stages:
357+ logging.debug("Our partner tells us {} has succeeded".format(stage))
358+ self._stages.remove(stage)
359+ self._op._done(stage)
360+ self._op._send_update()
361+
362+ def remote_fail(self, stage, reason):
363+ if stage in self._op._status['running']:
364+ self._op._status['running'].remove(stage)
365+ logging.debug("Our partner tells us {} failed: {}"
366+ .format(stage, reason))
367+ if stage in self._stages:
368+ self._stages.remove(stage)
369+ self._op._fail(stage, reason)
370+ self._op._send_update()
371+
372+
373+class _OperationBase(object):
374+ """Base class for operations."""
375+ def __init__(self, qr, cfgfile, cfg, target, section, status, ws=None):
376+ self._qr = qr
377+ self._key = cfg.get("DEFAULT", "key")
378+ self._cfgfile = cfgfile
379+ self._cfg = cfg
380+ self._target = target
381+ self._section = section
382+ self._status = status
383+ self._ws = ws
384+
385+ self._status['started_at'] = 0
386+ self._status['completed_at'] = 0
387+ self._status['stages'] = []
388+ self._status.setdefault('done', [])
389+ self._status.setdefault('done_at', {})
390+ self._status['failed'] = []
391+ self._status['failed_at'] = {}
392+ self._status['failed_cleanup'] = False
393+ self._status['check_failed'] = False
394+ self._status['skipping'] = []
395+ self._status['running'] = []
396+ self._status['logdir'] = cfg.get("DEFAULT", "logdir")
397+
398+ opts = dict(cfg.items("DEFAULT"))
399+ opts.update(dict(cfg.items(target)))
400+ if self._ws:
401+ opts.update(dict(cfg.items(self._ws, category="workspace")))
402+ self._status['message'] = opts.get("message", "").format(**opts)
403+
404+ self._opts = opts
405+ self._active_proc = None
406+
407+ logging.info("New Operation created: {}, {}, {}".format(self.__class__, self._ws, self._opts))
408+
409+ def _send_update(self):
410+ return None
411+
412+ def begin(self):
413+ pass
414+
415+ def complete(self):
416+ pass
417+
418+ @defer.inlineCallbacks
419+ def _run_stage(self, name, suppress_fail=False, suppress_done=False):
420+ logging.debug("Running stage {}".format(name))
421+ attempts = self._cfg.geteval(name, "attempts", category="command")
422+ interval = self._cfg.geteval(name, "attempt_interval",
423+ category="command")
424+ self._status['running'].append(name)
425+ if not suppress_fail and not suppress_done:
426+ yield self._running(name)
427+ yield self._send_update()
428+ try:
429+ # Perform multiple attempts if requested
430+ while True:
431+ try:
432+ yield self._spawn_cmd(name)
433+ except Exception:
434+ attempts -= 1
435+ if attempts <= 0:
436+ raise
437+ # Sleep a bit between attempts
438+ logging.debug("Waiting to retry stage...")
439+ yield task.deferLater(reactor, interval, lambda: None)
440+ else:
441+ break
442+ # Also need to track errors in saving the logs
443+ yield self._save_logs(name)
444+ except Exception as e:
445+ logging.exception(e)
446+ if not suppress_fail:
447+ yield self._fail(name, e)
448+ raise e
449+ else:
450+ if not suppress_done:
451+ yield self._done(name)
452+ finally:
453+ self._status['running'].remove(name)
454+ yield self._send_update()
455+
456+ def _make_env(self, name, opts):
457+ env = {}
458+ for sec, cat in (('DEFAULT', None), (self._ws, "workspace"),
459+ (name, "command")):
460+ if sec is None:
461+ continue
462+ for key, val in [e.split(':', 1)
463+ for e in self._cfg.getlist(sec, "environment",
464+ category=cat,
465+ default=[])]:
466+ env[key.strip()] = val.strip().format(**opts)
467+ return env
468+
469+ def _spawn_cmd(self, name):
470+ section = self._cfg.section(name, category="command")
471+ p = self.logger(name, self._cfg.get("DEFAULT", "logdir"), self._key)
472+ myopts = self._opts.copy()
473+
474+ myopts.update(dict(section.items()))
475+
476+ # Load any files we need into variables
477+ for key, filename in [e.split(':')
478+ for e in section.getlist('filevars')]:
479+ filename = filename.strip().format(**myopts)
480+ with open(filename.strip(), 'r') as file:
481+ myopts[key] = file.read()
482+
483+ env = self._make_env(name, myopts)
484+
485+ args = map(lambda s: s.format(**myopts),
486+ shlex.split(section.get("command")))
487+
488+ pwd = section.get("pwd").format(**myopts)
489+
490+ with open(os.path.join(self._qr.dir, "logs",
491+ 'commands.log'), 'a') as file:
492+ file.write(str(args) + '\n')
493+
494+ def _spawn_proc(_):
495+ logging.debug("Spawning: {} in {}".format(args, pwd))
496+ proc = reactor.spawnProcess(p, args[0], args,
497+ env=env,
498+ path=os.path.join(self._qr.dir, pwd),
499+ usePTY=True)
500+ # Only track the active process if it's one of the stages we
501+ # actually want to be able to abort. We don't want to abort pre
502+ # cmds as that will result in aborting a workspace.
503+ if name in self._stages:
504+ self._active_proc = proc
505+ p.ready.addCallbacks(_spawn_proc, p.deferred.errback)
506+ def _clean_proc(_):
507+ self._active_proc = None
508+ return _
509+ p.deferred.addBoth(_clean_proc)
510+ return p.deferred
511+
512+ def _load_ws_state(self, ws):
513+ statefile = os.path.join(self._qr.dir, ".kermit", ws + ".ws")
514+ makedirs(os.path.dirname(statefile))
515+ state = {}
516+ if os.path.exists(statefile):
517+ with open(statefile) as file:
518+ state = json.loads(file.read())
519+ return state
520+
521+ def _save_ws_state(self, ws, state):
522+ statefile = os.path.join(self._qr.dir, ".kermit", ws + ".ws")
523+ makedirs(os.path.dirname(statefile))
524+ with open(statefile, "w") as file:
525+ file.write(json.dumps(state))
526+
527+ @defer.inlineCallbacks
528+ def setup_workspace(self, ws):
529+ state = self._load_ws_state(ws)
530+ if not state.get("setup", False):
531+ for name in self._section.getlist("setup_cmds"):
532+ yield self._run_stage(name, suppress_done=True)
533+ else:
534+ state["setup"] = True
535+ self._save_ws_state(ws, state)
536+
537+ @defer.inlineCallbacks
538+ def abort_workspace(self, ws):
539+ for name in self._section.getlist("abort_cmds"):
540+ try:
541+ yield self._run_stage(name, suppress_fail=True,
542+ suppress_done=True)
543+ except Exception as e:
544+ logging.debug("Failed to abort workspace")
545+ logging.exception(e)
546+ self._status['failed_abort'] = True
547+ self._save_ws_state(ws, {"setup": False})
548+
549+ @defer.inlineCallbacks
550+ def run_pre_cmds(self, suppress_fail=False, suppress_done=False):
551+ for name in self._section.getlist("pre_cmds"):
552+ yield self._run_stage(name, suppress_fail=suppress_fail,
553+ suppress_done=suppress_done)
554+ else:
555+ self._status['started_at'] = time.time()
556+ yield self._send_update()
557+
558+ @defer.inlineCallbacks
559+ def run_post_cmds(self):
560+ for name in self._section.getlist("post_cmds"):
561+ try:
562+ yield self._run_stage(name, suppress_fail=True,
563+ suppress_done=True)
564+ except Exception:
565+ logging.debug("Failed to run post cmds")
566+ traceback.print_exc()
567+ self._status['failed_cleanup'] = True
568+
569+
570+class _LocalOperation(_OperationBase):
571+ logger = _LocalLoggingProcess
572+ def __init__(self, qr, cfgfile, cfg, target, section, status):
573+ _OperationBase.__init__(self, qr, cfgfile, cfg, target, section, status)
574+ self._logconn = None
575+ self._calc_stages()
576+
577+ def _calc_stages(self):
578+ section = self._cfg.section(self._target)
579+ cmds = section.getlist("commands")
580+ requested = self._cfg.getlist("DEFAULT", "stages")
581+ workspaces = section.getlist("workspaces", default=[])
582+
583+ for ws in self._cfg.getlist("DEFAULT", "ws_disable", default=[]):
584+ if ws in workspaces:
585+ workspaces.remove(ws)
586+
587+ logging.info("Only considering workspaces: {}".format(workspaces))
588+
589+ self._status['stages'] = cmds
590+
591+ # Deferred for each stage (fired when the stage completes)
592+ ds = {k: defer.Deferred() for k in cmds}
593+ # Mapping of stage to list of deferred it depends on
594+ deps = collections.defaultdict(list)
595+ # Mapping of workspace to list of stage names that are run in it
596+ byws = collections.defaultdict(list)
597+ # All stages that are being run
598+ allstages = set()
599+
600+ def add_stage(stage, ws=None):
601+ """
602+ Add a stage to the list of stages to run.
603+
604+ Always adds any dependent stages first.
605+
606+ """
607+ section = self._cfg.section(stage, category="command")
608+ if ws is None:
609+ ws = section.get("workspace", default=None)
610+ if ws not in workspaces:
611+ # Although this command could run in a separate workspace,
612+ # that workspace is not enabled for this target
613+ ws = None
614+
615+ if stage not in byws[ws]:
616+ depds = []
617+ for dep in section.getlist("dependencies"):
618+ if dep not in cmds:
619+ logging.error("Unknown stage {}. Ignoring".format(dep))
620+ continue
621+ add_stage(dep, ws)
622+ depds.append(ds[dep])
623+
624+ byws[ws].append(stage)
625+ deps[stage].extend(depds)
626+ allstages.add(stage)
627+
628+ # Remove any results that might exist, since we're redoing it
629+ self._status.get('done_at', {}).pop(stage, None)
630+ try:
631+ self._status.get('done', []).remove(stage)
632+ except ValueError:
633+ pass
634+
635+ lastd = None
636+ for cmd in cmds:
637+ if lastd is not None:
638+ deps[cmd].append(lastd)
639+ lastd = ds[cmd]
640+ # If the step hasn't been requested, skip it always
641+ if requested and cmd not in requested:
642+ self._status['skipping'].append(cmd)
643+ continue
644+ section = self._cfg.section(cmd, category='command')
645+ # If the step has previous completed, and results persist, skip it
646+ if cmd in self._status.get('done', []):
647+ if section.geteval("persist"):
648+ continue
649+ # In all cases, steps might be readded if they are dependencies of
650+ # a step that needs to be completed
651+ add_stage(cmd)
652+
653+ # Now for every stage that isn't being run, trigger the deferred
654+ for cmd in cmds:
655+ if cmd not in allstages:
656+ ds[cmd].callback(None)
657+
658+ self._stages = {
659+ k: _StageSpec(defer.DeferredList(deps[k], fireOnOneErrback=True),
660+ ds[k])
661+ for k in cmds
662+ }
663+
664+ self._byws = byws
665+ self._final_deferred = defer.DeferredList(list(ds.values()),
666+ fireOnOneErrback=True)
667+
668+ @defer.inlineCallbacks
669+ def run_cmds(self):
670+ for name in self._byws[None]:
671+ # First wait for any dependencies to complete (in case they
672+ # are running in separate, shared workspaces)
673+ logging.debug("Waiting for dependencies of {}".format(name))
674+ yield self._deps(name)
675+ logging.debug("Starting to run {}".format(name))
676+ yield self._run_stage(name)
677+ else:
678+ logging.debug("Waiting for the end of all stages")
679+ yield self._final_deferred
680+ self._status['completed_at'] = time.time()
681+
682+ def _running(self, name):
683+ pass
684+
685+ def _done(self, name):
686+ if name not in self._status['done']:
687+ self._status['done'].append(name)
688+ self._status['done_at'][name] = time.time()
689+
690+ if name in self._stages:
691+ self._stages[name].done.callback(None)
692+ del self._stages[name]
693+
694+ def _fail(self, name, reason):
695+ if name not in self._status['failed']:
696+ self._status['failed'].append(name)
697+ self._status['failed_at'][name] = time.time()
698+
699+ if name in self._stages:
700+ self._stages[name].done.errback(reason)
701+ del self._stages[name]
702+
703+ def _not_doing(self, name):
704+ if name in self._stages:
705+ self._stages[name].done.errback(AbortedError())
706+ del self._stages[name]
707+
708+ def _deps(self, name):
709+ return self._stages[name].deps
710+
711+ @defer.inlineCallbacks
712+ def handover(self):
713+ self._logconn = reactor.listenUNIX("\0kermit#{}#logs".format(self._key),
714+ _LocalLogfileFactory(self))
715+ for ws in self._byws:
716+ if ws is not None:
717+ yield self._handover(ws)
718+
719+ @defer.inlineCallbacks
720+ def _handover(self, ws):
721+ section = self._cfg.section(ws, category="workspace")
722+ user = section.get("username", pwd.getpwuid(os.getuid())[0])
723+ dir = self._cfg.get(self._target, "dir")
724+ logging.debug("Handing over to {} (user: {}): {}"
725+ .format(ws, user, self._byws[ws]))
726+
727+ factory = pb.PBClientFactory()
728+ sockname = _makesockname(os.path.join(dir, user))
729+ logging.debug("Connecting to {}".format(repr(sockname)))
730+ reactor.connectUNIX(sockname, factory)
731+ root = yield factory.getRootObject()
732+ yield root.callRemote("handover", _RemoteResults(self, self._byws[ws]),
733+ self._cfgfile, ws, self._byws[ws])
734+
735+ def begin(self):
736+ self._status['begin_at'] = time.time()
737+
738+ def complete(self):
739+ if self._logconn:
740+ self._logconn.stopListening()
741+ self._logconn = None
742+
743+ @defer.inlineCallbacks
744+ def abort(self):
745+ for ws in self._byws:
746+ if ws is not None:
747+ yield self._abort(ws)
748+ if self._active_proc:
749+ self._active_proc.signalProcess("TERM")
750+ if self._logconn:
751+ self._logconn.stopListening()
752+ self._logconn = None
753+
754+ @defer.inlineCallbacks
755+ def _abort(self, ws):
756+ section = self._cfg.section(ws, category="workspace")
757+ user = section.get("username", pwd.getpwuid(os.getuid())[0])
758+ dir = self._cfg.get(self._target, "dir")
759+ logging.debug("Aborting {} (user: {})"
760+ .format(ws, user))
761+
762+ factory = pb.PBClientFactory()
763+ reactor.connectUNIX(_makesockname(os.path.join(dir, user)), factory)
764+ root = yield factory.getRootObject()
765+ yield root.callRemote("abort", self._cfg.get("DEFAULT", "key"), ws)
766+
767+ def _send_update(self):
768+ """
769+ Send an update to the kermit server for the given key.
770+
771+ """
772+ return _send_update(self._qr, self._cfg, self._key, self._status)
773+
774+ @defer.inlineCallbacks
775+ def _save_logs(self, name):
776+ logs = self._cfg.getlist(name, "savelogs", default=[],
777+ category="command")
778+ logging.debug("Saving logs: {}".format(logs))
779+ for log in logs:
780+ log = log.format(**self._opts)
781+ shutil.copy(log, self._cfg.get("DEFAULT", "logdir"))
782+ yield None
783+
784+
785+class _SharedOperation(_OperationBase):
786+ logger = _RemoteLoggingProcess
787+ def __init__(self, qr, cfgfile, cfg, target, section, remote_op,
788+ stages, ws):
789+ _OperationBase.__init__(self, qr, cfgfile, cfg, target, section, {},
790+ ws=ws)
791+
792+ self._remote_op = remote_op
793+ self._stages = stages
794+
795+ @defer.inlineCallbacks
796+ def _save_logs(self, name):
797+ logs = self._cfg.getlist(name, "savelogs", default=[],
798+ category="command")
799+ for log in logs:
800+ log = log.format(**self._opts)
801+ d = defer.Deferred()
802+
803+ def _transfer_file(proto):
804+ proto.sendLine(os.path.basename(log))
805+ fp = basic.FileSender()
806+ infile = open(log, "r")
807+ transfer = fp.beginFileTransfer(infile, proto.transport)
808+ transfer.addBoth(lambda _: infile.close())
809+ transfer.chainDeferred(d)
810+
811+ f = _RemoteLogfileFactory(_transfer_file, d.errback)
812+
813+ logsockpath = "\0kermit#{}#logs".format(self._opts['key'])
814+ logging.debug("Saving log {}".format(log))
815+ reactor.connectUNIX(logsockpath, f)
816+ yield d
817+
818+ @defer.inlineCallbacks
819+ def run_cmds(self):
820+ for name in self._stages:
821+ logging.debug("Starting to run {}".format(name))
822+ yield self._run_stage(name)
823+
824+ def _running(self, name):
825+ return self._remote_op.callRemote("running", name)
826+
827+ def _done(self, name):
828+ return self._remote_op.callRemote("done", name)
829+
830+ def _fail(self, name, reason):
831+ return self._remote_op.callRemote("fail", name, reason)
832+
833+ @defer.inlineCallbacks
834+ def abort(self):
835+ for name in self._stages:
836+ yield self._remote_op.callRemote("abort", name)
837+ if self._active_proc and self._stages:
838+ # Only do this if we are still running stages. If there are no
839+ # stages, then we are in cleanup, and should continue doing that.
840+ self._active_proc.signalProcess("TERM")
841+
842+
843+class QueueRunner(pb.Root):
844+ """
845+ Maintains a queue of config files to handle, and processes them one by one.
846+
847+ The QueueRunner can operate in one of two modes:
848+ - as a persistent server, which processes queued up commit requests one
849+ at a time.
850+ - as a client, handing over any commit requests to an already running
851+ server (this mode is implemented via a classmethod).
852+
853+ Once the server completes its entire queue, it exits.
854+
855+ The server provides updates to the central kermit (running inside EnDroid)
856+ whenever progress is made.
857+
858+ """
859+
860+ def __init__(self, dir):
861+ self.dir = dir
862+ self._sockpath = _makesockname(dir)
863+ self._workqueues = collections.defaultdict(collections.OrderedDict)
864+ self._aborts = []
865+ self._jobs = []
866+ self._running = set()
867+ self.conn = None
868+
869+ os.chdir(dir)
870+ makedirs("logs")
871+ makedirs(".kermit")
872+
873+ observer = log.PythonLoggingObserver()
874+ observer.start()
875+
876+ logging.basicConfig(filename=os.path.join(".kermit", "qr.log"),
877+ level=logging.DEBUG,
878+ format="%(asctime)s[%(levelname)-3.3s] %(message)s",
879+ datefmt="%Y/%m/%d %H:%M:%S")
880+ logging.getLogger().addHandler(logging.StreamHandler())
881+
882+ def enqueue(self, cfgfile):
883+ logging.debug("Enqueuing another commit: {}".format(cfgfile))
884+ reactor.callWhenRunning(self._prepare, cfgfile)
885+
886+ def abort(self, key, ws=None):
887+ self._aborts.append((key, ws))
888+ reactor.callWhenRunning(self._do_aborts)
889+
890+ def _consider_shutdown(self):
891+ logging.debug("Considering shutdown, with following running: {}"
892+ .format(self._running))
893+ if not self._running:
894+ self.shutdown()
895+
896+ def shutdown(self):
897+ logging.info("Queue Runner shutting down")
898+ d = self.conn.stopListening()
899+ d.addBoth(lambda _: reactor.stop())
900+
901+ @defer.inlineCallbacks
902+ def _prepare(self, cfgfile):
903+ cfg = cfgparser.CfgParser(_CFG_DEFAULTS)
904+ cfg.read(cfgfile)
905+
906+ target = cfg.get("DEFAULT", "target")
907+ key = cfg.get("DEFAULT", "key")
908+ status = {}
909+ operation = None
910+
911+ try:
912+ status = yield self._check_checksum(cfg)
913+
914+ operation = _LocalOperation(self, cfgfile, cfg, target,
915+ cfg.section(target), status)
916+
917+ # Hand over any items that should be run in a shared queue
918+ yield operation.handover()
919+ except Exception as e:
920+ logging.error("Failed to prepare job {}".format(key))
921+ logging.exception(e)
922+
923+ status['check_failed'] = str(e)
924+
925+ if operation is not None:
926+ operation.complete()
927+
928+ try:
929+ yield _send_update(self, cfg, key, status)
930+ except Exception as f:
931+ logging.error("Failed to tell kermit we failed to prepare")
932+ logging.exception(f)
933+ else:
934+ try:
935+ yield operation._send_update()
936+ except Exception as e:
937+ logging.warning("Failed to update kermit: {}".format(str(e)))
938+
939+ self._workqueues[None][key] = operation
940+ finally:
941+ reactor.callWhenRunning(self._process_ws_queue, None)
942+
943+ @defer.inlineCallbacks
944+ def _process_ws_queue(self, ws):
945+ if ws in self._running:
946+ return
947+ logging.debug("Processing one item from queue: {}"
948+ .format(self._workqueues[ws]))
949+ if not self._workqueues[ws]:
950+ self._running.discard(ws)
951+ logging.debug("No work for {}. Will consider shutting down shortly"
952+ .format(ws))
953+ reactor.callLater(60, self._consider_shutdown)
954+ return
955+ self._running.add(ws)
956+
957+ key = iter(self._workqueues[ws]).next()
958+ operation = self._workqueues[ws][key]
959+
960+ operation.begin()
961+
962+ # Quite a simple function really:
963+ # - Run the pre_cmds
964+ # - Run each of the main commands, after waiting for its deps to run
965+ # - No matter what, run the post commands
966+ # - Then finally, schedule another run of the function
967+
968+ try:
969+ if ws in operation._cfg.getlist("DEFAULT", "ws_reset", default=[]):
970+ logging.info("Resetting workspace as requested")
971+ yield operation.abort_workspace(ws)
972+ yield operation.setup_workspace(ws)
973+
974+ # Run the "check" commands first to ensure everything is ready
975+ try:
976+ logging.debug("Preparing existing workspace {}".format(ws))
977+ yield operation.run_pre_cmds(suppress_fail=bool(ws),
978+ suppress_done=bool(ws))
979+ except Exception:
980+ if ws:
981+ logging.debug("Exception occurred; resetting workspace {}"
982+ .format(ws))
983+ yield operation.abort_workspace(ws)
984+ logging.debug("Trying to setup workspace {}".format(ws))
985+ yield operation.setup_workspace(ws)
986+ else:
987+ raise
988+
989+ # This is the main command loop
990+ yield operation.run_cmds()
991+ except Exception as f:
992+ # We'll already have notified kermit of the failure
993+ logging.exception(f)
994+ # But notify guys helping us to not bother anymore, or let the
995+ # main ws know that the rest of the steps have been aborted.
996+ try:
997+ yield operation.abort()
998+ except Exception as e:
999+ logging.error("Failed to abort")
1000+ logging.exception(e)
1001+ # But just swallow the error now
1002+
1003+ finally:
1004+ # Run the post commands no matter what
1005+ yield operation.run_post_cmds()
1006+ operation.complete()
1007+ self._workqueues[ws].pop(key, None)
1008+
1009+ # Make sure to try to process another item from the queue
1010+ self._running.discard(ws)
1011+ reactor.callWhenRunning(self._process_ws_queue, ws)
1012+
1013+ def _do_aborts(self):
1014+ if not self.conn:
1015+ return
1016+ logging.debug("Aborting: {}".format(self._aborts))
1017+ logging.debug("Queues: {}".format(self._workqueues))
1018+ for key, ws in self._aborts:
1019+ logging.warning("Aborting {} in {}".format(key, ws))
1020+ if key in self._workqueues.get(ws, []):
1021+ self._workqueues[ws][key].abort()
1022+ del self._workqueues[ws][key]
1023+ del self._aborts[:]
1024+
1025+ def _check_checksum(self, cfg):
1026+ """
1027+ Load existing data for a commit (if any) after checking the checksum.
1028+
1029+ The checksum for the 'commitdiff' specified in the given configuration
1030+ is checked against any stored checksum from a previous run of the
1031+ same commit.
1032+
1033+ This function returns a deferred that will fire with the existing
1034+ status information, or an empty dict if none exists or the checksums
1035+ don't match.
1036+
1037+ """
1038+ statedir = os.path.join(self.dir, "logs",
1039+ cfg.get("DEFAULT", "key"), ".kermit")
1040+ makedirs(statedir)
1041+
1042+ oldchecksum, progress = None, None
1043+ if os.path.exists(os.path.join(statedir, "checksum")):
1044+ with open(os.path.join(statedir, "checksum")) as file:
1045+ oldchecksum = file.read().strip()
1046+ if os.path.exists(os.path.join(statedir, "progress")):
1047+ with open(os.path.join(statedir, "progress")) as file:
1048+ progress = json.loads(file.read())
1049+
1050+ if not os.path.exists(cfg.get("DEFAULT", "commitdiff")):
1051+ raise ValueError("Specified commit diff doesn't exist or not "
1052+ "readable.")
1053+
1054+ difffile = os.path.join(statedir, "commit.diff")
1055+ shutil.copy(cfg.get("DEFAULT", "commitdiff"), difffile)
1056+ cfg.set("DEFAULT", "commitdiff", difffile)
1057+
1058+ p = ProcessOutput()
1059+
1060+ def handleChecksum(newchecksum):
1061+ newchecksum = newchecksum.strip()
1062+ logging.debug("Checksum calculated as {} (was {}). Progress is {}"
1063+ .format(newchecksum, oldchecksum, progress))
1064+ if newchecksum == oldchecksum:
1065+ return progress
1066+ else:
1067+ with open(os.path.join(statedir, "progress"), "w") as file:
1068+ file.write(json.dumps({}))
1069+ with open(os.path.join(statedir, "checksum"), "w") as file:
1070+ file.write(newchecksum)
1071+ return {}
1072+
1073+ p.deferred.addCallbacks(handleChecksum, lambda f: {})
1074+ reactor.spawnProcess(p, "md5sum", ("md5sum", difffile),
1075+ env=os.environ, path=self.dir)
1076+ return p.deferred
1077+
1078+ def start_listening(self):
1079+ """
1080+ This (perhaps poorly named) method might change the qr into a client
1081+ if it fails to set up the server.
1082+
1083+ """
1084+ self.conn = reactor.listenUNIX(self._sockpath, pb.PBServerFactory(self))
1085+ logging.info("Kermit Queue Runner started")
1086+ reactor.callLater(60, self._consider_shutdown)
1087+
1088+ @staticmethod
1089+ def send_queues(dir, cfgfile=None, abort=None):
1090+ reactor.callWhenRunning(QueueRunner._send_queues, dir, cfgfile, abort)
1091+
1092+ @staticmethod
1093+ @defer.inlineCallbacks
1094+ def _send_queues(dir, cfgfile=None, abort=None):
1095+ # This means we failed to listen. So send instead.
1096+ logging.debug("Failed to listen, so sending our queue instead")
1097+ factory = pb.PBClientFactory()
1098+ reactor.connectUNIX(_makesockname(dir), factory)
1099+ root = yield factory.getRootObject()
1100+ try:
1101+ if cfgfile:
1102+ yield root.callRemote("enqueue", cfgfile)
1103+ if abort:
1104+ yield root.callRemote("abort", abort)
1105+ except Exception as e:
1106+ logging.debug("Total failure")
1107+ logging.debug(str(fail))
1108+ reactor.stop()
1109+
1110+ def remote_enqueue(self, cfgfile):
1111+ """Callback for enqueuing a regular commit (from a peer process)."""
1112+ self.enqueue(cfgfile)
1113+
1114+ def remote_abort(self, key, ws=None):
1115+ logging.debug("Being told to abort {} in {}".format(key, ws))
1116+ # Both local and remote workspaces can get this call!
1117+ self.abort(key, ws)
1118+
1119+ def remote_handover(self, remote_op, cfgfile, ws, stages):
1120+ logging.debug("Been handed something to do: {} in {} (stages: {})"
1121+ .format(cfgfile, ws, stages))
1122+
1123+ cfg = cfgparser.CfgParser(_CFG_DEFAULTS)
1124+ cfg.read(cfgfile)
1125+
1126+ target = cfg.get("DEFAULT", "target")
1127+ key = cfg.get("DEFAULT", "key")
1128+
1129+ operation = _SharedOperation(self, cfgfile, cfg, target,
1130+ cfg.section(ws, category="workspace"),
1131+ remote_op, stages, ws=ws)
1132+
1133+ self._workqueues[ws][key] = operation
1134+ reactor.callWhenRunning(self._process_ws_queue, ws)
1135+
1136+
1137+class ProcessOutput(protocol.ProcessProtocol):
1138+ """
1139+ A nice simple ProcessProtocol that returns the stdout of the process.
1140+
1141+ Callers should use the .deferred field to receive the result.
1142+
1143+ """
1144+ def __init__(self):
1145+ self.deferred = defer.Deferred()
1146+ self.output = ""
1147+ def outReceived(self, data):
1148+ self.output += data
1149+ def processEnded(self, reason):
1150+ self.deferred, d = None, self.deferred
1151+ if reason.value.exitCode == 0:
1152+ d.callback(self.output)
1153+ else:
1154+ d.errback(reason)
1155+
1156+
1157+def main():
1158+ parser = ArgumentParser()
1159+ parser.add_argument("-a", "--abort")
1160+ parser.add_argument("userdir")
1161+ parser.add_argument("cfgfile", nargs='?')
1162+
1163+ args = parser.parse_args()
1164+
1165+ qr = QueueRunner(args.userdir)
1166+ try:
1167+ qr.start_listening()
1168+ except error.CannotListenError as e:
1169+ QueueRunner.send_queues(args.userdir, args.cfgfile, args.abort)
1170+ else:
1171+ if args.cfgfile:
1172+ qr.enqueue(args.cfgfile)
1173+ if args.abort:
1174+ qr.abort(args.abort)
1175+
1176+ reactor.run()
1177+
1178+if __name__ == "__main__":
1179+ main()
1180
1181=== added file 'bin/kermit_trigger'
1182--- bin/kermit_trigger 1970-01-01 00:00:00 +0000
1183+++ bin/kermit_trigger 2014-09-01 20:21:07 +0000
1184@@ -0,0 +1,56 @@
1185+#!/usr/bin/env python
1186+
1187+import os
1188+import sys
1189+import pwd
1190+import StringIO
1191+import argparse
1192+import subprocess
1193+import ConfigParser
1194+
1195+def main():
1196+ parser = argparse.ArgumentParser()
1197+ parser.add_argument("-w", "--workspace", action="store_true")
1198+ parser.add_argument("-a", "--abort")
1199+
1200+ args = parser.parse_args()
1201+
1202+ # Read the config file in from stdin
1203+ config = sys.stdin.read()
1204+
1205+ cfg = ConfigParser.SafeConfigParser({
1206+ 'qr_path': 'kermit_queue_runner',
1207+ })
1208+ cfg.readfp(StringIO.StringIO(config))
1209+
1210+ target = cfg.get("DEFAULT", "target")
1211+
1212+ basedir = cfg.get(target, "dir")
1213+ userdir = os.path.join(basedir, pwd.getpwuid(os.getuid())[0])
1214+ if not os.path.exists(userdir):
1215+ os.makedirs(userdir)
1216+
1217+ cmd = ["screen", "-c", "/dev/null", "-d", "-m",
1218+ cfg.get("DEFAULT", "qr_path"), userdir]
1219+
1220+ key = cfg.get("DEFAULT", "key")
1221+ cfg.set("DEFAULT", "logdir", os.path.join(userdir, 'logs', key))
1222+
1223+ filename = os.path.join(userdir, ".kermit", key + ".cfg")
1224+ if not args.workspace and not args.abort:
1225+ if not os.path.exists(os.path.dirname(filename)):
1226+ os.makedirs(os.path.dirname(filename))
1227+
1228+ with open(filename, "w") as file:
1229+ cfg.write(file)
1230+
1231+ cmd.append(filename)
1232+
1233+ if args.abort:
1234+ cmd.extend(("-a", args.abort))
1235+
1236+ st = subprocess.call(cmd)
1237+ sys.exit(st)
1238+
1239+if __name__ == "__main__":
1240+ main()
1241
1242=== modified file 'debian/control'
1243--- debian/control 2014-08-11 09:02:24 +0000
1244+++ debian/control 2014-09-01 20:21:07 +0000
1245@@ -12,3 +12,17 @@
1246 Description: XMPP bot framework
1247 EnDroid is a framework for building bots in XMPP. Its architecture
1248 is based around plugins, in order to make it as extensibile as possible.
1249+
1250+Package: endroid-kermit
1251+Architecture: all
1252+Depends: python,python-twisted,endroid,${misc:Depends}
1253+Suggests: endroid-kermit-daemons
1254+Description: Continuous Integration commit queue for EnDroid
1255+ Kermit is a CI commit queue handling plugin for EnDroid.
1256+
1257+Package: endroid-kermit-daemons
1258+Architecture: all
1259+Depends: python,python-twisted,screen,${misc:Depends}
1260+Description: Daemon processes for the Kermit EnDroid plugin
1261+ This package must be installed on any servers that will be used as Kermit
1262+ commit hosts.
1263
1264=== added file 'debian/endroid-kermit-daemons.install'
1265--- debian/endroid-kermit-daemons.install 1970-01-01 00:00:00 +0000
1266+++ debian/endroid-kermit-daemons.install 2014-09-01 20:21:07 +0000
1267@@ -0,0 +1,3 @@
1268+bin/kermit usr/bin/
1269+bin/kermit_trigger usr/bin/
1270+bin/kermit_queue_runner usr/bin/
1271
1272=== added file 'debian/endroid-kermit.install'
1273--- debian/endroid-kermit.install 1970-01-01 00:00:00 +0000
1274+++ debian/endroid-kermit.install 2014-09-01 20:21:07 +0000
1275@@ -0,0 +1,19 @@
1276+resources/kermit/profile.html usr/share/endroid/templates/kermit/
1277+resources/kermit/queue.html usr/share/endroid/templates/kermit/
1278+resources/kermit/response.json usr/share/endroid/templates/kermit/
1279+resources/kermit/alert.js usr/share/endroid/media/kermit/
1280+resources/kermit/queue.js usr/share/endroid/media/kermit/
1281+resources/kermit/profile.js usr/share/endroid/media/kermit/
1282+resources/kermit/services.js usr/share/endroid/media/kermit/
1283+resources/kermit/alert.css usr/share/endroid/media/kermit/
1284+resources/kermit/queue.css usr/share/endroid/media/kermit/
1285+resources/kermit/profile.css usr/share/endroid/media/kermit/
1286+resources/kermit/kermit.png usr/share/endroid/media/kermit/
1287+resources/kermit/kermit-logo.jpg usr/share/endroid/media/kermit/
1288+resources/kermit/kermit.css usr/share/endroid/media/kermit/
1289+resources/kermit/kermit-icon-120.png usr/share/endroid/media/kermit/
1290+resources/kermit/kermit-icon-152.png usr/share/endroid/media/kermit/
1291+resources/kermit/kermit-icon-60.png usr/share/endroid/media/kermit/
1292+resources/kermit/kermit-icon-76.png usr/share/endroid/media/kermit/
1293+resources/kermit/kermit-icon.png usr/share/endroid/media/kermit/
1294+resources/kermit/kermit_splash.png usr/share/endroid/media/kermit/
1295
1296=== added file 'debian/endroid-kermit.pyinstall'
1297--- debian/endroid-kermit.pyinstall 1970-01-01 00:00:00 +0000
1298+++ debian/endroid-kermit.pyinstall 2014-09-01 20:21:07 +0000
1299@@ -0,0 +1,1 @@
1300+src/endroid/plugins/kermit/*py endroid.plugins.kermit
1301
1302=== modified file 'debian/endroid.install'
1303--- debian/endroid.install 2014-04-05 19:53:27 +0000
1304+++ debian/endroid.install 2014-09-01 20:21:07 +0000
1305@@ -6,6 +6,6 @@
1306 bin/spelunk_hi5s usr/sbin/
1307 lib/wokkel-0.7.1-py2.7.egg usr/lib/endroid/dependencies
1308 var/endroid.db var/lib/endroid/db
1309-doc/EnDroid.png usr/share/endroid/media/
1310+resources/httpinterface/EnDroid.png usr/share/endroid/media/httpinterface/
1311 resources/httpinterface/index.html usr/share/endroid/templates/httpinterface/
1312 resources/httpinterface/notfound.html usr/share/endroid/templates/httpinterface/
1313
1314=== added file 'debian/endroid.pyinstall'
1315--- debian/endroid.pyinstall 1970-01-01 00:00:00 +0000
1316+++ debian/endroid.pyinstall 2014-09-01 20:21:07 +0000
1317@@ -0,0 +1,3 @@
1318+src/endroid/*py endroid
1319+src/endroid/plugins/*py endroid.plugins
1320+src/endroid/plugins/compute/*py endroid.plugins.compute
1321
1322=== added file 'etc/kermit.cfg'
1323--- etc/kermit.cfg 1970-01-01 00:00:00 +0000
1324+++ etc/kermit.cfg 2014-09-01 20:21:07 +0000
1325@@ -0,0 +1,157 @@
1326+# Configuration file for Kermit
1327+
1328+# Section names:
1329+#
1330+# - A naked section name describes a "target". This is something that the user
1331+# types in, and provides the starting point for what configuration applies
1332+# to a given commit.
1333+# - "workspace:" category sections describe auxilliary workspaces that may
1334+# be used to perform commits. Note that these are in addition to the
1335+# commit workspace that is created specifically for any given commit.
1336+# - "command:" category sections describe the commands that can be run to
1337+# perform a commit.
1338+#
1339+# 'target' sections will reference 'workspace' and 'command' sections; and
1340+# 'workspace' sections can also reference 'command' sections; and 'command'
1341+# sections can also reference other 'command' sections.
1342+#
1343+# Within 'target' sections, the following items have meaning:
1344+#
1345+# - "dir" is the directory that all operations for this target are performed
1346+# in. Note that multiple targets can share the same directory, in that the
1347+# same queues simply get used per person. Each workspace (including the
1348+# per-commit one) are rooted in this directory, so ensure commands won't
1349+# conflict with other commands run in parallel.
1350+# - "workspaces" is a list of the auxilliary workspaces that may be used to
1351+# perform commits to this target. If a given command requests a workspace
1352+# that is not listed for that target, then the command is simply run in
1353+# the commit workspace instead.
1354+# - "pre_cmds" and "post_cmds" are the lists of commands to run in the commit
1355+# workspace before/after the commit commands themselves.
1356+# - "commands" is the list of commands that need to be run (and pass) to
1357+# perform the actual commit. Each command implicitly depends upon the
1358+# completion of the command prior to it, and won't run until that command
1359+# has passed (though not necessarily within the same run. See the
1360+# "dependencies" option under the "command:" category, below).
1361+# - "server" specifies the server on which the commands will be run. Kermit
1362+# will try to ssh to this server as each user, using keys uploaded via the
1363+# key interface.
1364+# - "fingerprints" specifies a list (although one is usually sufficient) of
1365+# ssh fingerprints for the destination server. This can be set to the special
1366+# string 'ignore' if fingerprint checking should be disabled - though this
1367+# is not recommended.
1368+#
1369+# Within 'workspace' sections, the following items have meaning:
1370+#
1371+# - "username" is the name of the user that owns the workspace. This is used
1372+# to communicate with the right kermit process. Note that if left blank, the
1373+# workspace is assumed to be owned by the user owning the commit.
1374+# - "setup_cmds" and "abort_cmds" are lists of commands used to prepare a
1375+# shared workspace, or tear it down respectively. These commands are only
1376+# run when necessary, which is to say when the "pre_cmds" for the workspace
1377+# fail. The intention is to run expensive commands here, so that commits
1378+# making use of these workspaces can complete faster. Note that if "pre_cmds"
1379+# fails, forcing a rerun of the "setup_cmds", then the "pre_cmds" are not
1380+# run as well (i.e. "setup_cmds" is expected to perform the actions of
1381+# "pre_cmds" if they are necessary).
1382+# - "pre_cmds" and "post_cmds" are lists of commands to run before/after any
1383+# commit commands that have been offloaded to this workspace. "pre_cmds"
1384+# should include any items that are needed to prepare the workspace, and
1385+# make sure it is still valid (e.g. up to date). If any of them fail, it
1386+# will cause the workspaces to be torn down and set up from scratch.
1387+# "post_cmds" will be run no matter what happens (completion of all steps,
1388+# failure of a step, or abort of the whole commit), and must leave the
1389+# workspace ready for the next commit.
1390+#
1391+# Within 'command' sections, the following items have meaning:
1392+#
1393+# - "command" is the command line to run. This is parsed using shell parsing
1394+# rules, so quotes can be used to join space-separated words into single
1395+# tokens.
1396+# - "pwd" is the working directory to run the command in, relative to the
1397+# base directory of the target. Note that the working directory may not
1398+# be higher than the base directory of the target (with the default being
1399+# "./", i.e. the target directory).
1400+# - "dependencies" is a list of other commands that must always be run in the
1401+# workspace prior to this command. These commands will be run, even if they
1402+# have previously succeded for the patch in question and normally would
1403+# persist their results.
1404+# - "persist" is a boolean indicating whether the results of this command can
1405+# be kept from run to run (assuming the checksum of the patch to commit
1406+# does not change between runs). Default is no.
1407+# - "attempts" is the number of times to attempt the command. This can be
1408+# used for commands that are known to be flakey, or fail intermittently.
1409+# Default is 1.
1410+# - "attempt_interval" is the number of seconds to wait between retries of
1411+# this command. Default is 10.
1412+# - "workspace" is the name of the shared workspace to offload this command
1413+# to. This will only happen if the target lists this workspace as a valid
1414+# one in its "workspaces" configuration option.
1415+# - "environment" is a list of "key: value" pairs that are set in the
1416+# environment of the command when spawned. Default is to inherit the env
1417+# of the kermit process. Note that these variables are added (or
1418+# overwrite) the starting environment, and do not replace it.
1419+# - "filevars" is a list of "varname: filename" pairs that describe
1420+# interpolation variables (see below) whose values should be the contents
1421+# of the given files. See the section on value interpolation below for the
1422+# full semantics of this option.
1423+# - "savelogs" is a list of files that should be saved into the log directory.
1424+# Usually these are output files generated in some temporary directory, and
1425+# that will be either useful to the user, or required in a later step. The
1426+# file name in the logdir is the basename of the input file.
1427+#
1428+# Certain options in the "command:" sections can contain interpolation
1429+# variables (using Python's .format and attr-based lookups; i.e. {varname}).
1430+# The values available are looked up in order from the following:
1431+#
1432+# - Any "filevars" defined in the section.
1433+# - Any options defined in the "command:" section.
1434+# - Any options defined in the corresponding "workspace:" section. This only
1435+# applies to operations running in a shared workspace.
1436+# - Any options defined in the corresponding target section.
1437+# - Various global parameters (see below).
1438+#
1439+# Note that options here are not restricted to options understood by kermit.
1440+# Any options may be specified in the config file, and they will be used in
1441+# interpolation, allowing re-usable command snippets to be created, and
1442+# tweaked as needed for each target.
1443+#
1444+# The following values are interpolated:
1445+# - The filename portion of any "filevars". Note that, obviously, the
1446+# interpolation here only includes variables already loaded. Variables are
1447+# set in the order defined, so earlier file contents can be used in the names
1448+# of later filevars, if required.
1449+# - The values of any environment variables.
1450+# - The command line (after lexical parsing of the command line into separate
1451+# tokens. i.e. any interpolated variable can only be a single token).
1452+# - The working directory for the command ("pwd").
1453+# - The paths to any files in the "savelogs" directive.
1454+#
1455+# The following global configuration options are always present and usable
1456+# (though they can be overriden in configuration sections if desired):
1457+#
1458+# - "logdir": the directory into which logs should be/are written.
1459+# - "target": the current target.
1460+# - "key": the unique identifier for this kermit operation.
1461+# - "commitdiff": path to the file being committed.
1462+# - "buglist": the list of bug IDs being committed against.
1463+# - "commitmsg": the (optional) commit message specified by the user.
1464+# - "username": the username of the user the commit is for (note: this is not
1465+# necessarily the user the command is running as, i.e. in the case of a
1466+# shared workspace running as a separate user being used to run part of the
1467+# commit).
1468+# - "stages": list of the stages requested by the user (note: this may not
1469+# be exactly the stages being run, since the user may not have correctly
1470+# taken dependencies into account).
1471+#
1472+# There are also some cosmetic-only configuration options, used to customise
1473+# the web interface:
1474+#
1475+# - "message" is configurable under the 'target' section, is interpolated (with
1476+# options from the target and global sections only), and is displayed within
1477+# the commit panel on the web interface. An example might be to let the user
1478+# know where log files can be accessed.
1479+# - "description" is configured under the 'command' sections, and replaces the
1480+# short command name wherever the command is mentioned in the web interface.
1481+# - "recovery" is also configured under the 'command' sections, and is shown
1482+# in an alert within the command panel whenever the command fails.
1483
1484=== renamed file 'doc/EnDroid.png' => 'resources/httpinterface/EnDroid.png'
1485=== modified file 'resources/httpinterface/index.html'
1486--- resources/httpinterface/index.html 2014-04-05 11:42:51 +0000
1487+++ resources/httpinterface/index.html 2014-09-01 20:21:07 +0000
1488@@ -5,7 +5,7 @@
1489 </head>
1490 <body bgcolor="white">
1491 <h1>EnDroid web interface</h1>
1492- <img style='float: right' src='_media/EnDroid.png'>
1493+ <img style='float: right' src='_media/httpinterface/EnDroid.png'>
1494 {% if plugins %}
1495 <p>The following plugins have resources registered:</p>
1496 <ul>
1497
1498=== added directory 'resources/kermit'
1499=== added file 'resources/kermit/alert.css'
1500--- resources/kermit/alert.css 1970-01-01 00:00:00 +0000
1501+++ resources/kermit/alert.css 2014-09-01 20:21:07 +0000
1502@@ -0,0 +1,8 @@
1503+/*
1504+ * Styles for the global alert service.
1505+ */
1506+
1507+.alerts {
1508+ position: fixed;
1509+ z-index: 100;
1510+}
1511
1512=== added file 'resources/kermit/alert.js'
1513--- resources/kermit/alert.js 1970-01-01 00:00:00 +0000
1514+++ resources/kermit/alert.js 2014-09-01 20:21:07 +0000
1515@@ -0,0 +1,31 @@
1516+/*
1517+ * Implements a global alert service in angular.
1518+ */
1519+angular.module("endroid.kermit.alert", []).factory('alertService',
1520+ ['$rootScope', '$timeout', function($rootScope, $timeout) {
1521+ $rootScope.alerts = [];
1522+ var alertService = {
1523+ add: function(type, msg, timeout) {
1524+ $rootScope.alerts.push({
1525+ type: type,
1526+ msg: msg,
1527+ close: function() {
1528+ return alertService.closeAlert(this);
1529+ }
1530+ });
1531+ var idx = $rootScope.alerts.length - 1;
1532+ if (timeout) {
1533+ $timeout(function() {
1534+ alertService.closeAlertIdx(idx);
1535+ }, timeout);
1536+ }
1537+ },
1538+ closeAlert: function(thisAlert) {
1539+ return this.closeAlertIdx($rootScope.alerts.indexOf(thisAlert));
1540+ },
1541+ closeAlertIdx: function(index) {
1542+ return $rootScope.alerts.splice(index, 1);
1543+ }
1544+ };
1545+ return alertService;
1546+ }]);
1547
1548=== added file 'resources/kermit/kermit-icon-120.png'
1549Binary files resources/kermit/kermit-icon-120.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-icon-120.png 2014-09-01 20:21:07 +0000 differ
1550=== added file 'resources/kermit/kermit-icon-152.png'
1551Binary files resources/kermit/kermit-icon-152.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-icon-152.png 2014-09-01 20:21:07 +0000 differ
1552=== added file 'resources/kermit/kermit-icon-60.png'
1553Binary files resources/kermit/kermit-icon-60.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-icon-60.png 2014-09-01 20:21:07 +0000 differ
1554=== added file 'resources/kermit/kermit-icon-76.png'
1555Binary files resources/kermit/kermit-icon-76.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-icon-76.png 2014-09-01 20:21:07 +0000 differ
1556=== added file 'resources/kermit/kermit-icon.png'
1557Binary files resources/kermit/kermit-icon.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-icon.png 2014-09-01 20:21:07 +0000 differ
1558=== added file 'resources/kermit/kermit-logo.jpg'
1559Binary files resources/kermit/kermit-logo.jpg 1970-01-01 00:00:00 +0000 and resources/kermit/kermit-logo.jpg 2014-09-01 20:21:07 +0000 differ
1560=== added file 'resources/kermit/kermit.css'
1561--- resources/kermit/kermit.css 1970-01-01 00:00:00 +0000
1562+++ resources/kermit/kermit.css 2014-09-01 20:21:07 +0000
1563@@ -0,0 +1,57 @@
1564+/*
1565+ * Common styles for all kermit pages.
1566+ */
1567+
1568+.nav, .pagination, .carousel, .panel-title a { cursor: pointer; }
1569+
1570+.navbar-form .input-group-btn,
1571+.navbar-form .input-group-addon {
1572+ width: auto;
1573+}
1574+
1575+.kermit-logo {
1576+ position: absolute;
1577+ right: 0;
1578+ z-index: -1;
1579+}
1580+
1581+@media (max-width: 480px) {
1582+ .navbar { margin-bottom: 5px; }
1583+ h1 {
1584+ font-size: 210%;
1585+ margin-top: 0px;
1586+ margin-bottom: 0px;
1587+ }
1588+ .container > .navbar-header {
1589+ margin-right: 0;
1590+ }
1591+ .container > .navbar-header > button {
1592+ margin-right: 0;
1593+ }
1594+ .container { padding: 0 5px; }
1595+ .kermit-logo { display: none; }
1596+ .navbar-form { border: 0; }
1597+}
1598+
1599+/* Animations for alerts */
1600+.kermit-alert {
1601+ width: 600px;
1602+}
1603+.kermit-alert.ng-enter,
1604+.kermit-alert.ng-leave {
1605+ -webkit-transition: opacity 1s;
1606+ -moz-transition: opacity 1s;
1607+ -ms-transition: opacity 1s;
1608+ -o-transition: opacity 1s;
1609+ transition: opacity 1s;
1610+}
1611+.kermit-alert.ng-leave,
1612+.kermit-alert.ng-enter.ng-enter-active {
1613+ opacity: 1;
1614+}
1615+.kermit-alert.ng-enter,
1616+.kermit-alert.ng-leave.ng-leave-active {
1617+ opacity: 0;
1618+}
1619+
1620+
1621
1622=== added file 'resources/kermit/kermit.png'
1623Binary files resources/kermit/kermit.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit.png 2014-09-01 20:21:07 +0000 differ
1624=== added file 'resources/kermit/kermit_splash.png'
1625Binary files resources/kermit/kermit_splash.png 1970-01-01 00:00:00 +0000 and resources/kermit/kermit_splash.png 2014-09-01 20:21:07 +0000 differ
1626=== added file 'resources/kermit/profile.css'
1627--- resources/kermit/profile.css 1970-01-01 00:00:00 +0000
1628+++ resources/kermit/profile.css 2014-09-01 20:21:07 +0000
1629@@ -0,0 +1,64 @@
1630+/*
1631+ * Styles for the kermit queue page.
1632+ */
1633+
1634+.profile-toolbar {
1635+ min-height: 1em;
1636+}
1637+
1638+.profile-toolbar > .btn {
1639+ margin-top: -2em;
1640+ margin-bottom: 0.5em;
1641+ margin-left: 2px;
1642+}
1643+
1644+/* Main queue width control */
1645+@media (min-width: 768px) {
1646+ .queue-display {
1647+ max-width: 750px;
1648+ padding-right: 250px;
1649+ }
1650+}
1651+@media (min-width: 992px) {
1652+ .queue-display {
1653+ max-width: 750px;
1654+ padding-right: 138px; /* 280 - (992-750)/2 */
1655+ }
1656+}
1657+@media (min-width: 1200px) {
1658+ .queue-display {
1659+ max-width: 750px;
1660+ padding-right: 55px; /* 280 - (1200-750)/2 */
1661+ }
1662+}
1663+
1664+/* Animations for the list of commits */
1665+.key-entry.ng-hide-add,
1666+.key-entry.ng-hide-remove,
1667+.key-entry.ng-enter,
1668+.key-entry.ng-leave
1669+{
1670+ -webkit-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
1671+ -moz-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
1672+ -ms-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
1673+ -o-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
1674+ transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
1675+
1676+ position: relative;
1677+ display: block !important;
1678+}
1679+.key-entry.ng-hide-remove.ng-hide-remove-active,
1680+.key-entry.ng-hide-add,
1681+.key-entry.ng-enter.ng-enter-active,
1682+.key-entry.ng-leave {
1683+ opacity: 1;
1684+ left: 0;
1685+}
1686+.key-entry.ng-hide-add.ng-hide-add-active,
1687+.key-entry.ng-hide-remove,
1688+.key-entry.ng-leave.ng-leave-active,
1689+.key-entry.ng-enter {
1690+ opacity: 0;
1691+ left: -800px;
1692+}
1693+
1694
1695=== added file 'resources/kermit/profile.html'
1696--- resources/kermit/profile.html 1970-01-01 00:00:00 +0000
1697+++ resources/kermit/profile.html 2014-09-01 20:21:07 +0000
1698@@ -0,0 +1,147 @@
1699+<!DOCTYPE html>
1700+<html ng-app="endroid">
1701+ <head>
1702+ <title>User Keys - {{ username | escape }}</title>
1703+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
1704+ <script src='http://ajax.googleapis.com/ajax/libs/angularjs/1.2.8/angular.min.js'></script>
1705+ <script src='http://ajax.googleapis.com/ajax/libs/angularjs/1.2.8/angular-animate.min.js'></script>
1706+ <script src='http://angular-ui.github.io/bootstrap/ui-bootstrap-tpls-0.10.0.js'></script>
1707+ <script src='/_media/kermit/alert.js'></script>
1708+ <script src='/_media/kermit/services.js'></script>
1709+ <script src='/_media/kermit/profile.js'></script>
1710+
1711+ <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
1712+ <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css">
1713+ <link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
1714+
1715+ <link rel="stylesheet" href="/_media/kermit/kermit.css">
1716+ <link rel="stylesheet" href="/_media/kermit/alert.css">
1717+ <link rel="stylesheet" href="/_media/kermit/profile.css">
1718+
1719+ <link rel="icon" href='/_media/kermit/kermit.png' type='image/png'>
1720+
1721+ <link rel="apple-touch-icon" href="/_media/kermit/kermit-icon-60.png">
1722+ <link rel="apple-touch-icon" sizes="76x76" href="/_media/kermit/kermit-icon-76.png">
1723+ <link rel="apple-touch-icon" sizes="120x120" href="/_media/kermit/kermit-icon-120.png">
1724+ <link rel="apple-touch-icon" sizes="152x152" href="/_media/kermit/kermit-icon-152.png">
1725+ <link rel="apple-touch-startup-image" href="/_media/kermit/kermit_splash.png">
1726+ <meta name="apple-mobile-web-app-capable" content="yes">
1727+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
1728+ </head>
1729+
1730+ <body>
1731+ <script>
1732+ username = "{{ username | escape }}";
1733+ initial_keys = {{ keys }};
1734+ </script>
1735+
1736+ <nav class="navbar navbar-static-top navbar-inverse" role="navigation">
1737+ <div class="container">
1738+ <div class="navbar-header">
1739+ <button type="button" class="navbar-toggle" ng-init='navCollapse = true' ng-click='navCollapse = !navCollapse'>
1740+ <span class="sr-only">Toggle navigation</span>
1741+ <span class="icon-bar"></span>
1742+ <span class="icon-bar"></span>
1743+ <span class="icon-bar"></span>
1744+ </button>
1745+ <a class="navbar-brand" href="/">EnDroid</a>
1746+ </div>
1747+ <div class='collapse navbar-collapse' collapse='navCollapse'>
1748+ <ul class="nav navbar-nav">
1749+ <li><a href="/kermit/">Kermit Queue</a></li>
1750+ <!--<li class="dropdown">
1751+ <a class="dropdown-toggle">Actions <b class="caret"></b></a>
1752+ <ul class="dropdown-menu">
1753+ <li><a>The first action</a></li>
1754+ <li><a>A second action</a></li>
1755+ <li class="divider"></li>
1756+ <li><a>A final action</a></li>
1757+ </ul>
1758+ </li>-->
1759+ </ul>
1760+ <!--<form class="navbar-form navbar-right" role="search">
1761+ <div class="input-group">
1762+ <input type="text" class="form-control" placeholder="Search">
1763+ <span class="input-group-btn">
1764+ <button class="btn btn-default" type="submit">
1765+ <span class="glyphicon glyphicon-search"></span>
1766+ </button>
1767+ </span>
1768+ </div>
1769+ </form>-->
1770+ <p class="navbar-text navbar-right">Signed in as <a href="/kermit/profile" class="navbar-link">{{ username }}</a></p>
1771+ </div>
1772+ </div>
1773+ </nav>
1774+
1775+ <img class="kermit-logo" src='/_media/kermit/kermit-logo.jpg'>
1776+
1777+ <div class="container alerts">
1778+ <alert ng-repeat="alert in alerts" type="alert.type" class="pull-right kermit-alert" close="alert.close()">{{ '{{' }} alert.msg }}</alert>
1779+ </div>
1780+
1781+ <div class="container">
1782+ <div ng-controller="ProfileDisplay" class='queue-display' >
1783+ <script type='text/ng-template' id='uploadModel.html'>
1784+<div class='modal-header'>
1785+ <h3>Upload existing Private Key</h3>
1786+</div>
1787+<div class='modal-body'>
1788+ <div>
1789+ <div class='form-horizontal'>
1790+ <alert type="'warning'">
1791+ <i class='fa fa-warning fa-2x pull-left'></i>
1792+ Note that you must upload your full private key, so please consider the security implications of uploading it over the network. It might be best to let Kermit generate a new key for you, so only the public key is transmitted.</alert>
1793+ <div class='form-group'>
1794+ <label class='col-md-3 control-label' for='privatekey'>Private Key</label>
1795+ <div class='col-md-8'>
1796+ <textarea class='form-control' ng-model='info.privatekey' id='privatekey' name='privatekey' rows='5'></textarea>
1797+ </div>
1798+ </div>
1799+ </div>
1800+ </div>
1801+</div>
1802+<div class='modal-footer'>
1803+ <button class='btn btn-primary' ng-click='ok()'>Upload</button>
1804+ <button class='btn btn-danger' ng-click='cancel()'>Cancel</button>
1805+</div>
1806+ </script>
1807+ <h1>User Keys</h1>
1808+
1809+ <div class='profile-toolbar'>
1810+ <button class='btn btn-default pull-right' ng-click='generate()' tooltip='Generate a new key'>
1811+ <i class='fa fa-key' ng-class="{'fa-spin': generating}"></i>
1812+ </button>
1813+ <button class='btn btn-default pull-right' ng-click='open()' tooltip='Upload an existing key'>
1814+ <span class='fa fa-upload'></span>
1815+ </button>
1816+ </div>
1817+
1818+ <alert ng-show="keys.length == 0" type="'info'">
1819+ <i class='fa fa-unlock pull-left fa-2x'></i>
1820+ No keys currently configured.
1821+ </alert>
1822+
1823+ <alert ng-show="keys.length != 0" type="'info'">
1824+ <i class='fa fa-wrench pull-left fa-2x'></i>
1825+ Place one of the public keys listed below into the
1826+ <code>.ssh/authorized_keys</code> file on each server that you may
1827+ use for commits.
1828+ </alert>
1829+
1830+ {% raw %}
1831+ <div class='panel panel-default key-entry' ng-repeat='key in keys'>
1832+ <div class='panel-heading'>
1833+ {{ key.type }} Key
1834+ <button type="button" class="pull-right close" aria-hidden="true" ng-click='remove($index)'>&times;</button>
1835+ </div>
1836+ <div class='panel-body'>
1837+ <pre style='white-space: pre-wrap'>{{ key.keystr }}</pre>
1838+ </div>
1839+ </div>
1840+ {% endraw %}
1841+
1842+ </div>
1843+ </div>
1844+ </body>
1845+</html>
1846
1847=== added file 'resources/kermit/profile.js'
1848--- resources/kermit/profile.js 1970-01-01 00:00:00 +0000
1849+++ resources/kermit/profile.js 2014-09-01 20:21:07 +0000
1850@@ -0,0 +1,77 @@
1851+/*
1852+ * Main controllers, etc. for the kermit queue page.
1853+ */
1854+var module = angular.module("endroid", ["ui.bootstrap", "ngAnimate",
1855+ "endroid.kermit.alert", "asyncData"]);
1856+
1857+function ProfileDisplay($scope, $modal, alertService, asyncData, $http) {
1858+ $scope.keys = initial_keys;
1859+ $scope.generating = false;
1860+
1861+ $scope.generate = function() {
1862+ $scope.generating = true;
1863+ asyncData.getData("generatekey").then(function(data) {
1864+ $scope.keys.push(data.success);
1865+ $scope.generating = false;
1866+ }, function(data) {
1867+ $scope.generating = false;
1868+ alertService.add('danger', "Failed to generate a new key: " + data.fail,
1869+ 5000);
1870+ });
1871+ };
1872+
1873+ $scope.open = function() {
1874+ var modalInstance = $modal.open({
1875+ templateUrl: 'uploadModel.html',
1876+ controller: ModalInstanceCtrl,
1877+ });
1878+
1879+ modalInstance.result.then(function(result) {
1880+ alertService.add('success', "Key uploaded successfully", 5000);
1881+ $scope.keys.push(result);
1882+ }, function(failure) {
1883+ alertService.add('danger', "Failed to upload key: " + failure, 5000);
1884+ });
1885+ };
1886+
1887+ $scope.remove = function(idx) {
1888+ $http({
1889+ method: "GET",
1890+ url: "removekey?idx=" + $scope.keys[idx].idx,
1891+ }).then(function(data) {
1892+ $scope.keys.splice(idx, 1);
1893+ }, function(data) {
1894+ alertService.add('danger', "Failed to delete key: " + data.fail, 5000);
1895+ });
1896+ };
1897+};
1898+
1899+var ModalInstanceCtrl = function($scope, $modalInstance, $http) {
1900+ $scope.info = {
1901+ privatekey: '',
1902+ };
1903+
1904+ $scope.cancel = function() {
1905+ $modalInstance.dismiss("cancelled by you");
1906+ };
1907+
1908+ $scope.ok = function() {
1909+ $http({
1910+ method: "POST",
1911+ url: "uploadkey",
1912+ transformRequest: function(obj) {
1913+ var str = [];
1914+ angular.forEach(obj, function(v, k) {
1915+ str.push(encodeURIComponent(k) + "=" + encodeURIComponent(v));
1916+ });
1917+ return str.join("&");
1918+ },
1919+ data: $scope.info,
1920+ headers: {"Content-Type": 'application/x-www-form-urlencoded'}
1921+ }).then(function(data) {
1922+ $modalInstance.close(data.data.success);
1923+ }, function(data) {
1924+ $modalInstance.dismiss(data.data.fail);
1925+ });
1926+ };
1927+};
1928
1929=== added file 'resources/kermit/queue.css'
1930--- resources/kermit/queue.css 1970-01-01 00:00:00 +0000
1931+++ resources/kermit/queue.css 2014-09-01 20:21:07 +0000
1932@@ -0,0 +1,169 @@
1933+/*
1934+ * Styles for the kermit queue page.
1935+ */
1936+
1937+.queue-toolbar {
1938+ min-height: 10px;
1939+}
1940+
1941+.queue-toolbar > .btn {
1942+ margin-top: -30px;
1943+ margin-bottom: 5px;
1944+ margin-left: 2px;
1945+}
1946+
1947+.filter-bar .navbar-form {
1948+ padding: 0;
1949+ border: none;
1950+}
1951+
1952+.filter-bar .watermark {
1953+ opacity: 0.1;
1954+ position: absolute;
1955+ right: 0;
1956+ margin: 0.1em;
1957+}
1958+
1959+.active-filter {
1960+ position: relative;
1961+ display: inline-block;
1962+ margin-right: 2px;
1963+ margin-top: 2px;
1964+ border-radius: 0.25em;
1965+ color: #ffffff;
1966+ padding: 0.2em 0.2em 0.2em 0.6em;
1967+ font-size: 90%;
1968+ float: left;
1969+}
1970+
1971+.active-filter button.close {
1972+ font-size: 140%;
1973+ padding-left: 2px;
1974+}
1975+
1976+.active-filter.ng-enter,
1977+.active-filter.ng-leave
1978+{
1979+ -webkit-transition: 500ms all;
1980+ -moz-transition: 500ms all;
1981+ -ms-transition: 500ms all;
1982+ -o-transition: 500ms all;
1983+ transition: 500ms all;
1984+
1985+ position: relative;
1986+ display: inline-block !important;
1987+}
1988+.active-filter.ng-enter.ng-enter-active,
1989+.active-filter.ng-leave {
1990+ left: 0;
1991+ opacity: 1;
1992+}
1993+.active-filter.ng-leave.ng-leave-active,
1994+.active-filter.ng-enter {
1995+ left: -300px;
1996+ opacity: 0;
1997+}
1998+
1999+@media (max-width: 480px) {
2000+ div.commit-entry.panel {
2001+ margin: 0 -5px;
2002+ padding: 0;
2003+ border-left: 0;
2004+ border-right: 0;
2005+ border-radius: 0;
2006+ -webkit-border-radius: 0;
2007+ }
2008+ .commit-entry .list-group { margin: 0 -15px; }
2009+ .commit-entry .panel-heading {
2010+ border-radius: 0;
2011+ -webkit-border-radius: 0;
2012+ padding: 10px 5px;
2013+ }
2014+ .commit-entry .panel-body {
2015+ padding: 15px 5px;
2016+ }
2017+}
2018+
2019+/* Main queue width control */
2020+@media (min-width: 768px) {
2021+ .queue-display {
2022+ max-width: 750px;
2023+ padding-right: 250px;
2024+ }
2025+}
2026+@media (min-width: 992px) {
2027+ .queue-display {
2028+ max-width: 750px;
2029+ padding-right: 138px; /* 280 - (992-750)/2 */
2030+ }
2031+}
2032+@media (min-width: 1200px) {
2033+ .queue-display {
2034+ max-width: 750px;
2035+ padding-right: 55px; /* 280 - (1200-750)/2 */
2036+ }
2037+}
2038+
2039+/* Animations for the filter bar */
2040+.filter-bar.ng-hide-remove,
2041+.filter-bar.ng-hide-add {
2042+ -webkit-transition: all 0.5s;
2043+ -moz-transition: all 0.5s;
2044+ -ms-transition: all 0.5s;
2045+ -o-transition: all 0.5s;
2046+ transition: all 0.5s;
2047+
2048+ position: relative;
2049+ display: block !important;
2050+}
2051+.filter-bar.ng-hide-remove,
2052+.filter-bar.ng-hide-add.ng-hide-add-active {
2053+ opacity: 0;
2054+ height: 0px;
2055+ min-height: 0px;
2056+ margin-bottom: -2px;
2057+}
2058+.filter-bar.ng-hide-remove.ng-hide-remove-active,
2059+.filter-bar.ng-hide-add {
2060+ opacity: 1;
2061+ height: 87px;
2062+ min-height: 87px;
2063+ margin-bottom: 20px;
2064+}
2065+.filter-bar.ng-hide-add > div,
2066+.filter-bar.ng-hide-remove > div {
2067+ display: none !important;
2068+}
2069+
2070+/* Animations for the list of commits */
2071+.commit-entry.ng-hide-add,
2072+.commit-entry.ng-hide-remove,
2073+.commit-entry.ng-enter,
2074+.commit-entry.ng-leave
2075+{
2076+ -webkit-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
2077+ -moz-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
2078+ -ms-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
2079+ -o-transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
2080+ transition: 400ms cubic-bezier(0.250, 0.250, 0.750, 0.750) all;
2081+
2082+ position: relative;
2083+ display: block !important;
2084+}
2085+.commit-entry.ng-hide-remove.ng-hide-remove-active,
2086+.commit-entry.ng-hide-add,
2087+.commit-entry.ng-enter.ng-enter-active,
2088+.commit-entry.ng-leave {
2089+ opacity: 1;
2090+ left: 0;
2091+ height: 38px;
2092+}
2093+.commit-entry.ng-hide-add.ng-hide-add-active,
2094+.commit-entry.ng-hide-remove,
2095+.commit-entry.ng-leave.ng-leave-active,
2096+.commit-entry.ng-enter {
2097+ opacity: 0;
2098+ left: -800px;
2099+ height: 0px;
2100+}
2101+
2102
2103=== added file 'resources/kermit/queue.html'
2104--- resources/kermit/queue.html 1970-01-01 00:00:00 +0000
2105+++ resources/kermit/queue.html 2014-09-01 20:21:07 +0000
2106@@ -0,0 +1,305 @@
2107+<!DOCTYPE html>
2108+<html ng-app="endroid">
2109+ <head>
2110+ <title>Kermit Queue</title>
2111+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
2112+ <script src='http://ajax.googleapis.com/ajax/libs/angularjs/1.2.8/angular.js'></script>
2113+ <script src='http://ajax.googleapis.com/ajax/libs/angularjs/1.2.8/angular-animate.min.js'></script>
2114+ <script src='http://angular-ui.github.io/bootstrap/ui-bootstrap-tpls-0.10.0.js'></script>
2115+ <script src='/_media/kermit/alert.js'></script>
2116+ <script src='/_media/kermit/services.js'></script>
2117+ <script src='/_media/kermit/queue.js'></script>
2118+
2119+ <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
2120+ <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css">
2121+ <link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
2122+
2123+ <link rel="stylesheet" href="/_media/kermit/kermit.css">
2124+ <link rel="stylesheet" href="/_media/kermit/alert.css">
2125+ <link rel="stylesheet" href="/_media/kermit/queue.css">
2126+
2127+ <link rel="shortcut icon" href='/_media/kermit/kermit.png' type='image/png'>
2128+
2129+ <link rel="apple-touch-icon" href="/_media/kermit/kermit-icon-60.png">
2130+ <link rel="apple-touch-icon" sizes="76x76" href="/_media/kermit/kermit-icon-76.png">
2131+ <link rel="apple-touch-icon" sizes="120x120" href="/_media/kermit/kermit-icon-120.png">
2132+ <link rel="apple-touch-icon" sizes="152x152" href="/_media/kermit/kermit-icon-152.png">
2133+ <link rel="apple-touch-startup-image" href="/_media/kermit/kermit_splash.png">
2134+ <meta name="apple-mobile-web-app-capable" content="yes">
2135+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
2136+ </head>
2137+
2138+ <body>
2139+ <script>
2140+ username = "{{ username | escape }}";
2141+ </script>
2142+
2143+ <nav class="navbar navbar-static-top navbar-inverse" role="navigation">
2144+ <div class="container">
2145+ <div class="navbar-header">
2146+ <button type="button" class="navbar-toggle" ng-init='navCollapse = true' ng-click='navCollapse = !navCollapse'>
2147+ <span class="sr-only">Toggle navigation</span>
2148+ <span class="icon-bar"></span>
2149+ <span class="icon-bar"></span>
2150+ <span class="icon-bar"></span>
2151+ </button>
2152+ <a class="navbar-brand" target="_self" href="/">EnDroid</a>
2153+ </div>
2154+ <div class="collapse navbar-collapse" collapse='navCollapse'>
2155+ <ul class="nav navbar-nav">
2156+ <li class="active"><a target="_self" href="/kermit/">Kermit Queue</a></li>
2157+ <!--<li class="dropdown">
2158+ <a class="dropdown-toggle">Actions <b class="caret"></b></a>
2159+ <ul class="dropdown-menu">
2160+ <li><a>The first action</a></li>
2161+ <li><a>A second action</a></li>
2162+ <li class="divider"></li>
2163+ <li><a>A final action</a></li>
2164+ </ul>
2165+ </li>-->
2166+ </ul>
2167+ <!--<form class="navbar-form navbar-right" role="search">
2168+ <div class="input-group">
2169+ <input type="text" class="form-control" placeholder="Search">
2170+ <span class="input-group-btn">
2171+ <button class="btn btn-default" type="submit">
2172+ <span class="glyphicon glyphicon-search"></span>
2173+ </button>
2174+ </span>
2175+ </div>
2176+ </form>-->
2177+ <p class="navbar-text navbar-right">Signed in as <a target="_self" href="profile" class="navbar-link">{{ username }}</a></p>
2178+ </div>
2179+ </div>
2180+ </nav>
2181+
2182+ <img class="kermit-logo" src='/_media/kermit/kermit-logo.jpg'>
2183+
2184+ <div class="container alerts">
2185+ <alert ng-repeat="alert in alerts" type="alert.type" class="pull-right kermit-alert" close="alert.close()">{{ '{{' }} alert.msg }}</alert>
2186+ </div>
2187+
2188+ <div class="container">
2189+ <div ng-controller="QueueDisplay" class='queue-display' >
2190+ <script type='text/ng-template' id='submitModal.html'>
2191+<div class='modal-header'>
2192+ <h3>Submit new Commit to the Queue</h3>
2193+</div>
2194+<div class='modal-body'>
2195+ <div>
2196+ <tabset>
2197+ <tab heading="Basic Info">
2198+ <p></p>
2199+ <div class="form-horizontal">
2200+ <input type='hidden' id='username' name='username' ng-model='info.username' value='{{username}}' ng-init="info.username = '{{username|escape}}'">
2201+ <div class="form-group">
2202+ <label class="col-md-3 control-label" for="commitdiff">Patch File</label>
2203+ <div class="col-md-7">
2204+ <input id="commitdiff" name="commitdiff" placeholder="path to patch file" class="form-control input-md" type="text" ng-model="info.commitdiff">
2205+ </div>
2206+ </div>
2207+ <div class="form-group">
2208+ <label class="col-md-3 control-label" for="target">Target</label>
2209+ <div class="col-md-7">
2210+ <input id="target" name="target" typeahead="target for target in targets | filter:$viewValue | limitTo:8" placeholder="name of target" class="form-control input-md" type="text" ng-model="info.target" autocomplete="off">
2211+ </div>
2212+ </div>
2213+ <div class="form-group">
2214+ <label class="col-md-3 control-label" for="buglist">Bug IDs</label>
2215+ <div class="col-md-7">
2216+ <input id="buglist" name="buglist" placeholder="list of bugs to commit against" class="form-control input-md" type="text" ng-model="info.buglist">
2217+ </div>
2218+ </div>
2219+ </div>
2220+ </tab>
2221+ <tab heading="Commit Info">
2222+ <p></p>
2223+ <div class="form-horizontal">
2224+ <div class="form-group">
2225+ <label class="col-md-3 control-label" for="commitmsg">Commit Message</label>
2226+ <div class="col-md-7">
2227+ <textarea class="form-control" ng-model="info.commitmsg" id="commitmsg" name="commitmsg" rows='5'></textarea>
2228+ </div>
2229+ </div>
2230+ </div>
2231+ </tab>
2232+ {% raw %}
2233+ <tab heading="Advanced" disabled="stages.length == 0">
2234+ <div class='container-fluid'>
2235+ <div class='col-md-6'>
2236+ <h4>
2237+ Control Stages:
2238+ <i style='margin-left: 1em; cursor: help' class='pull-right glyphicon glyphicon-info-sign' tooltip='Note that dependencies between stages are obeyed, so multiple items may enable/disable at once' tooltip-popup-delay='500' tooltip-placement='left'></i>
2239+ </h4>
2240+ <div class="btn-group-vertical" style='width: 100%'>
2241+ <button ng-repeat="stage in stages"
2242+ class="btn btn-default col-md-5"
2243+ type="button"
2244+ kermit-name='{{ stage.name }}'
2245+ ng-class="{'btn-danger': info.stages.indexOf('{{ stage.name }}') == -1}"
2246+ ng-click="updateStages($event.target)">
2247+ {{ stage.name }}
2248+ </button>
2249+ </div>
2250+ </div>
2251+ <div class='col-md-6'>
2252+ <h4>
2253+ Control Workspaces:
2254+ <i style='margin-left: 1em; cursor: help' class='pull-right glyphicon glyphicon-info-sign' tooltip='Control whether shared workspaces are used, and whether to reuse them in their current state or set them up from scratch' tooltip-popup-delay='500' tooltip-placement='left'></i>
2255+ </h4>
2256+ <div class="btn-group-vertical" style='width: 100%'>
2257+ <button ng-repeat="workspace in workspaces"
2258+ class="btn btn-default col-md-5 btn-success"
2259+ type="button"
2260+ kermit-name='{{ workspace }}'
2261+ tooltip="{{ workspaceTooltip(workspace) }}"
2262+ ng-class="{'btn-danger': info.ws_disable.indexOf('{{ workspace }}') != -1, 'btn-warning': info.ws_reset.indexOf('{{ workspace }}') != -1}"
2263+ ng-click="updateWorkspaces($event.target)">
2264+ {{ workspace }}
2265+ </button>
2266+ </div>
2267+ </div>
2268+ </tab>
2269+ {% endraw %}
2270+ </tabset>
2271+ </div>
2272+</div>
2273+<div class='modal-footer'>
2274+ <button class='btn btn-primary' ng-click='ok()'>Submit</button>
2275+ <button class='btn btn-danger' ng-click='cancel()'>Cancel</button>
2276+</div>
2277+ </script>
2278+
2279+ <h1>Kermit Queue</h1>
2280+
2281+ <div class='queue-toolbar'>
2282+ <button class='btn btn-default pull-right' ng-click='open()'>
2283+ <span class='glyphicon glyphicon-plus'></span>
2284+ </button>
2285+ <button class='btn btn-default pull-right' ng-class='{"btn-info": active_filters.length > 2 || active_filters[0].type != "username" || active_filters[0].value != username, "active": filter_shown}' ng-click='filter_shown = !filter_shown; filter_class = filter_shown ? "active" : ""'>
2286+ <span class='glyphicon glyphicon-filter'></span>
2287+ </button>
2288+ <div class='btn pull-right' ng-show='update_pending'>
2289+ <span class='fa fa-spinner fa-spin'></span>
2290+ </div>
2291+ </div>
2292+
2293+ {% raw %}
2294+ <script type="text/ng-template" id="filter_template.html">
2295+ <a tooltip='{{ match.model.type | capitalise }}' tooltip-placement='left'>
2296+ <i class='fa fa-fw' ng-class='{"fa-user": match.model.type == "username", "fa-tasks": match.model.type == "state", "fa-dot-circle-o": match.model.type == "target", "fa-bug": match.model.type == "buglist", "fa-thumb-tack": match.model.type == "key"}'></i>
2297+ {{ match.label }}
2298+ </a>
2299+ </script>
2300+
2301+ <div class='navbar navbar-default filter-bar' ng-show='filter_shown'>
2302+ <div class='container-fluid'>
2303+ <i class='glyphicon glyphicon-filter fa-5x fa-fw watermark'></i>
2304+ <form class='navbar-form'>
2305+ <p>
2306+ <span ng-repeat='filter in active_filters | filter:{type: "!lastmod"}' class='active-filter label-default' ng-class='"label-" + filter_type_colours[filter.type]'>
2307+ <button type="button" class="close" ng-show="filter.type != 'lastmod'" aria-hidden="true" ng-click='remove_filter($index)'>&times;</button>
2308+ <i class='fa' ng-class='filter_type_classes[filter.type]'></i>
2309+ <span ng-show='filter.type != "lastmod"'>{{ filter.value }}</span>
2310+ </span><span class='active-filter label-default' ng-init='datefilter = active_filters[active_filters.length - 1]'>
2311+ <i class='fa fa-clock-o'></i>
2312+ <span datepicker-popup='mediumDate' close-text='Close' show-weeks='false' show-button-bar='false' starting-day='1' is-open='lastmod_open' ng-click='lastmod_open = !lastmod_open' datepicker-append-to-body='true' ng-model='datefilter.value' tooltip-append-to-body='true' tooltip='Show only jobs updated since...' style='padding-right: 0.3em; cursor: pointer'>{{ datefilter.value | date : "mediumDate" }}</span>
2313+ </span>
2314+ </p>
2315+ <p style='clear: both'></p>
2316+ <!-- Add new filter bar -->
2317+ <div class='form-group'>
2318+ <div class='input-group'>
2319+ <div class='input-group-btn'>
2320+ <button type='button' class='btn btn-default dropdown-toggle'>
2321+ <i class='fa fa-fw' ng-class='filter_type_classes[filtertype] + " " + "text-" + filter_type_colours[filtertype]'></i>
2322+ </button>
2323+ <ul class='dropdown-menu'>
2324+ <li ng-repeat='type in filter_types'>
2325+ <a ng-click='set_filter_type(type)' style='padding-left: 10px; cursor: pointer'><i class='fa fa-fw' ng-class='filter_type_classes[type] + " " + "text-" + filter_type_colours[type]'></i> {{ type | capitalise }}</a>
2326+ </li>
2327+ </ul>
2328+ </div>
2329+ <input type='text' ng-model='filter_value' placeholder='Add another filter' typeahead="opt as opt.value for opt in get_filter_options($viewValue) | limitTo:8" class='form-control' typeahead-template-url="filter_template.html" typeahead-wait-ms='200' typeahead-on-select="add_filter($item)" typeahead-editable="false">
2330+ </div>
2331+ </div>
2332+ </form>
2333+ </div>
2334+ </div>
2335+ {% endraw %}
2336+
2337+ <!-- Job entries -->
2338+
2339+ {% raw %}
2340+ <alert ng-show="jobs_shown == 0" type="'info'">
2341+ <i class='fa fa-bullhorn pull-left fa-2x'></i>
2342+ No jobs match the current filters.
2343+ </alert>
2344+ <accordion close-others="false">
2345+ <accordion-group ng-repeat="job in jobs track by job.key" class="commit-entry" ng-show="matches_filter(job)" is-open='isopen'>
2346+ <accordion-heading>
2347+ <div>
2348+ <i class="pull-right glyphicon" ng-class="{'glyphicon-chevron-down': isopen, 'glyphicon-chevron-right': !isopen}"></i>
2349+ <span class='pull-right' style='font-variant: small-caps; margin-right: 1em'>{{ job.status_string }}</span>
2350+ <span>Commit {{ job.status.inputargs.buglist }} to {{ job.status.inputargs.target }} as {{ job.status.inputargs.username }} ({{ job.key }})</span>
2351+ </div>
2352+ </accordion-heading>
2353+ <p ng-click='debug = !debug' style='margin-left: -16px; position: absolute; width: 16px; height: 16px'></p>
2354+ <button ng-show='job.status_string == "Running" || job.status_string == "Queued"' class='btn btn-danger pull-right' tooltip='Abort Operation' ng-click='abort($event, job.key)'>
2355+ <i class='glyphicon glyphicon-trash'></i>
2356+ </button>
2357+ <button ng-show='job.status_string == "Failed" || job.status_string == "Aborted"' class='btn btn-danger pull-right' tooltip='Resubmit Request' ng-click='resubmit($event, job.key)'>
2358+ <i class='fa fa-rotate-right' ng-class="{'fa-spin': resubmitting == job.key}"></i>
2359+ </button>
2360+ <p>Committing diff {{ job.status.inputargs.commitdiff }}
2361+ <span ng-show='job.status.currstatus.begin_at || job.status.currstatus.started_at'>(started at {{ (job.status.currstatus.begin_at || job.status.currstatus.started_at) * 1000 | date : 'HH:mm, MMM d' }})</span>
2362+ <span ng-show='job.status.currstatus.failed_at.keys().length > 0 && !job.status.currstatus.started_at'>(failed preliminary checks at {{ job.status.currstatus.failed_at * 1000 | date : 'HH:mm, MMM d' }})</span>
2363+ </p>
2364+ <p ng-show='job.status.currstatus.message'>
2365+ {{ job.status.currstatus.message }}
2366+ </p>
2367+ <alert type="'warning'" collapse="!debug">
2368+ <pre style='white-space: pre-wrap'>{{ job.status }}</pre>
2369+ </alert>
2370+ <alert type="'danger'" ng-show="job.status.currstatus.check_failed">
2371+ <i class='fa fa-warning pull-left fa-2x'></i>
2372+ {{ job.status.currstatus.check_failed }}
2373+ </alert>
2374+ <alert type="'danger'" ng-repeat="fail in job.status.currstatus.failed">
2375+ <i class='fa fa-warning pull-left fa-2x'></i>
2376+ <a target="_tab" href='logfile?key={{job.key}}&amp;stage={{fail}}' class='fa fa-file-text-o fa-2x pull-right' tooltip='View Log file (new window/tab)'></a>
2377+ {{ job.config.commands[fail].description || fail }} failed. {{ job.config.commands[fail].recovery }}
2378+ </alert>
2379+ <h4 ng-show='job.status.currstatus.stages'>Stages:</h4>
2380+ <ul class="list-group" ng-show='job.status.currstatus.stages'>
2381+ <li class='list-group-item' ng-class="{'list-group-item-danger': !job.status.currstatus.started_at && job.status.currstatus.failed.length > 0, 'list-group-item-success': job.status.currstatus.started_at, 'list-group-item-info': job.status.currstatus.running.length > 0 && !job.status.currstatus.started_at}">
2382+ <span>Running preliminary checks</span>
2383+ <div ng-show="job.status.currstatus.running.length > 0 && !job.status.currstatus.started_at && job.status.currstatus.failed.length == 0" class="progress progress-striped active pull-right" style="width: 30%">
2384+ <div class="progress-bar progress-bar-info" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%" tooltip="{{ job.config.commands[job.status.currstatus.running[0]].description || job.status.currstatus.running[0] }}">
2385+ </div>
2386+ </div>
2387+ <span ng-show="false" class="badge"></span>
2388+ <span ng-show="job.status.currstatus.started_at" class="badge"><span style="cursor: pointer" class="glyphicon glyphicon-ok" tooltip="{{ job.status.currstatus.started_at * 1000 | date : 'HH:mm, MMM d' }}" tooltip-append-to-body="true"></span></span>
2389+ <span ng-show="!job.status.currstatus.started_at && job.status.currstatus.failed.length > 0" class="badge"><span style="cursor: pointer" class="glyphicon glyphicon-fire" tooltip="{{ job.status.currstatus.failed_at[job.status.currstatus.failed[0]] * 1000 | date : 'HH:mm, MMM d' }}" tooltip-append-to-body="true"></span></span>
2390+ </li>
2391+ <li ng-repeat='stage in job.status.currstatus.stages' class="list-group-item" ng-class="{'list-group-item-danger': job.status.currstatus.failed.indexOf(stage) != -1, 'list-group-item-success': job.status.currstatus.done.indexOf(stage) != -1, 'list-group-item-warning': job.status.currstatus.skipping.indexOf(stage) != -1, 'list-group-item-info': job.status.currstatus.running.indexOf(stage) != -1}">
2392+ <span tooltip-placement="right" tooltip-popup-delay="200" tooltip="{{ job.config.commands[stage].command }}" style="cursor: help">{{ job.config.commands[stage].description || stage }}</span>
2393+ <div ng-show="job.status.currstatus.running.indexOf(stage) != -1" class="progress progress-striped active pull-right" style="width: 30%">
2394+ <div class="progress-bar progress-bar-info" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%">
2395+ </div>
2396+ </div>
2397+ <span ng-show="false" class="badge"></span>
2398+ <span ng-show="job.status.currstatus.done.indexOf(stage) != -1" class="badge"><span style="cursor: pointer" class="glyphicon glyphicon-ok" tooltip="{{ job.status.currstatus.done_at[stage] * 1000 | date : 'HH:mm, MMM d' }}" tooltip-append-to-body="true"></span></span>
2399+ <span ng-show="job.status.currstatus.failed.indexOf(stage) != -1" class="badge"><span style="cursor: pointer" class="glyphicon glyphicon-fire" tooltip="{{ job.status.currstatus.failed_at[stage] * 1000 | date : 'HH:mm, MMM d' }}" tooltip-append-to-body="true"></span></span>
2400+ <span ng-show="job.status.currstatus.skipping.indexOf(stage) != -1" class="badge"><span style="cursor: pointer" class="glyphicon glyphicon-flash" tooltip="Skipped as requested by submitter" tooltip-append-to-body="true"></span></span>
2401+ <span ng-show="job.config.commands[stage].workspace && (!job.status.inputargs.ws_disable || job.status.inputargs.ws_disable.indexOf(job.config.commands[stage].workspace) == -1)" ng-class="{'label-warning': job.status.inputargs.ws_reset && job.status.inputargs.ws_reset.indexOf(job.config.commands[stage].workspace) != -1}" class='label label-primary pull-right' style='margin-right: 5px'><span tooltip="{{ ((job.status.inputargs.ws_reset && job.status.inputargs.ws_reset.indexOf(job.config.commands[stage].workspace) != -1) ? 'Command run in a clean workspace as ' : 'Command run in a separate workspace as ') + job.config.workspaces[job.config.commands[stage].workspace].username }}" tooltip-append-to-body="true" style="cursor: pointer">{{ job.config.commands[stage].workspace }}</span></span>
2402+ </li>
2403+ </ul>
2404+ </accordion-group>
2405+ </accordion>
2406+
2407+ {% endraw %}
2408+ </div>
2409+ </div>
2410+ </body>
2411+</html>
2412
2413=== added file 'resources/kermit/queue.js'
2414--- resources/kermit/queue.js 1970-01-01 00:00:00 +0000
2415+++ resources/kermit/queue.js 2014-09-01 20:21:07 +0000
2416@@ -0,0 +1,497 @@
2417+/*
2418+ * Main controllers, etc. for the kermit queue page.
2419+ */
2420+var module = angular.module("endroid", ["ui.bootstrap", "ngAnimate",
2421+ "endroid.kermit.alert", "asyncData"]);
2422+
2423+module.config(['$locationProvider', function($locationProvider) {
2424+ $locationProvider.html5Mode(true).hashPrefix('!');
2425+}]);
2426+
2427+module.filter('capitalise', function() {
2428+ return function(input, scope) {
2429+ if (input != null)
2430+ input = input.toLowerCase();
2431+ return input.substring(0, 1).toUpperCase() + input.substring(1);
2432+ }
2433+ });
2434+
2435+function QueueDisplay($scope, $modal, $timeout, alertService, $http, asyncData,
2436+ $filter, $q, $location) {
2437+ $scope.debug = false;
2438+ $scope.names = [];
2439+ $scope.buglists = [];
2440+ $scope.targets = [];
2441+ $scope.states = ["Queued", "Running", "Aborted", "Failed", "Completed"];
2442+ $scope.filter_shown = false;
2443+ $scope.username = username;
2444+
2445+ $scope.known_jobs = {};
2446+ $scope.jobs = [];
2447+ $scope.jobs_shown = 0;
2448+ $scope.last_update = 0;
2449+ $scope.update_pending = false;
2450+
2451+ $scope.load_filter_lists = function() {
2452+ asyncData.getData("list?arg=user", function(data) {
2453+ return data.user;
2454+ }).then(function(names) {
2455+ $scope.names = names;
2456+ }, function() {
2457+ alertService.add('danger', "Failed to fetch known names.",
2458+ 5000);
2459+ });
2460+ asyncData.getData("list?arg=target").then(function(data) {
2461+ $scope.targets = data.target;
2462+ }, function() {
2463+ alertService.add('danger', "Failed to fetch known targets.", 5000);
2464+ });
2465+ asyncData.getData("list?arg=buglist").then(function(data) {
2466+ $scope.buglists = data.buglist;
2467+ }, function() {
2468+ alertService.add('danger', "Failed to fetch known bug lists.", 5000);
2469+ });
2470+ };
2471+
2472+ var filter_query = function(obj) {
2473+ var str = [];
2474+ angular.forEach(obj, function(v, k) {
2475+ str.push(encodeURIComponent(v.type) + "=" + encodeURIComponent(v.value));
2476+ });
2477+ console.log(str.join('&'));
2478+ return str.join('&');
2479+ };
2480+
2481+ var poll = function() {
2482+ if ($scope.poll_canceler) {
2483+ $scope.poll_canceler.resolve();
2484+ }
2485+ $scope.poll_canceler = $q.defer();
2486+ asyncData({
2487+ method: 'POST',
2488+ url: 'query',
2489+ transformRequest: function(obj) {
2490+ var str = filter_query(obj);
2491+ return 'wait=' + $scope.last_update + '&' + str;
2492+ },
2493+ data: $scope.active_filters,
2494+ timeout: $scope.poll_canceler.promise,
2495+ headers: {'Content-Type': 'application/x-www-form-urlencoded'}
2496+ }).then(function(data) {
2497+ $scope.jobs = data.jobs;
2498+ $scope.jobs.sort(function(a, b) {
2499+ if (a.key < b.key) return 1;
2500+ if (a.key > b.key) return -1;
2501+ return 0;
2502+ });
2503+ $scope.jobs_shown = 0;
2504+ $scope.last_update = data.last_update;
2505+ $scope.update_pending = false;
2506+ poll();
2507+ }, function(data) {
2508+ // Wait a bit if the connection failed. Should prob back off.
2509+ if (data.status != 0) {
2510+ $timeout(poll, 5000);
2511+ }
2512+ });
2513+ };
2514+
2515+ $scope.load_filter_lists();
2516+
2517+ var args = $location.search();
2518+
2519+ $scope.active_filters = [];
2520+
2521+ angular.forEach(args, function(v, k) {
2522+ if (k == "username" || k == "lastmod" || k == "target" || k == "buglist" ||
2523+ k == "state") {
2524+ if (v instanceof Array) {
2525+ angular.forEach(v, function(e) {
2526+ if (k == "lastmod") e = new Date(e);
2527+ $scope.active_filters.push({type: k, value: e});
2528+ });
2529+ } else {
2530+ if (k == "lastmod") v = new Date(v);
2531+ $scope.active_filters.push({type: k, value: v});
2532+ }
2533+ }
2534+ });
2535+
2536+ var sort_filters = function(a, b) {
2537+ if (a.type != b.type) {
2538+ if (a.type == "key") return -1;
2539+ if (b.type == "key") return 1;
2540+ if (a.type == "username") return -1;
2541+ if (b.type == "username") return 1;
2542+ if (a.type == "target") return -1;
2543+ if (b.type == "target") return 1;
2544+ if (a.type == "buglist") return -1;
2545+ if (b.type == "buglist") return 1;
2546+ if (a.type == "state") return -1;
2547+ if (b.type == "state") return 1;
2548+ throw "unexpected objects to compare " + a + ", " + b;
2549+ } else {
2550+ if (a.value < b.value) return -1;
2551+ if (a.value > b.value) return 1;
2552+ return 0;
2553+ }
2554+ };
2555+
2556+ $scope.active_filters.sort(sort_filters);
2557+
2558+ if ($scope.active_filters.length == 0) {
2559+ $scope.active_filters.push({type: "username", value: username});
2560+ }
2561+ if ($scope.active_filters[$scope.active_filters.length - 1].type != "lastmod") {
2562+ $scope.active_filters.push({type: "lastmod", value: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 14)});
2563+ }
2564+
2565+ $scope.filtertype = "any";
2566+ $scope.filter_types = [
2567+ "any", "key", "username", "target", "buglist", "state"
2568+ ];
2569+ $scope.filter_type_classes = {
2570+ any: "fa-asterisk",
2571+ key: "fa-thumb-tack",
2572+ username: "fa-user",
2573+ target: "fa-dot-circle-o",
2574+ buglist: "fa-bug",
2575+ state: "fa-tasks",
2576+ lastmod: "fa-clock-o"
2577+ };
2578+ $scope.filter_type_colours = {
2579+ key: "success",
2580+ username: "primary",
2581+ target: "danger",
2582+ buglist: "warning",
2583+ state: "info"
2584+ };
2585+
2586+ $scope.set_filter_type = function(type) {
2587+ console.log("Setting filter to " + type);
2588+ $scope.filtertype = type;
2589+ };
2590+
2591+ $scope.add_filter = function($item) {
2592+ $scope.active_filters.push($item);
2593+ $scope.active_filters.sort(sort_filters);
2594+ $scope.filter_value = null;
2595+ $scope.last_update = 0;
2596+ $scope.update_pending = true;
2597+ $location.replace();
2598+ $location.url($location.path() + '?' + filter_query($scope.active_filters));
2599+ poll();
2600+ };
2601+
2602+ $scope.remove_filter = function(idx) {
2603+ // Can't remove the date filter
2604+ if ($scope.active_filters[idx].type == "lastmod") return;
2605+ $scope.active_filters.splice(idx, 1);
2606+ $scope.last_update = 0;
2607+ $scope.update_pending = true;
2608+ $location.replace();
2609+ $location.url($location.path() + '?' + filter_query($scope.active_filters));
2610+ poll();
2611+ };
2612+
2613+ var typematch = function(type) {
2614+ return type == $scope.filtertype || $scope.filtertype == "any";
2615+ };
2616+
2617+ $scope.get_filter_options = function(val) {
2618+ var res = [];
2619+ var sofar = 0;
2620+ if (!isNaN(val) && typematch("key")) {
2621+ res.push({type: "key", value: "kermit-" + val})
2622+ }
2623+ names = $filter('filter')($scope.names, val);
2624+ for (var i = 0; i < names.length && typematch("username"); i++) {
2625+ res.push({type: "username", value: names[i]})
2626+ }
2627+ targets = $filter('filter')($scope.targets, val);
2628+ for (var i = 0; i < targets.length && typematch("target"); i++) {
2629+ res.push({type: "target", value: targets[i]})
2630+ }
2631+ states = $filter('filter')($scope.states, val);
2632+ for (var i = 0; i < states.length && typematch("state"); i++) {
2633+ res.push({type: "state", value: states[i]})
2634+ }
2635+ bugs = $filter('filter')($scope.buglists, val);
2636+ for (var i = 0; i < bugs.length && typematch("buglist"); i++) {
2637+ res.push({type: "buglist", value: bugs[i]})
2638+ }
2639+ if (typematch("username")) res.push({type: "username", value: val});
2640+ if (typematch("target")) res.push({type: "target", value: val});
2641+ if (typematch("buglist")) res.push({type: "buglist", value: val});
2642+ return res;
2643+ };
2644+
2645+ var check_filter = function(job) {
2646+ var username = job.status.inputargs.username;
2647+ var tgt = job.status.inputargs.target;
2648+ var buglist = job.status.inputargs.buglist;
2649+ var state = job.status_string;
2650+ var lastmod = job.lastmod;
2651+
2652+ var matches = true;
2653+
2654+ var keyfilters = $filter('filter')($scope.active_filters,
2655+ {type: "key"});
2656+ if (keyfilters.length > 0) {
2657+ matches = false;
2658+ angular.forEach(keyfilters, function(v) {
2659+ matches = matches || job.key == v.value;
2660+ });
2661+ }
2662+ if (!matches) return matches;
2663+
2664+ var userfilters = $filter('filter')($scope.active_filters,
2665+ {type: "username"});
2666+ if (userfilters.length > 0) {
2667+ matches = false;
2668+ angular.forEach(userfilters, function(v) {
2669+ matches = matches || username.toLowerCase().indexOf(v.value.toLowerCase()) != -1;
2670+ });
2671+ }
2672+ if (!matches) return matches;
2673+
2674+ var tgtfilters = $filter('filter')($scope.active_filters,
2675+ {type: "target"});
2676+ if (tgtfilters.length > 0) {
2677+ matches = false;
2678+ angular.forEach(tgtfilters, function(v) {
2679+ matches = matches || tgt.toLowerCase().indexOf(v.value.toLowerCase()) != -1;
2680+ });
2681+ }
2682+ if (!matches) return matches;
2683+
2684+ var bugfilters = $filter('filter')($scope.active_filters,
2685+ {type: "buglist"});
2686+ if (bugfilters.length > 0) {
2687+ matches = false;
2688+ angular.forEach(bugfilters, function(v) {
2689+ matches = matches || buglist.toLowerCase().indexOf(v.value.toLowerCase()) != -1;
2690+ });
2691+ }
2692+ if (!matches) return matches;
2693+
2694+ var statefilters = $filter('filter')($scope.active_filters,
2695+ {type: "state"});
2696+ if (statefilters.length > 0) {
2697+ matches = false;
2698+ angular.forEach(statefilters, function(v) {
2699+ matches = matches || state.toLowerCase().indexOf(v.value.toLowerCase()) != -1;
2700+ });
2701+ }
2702+ if (!matches) return matches;
2703+
2704+ return (new Date(lastmod) > new Date($scope.active_filters[$scope.active_filters.length - 1].value));
2705+ };
2706+
2707+ $scope.matches_filter = function(job) {
2708+ var res = check_filter(job);
2709+ if (job.shown !== undefined) {
2710+ if (job.shown != res) {
2711+ $scope.jobs_shown += res ? 1 : -1;
2712+ }
2713+ } else if (res) {
2714+ $scope.jobs_shown += 1;
2715+ }
2716+ job.shown = res;
2717+ return res;
2718+ };
2719+
2720+ $scope.open = function() {
2721+ var modalInstance = $modal.open({
2722+ templateUrl: 'submitModal.html',
2723+ controller: ModalInstanceCtrl,
2724+ backdrop: "static",
2725+ });
2726+
2727+ modalInstance.result.then(function(result) {
2728+ alertService.add('success', "Submitted commit to queue as " + result,
2729+ 5000);
2730+ }, function(failure) {
2731+ alertService.add('danger', "Failed to submit commit: " + failure,
2732+ 5000);
2733+ });
2734+ }
2735+
2736+ $scope.abort = function($event, key) {
2737+ $http({
2738+ method: "POST",
2739+ url: "abort",
2740+ data: 'key=' + encodeURIComponent(key),
2741+ headers: {'Content-Type': 'application/x-www-form-urlencoded'}
2742+ }).then(function(data) {
2743+ console.log("Aborting " + key);
2744+ alertService.add('warning',
2745+ "Aborted request " + key,
2746+ 5000);
2747+ $event.target.ngShow = false;
2748+ }, function(failure) {
2749+ alertService.add('danger',
2750+ 'Failed to abort request ' + key + ': ' +
2751+ unescape(failure.data.fail), 5000);
2752+ });
2753+ };
2754+
2755+ $scope.resubmitting = false;
2756+ $scope.resubmit = function($event, key) {
2757+ console.log(angular.element($event.target).children());
2758+ $scope.resubmitting = key;
2759+ $http({
2760+ method: "POST",
2761+ url: "submit",
2762+ data: 'resubmitKey=' + encodeURIComponent(key),
2763+ headers: {'Content-Type': 'application/x-www-form-urlencoded'}
2764+ }).then(function(data) {
2765+ console.log("Resubmitted " + key + ": " + data);
2766+ alertService.add('success',
2767+ "Resubmitted request " + key + " to the queue",
2768+ 5000);
2769+ $event.target.ngShow = false;
2770+ $scope.resubmitting = false;
2771+ }, function(failure) {
2772+ $scope.resubmitting = false;
2773+ alertService.add('danger',
2774+ 'Failed to resubmit request ' + key +
2775+ ' to the queue: ' + failure.data.fail, 5000);
2776+ });
2777+ };
2778+
2779+ poll();
2780+}
2781+
2782+var ModalInstanceCtrl = function($scope, $modalInstance, $http, $timeout,
2783+ asyncData, alertService) {
2784+ $scope.info = {
2785+ username: "",
2786+ target: "",
2787+ commitdiff: "",
2788+ buglist: "",
2789+ stages: [],
2790+ ws_reset: [],
2791+ ws_disable: [],
2792+ };
2793+ $scope.stages = [];
2794+ $scope.workspaces = [];
2795+ $scope.stage_properties = {};
2796+ $scope.targets = [];
2797+ asyncData.getData("list?arg=target", function(data) {
2798+ return data.target;
2799+ }).then(function(targets) {
2800+ $scope.targets = targets;
2801+ });
2802+ $scope.$watch("info.target", function() {
2803+ if ($scope.fetching_targets) {
2804+ $timeout.cancel($scope.fetching_targets);
2805+ }
2806+ $scope.fetching_targets = $timeout(function() {
2807+ var tgt = $scope.info.target;
2808+ asyncData.getData("stages?target=" + encodeURIComponent(tgt))
2809+ .then(function(data) {
2810+ $scope.stages = data.stages;
2811+ $scope.stage_properties = {};
2812+ $scope.info.stages = [];
2813+ angular.forEach(data.stages, function(v) {
2814+ $scope.info.stages.push(v.name);
2815+ $scope.stage_properties[v.name] = v;
2816+ });
2817+ console.log($scope.stage_properties);
2818+ $scope.fetching_targets = false;
2819+ $scope.workspaces = data.workspaces;
2820+ }, function() {
2821+ console.log("It appears " + tgt + " isn't a valid target");
2822+ $scope.stages = [];
2823+ $scope.stage_properties = {};
2824+ $scope.info.stages = [];
2825+ $scope.fetching_targets = false;
2826+ });
2827+ }, 500);
2828+ });
2829+
2830+ function addStage(s) {
2831+ if ($scope.info.stages.indexOf(s) != -1) return;
2832+ console.log("Adding stage " + s);
2833+ if (!$scope.stage_properties[s]) {
2834+ alertService.add("danger", "Invalid stage: " + s);
2835+ return;
2836+ }
2837+ angular.forEach($scope.stage_properties[s].deps, function(d) {
2838+ addStage(d);
2839+ });
2840+ $scope.info.stages.push(s);
2841+ }
2842+ function removeStage(s) {
2843+ var idx = $scope.info.stages.indexOf(s);
2844+ console.log("Removing stage " + s + " from " + idx);
2845+ if (idx == -1) return;
2846+ $scope.info.stages.splice(idx, 1);
2847+ angular.forEach($scope.stage_properties, function(o) {
2848+ console.log(o);
2849+ if (o.deps.indexOf(s) != -1) {
2850+ removeStage(o.name);
2851+ }
2852+ });
2853+ }
2854+
2855+ $scope.updateStages = function(stage) {
2856+ stage = stage.getAttribute('kermit-name');
2857+ console.log("Updating stage: " + stage);
2858+ var idx = $scope.info.stages.indexOf(stage);
2859+ if (idx == -1) {
2860+ addStage(stage);
2861+ } else {
2862+ removeStage(stage);
2863+ }
2864+ };
2865+
2866+ $scope.updateWorkspaces = function(button) {
2867+ workspace = button.getAttribute('kermit-name');
2868+ if ($scope.info.ws_reset.indexOf(workspace) != -1) {
2869+ $scope.info.ws_reset.splice($scope.info.ws_reset.indexOf(workspace), 1);
2870+ $scope.info.ws_disable.push(workspace);
2871+ } else if ($scope.info.ws_disable.indexOf(workspace) != -1) {
2872+ $scope.info.ws_disable.splice($scope.info.ws_disable.indexOf(workspace), 1);
2873+ } else {
2874+ $scope.info.ws_reset.push(workspace);
2875+ }
2876+ };
2877+
2878+ $scope.workspaceTooltip = function(workspace) {
2879+ if ($scope.info.ws_reset.indexOf(workspace) != -1) {
2880+ return "Reset workspace before using it";
2881+ } else if ($scope.info.ws_disable.indexOf(workspace) != -1) {
2882+ return "Don't use this shared workspace";
2883+ } else {
2884+ return "Use this workspace as configured";
2885+ }
2886+ };
2887+
2888+ $scope.ok = function() {
2889+ $http({
2890+ method: "POST",
2891+ url: "submit",
2892+ transformRequest: function(obj) {
2893+ var str = [];
2894+ angular.forEach(obj, function(v, k) {
2895+ str.push(encodeURIComponent(k) + "=" + encodeURIComponent(v));
2896+ });
2897+ console.log(str.join('&'));
2898+ return str.join("&");
2899+ },
2900+ data: $scope.info,
2901+ headers: {'Content-Type': 'application/x-www-form-urlencoded'}
2902+ }).then(function(data) {
2903+ console.log(data);
2904+ $modalInstance.close(data.data.success);
2905+ }, function(data) {
2906+ $modalInstance.dismiss(data.data.fail);
2907+ });
2908+ };
2909+
2910+ $scope.cancel = function() {
2911+ $modalInstance.dismiss('cancelled by you');
2912+ };
2913+};
2914
2915=== added file 'resources/kermit/response.json'
2916--- resources/kermit/response.json 1970-01-01 00:00:00 +0000
2917+++ resources/kermit/response.json 2014-09-01 20:21:07 +0000
2918@@ -0,0 +1,5 @@
2919+{% if success %}
2920+{"success": {{ success }}}
2921+{% else %}
2922+{"fail": "{{ fail|escape }}"}
2923+{% endif %}
2924
2925=== added file 'resources/kermit/services.js'
2926--- resources/kermit/services.js 1970-01-01 00:00:00 +0000
2927+++ resources/kermit/services.js 2014-09-01 20:21:07 +0000
2928@@ -0,0 +1,39 @@
2929+
2930+angular.module("filterFocus", []).directive("filterFocus", function($timeout) {
2931+ return {
2932+ link: function(scope, element, attrs) {
2933+ scope.$watch(attrs.filterFocus, function(value) {
2934+ if (value) {
2935+ $timeout(function() {
2936+ element[0].focus();
2937+ scope.$eval(attrs.filterFocus + " = false");
2938+ }, 500);
2939+ }
2940+ });
2941+ }
2942+ };
2943+});
2944+
2945+angular.module("asyncData", [])
2946+ .service("asyncData", ['$rootScope', '$q', '$http',
2947+ function($rootScope, $q, $http) {
2948+ var asyncData = function(args) {
2949+ var convertData = args['convertData'] || function(data) { return data; };
2950+ var deferred = $q.defer();
2951+
2952+ $http(args).then(function(data) {
2953+ deferred.resolve(convertData(data.data));
2954+ if (!$rootScope.$$phase) $rootScope.$apply();
2955+ }, function(data) {
2956+ deferred.reject(data);
2957+ });
2958+
2959+ return deferred.promise;
2960+ };
2961+
2962+ asyncData.getData = function(url, convertData) {
2963+ return asyncData({method: 'GET', url: url, convertData: convertData});
2964+ };
2965+
2966+ return asyncData;
2967+ }]);
2968
2969=== modified file 'src/endroid/database.py'
2970--- src/endroid/database.py 2014-07-28 15:50:00 +0000
2971+++ src/endroid/database.py 2014-09-01 20:21:07 +0000
2972@@ -3,28 +3,174 @@
2973 # Copyright 2012, Ensoft Ltd.
2974 # Created by Jonathan Millican
2975 # -----------------------------------------
2976+
2977 import sqlite3
2978 import os.path
2979-
2980-
2981-# Export constants for system column names
2982-EndroidUniqueID = '_endroid_unique_id'
2983+import logging
2984+
2985+__all__ = (
2986+ 'Value', 'LIKE',
2987+ 'Field',
2988+ 'Database',
2989+ 'EndroidUniqueID', 'EndroidLastModified',
2990+)
2991
2992
2993 class TableRow(dict):
2994- """A regular dict, plus a system 'id' attribute."""
2995+ """
2996+ A dict with special properties for access the magic fields of the row.
2997+
2998+ These should not be instantiated directly, but are instead returned from
2999+ "fetch" operations on tables.
3000+
3001+ """
3002 __slots__ = ()
3003
3004 def __init__(self, *args, **kwargs):
3005 super(TableRow, self).__init__(*args, **kwargs)
3006- if not EndroidUniqueID in self:
3007- raise ValueError("Cannot create table row from table with no {0} "
3008- "column!".format(EndroidUniqueID))
3009
3010 @property
3011 def id(self):
3012 return self[EndroidUniqueID]
3013
3014+ @property
3015+ def last_modified(self):
3016+ return self[EndroidLastModified]
3017+
3018+
3019+class Value(object):
3020+ """
3021+ Wrapper for a value to be matched in a condition.
3022+
3023+ 'cmp' must be one of the comparison operators supported by SQL.
3024+
3025+ """
3026+ def __init__(self, val, cmp='='):
3027+ self._value = str(val)
3028+ self._cmp = str(cmp)
3029+
3030+ def __str__(self):
3031+ return self._value
3032+
3033+ def __or__(self, other):
3034+ return OR(self, other)
3035+
3036+ def to_condition(self, field):
3037+ return Field.as_str(field) + self._cmp + "?"
3038+
3039+ def values(self):
3040+ return (self._value,)
3041+
3042+ @classmethod
3043+ def get_values(cls, obj):
3044+ if isinstance(obj, cls):
3045+ return obj.values()
3046+ else:
3047+ return (str(obj),)
3048+
3049+ @classmethod
3050+ def as_condition(cls, obj, field):
3051+ if isinstance(obj, cls):
3052+ return obj.to_condition(field)
3053+ else:
3054+ return Value.as_condition(Value(obj), field)
3055+
3056+
3057+class LIKE(Value):
3058+ "Value subclass for handling SQl 'LIKE' comparisons."
3059+ def __init__(self, val):
3060+ super(LIKE, self).__init__("%{}%".format(val), "LIKE")
3061+
3062+
3063+class OR(Value):
3064+ def __init__(self, *vals):
3065+ self._vals = vals
3066+
3067+ def to_condition(self, field):
3068+ return " OR ".join(Value.as_condition(v, field) for v in self._vals)
3069+
3070+ def values(self):
3071+ vals = []
3072+ for v in self._vals:
3073+ vals.extend(Value.get_values(v))
3074+ return tuple(vals)
3075+
3076+
3077+class Field(object):
3078+ """
3079+ Class representing a Field of a Table.
3080+
3081+ These need usually only be instantiated when a table is first being
3082+ created. Note that raw strings can be used as fields in create calls,
3083+ and they simply get the default type.
3084+
3085+ """
3086+ def __init__(self, name, type=None, default=None):
3087+ self._name = Field.as_str(name)
3088+ self._type = type
3089+ self._default = default
3090+
3091+ def __str__(self):
3092+ return self._name
3093+
3094+ @classmethod
3095+ def as_create_str(cls, obj):
3096+ if isinstance(obj, cls):
3097+ return "{} {} {}".format(obj._name, obj._type if obj._type else "",
3098+ "default " + obj._default if obj._default else "")
3099+ else:
3100+ return Field.as_create_str(Field(obj))
3101+
3102+ @classmethod
3103+ def as_str(cls, obj):
3104+ if isinstance(obj, cls):
3105+ return obj._name
3106+ else:
3107+ return Database._sanitize(obj)
3108+
3109+
3110+class Table(object):
3111+ """
3112+ Wrapper around a table (so that the table name doesn't need to be
3113+ passed as the first argument all the time).
3114+
3115+ """
3116+ def __init__(self, name, db):
3117+ self._name = name
3118+ self._db = db
3119+
3120+ def create(self, fields):
3121+ return self._db.create_table(self._name, fields)
3122+
3123+ def exists(self):
3124+ return self._db.table_exists(self._name)
3125+
3126+ def fetch(self, fields, conditions={}, distinct=False, max=False):
3127+ return self._db.fetch(self._name, fields, conditions, distinct, max)
3128+
3129+ def count(self, conditions):
3130+ return self._db.count(self._name, conditions)
3131+
3132+ def insert(self, fields):
3133+ return self._db.insert(self._name, fields)
3134+
3135+ def delete(self, conditions={}):
3136+ return self._db.delete(self._name, conditions)
3137+
3138+ def update(self, fields, conditions):
3139+ return self._db.update(self._name, fields, conditions)
3140+
3141+ def empty(self):
3142+ return self._db.empty_table(self._name)
3143+
3144+ def drop(self):
3145+ return self._db.delete_table(self._name)
3146+
3147+ @property
3148+ def name(self):
3149+ return self._db._tName(self._name)
3150+
3151+
3152 class Database(object):
3153 """
3154 Wrapper round an sqlite3 Database.
3155@@ -62,32 +208,48 @@
3156
3157 @staticmethod
3158 def _stringFromFieldNames(fields):
3159- return ", ".join(Database._sanitize(f) for f in fields)
3160+ return ", ".join(Field.as_str(f) for f in fields)
3161
3162 @staticmethod
3163 def _stringFromListItems(list):
3164- return ", ".join(Database._sanitize(l) for l in list)
3165+ return ", ".join(Field.as_str(l) for l in list)
3166
3167 @staticmethod
3168 def _tupleFromFieldValues(fields):
3169- return tuple(fields.values())
3170+ vals = []
3171+ for v in fields.values():
3172+ vals.extend(Value.get_values(v))
3173+ return tuple(vals)
3174
3175 @staticmethod
3176 def _buildConditions(conditions):
3177- return " and ".join(Database._sanitize(c) + "=?" for c in conditions) or "1"
3178-
3179+ return " and ".join(Value.as_condition(v, f)
3180+ for f, v in conditions.items()) or "1"
3181+
3182+ def table(self, name):
3183+ return Table(name, self)
3184+
3185 def create_table(self, name, fields):
3186 """
3187 Create a new table in the database called 'name' and containing fields
3188 'fields' (an iterable of strings giving field titles).
3189
3190 """
3191- if any(f.startswith('_endroid') for f in fields):
3192- raise ValueError("An attempt was made to create a table with system-reserved column-name (prefix '_endroid').")
3193+ if any(str(f).startswith('_endroid') for f in fields):
3194+ raise ValueError("An attempt was made to create a table with "
3195+ "system-reserved column-name (prefix '_endroid').")
3196 n = Database._sanitize(self._tName(name))
3197- fields_string = ', '.join(['{0} INTEGER PRIMARY KEY AUTOINCREMENT'.format(EndroidUniqueID)] + [Database._sanitize(i) for i in fields])
3198+ fields_string = ', '.join(map(Field.as_create_str,
3199+ [EndroidUniqueID] + list(fields)))
3200 query = 'CREATE TABLE {0} ({1});'.format(n, fields_string)
3201 Database.raw(query)
3202+
3203+ if EndroidLastModified in fields:
3204+ Database.raw("CREATE TRIGGER {3}_last_modified AFTER UPDATE ON {0} "
3205+ "BEGIN UPDATE {0} SET {1} = DATETIME('now') "
3206+ "WHERE {2} = new.{2}; END;"
3207+ .format(n, EndroidLastModified, EndroidUniqueID,
3208+ self._tName(name)))
3209
3210 def table_exists(self, name):
3211 """Check to see if a table called 'name' exists in the database."""
3212@@ -114,7 +276,7 @@
3213 Database.raw(query, tup)
3214 return Database.cursor.lastrowid
3215
3216- def fetch(self, name, fields, conditions={}):
3217+ def fetch(self, name, fields, conditions={}, distinct=False, max=False):
3218 """
3219 Get data from the table 'name'.
3220
3221@@ -122,30 +284,35 @@
3222 dictionary for each row which satisfies a condition in conditions.
3223
3224 Conditions is a dictionary mapping field names to values. A result
3225- will only be returned from a row if its values match those in conditions.
3226+ will only be returned from a row if its values match those in conditions
3227
3228- E.g.: conditions = {'user' : JoeBloggs}
3229+ E.g.: conditions = {'user': 'JoeBloggs'}
3230 will match only fields in rows which have JoeBloggs in the 'user' field.
3231
3232 """
3233 n = Database._sanitize(self._tName(name))
3234- fields = list(fields) + [EndroidUniqueID]
3235- query = "SELECT {0} FROM {1} WHERE ({2});".format(
3236- Database._stringFromListItems(fields), n,
3237- Database._buildConditions(conditions))
3238+ if not distinct and not max:
3239+ fields = list(fields) + [EndroidUniqueID]
3240+ if max:
3241+ cols = "MAX({})".format(Database._stringFromListItems(fields))
3242+ else:
3243+ cols = Database._stringFromListItems(fields)
3244+ query = "SELECT {3}{0} FROM {1} WHERE ({2});".format(
3245+ cols, n, Database._buildConditions(conditions),
3246+ "DISTINCT " if distinct else "")
3247 Database.raw(query, Database._tupleFromFieldValues(conditions))
3248 c = Database.cursor.fetchall()
3249 rows = [TableRow(dict(zip(fields, item))) for item in c]
3250 return rows
3251
3252 def count(self, name, conditions):
3253- """Return the number of rows in table 'name' which satisfy conditions."""
3254+ "Return the number of rows in table 'name' which satisfy conditions."
3255 n = Database._sanitize(self._tName(name))
3256 query = "SELECT COUNT(*) FROM {0} WHERE ({1});".format(n, Database._buildConditions(conditions))
3257 r = Database.raw(query, Database._tupleFromFieldValues(conditions)).fetchall()
3258 return r[0][0]
3259
3260- def delete(self, name, conditions):
3261+ def delete(self, name, conditions={}):
3262 """Delete rows from table 'name' which satisfy conditions."""
3263 n = Database._sanitize(self._tName(name))
3264 query = "DELETE FROM {0} WHERE ({1});".format(n, Database._buildConditions(conditions))
3265@@ -168,18 +335,23 @@
3266
3267 def empty_table(self, name):
3268 """Remove all rows from table 'name'."""
3269- n = Database._sanitize(self._tName(name))
3270- query = "DELETE FROM {0} WHERE 1;".format(n)
3271- Database.raw(query)
3272+ return self.delete(name)
3273
3274 def delete_table(self, name):
3275 """Delete table 'name'."""
3276 n = Database._sanitize(self._tName(name))
3277 query = "DROP TABLE {0};".format(n)
3278 Database.raw(query)
3279-
3280+
3281 @staticmethod
3282 def raw(command, params=()):
3283+ logging.debug("SQL command {} params {}".format(command, params))
3284 p = Database.cursor.execute(command, params)
3285 Database.connection.commit()
3286 return p
3287+
3288+# Export constants for system column names
3289+EndroidUniqueID = Field('_endroid_unique_id',
3290+ type="INTEGER PRIMARY KEY AUTOINCREMENT")
3291+EndroidLastModified = Field('_endroid_last_modified', type="DATETIME",
3292+ default="current_timestamp")
3293
3294=== modified file 'src/endroid/pluginmanager.py'
3295--- src/endroid/pluginmanager.py 2014-08-11 15:30:01 +0000
3296+++ src/endroid/pluginmanager.py 2014-09-01 20:21:07 +0000
3297@@ -304,12 +304,12 @@
3298 try:
3299 __import__(modname)
3300 except ImportError as i:
3301- logging.error(i)
3302+ logging.exception(i)
3303 logging.error("**Could Not Import Plugin \"" + modname
3304 + "\". Check That It Exists In Your PYTHONPATH.")
3305 return
3306 except Exception as e:
3307- logging.error(e)
3308+ logging.exception(e)
3309 logging.error("**Failed to import plugin {}".format(modname))
3310 return
3311 else:
3312
3313=== modified file 'src/endroid/plugins/httpinterface.py'
3314--- src/endroid/plugins/httpinterface.py 2014-04-05 11:51:30 +0000
3315+++ src/endroid/plugins/httpinterface.py 2014-09-01 20:21:07 +0000
3316@@ -189,7 +189,7 @@
3317 [BasicCredentialFactory("EnDroid")])
3318
3319 def endroid_init(self, pluginmanager, port, interface, media_dir,
3320- template_dir, credplugins):
3321+ template_dir, credplugins, baseurl):
3322 def _get_cred(plugin):
3323 """
3324 Get a credential checker for one entry in the credplugins list.
3325@@ -198,14 +198,13 @@
3326 try:
3327 cred = plugin.http_cred_checker()
3328 except Exception as e:
3329- logging.exception(e)
3330 try:
3331 cred = pluginmanager.get(plugin).http_cred_checker()
3332 except Exception as e:
3333 logging.exception(e)
3334 cred = None
3335 return cred
3336- self._creds = [_get_cred(p) for p in credplugins]
3337+ self._creds = [_get_cred(p) for p in credplugins if p]
3338 self._root = IndexPage(self)
3339 self._media = File(media_dir)
3340 self._root.putChild("_media", self._media)
3341@@ -216,6 +215,8 @@
3342 return time.strftime(fmt, time.localtime(val))
3343 self.jinja.filters['datetime'] = datetime_filter
3344
3345+ self.baseurl = baseurl
3346+
3347 factory = Site(self._root)
3348 logging.info("Starting web server on {}:{}; static files in {}"
3349 .format(interface, port, media_dir))
3350@@ -303,6 +304,10 @@
3351 """
3352 return InMemoryUsernamePasswordDatabaseDontUse(endroid='password')
3353
3354+ @property
3355+ def baseurl(self):
3356+ return HTTPInterface._singleton.baseurl
3357+
3358 def endroid_init(self):
3359 """
3360 Initialises the singleton object on first call only.
3361@@ -315,7 +320,9 @@
3362 media_dir = self.vars.get("media_dir", DEFAULT_MEDIA_DIR)
3363 templ_dir = self.vars.get("template_dir", DEFAULT_TEMPL_DIR)
3364 credplugins = self.vars.get("credential_plugins", [self])
3365+ baseurl = self.vars.get("baseurl", "http://localhost/")
3366 HTTPInterface._singleton.endroid_init(self.plugins, port,
3367 interface, media_dir,
3368- templ_dir, credplugins)
3369+ templ_dir, credplugins,
3370+ baseurl)
3371 HTTPInterface.enInited = True
3372
3373=== added directory 'src/endroid/plugins/kermit'
3374=== added file 'src/endroid/plugins/kermit/__init__.py'
3375--- src/endroid/plugins/kermit/__init__.py 1970-01-01 00:00:00 +0000
3376+++ src/endroid/plugins/kermit/__init__.py 2014-09-01 20:21:07 +0000
3377@@ -0,0 +1,309 @@
3378+# -----------------------------------------------------------------------------
3379+# EnDroid - Remote notification plugin
3380+# Copyright 2012, Ensoft Ltd
3381+# -----------------------------------------------------------------------------
3382+
3383+import os
3384+import re
3385+import json
3386+import time
3387+import shlex
3388+import logging
3389+import cfgparser
3390+
3391+from Crypto.PublicKey import RSA
3392+from twisted.conch.ssh import keys
3393+from twisted.internet import reactor, defer, threads, task
3394+from twisted.internet.protocol import ProcessProtocol
3395+
3396+from endroid.database import Database, EndroidUniqueID, EndroidLastModified
3397+from endroid.usermanagement import JID
3398+from endroid.plugins.command import CommandPlugin, command
3399+
3400+from .web import KermitRoot
3401+from . import ssh
3402+
3403+
3404+class Kermit(CommandPlugin):
3405+ dependencies = ('endroid.plugins.httpinterface',)
3406+ help = "Automatically commit patches without having to do the legwork."
3407+ DB_TABLE = "Queue"
3408+ KEY_TABLE = "Keys"
3409+
3410+ def endroid_init(self):
3411+ self.http = self.get('endroid.plugins.httpinterface')
3412+ self.http.register_resource(self, KermitRoot(self))
3413+
3414+ self.config_dir = self.vars.get("config_dir")
3415+ self.trigger_path = self.vars.get("trigger_path", "kermit_trigger")
3416+ self.qr_path = self.vars.get("qr_path", "kermit_queue_runner")
3417+
3418+ self.db = self.database.table(self.DB_TABLE)
3419+ self.keydb = self.database.table(self.KEY_TABLE)
3420+
3421+ if not self.db.exists():
3422+ self.db.create(("user", "target", "status", EndroidLastModified))
3423+ if not self.keydb.exists():
3424+ self.keydb.create(("user", "keystr"))
3425+
3426+ self.waiters = []
3427+
3428+
3429+ def notify_waiters(self):
3430+ for querier, waiter in self.waiters:
3431+ reactor.callLater(1, querier.respond, waiter)
3432+ del self.waiters[:]
3433+
3434+
3435+ def read_config(self, target=None):
3436+ files = [os.path.join(self.config_dir, "kermit.cfg")]
3437+ if target:
3438+ files.append(os.path.join(self.config_dir, target + ".cfg"))
3439+ c = cfgparser.CfgParser()
3440+ c.read(files)
3441+ return c
3442+
3443+
3444+ # Key Management
3445+
3446+ def _generate_key(self, username):
3447+ key = RSA.generate(2048)
3448+ return keys.Key.fromString(key.exportKey("PEM"))
3449+
3450+ def generate_key(self, username):
3451+ return threads.deferToThread(self._generate_key, username)
3452+
3453+ def add_key(self, username, keystr):
3454+ key = keys.Key.fromString(keystr)
3455+ if key.isPublic():
3456+ raise ValueError("Uploaded key must be a private key")
3457+
3458+ id = self.keydb.insert({
3459+ "user": username,
3460+ "keystr": key.toString("OPENSSH"),
3461+ })
3462+ return {
3463+ 'idx': id,
3464+ 'type': key.public().type(),
3465+ 'keystr': key.public().toString("OPENSSH"),
3466+ }
3467+
3468+
3469+ def remove_key(self, username, idx):
3470+ self.keydb.delete({"user": username, EndroidUniqueID: idx})
3471+
3472+
3473+ def get_private_keys(self, username):
3474+ rows = self.keydb.fetch(("keystr",), {"user": username})
3475+ return [keys.Key.fromString(k['keystr']) for k in rows]
3476+
3477+
3478+ def list_keys(self, username):
3479+ rows = self.keydb.fetch(("keystr",), {"user": username})
3480+ ks = ((k.id, keys.Key.fromString(k['keystr']).public()) for k in rows)
3481+ return [{'idx': i, 'type': k.type(), 'keystr': k.toString("OPENSSH")}
3482+ for i, k in ks]
3483+
3484+
3485+ @staticmethod
3486+ def _parse(arg):
3487+ parts = shlex.split(arg)
3488+
3489+ if len(parts) != 5:
3490+ raise ValueError("Invalid commit command")
3491+ if parts[1].lower() != 'to' or parts[3].lower() != 'against':
3492+ raise ValueError("Malformed commit command")
3493+
3494+ return parts[0::2]
3495+
3496+
3497+ def abort_commit(self, deferred, key):
3498+ row = self.db.fetch(("target", "user", "status"),
3499+ {EndroidUniqueID: key})
3500+ if not row:
3501+ raise ValueError("Unknown queue item ({})".format(key))
3502+ target = row[0]['target']
3503+ user = row[0]['user']
3504+ status = json.loads(row[0]['status'])
3505+
3506+ config = self.read_config(target)
3507+ config.set("DEFAULT", "target", target)
3508+ config.set("DEFAULT", "username", user)
3509+ config.set("DEFAULT", "key", "kermit-{}".format(key))
3510+
3511+ def _reset_status(_):
3512+ status['currstatus']['running'] = []
3513+ status['currstatus']['failed'] = []
3514+ status['currstatus']['failed_at'] = {}
3515+ status['aborted_at'] = time.time()
3516+ self.db.update({
3517+ "status": json.dumps(status),
3518+ }, {EndroidUniqueID: key})
3519+ self.notify_waiters()
3520+ return key
3521+
3522+ d = ssh.spawn_trigger(config.get(target, "server", default="localhost"),
3523+ config.getlist(target, "fingerprints",
3524+ default=[]),
3525+ user, self.get_private_keys(user),
3526+ "-a kermit-{}".format(key),
3527+ config)
3528+
3529+ d.addCallback(_reset_status)
3530+ d.chainDeferred(deferred)
3531+
3532+
3533+ def resubmit_commit(self, deferred, key):
3534+ row = self.db.fetch(("status",), {EndroidUniqueID: key})
3535+ if not row:
3536+ raise ValueError("Unknown queue item ({})".format(key))
3537+ args = json.loads(row[0]['status'])['inputargs']
3538+
3539+ username = args['username']
3540+ commitdiff = args['commitdiff']
3541+ target = args['target']
3542+ buglist = args['buglist']
3543+ commitmsg = args['commitmsg']
3544+ stages = args.get('stages', '')
3545+ ws_disable = args.get('ws_disable', '')
3546+ ws_reset = args.get('ws_reset', '')
3547+
3548+ self.submit_commit(deferred, username, commitdiff, target, buglist,
3549+ commitmsg, stages, key=key, ws_disable=ws_disable,
3550+ ws_reset=ws_reset)
3551+
3552+
3553+ def submit_commit(self, deferred, user, commitdiff, target, buglist,
3554+ commitmsg='', stages='', key=None, ws_disable='',
3555+ ws_reset=''):
3556+ if not os.path.exists(os.path.join(self.config_dir, target + '.cfg')):
3557+ deferred.errback(OSError("No configuration file for target"))
3558+ return
3559+ config = self.read_config(target)
3560+ config.set("DEFAULT", "commitdiff", commitdiff)
3561+ config.set("DEFAULT", "target", target)
3562+ config.set("DEFAULT", "buglist", buglist)
3563+ config.set("DEFAULT", "commitmsg", commitmsg)
3564+ config.set("DEFAULT", "username", user)
3565+ config.set("DEFAULT", "stages", stages)
3566+ config.set("DEFAULT", "ws_disable", ws_disable)
3567+ config.set("DEFAULT", "ws_reset", ws_reset)
3568+ config.set("DEFAULT", "qr_path", self.qr_path)
3569+ config.set("DEFAULT", "trigger_path", self.trigger_path)
3570+ config.set("DEFAULT", "kermit_baseurl", self.http.baseurl)
3571+
3572+ def _reset_status(_):
3573+ rows = self.db.fetch(("status",), {EndroidUniqueID: key})
3574+ if rows:
3575+ status = json.loads(rows[0]['status'])
3576+ status['currstatus']['running'] = []
3577+ status['currstatus']['failed'] = []
3578+ status['currstatus']['failed_at'] = {}
3579+ status['aborted_at'] = 0
3580+ self.db.update({
3581+ "status": json.dumps(status),
3582+ }, {EndroidUniqueID: key})
3583+ self.notify_waiters()
3584+ return _
3585+
3586+ deferred.addCallback(_reset_status)
3587+
3588+ args = {
3589+ 'username': user,
3590+ 'commitdiff': commitdiff,
3591+ 'target': target,
3592+ 'buglist': buglist,
3593+ 'commitmsg': commitmsg,
3594+ 'stages': stages,
3595+ 'ws_disable': ws_disable,
3596+ 'ws_reset': ws_reset,
3597+ }
3598+
3599+ if key:
3600+ rowid = key
3601+ else:
3602+ rowid = self.db.insert({
3603+ "user": user,
3604+ "target": target,
3605+ "status": json.dumps({
3606+ 'inputargs': args,
3607+ 'currstatus': {},
3608+ 'aborted_at': 0,
3609+ }),
3610+ })
3611+ def _cleanup(fail):
3612+ self.db.delete({EndroidUniqueID: rowid})
3613+ return fail
3614+ deferred.addErrback(_cleanup)
3615+ config.set("DEFAULT", "key", "kermit-{}".format(rowid))
3616+
3617+ workspaces = config.getlist(target, "workspaces")
3618+ server = config.get(target, "server", default="localhost")
3619+ fprints = config.getlist(target, "fingerprints", default=[])
3620+
3621+ ds = []
3622+ for ws in workspaces:
3623+ section = config.section(ws, category="workspace")
3624+ shuser = section.get("username", default=user)
3625+
3626+ logging.info("Spawning workspace process: {}@{}"
3627+ .format(shuser, server))
3628+ d = ssh.spawn_trigger(server, fprints,
3629+ shuser, self.get_private_keys(shuser),
3630+ "-w", config)
3631+
3632+ ds.append(d)
3633+
3634+ dl = defer.DeferredList(ds, fireOnOneErrback=True, consumeErrors=True)
3635+ # Sleep a little bit to give the shared workspaces time to start
3636+ # listening
3637+ def _get_subfailure(fail):
3638+ fail.trap(defer.FirstError)
3639+ return fail.value.subFailure
3640+ dl.addCallback(lambda _: task.deferLater(reactor, 2, lambda: _))
3641+ dl.addCallbacks(lambda _: ssh.spawn_trigger(server, fprints, user,
3642+ self.get_private_keys(user),
3643+ "", config),
3644+ _get_subfailure)
3645+ dl.chainDeferred(deferred)
3646+
3647+
3648+ @command(helphint="<patch> to <target> against <buglist>")
3649+ def commit(self, msg, arg):
3650+ commitdiff, target, buglist = self._parse(arg)
3651+
3652+ d = defer.Deferred()
3653+ d.addCallbacks(lambda k: msg.reply("Commit started as {}".format(k)),
3654+ lambda f: msg.reply("Failed to start commit: {}"
3655+ .format(f)))
3656+
3657+ self.submit_commit(d, JID(msg.sender_full).user, commitdiff,
3658+ target, buglist)
3659+
3660+
3661+ @command(helphint="<commit-id>")
3662+ def abort(self, msg, arg):
3663+ d = defer.Deferred()
3664+ d.addCallbacks(lambda k: msg.reply("Commit {} abort".format(k)),
3665+ lambda f: msg.reply("Failed to abort commit {}: {}"
3666+ .format(arg, f)))
3667+ self.abort_commit(d, int(arg))
3668+
3669+
3670+ @command(helphint="<commit-id>")
3671+ def resubmit(self, msg, arg):
3672+ d = defer.Deferred()
3673+ d.addCallbacks(lambda k: msg.reply("Commit {} resubmitted".format(k)),
3674+ lambda f: msg.reply("Failed to resubmit commit {}: {}"
3675+ .format(arg, f)))
3676+ self.resubmit_commit(d, int(arg))
3677+
3678+
3679+ @command(synonyms=(('clear', 'commit'),), hidden=True)
3680+ def clear_commits(self, msg, arg):
3681+ if arg:
3682+ self.db.delete({EndroidUniqueID: int(arg)})
3683+ else:
3684+ self.db.delete()
3685+ self.notify_waiters()
3686+ msg.reply("Done.")
3687
3688=== added file 'src/endroid/plugins/kermit/ssh.py'
3689--- src/endroid/plugins/kermit/ssh.py 1970-01-01 00:00:00 +0000
3690+++ src/endroid/plugins/kermit/ssh.py 2014-09-01 20:21:07 +0000
3691@@ -0,0 +1,301 @@
3692+
3693+import struct
3694+import logging
3695+import weakref
3696+
3697+from twisted.conch.ssh import (
3698+ transport, keys, connection, channel, common, userauth, filetransfer,
3699+)
3700+from twisted.internet import reactor, defer, protocol, task, error
3701+
3702+# Connection classes
3703+
3704+class _Transport(transport.SSHClientTransport):
3705+
3706+ def __init__(self, deferred, fingerprints, username, keys):
3707+ self._deferred = deferred
3708+ self._fingerprints = fingerprints
3709+ self._username = username
3710+ self._keys = keys
3711+ self.connected = False
3712+
3713+ def verifyHostKey(self, key, fingerprint):
3714+ for f in self._fingerprints:
3715+ if f.lower() == 'ignore' or f.lower() == fingerprint.lower():
3716+ return defer.succeed(True)
3717+ logging.warning("Fingerprint doesn't match: " + fingerprint)
3718+ d, self._deferred = self._deferred, None
3719+ err = ValueError("fingerprint ({}) doesn't match config"
3720+ .format(fingerprint))
3721+ d.errback(err)
3722+ return defer.fail(err)
3723+
3724+ def connectionSecure(self):
3725+ self.connected = True
3726+ self.requestService(_Auth(str(self._username),
3727+ _Conn(self._deferred,
3728+ self._connection_authed),
3729+ self._keys))
3730+
3731+ def _connection_authed(self):
3732+ self._deferred = None
3733+
3734+ def connectionLost(self, reason):
3735+ self.connected = False
3736+ logging.warning("Lost connection: " + str(reason))
3737+ d, self._deferred = self._deferred, None
3738+ if d is not None:
3739+ d.errback(ValueError("failed to setup connection for user {}"
3740+ .format(self._username)))
3741+
3742+class _Auth(userauth.SSHUserAuthClient):
3743+
3744+ def __init__(self, username, conn, keys):
3745+ userauth.SSHUserAuthClient.__init__(self, username, conn)
3746+ self._lastkey = None
3747+ self._keys = keys
3748+
3749+ def getPassword(self, prompt=None):
3750+ return
3751+
3752+ def getPublicKey(self):
3753+ if not self._keys:
3754+ self._lastkey = None
3755+ return None
3756+ self._lastkey = self._keys.pop(0)
3757+ return self._lastkey.public()
3758+
3759+ def getPrivateKey(self):
3760+ return defer.succeed(self._lastkey)
3761+
3762+class _Conn(connection.SSHConnection):
3763+
3764+ def __init__(self, deferred, authfn):
3765+ connection.SSHConnection.__init__(self)
3766+ self._deferred = deferred
3767+ self._authfn = authfn
3768+ self._timeout = None
3769+
3770+ def serviceStarted(self):
3771+ connection.SSHConnection.serviceStarted(self)
3772+ self._authfn()
3773+ d, self._deferred = self._deferred, None
3774+ d.callback(self)
3775+
3776+ def serviceStopped(self):
3777+ connection.SSHConnection.serviceStopped(self)
3778+
3779+ def openChannel(self, channel, extra=''):
3780+ if self._timeout:
3781+ try:
3782+ self._timeout.cancel()
3783+ except error.AlreadyCalled as e:
3784+ logging.error("Cancelling already called timeout. Currently connected? {}".format(self.transport.connected))
3785+ self._timeout = None
3786+ connection.SSHConnection.openChannel(self, channel, extra=extra)
3787+
3788+ def channelClosed(self, channel):
3789+ connection.SSHConnection.channelClosed(self, channel)
3790+ if len(self.channels) == 0:
3791+ self._timeout = reactor.callLater(600, self._disconnect)
3792+
3793+ def _disconnect(self):
3794+ logging.info("Connection for {} timed out"
3795+ .format(self.transport._username))
3796+ self.transport.sendDisconnect(transport.DISCONNECT_BY_APPLICATION,
3797+ "connection keepalive timeout reached")
3798+
3799+class _Factory(protocol.ClientFactory):
3800+
3801+ def __init__(self, deferred, fingerprints, username, keys):
3802+ self._deferred = deferred
3803+ self._fingerprints = fingerprints
3804+ self._username = username
3805+ self._keys = keys
3806+
3807+ def buildProtocol(self, addr):
3808+ trans = _Transport(self._deferred, self._fingerprints,
3809+ self._username, self._keys)
3810+ self._deferred = None
3811+ return trans
3812+
3813+ def clientConnectionFailed(self, connector, reason):
3814+ d, self._deferred = self._deferred, None
3815+ d.errback(reason)
3816+
3817+# Trigger command classes
3818+
3819+class TriggerError(Exception):
3820+ def __init__(self, command, retcode, sig=None):
3821+ self.command = command
3822+ self.retcode = retcode
3823+ self.sig = sig
3824+ super(TriggerError, self).__init__((command, retcode, sig))
3825+
3826+ def __str__(self):
3827+ return "process '{}' failed: {}{}".format(self.command, self.retcode,
3828+ " {}".format(self.sig)
3829+ if self.sig else "")
3830+
3831+class _TriggerChan(channel.SSHChannel):
3832+ name = 'session'
3833+
3834+ def __init__(self, deferred, args, config, conn):
3835+ channel.SSHChannel.__init__(self, conn=conn)
3836+ self._deferred = deferred
3837+ self._config = config
3838+ self._command = config.get("DEFAULT", "trigger_path")
3839+ if args:
3840+ self._command += " " + args
3841+ self._retcode = None
3842+ self._sig = None
3843+
3844+ def channelOpen(self, data):
3845+ d = self.conn.sendRequest(self, 'exec', common.NS(self._command),
3846+ wantReply=True)
3847+ d.addCallbacks(self._spawned, self._failed)
3848+
3849+ def _spawned(self, _):
3850+ self._config.write(self)
3851+ self.conn.sendEOF(self)
3852+
3853+ def _failed(self, failure):
3854+ d, self._deferred = self._deferred, None
3855+ d.errback(failure)
3856+
3857+ def request_exit_status(self, data):
3858+ self._retcode = struct.unpack("!i", data)[0]
3859+ logging.info("Exit status received: {}".format(self._retcode))
3860+
3861+ def request_exit_signal(self, data):
3862+ strlen = struct.unpack("!i", data[:4])[0]
3863+ sig, core, msglen = struct.unpack("!{}s?i".format(strlen),
3864+ data[4:strlen+9])
3865+ msg, langlen = struct.unpack("!{}si".format(msglen),
3866+ data[9+strlen:13+strlen+msglen])
3867+ lang = struct.unpack("!{}s".format(langlen), data[13+strlen+msglen:])[0]
3868+ logging.info("Signal {}. Core: {}. Msg: {}. Lang: {}"
3869+ .format(sig, core, msg, lang))
3870+ self._retcode = -1
3871+ self._sig = sig
3872+
3873+ def dataReceived(self, data):
3874+ logging.info("Received some data: " + repr(data))
3875+
3876+ def extReceived(self, dataType, data):
3877+ if dataType == connection.EXTENDED_DATA_STDERR:
3878+ logging.info("Stderr data: {}".format(repr(data)))
3879+ else:
3880+ logging.warning("Unexpected ext data (type {}): {}"
3881+ .format(dataType, repr(data)))
3882+
3883+ def closed(self):
3884+ logging.info("Channel has closed")
3885+ d, self._deferred = self._deferred, None
3886+ if self._retcode == 0:
3887+ d.callback(None)
3888+ else:
3889+ d.errback(TriggerError(self._command, self._retcode, self._sig))
3890+
3891+# Log file reading classes
3892+
3893+class _LogfileChan(channel.SSHChannel):
3894+ name = "session"
3895+
3896+ def __init__(self, deferred, conn):
3897+ channel.SSHChannel.__init__(self, conn=conn)
3898+ self._deferred = deferred
3899+ self._client = None
3900+
3901+ def channelOpen(self, data):
3902+ d = self.conn.sendRequest(self, 'subsystem', common.NS('sftp'),
3903+ wantReply=True)
3904+ d.addCallbacks(self._connected, self._failed)
3905+
3906+ def _connected(self, _):
3907+ self._client = filetransfer.FileTransferClient()
3908+ self._client.makeConnection(self)
3909+ self.dataReceived = self._client.dataReceived
3910+ d, self._deferred = self._deferred, None
3911+ d.callback(self._client)
3912+
3913+ def _failed(self, failure):
3914+ d, self._deferred = self._deferred, None
3915+ d.errback(failure)
3916+
3917+ def closed(self):
3918+ self._client = None
3919+
3920+
3921+# Public API (and caching var)
3922+
3923+_conn_cache = weakref.WeakValueDictionary()
3924+
3925+def connect(server, fingerprints, username, keys, port=22):
3926+ """
3927+ Connect to username@server:port with keys, and verify server fingerprint.
3928+
3929+ This will use a cached connection if one exists for the given server,
3930+ port, username combo (in this case, it does not reverify the fingerprint,
3931+ nor use the keys - they are assumed to have been the same or equivalent
3932+ when the cached connection was set up).
3933+
3934+ Returns a Deferred that fires with the SSHConnection instance (on which
3935+ e.g. openChannel can then be called).
3936+
3937+ """
3938+ if (server, port, username) in _conn_cache:
3939+ conn = _conn_cache[(server, port, username)]
3940+ if conn.transport.connected:
3941+ return defer.succeed(_conn_cache[(server, port, username)])
3942+
3943+ d = defer.Deferred()
3944+ factory = _Factory(d, fingerprints, username, keys)
3945+ reactor.connectTCP(server, port, factory)
3946+ def _cache_conn(conn):
3947+ _conn_cache[(server, port, username)] = conn
3948+ return conn
3949+ d.addCallback(_cache_conn)
3950+ return d
3951+
3952+
3953+def get_logviewer(server, fingerprints, username, keys, port=22):
3954+ """
3955+ Gets an sftp subsystem channel to the remote server.
3956+
3957+ Returns a Deferred that fires with a FileTransferClient. You should only
3958+ really call the following on it:
3959+ - openFile
3960+ - openDirectory
3961+ - transport.loseConnection
3962+
3963+ The latter of these MUST be called when the connection is no longer
3964+ required.
3965+
3966+ """
3967+ d = defer.Deferred()
3968+ d2 = connect(server, fingerprints, username, keys, port=port)
3969+ d2.addCallback(lambda c: c.openChannel(_LogfileChan(d, conn=c)))
3970+ d2.addErrback(lambda f: d.errback(f))
3971+ return d
3972+
3973+
3974+def spawn_trigger(server, fingerprints, username, keys, args, config, port=22):
3975+ """
3976+ Spawns the kermit_trigger command on the given server.
3977+
3978+ Additional arguments can be passed in 'args'; the CfgParser object
3979+ containing the config for the commit being affected must be passed in
3980+ 'config'.
3981+
3982+ Returns a Deferred that fires (with None) when the process completes, or
3983+ with the error, which could be a TriggerError if the process returns a
3984+ non-zero exit code.
3985+
3986+ """
3987+ d = defer.Deferred()
3988+ d2 = connect(server, fingerprints, username, keys, port=port)
3989+ d2.addCallback(lambda c:
3990+ c.openChannel(_TriggerChan(d, args, config, conn=c)))
3991+ d2.addErrback(lambda f: d.errback(f))
3992+ return d
3993
3994=== added file 'src/endroid/plugins/kermit/web.py'
3995--- src/endroid/plugins/kermit/web.py 1970-01-01 00:00:00 +0000
3996+++ src/endroid/plugins/kermit/web.py 2014-09-01 20:21:07 +0000
3997@@ -0,0 +1,398 @@
3998+# -----------------------------------------------------------------------------
3999+# EnDroid - Kermit plugin web interface
4000+# Copyright 2014, Ensoft Ltd
4001+# -----------------------------------------------------------------------------
4002+
4003+import re
4004+import json
4005+import os.path
4006+import logging
4007+import datetime
4008+import cfgparser
4009+
4010+from endroid.database import EndroidUniqueID, LIKE, OR, Value, EndroidLastModified
4011+
4012+from twisted.web import resource, server, util
4013+from twisted.python import failure
4014+from twisted.internet import defer, reactor
4015+from twisted.conch.ssh import filetransfer
4016+
4017+from . import ssh
4018+
4019+def _parse_date(dt, fmt="%Y-%m-%dT%H:%M:%S.%fZ"):
4020+ return datetime.datetime.strptime(dt, fmt)
4021+
4022+
4023+def _parse_key(key):
4024+ m = re.match("kermit-(\d+)", key)
4025+ if not m:
4026+ raise ValueError("Malformed entry index ({})"
4027+ .format(request.args["key"][0]))
4028+ return int(m.group(1))
4029+
4030+
4031+class KermitPage(resource.Resource):
4032+ isLeaf = True
4033+
4034+ def __init__(self, plugin):
4035+ resource.Resource.__init__(self)
4036+ self._plugin = plugin
4037+
4038+ def _render_success(self, request, key):
4039+ request.setHeader("Content-Type", "text/json")
4040+ request.write(self._plugin.http.get_template("kermit/response.json")
4041+ .render({
4042+ "success": json.dumps(key),
4043+ }).encode('utf-8'))
4044+ request.finish()
4045+ return server.NOT_DONE_YET
4046+
4047+ def _render_fail(self, request, fail):
4048+ request.setHeader("Content-Type", "text/json")
4049+ request.setResponseCode(500)
4050+ request.write(self._plugin.http.get_template("kermit/response.json")
4051+ .render({
4052+ "fail": str(fail.value),
4053+ }).encode("utf-8"))
4054+ request.finish()
4055+ return server.NOT_DONE_YET
4056+
4057+
4058+class KermitStager(KermitPage):
4059+ def render_GET(self, request):
4060+ target = request.args['target'][0]
4061+ config = self._plugin.read_config(target)
4062+ request.setHeader("Content-Type", "application/json")
4063+ if not config.has_section(target):
4064+ request.setResponseCode(404)
4065+ return json.dumps({"fail": "no such section {}".format(target)})
4066+
4067+ result = []
4068+ for stage in config.getlist(target, "commands"):
4069+ section = config.section(stage, category="command")
4070+ result.append({
4071+ "name": stage,
4072+ "deps": section.getlist("dependencies", default=[]),
4073+ })
4074+ return json.dumps({
4075+ "stages": result,
4076+ "workspaces": config.getlist(target, "workspaces", default=[])
4077+ })
4078+
4079+
4080+class KermitLister(KermitPage):
4081+ def render_GET(self, request):
4082+ field = request.args['arg'][0]
4083+ rows = self._plugin.db.fetch((field,), distinct=True)
4084+ request.setHeader("Content-Type", "application/json")
4085+ return json.dumps({field: [r[field] for r in rows]})
4086+
4087+
4088+class KermitUpdater(KermitPage):
4089+ def _error(self, request, error):
4090+ return self._plugin.http.get_template("kermit/response.json").render({
4091+ 'fail': error,
4092+ }).encode("utf-8")
4093+
4094+ def render_POST(self, request):
4095+ request.setHeader("Content-Type", "application/json")
4096+ m = re.match("/kermit/update/kermit-(\d+)", request.path)
4097+ if not m:
4098+ return self._error(request, "Invalid update URL")
4099+
4100+ key = int(m.group(1))
4101+ data = json.loads(request.content.read())
4102+
4103+ row = self._plugin.db.fetch(("status",), {EndroidUniqueID: key})
4104+ if not row:
4105+ return self._error(request, "Unknown commit ID: {}".format(key))
4106+ row = row[0]
4107+
4108+ olddata = json.loads(row['status'])
4109+ olddata['currstatus'] = data
4110+ self._plugin.db.update({'status': json.dumps(olddata)},
4111+ {EndroidUniqueID: key})
4112+
4113+ self._plugin.notify_waiters()
4114+
4115+ return self._plugin.http.get_template("kermit/response.json").render({
4116+ 'success': key,
4117+ }).encode('utf-8')
4118+
4119+
4120+class KermitQuerier(KermitPage):
4121+ def render_POST(self, request):
4122+ def _cancel(_):
4123+ logging.info("Cancelling long poll due to client disconnect")
4124+ self._plugin.waiters.remove((self, request))
4125+ lastupdate = request.args.get('wait', None)
4126+ logging.info("Query received. Args are {}".format(request.args))
4127+ if lastupdate:
4128+ res = self._plugin.db.fetch((EndroidLastModified,), max=True)
4129+ logging.info("Latest of all updates was {}".format(res))
4130+ if res:
4131+ latest = datetime.datetime.strptime(res[0].last_modified, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%dT%H:%M:%S.%fZ")
4132+ logging.info("Waiting for {}. Latest is {}.".format(lastupdate, latest))
4133+ if latest <= lastupdate[0]:
4134+ self._plugin.waiters.append((self, request))
4135+ request.notifyFinish().addErrback(_cancel)
4136+ return server.NOT_DONE_YET
4137+
4138+ return self.respond(request)
4139+
4140+ def respond(self, request):
4141+ conds = {}
4142+ if 'username' in request.args:
4143+ conds['user'] = OR(*(LIKE(u) for u in request.args['username']))
4144+ if 'target' in request.args:
4145+ conds['target'] = OR(*(LIKE(u) for u in request.args['target']))
4146+ if 'lastmod' in request.args:
4147+ conds[EndroidLastModified] = Value(request.args['lastmod'][0], '<')
4148+ jobs = self._plugin.db.fetch(("status", EndroidLastModified), conds)
4149+ request.setHeader("Content-Type", "application/json")
4150+ res = []
4151+ for job in jobs:
4152+ status = json.loads(job['status'])
4153+ cfg = self._plugin.read_config(status['inputargs']['target'])
4154+ config = {
4155+ "commands": {},
4156+ "workspaces": {},
4157+ }
4158+ for stage in status['currstatus'].get('running', []):
4159+ section = cfg.section(stage, category="command")
4160+ config['commands'][stage] = dict(section.items())
4161+ for stage in status['currstatus'].get('failed', []):
4162+ if cfg.has_section(stage, category='command'):
4163+ section = cfg.section(stage, category="command")
4164+ config['commands'][stage] = dict(section.items())
4165+ for stage in status['currstatus'].get('stages', []):
4166+ section = cfg.section(stage, category="command")
4167+ config['commands'][stage] = dict(section.items())
4168+ for ws in cfg.getlist(status['inputargs']['target'], "workspaces"):
4169+ section = cfg.section(ws, category="workspace")
4170+ config['workspaces'][ws] = dict(section.items())
4171+ lastmod = datetime.datetime.strptime(job.last_modified, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%dT%H:%M:%S.%fZ")
4172+ res.append({
4173+ "status": status,
4174+ "config": config,
4175+ "key": "kermit-{}".format(job.id),
4176+ "lastmod": lastmod,
4177+ "status_string":
4178+ "Completed" if status['currstatus'].get('completed_at',
4179+ False) else
4180+ "Failed" if status['currstatus'].get('failed_at', False) or
4181+ status['currstatus'].get('check_failed',
4182+ False) else
4183+ "Aborted" if status.get('aborted_at', False) else
4184+ "Running" if status['currstatus'].get('running', False) else
4185+ "Queued",
4186+ })
4187+ latest = self._plugin.db.fetch((EndroidLastModified,), max=True)
4188+ if latest:
4189+ latest = datetime.datetime.strptime(latest[0].last_modified, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%dT%H:%M:%S.%fZ")
4190+ request.write(json.dumps({"last_update": latest, "jobs": res}))
4191+ request.finish()
4192+ return server.NOT_DONE_YET
4193+
4194+
4195+class KermitSubmitter(KermitPage):
4196+ def render_POST(self, request):
4197+ d = defer.Deferred()
4198+ d.addCallbacks(lambda k: self._render_success(request, k),
4199+ lambda f: self._render_fail(request, f))
4200+
4201+ try:
4202+ if "resubmitKey" in request.args:
4203+ key = _parse_key(request.args["resubmitKey"][0])
4204+ self._plugin.resubmit_commit(d, key)
4205+ else:
4206+ self._plugin.submit_commit(d,
4207+ request.args['username'][0],
4208+ request.args['commitdiff'][0],
4209+ request.args['target'][0],
4210+ request.args['buglist'][0],
4211+ request.args.get('commitmsg', [''])[0],
4212+ request.args['stages'][0],
4213+ ws_disable=request.args['ws_disable'][0],
4214+ ws_reset=request.args['ws_reset'][0])
4215+ except Exception as e:
4216+ logging.exception(e)
4217+ d.errback(e)
4218+
4219+ return server.NOT_DONE_YET
4220+
4221+
4222+class KermitAborter(KermitPage):
4223+ def render_POST(self, request):
4224+ d = defer.Deferred()
4225+ d.addCallbacks(lambda k: self._render_success(request, k),
4226+ lambda f: self._render_fail(request, f))
4227+ try:
4228+ key = _parse_key(request.args["key"][0])
4229+ self._plugin.abort_commit(d, key)
4230+ except Exception as e:
4231+ logging.exception(e)
4232+ d.errback(e)
4233+
4234+ return server.NOT_DONE_YET
4235+
4236+
4237+class KermitQueue(KermitPage):
4238+ def render_GET(self, request):
4239+ items = self._plugin.db.fetch((EndroidUniqueID, "user",
4240+ "target", "status"))
4241+
4242+ for item in items:
4243+ item['key'] = "kermit-{}".format(item.id)
4244+ item['status'] = json.loads(item['status'])
4245+ config = self._plugin.read_config(item['target'])
4246+ item['config'] = config
4247+
4248+ return self._plugin.http.get_template("kermit/queue.html").render({
4249+ "items": items,
4250+ "username": request.getUser(),
4251+ }).encode("utf-8")
4252+
4253+
4254+# Key Management
4255+
4256+class KermitProfile(KermitPage):
4257+ def render_GET(self, request):
4258+ return self._plugin.http.get_template("kermit/profile.html").render({
4259+ "username": request.getUser(),
4260+ "keys": self._plugin.list_keys(request.getUser()),
4261+ }).encode("utf-8")
4262+
4263+
4264+class KermitUploadKey(KermitPage):
4265+ def render_POST(self, request):
4266+ try:
4267+ key = self._plugin.add_key(request.getUser(),
4268+ request.args["privatekey"][0])
4269+ except Exception as e:
4270+ return self._render_fail(request, failure.Failure(e))
4271+ else:
4272+ return self._render_success(request, key)
4273+
4274+
4275+class KermitGenKey(KermitPage):
4276+ def render_GET(self, request):
4277+ def _save_key(key):
4278+ return self._plugin.add_key(request.getUser(),
4279+ key.toString("OPENSSH"))
4280+ d = self._plugin.generate_key(request.getUser())
4281+ d.addCallback(_save_key)
4282+ d.addCallbacks(lambda k: self._render_success(request, k),
4283+ lambda f: self._render_fail(request, f))
4284+ return server.NOT_DONE_YET
4285+
4286+
4287+class KermitDelKey(KermitPage):
4288+ def render_GET(self, request):
4289+ try:
4290+ self._plugin.remove_key(request.getUser(), request.args['idx'][0])
4291+ except Exception as e:
4292+ return self._render_fail(request, failure.Failure(e))
4293+ else:
4294+ return self._render_success(request, 'done')
4295+
4296+
4297+# Logfile access
4298+
4299+_CHUNK_SIZE = 1024 * 1024
4300+
4301+class LogViewer(KermitPage):
4302+ def render_GET(self, request):
4303+ self._get_log(request)
4304+ return server.NOT_DONE_YET
4305+
4306+ @defer.inlineCallbacks
4307+ def _get_log(self, request):
4308+ key = _parse_key(request.args['key'][0])
4309+ row = self._plugin.db.fetch(("status",), {EndroidUniqueID: key})
4310+
4311+ if not row:
4312+ self._render_fail(request,
4313+ failure.Failure(KeyError("no such commit entry: {}"
4314+ .format(request.args['key'][0]))))
4315+ return
4316+ job = row[0]
4317+
4318+ status = json.loads(job['status'])
4319+ target = status['inputargs']['target']
4320+ cfg = self._plugin.read_config(target)
4321+ section = cfg.section(target)
4322+ user = request.getUser()
4323+
4324+ # The userdir in the filename is the owner of the operation, not
4325+ # (necessarily) the person making the request
4326+ filename = os.path.join(section.get("dir"),
4327+ status['inputargs']['username'], 'logs',
4328+ request.args['key'][0],
4329+ request.args['stage'][0] + '.log')
4330+
4331+ logging.info("Opening {} as {} on {}".format(filename, user,
4332+ section.get('server')))
4333+
4334+ try:
4335+ viewer = yield ssh.get_logviewer(
4336+ section.get('server'),
4337+ section.getlist('fingerprints', default=[]),
4338+ user,
4339+ self._plugin.get_private_keys(user))
4340+
4341+ file = yield viewer.openFile(str(filename),
4342+ filetransfer.FXF_READ, {})
4343+
4344+ attrs = yield defer.maybeDeferred(file.getAttrs)
4345+
4346+ request.setHeader("Content-Type", "text/plain")
4347+ read_bytes = 0
4348+ while read_bytes < attrs['size']:
4349+ data = yield defer.maybeDeferred(file.readChunk,
4350+ read_bytes, _CHUNK_SIZE)
4351+ logging.info("Read {} bytes".format(len(data)))
4352+ read_bytes += len(data)
4353+ request.write(data)
4354+
4355+ request.finish()
4356+ yield defer.maybeDeferred(file.close)
4357+ except Exception as e:
4358+ logging.error("Failed to read the whole file")
4359+ logging.exception(e)
4360+ self._render_fail(request, failure.Failure(e))
4361+ finally:
4362+ viewer.transport.loseConnection()
4363+
4364+
4365+# Web root
4366+
4367+class KermitRoot(resource.Resource):
4368+ def __init__(self, plugin):
4369+ resource.Resource.__init__(self)
4370+ self._plugin = plugin
4371+ self.putChild("", plugin.http.authed_resource(KermitQueue(plugin)))
4372+ self.putChild("update", KermitUpdater(plugin))
4373+ self.putChild("submit", KermitSubmitter(plugin))
4374+ self.putChild("list", KermitLister(plugin))
4375+ self.putChild("stages", KermitStager(plugin))
4376+ self.putChild("query",
4377+ plugin.http.authed_resource(KermitQuerier(plugin)))
4378+ self.putChild("abort", KermitAborter(plugin))
4379+
4380+ # Key management
4381+ self.putChild("profile",
4382+ plugin.http.authed_resource(KermitProfile(plugin)))
4383+ self.putChild("uploadkey",
4384+ plugin.http.authed_resource(KermitUploadKey(plugin)))
4385+ self.putChild("generatekey",
4386+ plugin.http.authed_resource(KermitGenKey(plugin)))
4387+ self.putChild("removekey",
4388+ plugin.http.authed_resource(KermitDelKey(plugin)))
4389+
4390+ # Logfile access
4391+ self.putChild("logfile",
4392+ plugin.http.authed_resource(LogViewer(plugin)))
4393+
4394+ def render_GET(self, request):
4395+ return util.redirectTo(request.path + '/', request)

Subscribers

People subscribed via source and target branches