[IMP] runbot: share sources between builds

Multibuild can create generate a lots of checkout, especially for small
and fast jobs, which can overload runbot discs since we are trying not
to clean build immediatly. (To ease bug fix and allow wake up)

This commit proposes to store source on a single place, so that
docker can add them as ro volume in the build directory.
The checkout is also moved to the installs jobs, so that
builds containing only create builds steps won't checkout
the sources.

This change implies to use --addons-path correctly, since odoo
and enterprise addons wont be merged in the same repo anymore.
This will allow to test addons a dev will do, with a closer
command line.

This implies to change the code structure a litle, some changes
where made to remove no-so-usefull fields on build, and some
hard-coded logic (manifest_names and server_names) are now
stored on repo instead.

This changes implies that a build CANNOT write in his sources.
It shouldn't be the case, but it means that runbot cannot be
tested on runbot untill datas are written elsewhere than in static.

Other possibilities are possible, like bind mounting the sources
in the build directory instead of adding ro volumes in docker.
Unfortunately, this needs to give access to mount as sudo for
runbot user and changes docjker config to allow mounts
in volumes which is not the case by default. A plus of this
solution would be to be able to make an overlay mount.
This commit is contained in:
Xavier-Do 2019-07-08 14:44:32 +02:00
parent 0830557cd6
commit f7a4fb7ac3
14 changed files with 382 additions and 185 deletions

1
.gitignore vendored
View File

@ -7,4 +7,5 @@
# runbot work files # runbot work files
runbot/static/build runbot/static/build
runbot/static/repo runbot/static/repo
runbot/static/sources
runbot/static/nginx runbot/static/nginx

View File

@ -6,7 +6,7 @@
'author': "Odoo SA", 'author': "Odoo SA",
'website': "http://runbot.odoo.com", 'website': "http://runbot.odoo.com",
'category': 'Website', 'category': 'Website',
'version': '4.2', 'version': '4.3',
'depends': ['website', 'base'], 'depends': ['website', 'base'],
'data': [ 'data': [
'security/runbot_security.xml', 'security/runbot_security.xml',

View File

@ -17,6 +17,20 @@ from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class Commit():
def __init__(self, repo, sha):
self.repo = repo
self.sha = sha
def _source_path(self, *path):
return self.repo._source_path(self.sha, *path)
def export(self):
return self.repo._git_export(self.sha)
def __str__(self):
return '%s:%s' % (self.repo.short_name, self.sha)
def fqdn(): def fqdn():
return socket.getfqdn() return socket.getfqdn()

View File

@ -39,7 +39,9 @@ def build_odoo_cmd(odoo_cmd):
# build cmd # build cmd
cmd_chain = [] cmd_chain = []
cmd_chain.append('cd /data/build') cmd_chain.append('cd /data/build')
cmd_chain.append('head -1 odoo-bin | grep -q python3 && sudo pip3 install -r requirements.txt || sudo pip install -r requirements.txt') server_path = odoo_cmd[0]
requirement_path = os.path.join(os.path.dirname(server_path), 'requirements.txt')
cmd_chain.append('head -1 %s | grep -q python3 && sudo pip3 install -r %s || sudo pip install -r %s' % (server_path, requirement_path, requirement_path))
cmd_chain.append(' '.join(odoo_cmd)) cmd_chain.append(' '.join(odoo_cmd))
return ' && '.join(cmd_chain) return ' && '.join(cmd_chain)
@ -60,7 +62,7 @@ def docker_build(log_path, build_dir):
dbuild = subprocess.Popen(['docker', 'build', '--tag', 'odoo:runbot_tests', '.'], stdout=logs, stderr=logs, cwd=docker_dir) dbuild = subprocess.Popen(['docker', 'build', '--tag', 'odoo:runbot_tests', '.'], stdout=logs, stderr=logs, cwd=docker_dir)
dbuild.wait() dbuild.wait()
def docker_run(run_cmd, log_path, build_dir, container_name, exposed_ports=None, cpu_limit=None, preexec_fn=None): def docker_run(run_cmd, log_path, build_dir, container_name, exposed_ports=None, cpu_limit=None, preexec_fn=None, ro_volumes=None):
"""Run tests in a docker container """Run tests in a docker container
:param run_cmd: command string to run in container :param run_cmd: command string to run in container
:param log_path: path to the logfile that will contain odoo stdout and stderr :param log_path: path to the logfile that will contain odoo stdout and stderr
@ -68,6 +70,7 @@ def docker_run(run_cmd, log_path, build_dir, container_name, exposed_ports=None,
This directory is shared as a volume with the container This directory is shared as a volume with the container
:param container_name: used to give a name to the container for later reference :param container_name: used to give a name to the container for later reference
:param exposed_ports: if not None, starting at 8069, ports will be exposed as exposed_ports numbers :param exposed_ports: if not None, starting at 8069, ports will be exposed as exposed_ports numbers
:params ro_volumes: dict of dest:source volumes to mount readonly in builddir
""" """
_logger.debug('Docker run command: %s', run_cmd) _logger.debug('Docker run command: %s', run_cmd)
logs = open(log_path, 'w') logs = open(log_path, 'w')
@ -81,13 +84,18 @@ def docker_run(run_cmd, log_path, build_dir, container_name, exposed_ports=None,
'--shm-size=128m', '--shm-size=128m',
'--init', '--init',
] ]
if ro_volumes:
for dest, source in ro_volumes.items():
logs.write("Adding readonly volume '%s' pointing to %s \n" % (dest, source))
docker_command.append('--volume=%s:/data/build/%s:ro' % (source, dest))
serverrc_path = os.path.expanduser('~/.openerp_serverrc') serverrc_path = os.path.expanduser('~/.openerp_serverrc')
odoorc_path = os.path.expanduser('~/.odoorc') odoorc_path = os.path.expanduser('~/.odoorc')
final_rc = odoorc_path if os.path.exists(odoorc_path) else serverrc_path if os.path.exists(serverrc_path) else None final_rc = odoorc_path if os.path.exists(odoorc_path) else serverrc_path if os.path.exists(serverrc_path) else None
if final_rc: if final_rc:
docker_command.extend(['--volume=%s:/home/odoo/.odoorc:ro' % final_rc]) docker_command.extend(['--volume=%s:/home/odoo/.odoorc:ro' % final_rc])
if exposed_ports: if exposed_ports:
for dp,hp in enumerate(exposed_ports, start=8069): for dp, hp in enumerate(exposed_ports, start=8069):
docker_command.extend(['-p', '127.0.0.1:%s:%s' % (hp, dp)]) docker_command.extend(['-p', '127.0.0.1:%s:%s' % (hp, dp)])
if cpu_limit: if cpu_limit:
docker_command.extend(['--ulimit', 'cpu=%s' % int(cpu_limit)]) docker_command.extend(['--ulimit', 'cpu=%s' % int(cpu_limit)])

View File

@ -8,10 +8,11 @@ import shutil
import subprocess import subprocess
import time import time
import datetime import datetime
from ..common import dt2time, fqdn, now, grep, uniq_list, local_pgadmin_cursor, s2human from ..common import dt2time, fqdn, now, grep, uniq_list, local_pgadmin_cursor, s2human, Commit
from ..container import docker_build, docker_stop, docker_is_running from ..container import docker_build, docker_stop, docker_is_running
from odoo.addons.runbot.models.repo import HashMissingException
from odoo import models, fields, api from odoo import models, fields, api
from odoo.exceptions import UserError from odoo.exceptions import UserError, ValidationError
from odoo.http import request from odoo.http import request
from odoo.tools import appdirs from odoo.tools import appdirs
from collections import defaultdict from collections import defaultdict
@ -47,7 +48,6 @@ class runbot_build(models.Model):
committer_email = fields.Char('Committer Email') committer_email = fields.Char('Committer Email')
subject = fields.Text('Subject') subject = fields.Text('Subject')
sequence = fields.Integer('Sequence') sequence = fields.Integer('Sequence')
modules = fields.Char("Modules to Install")
# state machine # state machine
@ -73,10 +73,6 @@ class runbot_build(models.Model):
build_time = fields.Integer(compute='_compute_build_time', string='Job time') build_time = fields.Integer(compute='_compute_build_time', string='Job time')
build_age = fields.Integer(compute='_compute_build_age', string='Build age') build_age = fields.Integer(compute='_compute_build_age', string='Build age')
duplicate_id = fields.Many2one('runbot.build', 'Corresponding Build', index=True) duplicate_id = fields.Many2one('runbot.build', 'Corresponding Build', index=True)
server_match = fields.Selection([('builtin', 'This branch includes Odoo server'),
('match', 'This branch includes Odoo server'),
('default', 'No match found - defaults to master')],
string='Server branch matching')
revdep_build_ids = fields.Many2many('runbot.build', 'runbot_rev_dep_builds', revdep_build_ids = fields.Many2many('runbot.build', 'runbot_rev_dep_builds',
column1='rev_dep_id', column2='dependent_id', column1='rev_dep_id', column2='dependent_id',
string='Builds that depends on this build') string='Builds that depends on this build')
@ -101,6 +97,11 @@ class runbot_build(models.Model):
log_list = fields.Char('Comma separted list of step_ids names with logs', compute="_compute_log_list", store=True) log_list = fields.Char('Comma separted list of step_ids names with logs', compute="_compute_log_list", store=True)
orphan_result = fields.Boolean('No effect on the parent result', default=False) orphan_result = fields.Boolean('No effect on the parent result', default=False)
commit_path_mode = fields.Selection([('rep_sha', 'repo name + sha'),
('soft', 'repo name only'),
],
default='soft',
string='Source export path mode')
@api.depends('config_id') @api.depends('config_id')
def _compute_log_list(self): # storing this field because it will be access trhoug repo viewn and keep track of the list at create def _compute_log_list(self): # storing this field because it will be access trhoug repo viewn and keep track of the list at create
for build in self: for build in self:
@ -281,6 +282,14 @@ class runbot_build(models.Model):
extra_info.update({'local_state': 'duplicate', 'duplicate_id': duplicate_id}) extra_info.update({'local_state': 'duplicate', 'duplicate_id': duplicate_id})
# maybe update duplicate priority if needed # maybe update duplicate priority if needed
docker_source_folders = set()
for commit in build_id.get_all_commit():
docker_source_folder = build_id._docker_source_folder(commit)
if docker_source_folder in docker_source_folders:
extra_info['commit_path_mode'] = 'rep_sha'
continue
docker_source_folders.add(docker_source_folder)
build_id.write(extra_info) build_id.write(extra_info)
if build_id.local_state == 'duplicate' and build_id.duplicate_id.global_state in ('running', 'done'): # and not build_id.parent_id: if build_id.local_state == 'duplicate' and build_id.duplicate_id.global_state in ('running', 'done'): # and not build_id.parent_id:
build_id._github_status() build_id._github_status()
@ -396,7 +405,6 @@ class runbot_build(models.Model):
'committer': build.committer, 'committer': build.committer,
'committer_email': build.committer_email, 'committer_email': build.committer_email,
'subject': build.subject, 'subject': build.subject,
'modules': build.modules,
'build_type': 'rebuild', 'build_type': 'rebuild',
} }
if exact: if exact:
@ -410,7 +418,6 @@ class runbot_build(models.Model):
values.update({ values.update({
'config_id': build.config_id.id, 'config_id': build.config_id.id,
'extra_params': build.extra_params, 'extra_params': build.extra_params,
'server_match': build.server_match,
'orphan_result': build.orphan_result, 'orphan_result': build.orphan_result,
}) })
#if replace: ? #if replace: ?
@ -436,6 +443,8 @@ class runbot_build(models.Model):
self.write({'local_state': 'done', 'local_result': 'skipped', 'duplicate_id': False}) self.write({'local_state': 'done', 'local_result': 'skipped', 'duplicate_id': False})
def _local_cleanup(self): def _local_cleanup(self):
if self.pool._init:
return
_logger.debug('Local cleaning') _logger.debug('Local cleaning')
@ -457,8 +466,7 @@ class runbot_build(models.Model):
existing = builds.exists() existing = builds.exists()
remaining = (builds - existing) remaining = (builds - existing)
if remaining: if remaining:
dest_list = [dest for sublist in [dest_by_builds_ids[rem_id] for rem_id in remaining] for dest in sublist] dest_list = [dest for sublist in [dest_by_builds_ids[rem_id] for rem_id in remaining.ids] for dest in sublist]
#dest_list = [dest for dest in dest_by_builds_ids[rem_id] for rem_id in remaining]
_logger.debug('(%s) (%s) not deleted because no corresponding build found' % (label, " ".join(dest_list))) _logger.debug('(%s) (%s) not deleted because no corresponding build found' % (label, " ".join(dest_list)))
for build in existing: for build in existing:
if fields.Datetime.from_string(build.create_date) + datetime.timedelta(days=max_days) < datetime.datetime.now(): if fields.Datetime.from_string(build.create_date) + datetime.timedelta(days=max_days) < datetime.datetime.now():
@ -546,7 +554,7 @@ class runbot_build(models.Model):
build._log('_schedule', 'Init build environment with config %s ' % build.config_id.name) build._log('_schedule', 'Init build environment with config %s ' % build.config_id.name)
# notify pending build - avoid confusing users by saying nothing # notify pending build - avoid confusing users by saying nothing
build._github_status() build._github_status()
build._checkout() os.makedirs(build._path('logs'), exist_ok=True)
build._log('_schedule', 'Building docker image') build._log('_schedule', 'Building docker image')
docker_build(build._path('logs', 'docker_build.txt'), build._path()) docker_build(build._path('logs', 'docker_build.txt'), build._path())
except Exception: except Exception:
@ -632,128 +640,102 @@ class runbot_build(models.Model):
root = self.env['runbot.repo']._root() root = self.env['runbot.repo']._root()
return os.path.join(root, 'build', build.dest, *l) return os.path.join(root, 'build', build.dest, *l)
def _server(self, *l, **kw): # not really build related, specific to odoo version, could be a data def _server(self, *path):
"""Return the build server path""" """Return the absolute path to the direcory containing the server file, adding optional *path"""
self.ensure_one() self.ensure_one()
build = self commit = self.get_server_commit()
if os.path.exists(build._path('odoo')): if os.path.exists(commit._source_path('odoo')):
return build._path('odoo', *l) return commit._source_path('odoo', *path)
return build._path('openerp', *l) return commit._source_path('openerp', *path)
def _filter_modules(self, modules, available_modules, explicit_modules): def _filter_modules(self, modules, available_modules, explicit_modules):
# TODO add blacklist_modules and blacklist prefixes as data on repo
blacklist_modules = set(['auth_ldap', 'document_ftp', 'base_gengo', blacklist_modules = set(['auth_ldap', 'document_ftp', 'base_gengo',
'website_gengo', 'website_instantclick', 'website_gengo', 'website_instantclick',
'pad', 'pad_project', 'note_pad', 'pad', 'pad_project', 'note_pad',
'pos_cache', 'pos_blackbox_be']) 'pos_cache', 'pos_blackbox_be'])
mod_filter = lambda m: ( def mod_filter(module):
m in available_modules and if module not in available_modules:
(m in explicit_modules or (not m.startswith(('hw_', 'theme_', 'l10n_')) and return False
m not in blacklist_modules)) if module in explicit_modules:
) return True
return uniq_list(filter(mod_filter, modules)) if module.startswith(('hw_', 'theme_', 'l10n_')):
return False
if module in blacklist_modules:
return False
return True
def _checkout(self): return uniq_list([module for module in modules if mod_filter(module)])
def _get_available_modules(self, commit):
for manifest_file_name in commit.repo.manifest_files.split(','): # '__manifest__.py' '__openerp__.py'
for addons_path in commit.repo.addons_paths.split(','): # '' 'addons' 'odoo/addons'
sep = os.path.join(addons_path, '*')
for manifest_path in glob.glob(commit._source_path(sep, manifest_file_name)):
module = os.path.basename(os.path.dirname(manifest_path))
yield (addons_path, module, manifest_file_name)
def _docker_source_folder(self, commit):
# in case some build have commits with the same repo name (ex: foo/bar, foo-ent/bar)
# it can be usefull to uniquify commit export path using hash
if self.commit_path_mode == 'rep_sha':
return '%s-%s' % (commit.repo._get_repo_name_part(), commit.sha[:8])
else:
return commit.repo._get_repo_name_part()
def _checkout(self, commits=None):
self.ensure_one() # will raise exception if hash not found, we don't want to fail for all build. self.ensure_one() # will raise exception if hash not found, we don't want to fail for all build.
# starts from scratch
build = self
if os.path.isdir(build._path()):
shutil.rmtree(build._path())
# runbot log path
os.makedirs(build._path("logs"), exist_ok=True)
os.makedirs(build._server('addons'), exist_ok=True)
# update repo if needed
if not build.repo_id._hash_exists(build.name):
build.repo_id._update()
# checkout branch # checkout branch
build.branch_id.repo_id._git_export(build.name, build._path()) exports = {}
for commit in commits or self.get_all_commit():
build_export_path = self._docker_source_folder(commit)
if build_export_path in exports:
self.log('_checkout', 'Multiple repo have same export path in build, some source may be missing for %s' % build_export_path, level='ERROR')
self._kill(result='ko')
try:
exports[build_export_path] = commit.export()
except HashMissingException:
self._log('_checkout', "Commit %s is unreachable. Did you force push the branch since build creation?" % commit, level='ERROR')
self.kill(result='ko')
return exports
has_server = os.path.isfile(build._server('__init__.py')) def _get_modules_to_test(self, commits=None):
server_match = 'builtin' self.ensure_one() # will raise exception if hash not found, we don't want to fail for all build.
# checkout branch
# build complete set of modules to install repo_modules = []
modules_to_move = [] available_modules = []
modules_to_test = ((build.branch_id.modules or '') + ',' + for commit in commits or self.get_all_commit():
(build.repo_id.modules or '')) for (addons_path, module, manifest_file_name) in self._get_available_modules(commit):
modules_to_test = list(filter(None, modules_to_test.split(','))) # ??? if commit.repo == self.repo_id:
explicit_modules = set(modules_to_test) repo_modules.append(module)
_logger.debug("manual modules_to_test for build %s: %s", build.dest, modules_to_test) if module in available_modules:
self._log(
if not has_server: 'Building environment',
if build.repo_id.modules_auto == 'repo': '%s is a duplicated modules (found in "%s")' % (module, commit._source_path(addons_path, module, manifest_file_name)),
modules_to_test += [ level='WARNING'
os.path.basename(os.path.dirname(a)) )
for a in (glob.glob(build._path('*/__openerp__.py')) +
glob.glob(build._path('*/__manifest__.py')))
]
_logger.debug("local modules_to_test for build %s: %s", build.dest, modules_to_test)
# todo make it backward compatible, or create migration script?
for build_dependency in build.dependency_ids:
closest_branch = build_dependency.closest_branch_id
latest_commit = build_dependency.dependency_hash
repo = closest_branch.repo_id or build_dependency.repo_id
closest_name = closest_branch.name or 'no_branch'
if build_dependency.match_type == 'default':
server_match = 'default'
elif server_match != 'default':
server_match = 'match'
build._log(
'_checkout', 'Checkouting %s from %s' % (closest_name, repo.name)
)
if not repo._hash_exists(latest_commit):
repo._update(force=True)
if not repo._hash_exists(latest_commit):
try:
repo._git(['fetch', 'origin', latest_commit])
except:
pass
if not repo._hash_exists(latest_commit):
build._log('_checkout', "Dependency commit %s in repo %s is unreachable. Did you force push the branch since build creation?" % (latest_commit, repo.name))
raise Exception
repo._git_export(latest_commit, build._path())
# Finally mark all addons to move to openerp/addons
modules_to_move += [
os.path.dirname(module)
for module in (glob.glob(build._path('*/__openerp__.py')) +
glob.glob(build._path('*/__manifest__.py')))
]
# move all addons to server addons path
for module in uniq_list(glob.glob(build._path('addons/*')) + modules_to_move):
basename = os.path.basename(module)
addon_path = build._server('addons', basename)
if os.path.exists(addon_path):
build._log(
'Building environment',
'You have duplicate modules in your branches "%s"' % basename
)
if os.path.islink(addon_path) or os.path.isfile(addon_path):
os.remove(addon_path)
else: else:
shutil.rmtree(addon_path) available_modules.append(module)
shutil.move(module, build._server('addons')) explicit_modules = uniq_list([module for module in (self.branch_id.modules or '').split(',') + (self.repo_id.modules or '').split(',') if module])
available_modules = [ if explicit_modules:
os.path.basename(os.path.dirname(a)) _logger.debug("explicit modules_to_test for build %s: %s", self.dest, explicit_modules)
for a in (glob.glob(build._server('addons/*/__openerp__.py')) +
glob.glob(build._server('addons/*/__manifest__.py')))
]
if build.repo_id.modules_auto == 'all' or (build.repo_id.modules_auto != 'none' and has_server):
modules_to_test += available_modules
modules_to_test = self._filter_modules(modules_to_test, if set(explicit_modules) - set(available_modules):
set(available_modules), explicit_modules) self.log('checkout', 'Some explicit modules (branch or repo defined) are not in available module list.', level='WARNING')
_logger.debug("modules_to_test for build %s: %s", build.dest, modules_to_test)
build.write({'server_match': server_match, if self.repo_id.modules_auto == 'all':
'modules': ','.join(modules_to_test)}) modules_to_test = available_modules
elif self.repo_id.modules_auto == 'repo':
modules_to_test = explicit_modules + repo_modules
_logger.debug("local modules_to_test for build %s: %s", self.dest, modules_to_test)
else:
modules_to_test = explicit_modules
modules_to_test = self._filter_modules(modules_to_test, available_modules, explicit_modules)
_logger.debug("modules_to_test for build %s: %s", self.dest, modules_to_test)
return modules_to_test
def _local_pg_dropdb(self, dbname): def _local_pg_dropdb(self, dbname):
with local_pgadmin_cursor() as local_cr: with local_pgadmin_cursor() as local_cr:
@ -837,28 +819,51 @@ class runbot_build(models.Model):
if not child.duplicate_id: if not child.duplicate_id:
child._ask_kill() child._ask_kill()
def _cmd(self): # why not remove build.modules output ? def get_all_commit(self):
return [Commit(self.repo_id, self.name)] + [Commit(dep.get_repo(), dep.dependency_hash) for dep in self.dependency_ids]
def get_server_commit(self, commits=None):
"""
returns a Commit() of the first repo containing server files found in commits or in build commits
the commits param is not used in code base but could be usefull for jobs and crons
"""
for commit in (commits or self.get_all_commit()):
if commit.repo.server_files:
return commit
raise ValidationError('No repo found with defined server_files')
def get_addons_path(self, commits=None):
for commit in (commits or self.get_all_commit()):
source_path = self._docker_source_folder(commit)
for addons_path in commit.repo.addons_paths.split(','):
if os.path.isdir(commit._source_path(addons_path)):
yield os.path.join(source_path, addons_path).strip(os.sep)
def get_server_info(self, commit=None):
server_dir = False
server = False
commit = commit or self.get_server_commit()
for server_file in commit.repo.server_files.split(','):
if os.path.isfile(commit._source_path(server_file)):
return (self._docker_source_folder(commit), server_file)
self._log('server_info', 'No server found in %s' % commit, level='ERROR')
raise ValidationError('No server found in %s' % commit)
def _cmd(self):
"""Return a tuple describing the command to start the build """Return a tuple describing the command to start the build
First part is list with the command and parameters First part is list with the command and parameters
Second part is a list of Odoo modules Second part is a list of Odoo modules
""" """
self.ensure_one() self.ensure_one()
build = self build = self
bins = [
'odoo-bin', # >= 10.0
'openerp-server', # 9.0, 8.0
'openerp-server.py', # 7.0
'bin/openerp-server.py', # < 7.0
]
for odoo_bin in bins:
if os.path.isfile(build._path(odoo_bin)):
break
(server_dir, server_file) = self.get_server_info()
addons_paths = self.get_addons_path()
# commandline # commandline
cmd = [ os.path.join('/data/build', odoo_bin), ] cmd = [os.path.join('/data/build', server_dir, server_file), '--addons-path', ",".join(addons_paths)]
# options # options
if grep(build._server("tools/config.py"), "no-xmlrpcs"): # move that to configs ? if grep(build._server("tools/config.py"), "no-xmlrpcs"): # move that to configs ?
cmd.append("--no-xmlrpcs") cmd.append("--no-xmlrpcs")
if grep(build._server("tools/config.py"), "no-netrpc"): if grep(build._server("tools/config.py"), "no-netrpc"):
cmd.append("--no-netrpc") cmd.append("--no-netrpc")
if grep(build._server("tools/config.py"), "log-db"): if grep(build._server("tools/config.py"), "log-db"):
@ -879,7 +884,7 @@ class runbot_build(models.Model):
# use the username of the runbot host to connect to the databases # use the username of the runbot host to connect to the databases
cmd += ['-r %s' % pwd.getpwuid(os.getuid()).pw_name] cmd += ['-r %s' % pwd.getpwuid(os.getuid()).pw_name]
return cmd, build.modules return cmd
def _github_status_notify_all(self, status): def _github_status_notify_all(self, status):
"""Notify each repo with a status""" """Notify each repo with a status"""

View File

@ -5,7 +5,7 @@ import os
import re import re
import shlex import shlex
import time import time
from ..common import now, grep, get_py_version, time2str, rfind from ..common import now, grep, get_py_version, time2str, rfind, Commit
from ..container import docker_run, docker_get_gateway_ip, build_odoo_cmd from ..container import docker_run, docker_get_gateway_ip, build_odoo_cmd
from odoo import models, fields, api from odoo import models, fields, api
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
@ -18,6 +18,7 @@ _re_warning = r'^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d{3} \d+ WARNING '
PYTHON_DEFAULT = "# type python code here\n\n\n\n\n\n" PYTHON_DEFAULT = "# type python code here\n\n\n\n\n\n"
class Config(models.Model): class Config(models.Model):
_name = "runbot.build.config" _name = "runbot.build.config"
_inherit = "mail.thread" _inherit = "mail.thread"
@ -212,7 +213,6 @@ class ConfigStep(models.Model):
'committer': build.committer, 'committer': build.committer,
'committer_email': build.committer_email, 'committer_email': build.committer_email,
'subject': build.subject, 'subject': build.subject,
'modules': build.modules,
'hidden': self.hide_build, 'hidden': self.hide_build,
'orphan_result': self.make_orphan, 'orphan_result': self.make_orphan,
}) })
@ -239,11 +239,13 @@ class ConfigStep(models.Model):
return safe_eval(self.sudo().python_code.strip(), eval_ctx, mode="exec", nocopy=True) return safe_eval(self.sudo().python_code.strip(), eval_ctx, mode="exec", nocopy=True)
def _run_odoo_run(self, build, log_path): def _run_odoo_run(self, build, log_path):
exports = build._checkout()
# adjust job_end to record an accurate job_20 job_time # adjust job_end to record an accurate job_20 job_time
build._log('run', 'Start running build %s' % build.dest) build._log('run', 'Start running build %s' % build.dest)
# run server # run server
cmd, _ = build._cmd() cmd = build._cmd()
if os.path.exists(build._server('addons/im_livechat')): server = build._server()
if os.path.exists(os.path.join(server, 'addons/im_livechat')):
cmd += ["--workers", "2"] cmd += ["--workers", "2"]
cmd += ["--longpolling-port", "8070"] cmd += ["--longpolling-port", "8070"]
cmd += ["--max-cron-threads", "1"] cmd += ["--max-cron-threads", "1"]
@ -255,7 +257,7 @@ class ConfigStep(models.Model):
# we need to have at least one job of type install_odoo to run odoo, take the last one for db_name. # we need to have at least one job of type install_odoo to run odoo, take the last one for db_name.
cmd += ['-d', '%s-%s' % (build.dest, db_name)] cmd += ['-d', '%s-%s' % (build.dest, db_name)]
if grep(build._server("tools/config.py"), "db-filter"): if grep(os.path.join(server, "tools/config.py"), "db-filter"):
if build.repo_id.nginx: if build.repo_id.nginx:
cmd += ['--db-filter', '%d.*$'] cmd += ['--db-filter', '%d.*$']
else: else:
@ -269,10 +271,11 @@ class ConfigStep(models.Model):
build_port = build.port build_port = build.port
self.env.cr.commit() # commit before docker run to be 100% sure that db state is consistent with dockers self.env.cr.commit() # commit before docker run to be 100% sure that db state is consistent with dockers
self.invalidate_cache() self.invalidate_cache()
return docker_run(build_odoo_cmd(cmd), log_path, build_path, docker_name, exposed_ports=[build_port, build_port + 1]) return docker_run(build_odoo_cmd(cmd), log_path, build_path, docker_name, exposed_ports=[build_port, build_port + 1], ro_volumes=exports)
def _run_odoo_install(self, build, log_path): def _run_odoo_install(self, build, log_path):
cmd, _ = build._cmd() exports = build._checkout()
cmd = build._cmd()
# create db if needed # create db if needed
db_name = "%s-%s" % (build.dest, self.db_name) db_name = "%s-%s" % (build.dest, self.db_name)
if self.create_db: if self.create_db:
@ -308,13 +311,13 @@ class ConfigStep(models.Model):
max_timeout = int(self.env['ir.config_parameter'].get_param('runbot.runbot_timeout', default=10000)) max_timeout = int(self.env['ir.config_parameter'].get_param('runbot.runbot_timeout', default=10000))
timeout = min(self.cpu_limit, max_timeout) timeout = min(self.cpu_limit, max_timeout)
return docker_run(build_odoo_cmd(cmd), log_path, build._path(), build._get_docker_name(), cpu_limit=timeout) return docker_run(build_odoo_cmd(cmd), log_path, build._path(), build._get_docker_name(), cpu_limit=timeout, ro_volumes=exports)
def _modules_to_install(self, build): def _modules_to_install(self, build):
modules_to_install = set([mod.strip() for mod in self.install_modules.split(',')]) modules_to_install = set([mod.strip() for mod in self.install_modules.split(',')])
if '*' in modules_to_install: if '*' in modules_to_install:
modules_to_install.remove('*') modules_to_install.remove('*')
default_mod = set([mod.strip() for mod in build.modules.split(',')]) default_mod = set(build._get_modules_to_test())
modules_to_install = default_mod | modules_to_install modules_to_install = default_mod | modules_to_install
# todo add without support # todo add without support
return modules_to_install return modules_to_install
@ -329,13 +332,18 @@ class ConfigStep(models.Model):
return [] return []
def _coverage_params(self, build, modules_to_install): def _coverage_params(self, build, modules_to_install):
available_modules = [ # todo extract this to build method pattern_to_omit = set()
os.path.basename(os.path.dirname(a)) for commit in self.get_all_commit:
for a in (glob.glob(build._server('addons/*/__openerp__.py')) + docker_source_folder = build._docker_source_folder(commit)
glob.glob(build._server('addons/*/__manifest__.py'))) for manifest_file in commit.repo.manifest_files.split(','):
] pattern_to_omit.add('*%s' % manifest_file)
module_to_omit = set(available_modules) - modules_to_install for (addons_path, module, manifest_file_name) in build._get_available_modules(commit):
return ['--omit', ','.join('*addons/%s/*' % m for m in module_to_omit) + ',*__manifest__.py'] module = os.path.basename(module_path)
if module not in modules_to_install:
# we want to omit docker_source_folder/[addons/path/]module/*
module_path_in_docker = os.path.join(docker_source_folder, addons_path, module)
pattern_to_omit.add('%s/*' % (module_path_in_docker))
return ['--omit', ','.join(pattern_to_omit)]
def _make_results(self, build): def _make_results(self, build):
build_values = {} build_values = {}

View File

@ -9,3 +9,7 @@ class RunbotBuildDependency(models.Model):
dependency_hash = fields.Char('Name of commit', index=True) dependency_hash = fields.Char('Name of commit', index=True)
closest_branch_id = fields.Many2one('runbot.branch', 'Branch', required=True, ondelete='cascade') closest_branch_id = fields.Many2one('runbot.branch', 'Branch', required=True, ondelete='cascade')
match_type = fields.Char('Match Type') match_type = fields.Char('Match Type')
def get_repo(self):
return self.closest_branch_id.repo_id or self.dependecy_repo_id

View File

@ -11,14 +11,17 @@ import signal
import subprocess import subprocess
import time import time
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
from odoo import models, fields, api from odoo import models, fields, api
from odoo.modules.module import get_module_resource from odoo.modules.module import get_module_resource
from odoo.tools import config from odoo.tools import config
from ..common import fqdn, dt2time from ..common import fqdn, dt2time, Commit
from psycopg2.extensions import TransactionRollbackError from psycopg2.extensions import TransactionRollbackError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class HashMissingException(Exception):
pass
class runbot_repo(models.Model): class runbot_repo(models.Model):
@ -55,6 +58,10 @@ class runbot_repo(models.Model):
repo_config_id = fields.Many2one('runbot.build.config', 'Run Config') repo_config_id = fields.Many2one('runbot.build.config', 'Run Config')
config_id = fields.Many2one('runbot.build.config', 'Run Config', compute='_compute_config_id', inverse='_inverse_config_id') config_id = fields.Many2one('runbot.build.config', 'Run Config', compute='_compute_config_id', inverse='_inverse_config_id')
server_files = fields.Char('Server files', help='Comma separated list of possible server files') # odoo-bin,openerp-server,openerp-server.py
manifest_files = fields.Char('Addons files', help='Comma separated list of possible addons files', default='__manifest__.py,__openerp__.py')
addons_paths = fields.Char('Addons files', help='Comma separated list of possible addons path', default='')
def _compute_config_id(self): def _compute_config_id(self):
for repo in self: for repo in self:
if repo.repo_config_id: if repo.repo_config_id:
@ -71,15 +78,25 @@ class runbot_repo(models.Model):
default = os.path.join(os.path.dirname(__file__), '../static') default = os.path.join(os.path.dirname(__file__), '../static')
return os.path.abspath(default) return os.path.abspath(default)
def _source_path(self, sha, *path):
"""
returns the absolute path to the source folder of the repo (adding option *path)
"""
self.ensure_one()
return os.path.join(self._root(), 'sources', self._get_repo_name_part(), sha, *path)
@api.depends('name') @api.depends('name')
def _get_path(self): def _get_path(self):
"""compute the server path of repo from the name""" """compute the server path of repo from the name"""
root = self._root() root = self._root()
for repo in self: for repo in self:
name = repo.name repo.path = os.path.join(root, 'repo', repo._sanitized_name(repo.name))
for i in '@:/':
name = name.replace(i, '_') @api.model
repo.path = os.path.join(root, 'repo', name) def _sanitized_name(self, name):
for i in '@:/':
name = name.replace(i, '_')
return name
@api.depends('name') @api.depends('name')
def _get_base_url(self): def _get_base_url(self):
@ -95,24 +112,48 @@ class runbot_repo(models.Model):
for repo in self: for repo in self:
repo.short_name = '/'.join(repo.base.split('/')[-2:]) repo.short_name = '/'.join(repo.base.split('/')[-2:])
def _get_repo_name_part(self):
self.ensure_one
return self._sanitized_name(self.name.split('/')[-1])
def _git(self, cmd): def _git(self, cmd):
"""Execute a git command 'cmd'""" """Execute a git command 'cmd'"""
for repo in self: self.ensure_one()
cmd = ['git', '--git-dir=%s' % repo.path] + cmd cmd = ['git', '--git-dir=%s' % self.path] + cmd
_logger.debug("git command: %s", ' '.join(cmd)) _logger.debug("git command: %s", ' '.join(cmd))
return subprocess.check_output(cmd).decode('utf-8') return subprocess.check_output(cmd).decode('utf-8')
def _git_rev_parse(self, branch_name): def _git_rev_parse(self, branch_name):
return self._git(['rev-parse', branch_name]).strip() return self._git(['rev-parse', branch_name]).strip()
def _git_export(self, treeish, dest): def _git_export(self, sha):
"""Export a git repo to dest""" """Export a git repo into a sources"""
# TODO add automated tests
self.ensure_one() self.ensure_one()
_logger.debug('checkout %s %s %s', self.name, treeish, dest) export_path = self._source_path(sha)
p1 = subprocess.Popen(['git', '--git-dir=%s' % self.path, 'archive', treeish], stdout=subprocess.PIPE)
p2 = subprocess.Popen(['tar', '-xmC', dest], stdin=p1.stdout, stdout=subprocess.PIPE) if os.path.isdir(export_path):
_logger.info('git export: checkouting to %s (already exists)' % export_path)
return export_path
if not self._hash_exists(sha):
self._update(force=True)
if not self._hash_exists(sha):
try:
self._git(['fetch', 'origin', sha])
except:
pass
if not self._hash_exists(sha):
raise HashMissingException()
_logger.info('git export: checkouting to %s (new)' % export_path)
os.makedirs(export_path)
p1 = subprocess.Popen(['git', '--git-dir=%s' % self.path, 'archive', sha], stdout=subprocess.PIPE)
p2 = subprocess.Popen(['tar', '-xmC', export_path], stdin=p1.stdout, stdout=subprocess.PIPE)
p1.stdout.close() # Allow p1 to receive a SIGPIPE if p2 exits. p1.stdout.close() # Allow p1 to receive a SIGPIPE if p2 exits.
p2.communicate()[0] p2.communicate()[0]
# TODO get result and fallback on cleaing in case of problem
return export_path
def _hash_exists(self, commit_hash): def _hash_exists(self, commit_hash):
""" Verify that a commit hash exists in the repo """ """ Verify that a commit hash exists in the repo """

View File

@ -21,10 +21,7 @@
<t t-if="bu.global_result=='killed'"><i class="text-danger fa fa-times"/> killed</t> <t t-if="bu.global_result=='killed'"><i class="text-danger fa fa-times"/> killed</t>
<t t-if="bu.global_result=='manually_killed'"><i class="text-danger fa fa-times"/> manually killed</t> <t t-if="bu.global_result=='manually_killed'"><i class="text-danger fa fa-times"/> manually killed</t>
</t> </t>
<t t-if="bu.real_build.server_match == 'default'">
<i class="text-warning fa fa-question-circle fa-fw"
title="Server branch cannot be determined exactly. Please use naming convention '12.0-my-branch' to build with '12.0' server branch."/>
</t>
<t t-if="bu.revdep_build_ids"> <t t-if="bu.revdep_build_ids">
<small class="pull-right">Dep builds: <small class="pull-right">Dep builds:
<t t-foreach="bu.sorted_revdep_build_ids()" t-as="rbu"> <t t-foreach="bu.sorted_revdep_build_ids()" t-as="rbu">

View File

@ -4,12 +4,21 @@ from odoo.tools.config import configmanager
from odoo.tests import common from odoo.tests import common
def rev_parse(repo, branch_name):
"""
simulate a rev parse by returning a fake hash of form
'rp_odoo-dev/enterprise_saas-12.2__head'
should be overwitten if a pr head should match a branch head
"""
head_hash = 'rp_%s_%s_head' % (repo.name.split(':')[1], branch_name.split('/')[-1])
return head_hash
class Test_Build(common.TransactionCase): class Test_Build(common.TransactionCase):
def setUp(self): def setUp(self):
super(Test_Build, self).setUp() super(Test_Build, self).setUp()
self.Repo = self.env['runbot.repo'] self.Repo = self.env['runbot.repo']
self.repo = self.Repo.create({'name': 'bla@example.com:foo/bar'}) self.repo = self.Repo.create({'name': 'bla@example.com:foo/bar', 'server_files': 'server.py', 'addons_paths': 'addons,core/addons'})
self.Branch = self.env['runbot.branch'] self.Branch = self.env['runbot.branch']
self.branch = self.Branch.create({ self.branch = self.Branch.create({
'repo_id': self.repo.id, 'repo_id': self.repo.id,
@ -62,10 +71,12 @@ class Test_Build(common.TransactionCase):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
builds.write({'local_state': 'duplicate'}) builds.write({'local_state': 'duplicate'})
@patch('odoo.addons.runbot.models.build.os.path.isfile')
@patch('odoo.addons.runbot.models.build.os.mkdir') @patch('odoo.addons.runbot.models.build.os.mkdir')
@patch('odoo.addons.runbot.models.build.grep') @patch('odoo.addons.runbot.models.build.grep')
def test_build_cmd_log_db(self, mock_grep, mock_mkdir): def test_build_cmd_log_db(self, mock_grep, mock_mkdir, mock_is_file):
""" test that the logdb connection URI is taken from the .odoorc file """ """ test that the logdb connection URI is taken from the .odoorc file """
mock_is_file.return_value = True
uri = 'postgres://someone:pass@somewhere.com/db' uri = 'postgres://someone:pass@somewhere.com/db'
self.env['ir.config_parameter'].sudo().set_param("runbot.runbot_logdb_uri", uri) self.env['ir.config_parameter'].sudo().set_param("runbot.runbot_logdb_uri", uri)
build = self.Build.create({ build = self.Build.create({
@ -73,9 +84,121 @@ class Test_Build(common.TransactionCase):
'name': 'd0d0caca0000ffffffffffffffffffffffffffff', 'name': 'd0d0caca0000ffffffffffffffffffffffffffff',
'port': '1234', 'port': '1234',
}) })
cmd = build._cmd()[0] cmd = build._cmd()
self.assertIn('--log-db=%s' % uri, cmd) self.assertIn('--log-db=%s' % uri, cmd)
@patch('odoo.addons.runbot.models.build.os.path.isdir')
@patch('odoo.addons.runbot.models.build.os.path.isfile')
@patch('odoo.addons.runbot.models.build.os.mkdir')
@patch('odoo.addons.runbot.models.build.grep')
def test_build_cmd_server_path_no_dep(self, mock_grep, mock_mkdir, mock_is_file, mock_is_dir):
""" test that the server path and addons path """
mock_is_file.return_value = True
mock_is_dir.return_value = True
build = self.Build.create({
'branch_id': self.branch.id,
'name': 'd0d0caca0000ffffffffffffffffffffffffffff',
'port': '1234',
})
cmd = build._cmd()
self.assertEqual('/data/build/bar/server.py', cmd[0])
self.assertIn('--addons-path', cmd)
addons_path_pos = cmd.index('--addons-path') + 1
self.assertEqual(cmd[addons_path_pos], 'bar/addons,bar/core/addons')
@patch('odoo.addons.runbot.models.branch.runbot_branch._is_on_remote')
@patch('odoo.addons.runbot.models.build.os.path.isdir')
@patch('odoo.addons.runbot.models.build.os.path.isfile')
@patch('odoo.addons.runbot.models.build.os.mkdir')
@patch('odoo.addons.runbot.models.build.grep')
def test_build_cmd_server_path_with_dep(self, mock_grep, mock_mkdir, mock_is_file, mock_is_dir, mock_is_on_remote):
""" test that the server path and addons path """
def is_file(file):
self.assertIn('sources/bar/dfdfcfcf0000ffffffffffffffffffffffffffff/server.py', file)
return True
def is_dir(file):
paths = [
'sources/bar/dfdfcfcf0000ffffffffffffffffffffffffffff/addons',
'sources/bar/dfdfcfcf0000ffffffffffffffffffffffffffff/core/addons',
'sources/bar-ent/d0d0caca0000ffffffffffffffffffffffffffff'
]
self.assertTrue(any([path in file for path in paths])) # checking that addons path existence check looks ok
return True
mock_is_file.side_effect = is_file
mock_is_dir.side_effect = is_dir
mock_is_on_remote.return_value = True
repo_ent = self.env['runbot.repo'].create({
'name': 'bla@example.com:foo/bar-ent',
'server_files': '',
})
repo_ent.dependency_ids = self.repo
enterprise_branch = self.env['runbot.branch'].create({
'repo_id': repo_ent.id,
'name': 'refs/heads/master'
})
def rev_parse(repo, branch_name):
self.assertEqual(repo, self.repo)
self.assertEqual(branch_name, 'refs/heads/master')
return 'dfdfcfcf0000ffffffffffffffffffffffffffff'
with patch('odoo.addons.runbot.models.repo.runbot_repo._git_rev_parse', new=rev_parse):
build = self.Build.create({
'branch_id': enterprise_branch.id,
'name': 'd0d0caca0000ffffffffffffffffffffffffffff',
'port': '1234',
})
cmd = build._cmd()
self.assertIn('--addons-path', cmd)
addons_path_pos = cmd.index('--addons-path') + 1
self.assertEqual(cmd[addons_path_pos], 'bar-ent,bar/addons,bar/core/addons')
self.assertEqual('/data/build/bar/server.py', cmd[0])
@patch('odoo.addons.runbot.models.branch.runbot_branch._is_on_remote')
@patch('odoo.addons.runbot.models.build.os.path.isdir')
@patch('odoo.addons.runbot.models.build.os.path.isfile')
@patch('odoo.addons.runbot.models.build.os.mkdir')
@patch('odoo.addons.runbot.models.build.grep')
def test_build_cmd_server_path_with_dep_collision(self, mock_grep, mock_mkdir, mock_is_file, mock_is_dir, mock_is_on_remote):
""" test that the server path and addons path """
def is_file(file):
self.assertIn('sources/bar/dfdfcfcf0000ffffffffffffffffffffffffffff/server.py', file)
return True
mock_is_file.side_effect = is_file
mock_is_dir.return_value = True
mock_is_on_remote.return_value = True
repo_ent = self.env['runbot.repo'].create({
'name': 'bla@example.com:foo-ent/bar',
'server_files': '',
})
repo_ent.dependency_ids = self.repo
enterprise_branch = self.env['runbot.branch'].create({
'repo_id': repo_ent.id,
'name': 'refs/heads/master'
})
def rev_parse(repo, branch_name):
self.assertEqual(repo, self.repo)
self.assertEqual(branch_name, 'refs/heads/master')
return 'dfdfcfcf0000ffffffffffffffffffffffffffff'
with patch('odoo.addons.runbot.models.repo.runbot_repo._git_rev_parse', new=rev_parse):
build = self.Build.create({
'branch_id': enterprise_branch.id,
'name': 'd0d0caca0000ffffffffffffffffffffffffffff',
'port': '1234',
})
cmd = build._cmd()
self.assertIn('--addons-path', cmd)
addons_path_pos = cmd.index('--addons-path') + 1
self.assertEqual(cmd[addons_path_pos], 'bar-d0d0caca,bar-dfdfcfcf/addons,bar-dfdfcfcf/core/addons')
self.assertEqual('/data/build/bar-dfdfcfcf/server.py', cmd[0])
def test_build_config_from_branch_default(self): def test_build_config_from_branch_default(self):
"""test build config_id is computed from branch default config_id""" """test build config_id is computed from branch default config_id"""
build = self.Build.create({ build = self.Build.create({
@ -266,16 +389,6 @@ class Test_Build(common.TransactionCase):
self.assertEqual(build_parent.nb_testing, 0) self.assertEqual(build_parent.nb_testing, 0)
self.assertEqual(build_parent.global_state, 'done') self.assertEqual(build_parent.global_state, 'done')
def rev_parse(repo, branch_name):
"""
simulate a rev parse by returning a fake hash of form
'rp_odoo-dev/enterprise_saas-12.2__head'
should be overwitten if a pr head should match a branch head
"""
head_hash = 'rp_%s_%s_head' % (repo.name.split(':')[1], branch_name.split('/')[-1])
return head_hash
class TestClosestBranch(common.TransactionCase): class TestClosestBranch(common.TransactionCase):
def branch_description(self, branch): def branch_description(self, branch):

View File

@ -45,6 +45,9 @@ class TestSchedule(common.TransactionCase):
build_ids = self.Build.search(domain_host + [('local_state', 'in', ['testing', 'running', 'deathrow'])]) build_ids = self.Build.search(domain_host + [('local_state', 'in', ['testing', 'running', 'deathrow'])])
mock_running.return_value = False mock_running.return_value = False
self.assertEqual(build.local_state, 'testing') self.assertEqual(build.local_state, 'testing')
build_ids._schedule() # too fast, docker not started
self.assertEqual(build.local_state, 'testing')
build_ids.write({'job_start': datetime.datetime.now() - datetime.timedelta(seconds=20)}) # job is now a little older
build_ids._schedule() build_ids._schedule()
self.assertEqual(build.local_state, 'done') self.assertEqual(build.local_state, 'done')
self.assertEqual(build.local_result, 'ok') self.assertEqual(build.local_result, 'ok')

View File

@ -32,7 +32,6 @@
<field name="build_time"/> <field name="build_time"/>
<field name="build_age"/> <field name="build_age"/>
<field name="duplicate_id"/> <field name="duplicate_id"/>
<field name="modules"/>
<field name="build_type" groups="base.group_no_one"/> <field name="build_type" groups="base.group_no_one"/>
<field name="config_id" readonly="1"/> <field name="config_id" readonly="1"/>
<field name="config_id" groups="base.group_no_one"/> <field name="config_id" groups="base.group_no_one"/>

View File

@ -23,6 +23,9 @@
<field name="group_ids" widget="many2many_tags"/> <field name="group_ids" widget="many2many_tags"/>
<field name="hook_time"/> <field name="hook_time"/>
<field name="config_id"/> <field name="config_id"/>
<field name="server_files"/>
<field name="manifest_files"/>
<field name="addons_paths"/>
</group> </group>
</sheet> </sheet>
</form> </form>

View File

@ -21,7 +21,8 @@ class Step(models.Model):
return super(Step, self)._run_step(build, log_path) return super(Step, self)._run_step(build, log_path)
def _runbot_cla_check(self, build, log_path): def _runbot_cla_check(self, build, log_path):
cla_glob = glob.glob(build._path("doc/cla/*/*.md")) build._checkout()
cla_glob = glob.glob(build.get_server_commit()._source_path("doc/cla/*/*.md"))
if cla_glob: if cla_glob:
description = "%s Odoo CLA signature check" % build.author description = "%s Odoo CLA signature check" % build.author
mo = re.search('[^ <@]+@[^ @>]+', build.author_email or '') mo = re.search('[^ <@]+@[^ @>]+', build.author_email or '')