diff --git a/README.md b/README.md
index 8922718a..92883dea 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,26 @@ pip install -e homu
4. Add a Webhook to your continuous integration service:
- - Buildbot
+ - Buildbot 0.9 and later
+
+ Insert the following code to the `master.cfg` file:
+
+ ```python
+ from buildbot.status.status_push import HttpStatusPush
+
+ def homuStatusUpdate(build):
+ build['secret'] = 'repo.NAME.buildbot.secret in cfg.toml'
+ return build
+
+ c['services'].append(HttpStatusPush(
+ serverUrl='http://HOST:PORT/buildbot',
+ format_fn=homuStatusUpdate,
+ wantProperties=True,
+ wantSteps=True,
+ ))
+ ```
+
+ - Buildbot before 0.9
Insert the following code to the `master.cfg` file:
diff --git a/homu/buildbot.py b/homu/buildbot.py
new file mode 100644
index 00000000..94f53b31
--- /dev/null
+++ b/homu/buildbot.py
@@ -0,0 +1,251 @@
+from contextlib import contextmanager
+import json
+import re
+import requests
+import time
+
+INTERRUPTED_BY_HOMU_FMT = 'Interrupted by Homu ({})'
+INTERRUPTED_BY_HOMU_RE = re.compile(r'Interrupted by Homu \((.+?)\)')
+
+
+@contextmanager
+def buildbot_sess(repo_cfg):
+ sess = requests.Session()
+
+ try:
+ sess.api_url = '{}/api/v2'.format(repo_cfg['buildbot']['url'])
+ requests.get(sess.api_url)
+ sess.v1 = False
+ except requests.exceptions.RequestException:
+ sess.api_url = repo_cfg['buildbot']['url']
+ sess.v1 = True
+
+ sess.post(
+ '{}{}/login'.format(
+ sess.api_url,
+ '' if sess.v1 else '/auth',
+ ),
+ allow_redirects=False,
+ data={
+ 'username': repo_cfg['buildbot']['username'],
+ 'passwd': repo_cfg['buildbot']['password'],
+ })
+
+ yield sess
+
+ sess.get(
+ '{}{}/logout'.format(
+ sess.api_url,
+ '' if sess.v1 else '/auth',
+ ), allow_redirects=False)
+
+
+def buildbot_stopselected(repo_cfg):
+ err = ''
+ with buildbot_sess(repo_cfg) as sess:
+ if sess.v1:
+ res = sess.post(
+ sess.api_url + '/builders/_selected/stopselected', # noqa
+ allow_redirects=False,
+ data={
+ 'selected': repo_cfg['buildbot']['builders'],
+ 'comments': INTERRUPTED_BY_HOMU_FMT.format(int(time.time())), # noqa
+ })
+
+ if 'authzfail' in res.text:
+ err = 'Authorization failed'
+ else:
+ mat = re.search('(?s)
(.*?)
', res.text)
+ if mat:
+ err = mat.group(1).strip()
+ if not err:
+ err = 'Unknown error'
+ else:
+ err = ''
+ else:
+ # Buildbot 0.9 only accepts builder ids, so we need to translate the
+ # name list into an id list
+ builders = requests.get('{}/builders?field=builderid&field=name'.format(
+ sess.api_url,
+ )).json()['builders']
+ id_map = {b['name']: b['builderid'] for b in builders}
+ for builder in repo_cfg['buildbot']['builders']:
+ builds = requests.get('{}/builders/{}/builds?field=complete&field=number'.format(
+ sess.api_url,
+ id_map[builder],
+ )).json()['builds']
+ builds = [b for b in builds if not b['complete']]
+ for build in builds:
+ res = sess.post(
+ '{}/builders/{}/builds/{}'.format(
+ sess.api_url,
+ id_map[builder],
+ build['number'],
+ ),
+ allow_redirects=False,
+ json={
+ 'jsonrpc': '2.0',
+ 'method': 'stop',
+ 'id': 'stop',
+ 'params': {
+ 'reason': INTERRUPTED_BY_HOMU_FMT.format(int(time.time())), # noqa
+ },
+ },
+ )
+
+ if not res.ok:
+ err = '{}: {}'.format(
+ res.status_code,
+ res.json()['error']['message'],
+ )
+
+ return err
+
+
+def buildbot_rebuild(sess, builder, url):
+ err = ''
+ if sess.v1:
+ res = sess.post(url + '/rebuild', allow_redirects=False, data={
+ 'useSourcestamp': 'exact',
+ 'comments': 'Initiated by Homu',
+ })
+
+ if 'authzfail' in res.text:
+ err = 'Authorization failed'
+ elif builder in res.text:
+ err = ''
+ else:
+ mat = re.search('(.+?)', res.text)
+ err = mat.group(1) if mat else 'Unknown error'
+ else:
+ res = sess.post(url.replace('#', 'api/v2/'), allow_redirects=False, json={
+ 'jsonrpc': '2.0',
+ 'method': 'rebuild',
+ 'id': 'rebuild',
+ 'params': {
+ 'useSourcestamp': 'exact',
+ 'comments': 'Initiated by Homu',
+ },
+ })
+
+ if not res.ok:
+ err = '{}: {}'.format(
+ res.status_code,
+ res.json()['error']['message'],
+ )
+
+ return err
+
+
+class BuildbotBuilderStep:
+ def __init__(self, step):
+ self.name = step['name']
+ self.number = step.get('number', -1)
+ self.text = step.get('text', [])
+ self.urls = step.get('urls', [])
+
+class BuildbotStatusPacket:
+ def __init__(self, v1, packet):
+ # Buildbot 0.9+ does not have events, only a complete flag depending on
+ # whether this was triggered by buildStarted or buildFinished
+ self._v1 = v1
+ if v1:
+ self.event = packet['event']
+ else:
+ self.event = 'buildFinished' if packet['complete'] else 'buildStarted'
+ self._info = packet['payload']['build'] if v1 else packet
+
+ self.builder_name = self._info['builderName'] if v1 else self._info['builder']['name']
+ self.properties = dict(x[:2] for x in self._info['properties']) if v1 else {k: v[0] for k, v in self._info['properties'].items()}
+ self.results = self._info['results']
+ self.steps = [BuildbotBuilderStep(s) for s in self._info['steps']]
+ self.text = self._info['text' if v1 else 'state_string']
+
+ def url(self, repo_cfg):
+ return self._info['url'] if not self._v1 else '{}/builders/{}/builds/{}'.format(
+ repo_cfg['buildbot']['url'],
+ self.builder_name,
+ self.properties['buildnumber'],
+ )
+
+ def interrupted(self):
+ return 'interrupted' in self.text if self._v1 else self.results == 6
+
+ def interrupt_reason(self, repo_cfg):
+ if self._v1:
+ step_name = ''
+ for step in reversed(self.steps):
+ if 'interrupted' in step.text:
+ step_name = step.name
+ break
+
+ if step_name:
+ url = (
+ '{}/builders/{}/builds/{}/steps/{}/logs/interrupt' # noqa
+ ).format(
+ repo_cfg['buildbot']['url'],
+ self.builder_name,
+ self.properties['buildnumber'],
+ step_name,
+ )
+ res = requests.get(url)
+ return res.text
+ else:
+ def cancelled_url(builder_id, build_number, step_number):
+ return (
+ '{}/api/v2/builders/{}/builds/{}/steps/{}/logs/cancelled/contents?field=content' # noqa
+ ).format(
+ repo_cfg['buildbot']['url'],
+ builder_id,
+ build_number,
+ step_number,
+ )
+ for step in reversed(self.steps):
+ res = requests.get(cancelled_url(
+ self._info['builderid'],
+ self.properties['buildnumber'],
+ step.number))
+ if res.ok:
+ return res.json()['logchunks'][0]['content']
+ else:
+ # Trigger steps don't store the cancelled reason, so we have
+ # to search for it recursively
+ for url in step.urls:
+ if 'buildrequests/' in url['url']:
+ buildrequest_id = url['url'].rsplit('/', 1)[-1]
+ builder_id = requests.get(
+ '{}/api/v2/buildrequests/{}?field=builderid'.format(
+ repo_cfg['buildbot']['url'],
+ buildrequest_id,
+ )).json()['buildrequests'][0]['builderid']
+ build_number = requests.get(
+ '{}/api/v2/builders/{}/builds?field=number&field=buildrequestid&buildrequestid__eq={}'.format(
+ repo_cfg['buildbot']['url'],
+ builder_id,
+ buildrequest_id,
+ )).json()['builds'][0]['number']
+ steps = requests.get(
+ '{}/api/v2/builders/{}/builds/{}/steps?field=number'.format(
+ repo_cfg['buildbot']['url'],
+ builder_id,
+ build_number,
+ )).json()['steps']
+ for inner_step in reversed(steps):
+ res = requests.get(cancelled_url(
+ builder_id,
+ build_number,
+ inner_step['number']))
+ if res.ok:
+ return res.json()['logchunks'][0]['content']
+ return None
+
+ def __repr__(self):
+ return {k: v for k, v in self._info.items() if k != 'secret'}.__repr__()
+
+
+class BuildbotStatus:
+ def __init__(self, request):
+ v1 = request.json is None
+ self.packets = [BuildbotStatusPacket(v1, p) for p in (
+ json.loads(request.forms.packets) if v1 else [request.json])]
+ self.secret = request.forms.secret if v1 else request.json['secret']
diff --git a/homu/main.py b/homu/main.py
index 556fa2e8..ae31b330 100644
--- a/homu/main.py
+++ b/homu/main.py
@@ -10,7 +10,6 @@
import traceback
import sqlite3
import requests
-from contextlib import contextmanager
from itertools import chain
from queue import Queue
import os
@@ -18,6 +17,11 @@
from enum import IntEnum
import subprocess
from .git_helper import SSH_KEY_FILE
+from .buildbot import (
+ buildbot_rebuild,
+ buildbot_sess,
+ buildbot_stopselected,
+)
import shlex
import random
@@ -30,30 +34,11 @@
'failure': 5,
}
-INTERRUPTED_BY_HOMU_FMT = 'Interrupted by Homu ({})'
-INTERRUPTED_BY_HOMU_RE = re.compile(r'Interrupted by Homu \((.+?)\)')
TEST_TIMEOUT = 3600 * 10
global_cfg = {}
-@contextmanager
-def buildbot_sess(repo_cfg):
- sess = requests.Session()
-
- sess.post(
- repo_cfg['buildbot']['url'] + '/login',
- allow_redirects=False,
- data={
- 'username': repo_cfg['buildbot']['username'],
- 'passwd': repo_cfg['buildbot']['password'],
- })
-
- yield sess
-
- sess.get(repo_cfg['buildbot']['url'] + '/logout', allow_redirects=False)
-
-
db_query_lock = Lock()
@@ -583,25 +568,7 @@ def parse_commands(body, username, repo_cfg, state, my_username, db, states,
if not _try_auth_verified:
continue
if 'buildbot' in repo_cfg:
- with buildbot_sess(repo_cfg) as sess:
- res = sess.post(
- repo_cfg['buildbot']['url'] + '/builders/_selected/stopselected', # noqa
- allow_redirects=False,
- data={
- 'selected': repo_cfg['buildbot']['builders'],
- 'comments': INTERRUPTED_BY_HOMU_FMT.format(int(time.time())), # noqa
- })
-
- if 'authzfail' in res.text:
- err = 'Authorization failed'
- else:
- mat = re.search('(?s)(.*?)
', res.text)
- if mat:
- err = mat.group(1).strip()
- if not err:
- err = 'Unknown error'
- else:
- err = ''
+ err = buildbot_stopselected(repo_cfg)
if err:
state.add_comment(
@@ -1169,19 +1136,7 @@ def start_rebuild(state, repo_cfgs):
with buildbot_sess(repo_cfg) as sess:
for builder, url in builders:
- res = sess.post(url + '/rebuild', allow_redirects=False, data={
- 'useSourcestamp': 'exact',
- 'comments': 'Initiated by Homu',
- })
-
- if 'authzfail' in res.text:
- err = 'Authorization failed'
- elif builder in res.text:
- err = ''
- else:
- mat = re.search('(.+?)', res.text)
- err = mat.group(1) if mat else 'Unknown error'
-
+ err = buildbot_rebuild(sess, builder, url)
if err:
state.add_comment(':bomb: Failed to start rebuilding: `{}`'.format(err)) # noqa
return False
diff --git a/homu/server.py b/homu/server.py
index f3d93b9a..b619373d 100644
--- a/homu/server.py
+++ b/homu/server.py
@@ -5,9 +5,12 @@
PullReqState,
parse_commands,
db_query,
- INTERRUPTED_BY_HOMU_RE,
synchronize,
)
+from .buildbot import (
+ BuildbotStatus,
+ INTERRUPTED_BY_HOMU_RE,
+)
from . import utils
from .utils import lazy_debug
import github3
@@ -551,13 +554,14 @@ def buildbot():
response.content_type = 'text/plain'
- for row in json.loads(request.forms.packets):
- if row['event'] == 'buildFinished':
- info = row['payload']['build']
- lazy_debug(logger, lambda: 'info: {}'.format(info))
- props = dict(x[:2] for x in info['properties'])
+ status = BuildbotStatus(request)
- if 'retry' in info['text']:
+ for packet in status.packets:
+ if packet.event == 'buildFinished':
+ lazy_debug(logger, lambda: 'info: {}'.format(packet))
+ props = packet.properties
+
+ if 'retry' in packet.text:
continue
if not props['revision']:
@@ -572,80 +576,61 @@ def buildbot():
lazy_debug(logger, lambda: 'state: {}, {}'.format(state, state.build_res_summary())) # noqa
- if info['builderName'] not in state.build_res:
+ if packet.builder_name not in state.build_res:
lazy_debug(logger,
- lambda: 'Invalid builder from Buildbot: {}'.format(info['builderName'])) # noqa
+ lambda: 'Invalid builder from Buildbot: {}'.format(packet.builder_name)) # noqa
continue
repo_cfg = g.repo_cfgs[repo_label]
- if request.forms.secret != repo_cfg['buildbot']['secret']:
+ if status.secret != repo_cfg['buildbot']['secret']:
abort(400, 'Invalid secret')
- build_succ = 'successful' in info['text'] or info['results'] == 0
+ build_succ = 'successful' in packet.text or packet.results == 0
- url = '{}/builders/{}/builds/{}'.format(
- repo_cfg['buildbot']['url'],
- info['builderName'],
- props['buildnumber'],
- )
+ url = packet.url(repo_cfg)
- if 'interrupted' in info['text']:
- step_name = ''
- for step in reversed(info['steps']):
- if 'interrupted' in step.get('text', []):
- step_name = step['name']
- break
-
- if step_name:
- try:
- url = ('{}/builders/{}/builds/{}/steps/{}/logs/interrupt' # noqa
- ).format(repo_cfg['buildbot']['url'],
- info['builderName'],
- props['buildnumber'],
- step_name,)
- res = requests.get(url)
- except Exception as ex:
- logger.warn('/buildbot encountered an error during '
- 'github logs request')
- # probably related to
- # https://gitlab.com/pycqa/flake8/issues/42
- lazy_debug(logger, lambda: 'buildbot logs err: {}'.format(ex)) # noqa
- abort(502, 'Bad Gateway')
-
- mat = INTERRUPTED_BY_HOMU_RE.search(res.text)
- if mat:
- interrupt_token = mat.group(1)
- if getattr(state, 'interrupt_token',
- '') != interrupt_token:
- state.interrupt_token = interrupt_token
-
- if state.status == 'pending':
- state.set_status('')
-
- desc = (':snowman: The build was interrupted '
- 'to prioritize another pull request.')
- state.add_comment(desc)
- utils.github_create_status(state.get_repo(),
- state.head_sha,
- 'error', url,
- desc,
- context='homu')
-
- g.queue_handler()
-
- continue
+ if packet.interrupted():
+ try:
+ mat = INTERRUPTED_BY_HOMU_RE.search(packet.interrupt_reason(repo_cfg))
+ except Exception as ex:
+ logger.warn('/buildbot encountered an error during '
+ 'github logs request')
+ # probably related to
+ # https://gitlab.com/pycqa/flake8/issues/42
+ lazy_debug(logger, lambda: 'buildbot logs err: {}'.format(ex)) # noqa
+ abort(502, 'Bad Gateway')
+ if mat:
+ interrupt_token = mat.group(1)
+ if getattr(state, 'interrupt_token',
+ '') != interrupt_token:
+ state.interrupt_token = interrupt_token
+
+ if state.status == 'pending':
+ state.set_status('')
+
+ desc = (':snowman: The build was interrupted '
+ 'to prioritize another pull request.')
+ state.add_comment(desc)
+ utils.github_create_status(state.get_repo(),
+ state.head_sha,
+ 'error', url,
+ desc,
+ context='homu')
+
+ g.queue_handler()
+
+ continue
else:
logger.error('Corrupt payload from Buildbot')
- report_build_res(build_succ, url, info['builderName'],
+ report_build_res(build_succ, url, packet.builder_name,
state, logger, repo_cfg)
- elif row['event'] == 'buildStarted':
- info = row['payload']['build']
- lazy_debug(logger, lambda: 'info: {}'.format(info))
- props = dict(x[:2] for x in info['properties'])
+ elif packet.event == 'buildStarted':
+ lazy_debug(logger, lambda: 'info: {}'.format(packet))
+ props = packet.properties
if not props['revision']:
continue
@@ -655,19 +640,15 @@ def buildbot():
except ValueError:
pass
else:
- if info['builderName'] in state.build_res:
+ if packet.builder_name in state.build_res:
repo_cfg = g.repo_cfgs[repo_label]
- if request.forms.secret != repo_cfg['buildbot']['secret']:
+ if status.secret != repo_cfg['buildbot']['secret']:
abort(400, 'Invalid secret')
- url = '{}/builders/{}/builds/{}'.format(
- repo_cfg['buildbot']['url'],
- info['builderName'],
- props['buildnumber'],
- )
+ url = packet.url(repo_cfg)
- state.set_build_res(info['builderName'], None, url)
+ state.set_build_res(packet.builder_name, None, url)
if g.buildbot_slots[0] == props['revision']:
g.buildbot_slots[0] = ''