runbot/runbot/models/repo.py
2022-10-21 10:32:40 +02:00

580 lines
26 KiB
Python

# -*- coding: utf-8 -*-
import datetime
import json
import logging
import re
import subprocess
import time
import requests
from pathlib import Path
from odoo import models, fields, api
from ..common import os, RunbotException
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
def _sanitize(name):
for i in '@:/':
name = name.replace(i, '_')
return name
class Trigger(models.Model):
"""
List of repo parts that must be part of the same bundle
"""
_name = 'runbot.trigger'
_inherit = 'mail.thread'
_description = 'Triggers'
_order = 'sequence, id'
sequence = fields.Integer('Sequence')
name = fields.Char("Name")
description = fields.Char("Description", help="Informative description")
project_id = fields.Many2one('runbot.project', string="Project id", required=True) # main/security/runbot
repo_ids = fields.Many2many('runbot.repo', relation='runbot_trigger_triggers', string="Triggers", domain="[('project_id', '=', project_id)]")
dependency_ids = fields.Many2many('runbot.repo', relation='runbot_trigger_dependencies', string="Dependencies")
config_id = fields.Many2one('runbot.build.config', string="Config", required=True)
batch_dependent = fields.Boolean('Batch Dependent', help="Force adding batch in build parameters to make it unique and give access to bundle")
ci_context = fields.Char("Ci context", default='ci/runbot', tracking=True)
category_id = fields.Many2one('runbot.category', default=lambda self: self.env.ref('runbot.default_category', raise_if_not_found=False))
version_domain = fields.Char(string="Version domain")
hide = fields.Boolean('Hide trigger on main page')
manual = fields.Boolean('Only start trigger manually', default=False)
upgrade_dumps_trigger_id = fields.Many2one('runbot.trigger', string='Template/complement trigger', tracking=True)
upgrade_step_id = fields.Many2one('runbot.build.config.step', compute="_compute_upgrade_step_id", store=True)
ci_url = fields.Char("ci url")
ci_description = fields.Char("ci description")
has_stats = fields.Boolean('Has a make_stats config step', compute="_compute_has_stats", store=True)
team_ids = fields.Many2many('runbot.team', string="Runbot Teams", help="Teams responsible of this trigger, mainly usefull for nightly")
active = fields.Boolean("Active", default=True)
@api.depends('config_id.step_order_ids.step_id.make_stats')
def _compute_has_stats(self):
for trigger in self:
trigger.has_stats = any(trigger.config_id.step_order_ids.step_id.mapped('make_stats'))
@api.depends('upgrade_dumps_trigger_id', 'config_id', 'config_id.step_order_ids.step_id.job_type')
def _compute_upgrade_step_id(self):
for trigger in self:
trigger.upgrade_step_id = False
if trigger.upgrade_dumps_trigger_id:
trigger.upgrade_step_id = self._upgrade_step_from_config(trigger.config_id)
def _upgrade_step_from_config(self, config):
upgrade_step = next((step_order.step_id for step_order in config.step_order_ids if step_order.step_id._is_upgrade_step()), False)
if not upgrade_step:
raise UserError('Upgrade trigger should have a config with step of type Configure Upgrade')
return upgrade_step
def _reference_builds(self, bundle):
self.ensure_one()
if self.upgrade_step_id: # this is an upgrade trigger, add corresponding builds
custom_config = next((trigger_custom.config_id for trigger_custom in bundle.trigger_custom_ids if trigger_custom.trigger_id == self), False)
step = self._upgrade_step_from_config(custom_config) if custom_config else self.upgrade_step_id
refs_builds = step._reference_builds(bundle, self)
return [(4, b.id) for b in refs_builds]
return []
def get_version_domain(self):
if self.version_domain:
return safe_eval(self.version_domain)
return []
class Remote(models.Model):
"""
Regroups repo and it duplicates (forks): odoo+odoo-dev for each repo
"""
_name = 'runbot.remote'
_description = 'Remote'
_order = 'sequence, id'
_inherit = 'mail.thread'
name = fields.Char('Url', required=True, tracking=True)
repo_id = fields.Many2one('runbot.repo', required=True, tracking=True)
owner = fields.Char(compute='_compute_base_infos', string='Repo Owner', store=True, readonly=True, tracking=True)
repo_name = fields.Char(compute='_compute_base_infos', string='Repo Name', store=True, readonly=True, tracking=True)
repo_domain = fields.Char(compute='_compute_base_infos', string='Repo domain', store=True, readonly=True, tracking=True)
base_url = fields.Char(compute='_compute_base_url', string='Base URL', readonly=True, tracking=True)
short_name = fields.Char('Short name', compute='_compute_short_name', tracking=True)
remote_name = fields.Char('Remote name', compute='_compute_remote_name', tracking=True)
sequence = fields.Integer('Sequence', tracking=True)
fetch_heads = fields.Boolean('Fetch branches', default=True, tracking=True)
fetch_pull = fields.Boolean('Fetch PR', default=False, tracking=True)
send_status = fields.Boolean('Send status', default=False, tracking=True)
token = fields.Char("Github token", groups="runbot.group_runbot_admin")
@api.depends('name')
def _compute_base_infos(self):
for remote in self:
name = re.sub('.+@', '', remote.name)
name = re.sub('^https://', '', name) # support https repo style
name = re.sub('.git$', '', name)
name = name.replace(':', '/')
s = name.split('/')
remote.repo_domain = s[-3]
remote.owner = s[-2]
remote.repo_name = s[-1]
@api.depends('repo_domain', 'owner', 'repo_name')
def _compute_base_url(self):
for remote in self:
remote.base_url = '%s/%s/%s' % (remote.repo_domain, remote.owner, remote.repo_name)
@api.depends('name', 'base_url')
def _compute_short_name(self):
for remote in self:
remote.short_name = '/'.join(remote.base_url.split('/')[-2:])
def _compute_remote_name(self):
for remote in self:
remote.remote_name = _sanitize(remote.short_name)
def create(self, values_list):
remote = super().create(values_list)
if not remote.repo_id.main_remote_id:
remote.repo_id.main_remote_id = remote
remote._cr.postcommit.add(remote.repo_id._update_git_config)
return remote
def write(self, values):
res = super().write(values)
self._cr.postcommit.add(self.repo_id._update_git_config)
return res
def _make_github_session(self):
session = requests.Session()
if self.token:
session.auth = (self.token, 'x-oauth-basic')
session.headers.update({'Accept': 'application/vnd.github.she-hulk-preview+json'})
return session
def _github(self, url, payload=None, ignore_errors=False, nb_tries=2, recursive=False, session=None):
generator = self.sudo()._github_generator(url, payload=payload, ignore_errors=ignore_errors, nb_tries=nb_tries, recursive=recursive, session=session)
if recursive:
return generator
result = list(generator)
return result[0] if result else False
def _github_generator(self, url, payload=None, ignore_errors=False, nb_tries=2, recursive=False, session=None):
"""Return a http request to be sent to github"""
for remote in self:
if remote.owner and remote.repo_name and remote.repo_domain:
url = url.replace(':owner', remote.owner)
url = url.replace(':repo', remote.repo_name)
url = 'https://api.%s%s' % (remote.repo_domain, url)
session = session or remote._make_github_session()
while url:
if recursive:
_logger.info('Getting page %s', url)
try_count = 0
while try_count < nb_tries:
try:
if payload:
response = session.post(url, data=json.dumps(payload))
else:
response = session.get(url)
response.raise_for_status()
if try_count > 0:
_logger.info('Success after %s tries', (try_count + 1))
if recursive:
link = response.headers.get('link')
url = False
if link:
url = {link.split(';')[1]: link.split(';')[0] for link in link.split(',')}.get(' rel="next"')
if url:
url = url.strip('<> ')
yield response.json()
break
else:
yield response.json()
return
except requests.HTTPError:
try_count += 1
if try_count < nb_tries:
time.sleep(2)
else:
if ignore_errors:
_logger.exception('Ignored github error %s %r (try %s/%s)', url, payload, try_count, nb_tries)
url = False
else:
raise
class Repo(models.Model):
_name = 'runbot.repo'
_description = "Repo"
_order = 'sequence, id'
_inherit = 'mail.thread'
name = fields.Char("Name", tracking=True) # odoo/enterprise/upgrade/security/runbot/design_theme
identity_file = fields.Char("Identity File", help="Identity file to use with git/ssh", groups="runbot.group_runbot_admin")
main_remote_id = fields.Many2one('runbot.remote', "Main remote", tracking=True)
remote_ids = fields.One2many('runbot.remote', 'repo_id', "Remotes")
project_id = fields.Many2one('runbot.project', required=True, tracking=True,
help="Default bundle project to use when pushing on this repos",
default=lambda self: self.env.ref('runbot.main_project', raise_if_not_found=False))
# -> not verry usefull, remove it? (iterate on projects or contraints triggers:
# all trigger where a repo is used must be in the same project.
modules = fields.Char("Modules to install", help="Comma-separated list of modules to install and test.", tracking=True)
server_files = fields.Char('Server files', help='Comma separated list of possible server files', tracking=True) # odoo-bin,openerp-server,openerp-server.py
manifest_files = fields.Char('Manifest files', help='Comma separated list of possible manifest files', default='__manifest__.py', tracking=True)
addons_paths = fields.Char('Addons paths', help='Comma separated list of possible addons path', default='', tracking=True)
sequence = fields.Integer('Sequence', tracking=True)
path = fields.Char(compute='_get_path', string='Directory', readonly=True)
mode = fields.Selection([('disabled', 'Disabled'),
('poll', 'Poll'),
('hook', 'Hook')],
default='poll',
string="Mode", required=True, help="hook: Wait for webhook on /runbot/hook/<id> i.e. github push event", tracking=True)
hook_time = fields.Float('Last hook time', compute='_compute_hook_time')
last_processed_hook_time = fields.Float('Last processed hook time')
get_ref_time = fields.Float('Last refs db update', compute='_compute_get_ref_time')
trigger_ids = fields.Many2many('runbot.trigger', relation='runbot_trigger_triggers', readonly=True)
single_version = fields.Many2one('runbot.version', "Single version", help="Limit the repo to a single version for non versionned repo")
forbidden_regex = fields.Char('Forbidden regex', help="Regex that forid bundle creation if branch name is matching", tracking=True)
invalid_branch_message = fields.Char('Forbidden branch message', tracking=True)
def _compute_get_ref_time(self):
self.env.cr.execute("""
SELECT repo_id, time FROM runbot_repo_reftime
WHERE id IN (
SELECT max(id) FROM runbot_repo_reftime
WHERE repo_id = any(%s) GROUP BY repo_id
)
""", [self.ids])
times = dict(self.env.cr.fetchall())
for repo in self:
repo.get_ref_time = times.get(repo.id, 0)
def _compute_hook_time(self):
self.env.cr.execute("""
SELECT repo_id, time FROM runbot_repo_hooktime
WHERE id IN (
SELECT max(id) FROM runbot_repo_hooktime
WHERE repo_id = any(%s) GROUP BY repo_id
)
""", [self.ids])
times = dict(self.env.cr.fetchall())
for repo in self:
repo.hook_time = times.get(repo.id, 0)
def set_hook_time(self, value):
for repo in self:
self.env['runbot.repo.hooktime'].create({'time': value, 'repo_id': repo.id})
self.invalidate_cache()
def set_ref_time(self, value):
for repo in self:
self.env['runbot.repo.reftime'].create({'time': value, 'repo_id': repo.id})
self.invalidate_cache()
def _gc_times(self):
self.env.cr.execute("""
DELETE from runbot_repo_reftime WHERE id NOT IN (
SELECT max(id) FROM runbot_repo_reftime GROUP BY repo_id
)
""")
self.env.cr.execute("""
DELETE from runbot_repo_hooktime WHERE id NOT IN (
SELECT max(id) FROM runbot_repo_hooktime GROUP BY repo_id
)
""")
@api.depends('name')
def _get_path(self):
"""compute the server path of repo from the name"""
root = self.env['runbot.runbot']._root()
for repo in self:
repo.path = os.path.join(root, 'repo', _sanitize(repo.name))
def _git(self, cmd, errors='strict'):
"""Execute a git command 'cmd'"""
self.ensure_one()
config_args = []
if self.identity_file:
config_args = ['-c', 'core.sshCommand=ssh -i %s/.ssh/%s' % (str(Path.home()), self.identity_file)]
cmd = ['git', '-C', self.path] + config_args + cmd
_logger.info("git command: %s", ' '.join(cmd))
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode(errors=errors)
def _fetch(self, sha):
if not self._hash_exists(sha):
self._update(force=True)
if not self._hash_exists(sha):
for remote in self.remote_ids:
try:
self._git(['fetch', remote.remote_name, sha])
_logger.info('Success fetching specific head %s on %s', sha, remote)
break
except subprocess.CalledProcessError:
pass
if not self._hash_exists(sha):
raise RunbotException("Commit %s is unreachable. Did you force push the branch?" % sha)
def _hash_exists(self, commit_hash):
""" Verify that a commit hash exists in the repo """
self.ensure_one()
try:
self._git(['cat-file', '-e', commit_hash])
except subprocess.CalledProcessError:
return False
return True
def _is_branch_forbidden(self, branch_name):
self.ensure_one()
if self.forbidden_regex:
return re.match(self.forbidden_regex, branch_name)
return False
def _get_fetch_head_time(self):
self.ensure_one()
fname_fetch_head = os.path.join(self.path, 'FETCH_HEAD')
if os.path.exists(fname_fetch_head):
return os.path.getmtime(fname_fetch_head)
return 0
def _get_refs(self, max_age=30, ignore=None):
"""Find new refs
:return: list of tuples with following refs informations:
name, sha, date, author, author_email, subject, committer, committer_email
"""
self.ensure_one()
get_ref_time = round(self._get_fetch_head_time(), 4)
commit_limit = time.time() - 60*60*24*max_age
if not self.get_ref_time or get_ref_time > self.get_ref_time:
try:
self.set_ref_time(get_ref_time)
fields = ['refname', 'objectname', 'committerdate:unix', 'authorname', 'authoremail', 'subject', 'committername', 'committeremail']
fmt = "%00".join(["%(" + field + ")" for field in fields])
cmd = ['for-each-ref', '--format', fmt, '--sort=-committerdate', 'refs/*/heads/*']
if any(remote.fetch_pull for remote in self.remote_ids):
cmd.append('refs/*/pull/*')
git_refs = self._git(cmd)
git_refs = git_refs.strip()
if not git_refs:
return []
refs = [tuple(field for field in line.split('\x00')) for line in git_refs.split('\n')]
refs = [r for r in refs if int(r[2]) > commit_limit or self.env['runbot.branch'].match_is_base(r[0].split('/')[-1])]
if ignore:
refs = [r for r in refs if r[0].split('/')[-1] not in ignore]
return refs
except Exception:
_logger.exception('Fail to get refs for repo %s', self.name)
self.env['runbot.runbot'].warning('Fail to get refs for repo %s', self.name)
return []
def _find_or_create_branches(self, refs):
"""Parse refs and create branches that does not exists yet
:param refs: list of tuples returned by _get_refs()
:return: dict {branch.name: branch.id}
The returned structure contains all the branches from refs newly created
or older ones.
"""
# FIXME WIP
names = [r[0].split('/')[-1] for r in refs]
branches = self.env['runbot.branch'].search([('name', 'in', names), ('remote_id', 'in', self.remote_ids.ids)])
ref_branches = {branch.ref(): branch for branch in branches}
new_branch_values = []
for ref_name, sha, date, author, author_email, subject, committer, committer_email in refs:
if not ref_branches.get(ref_name):
# format example:
# refs/ruodoo-dev/heads/12.0-must-fail
# refs/ruodoo/pull/1
_, remote_name, branch_type, name = ref_name.split('/')
remote_id = self.remote_ids.filtered(lambda r: r.remote_name == remote_name).id
if not remote_id:
_logger.warning('Remote %s not found', remote_name)
continue
new_branch_values.append({'remote_id': remote_id, 'name': name, 'is_pr': branch_type == 'pull'})
# TODO catch error for pr info. It may fail for multiple raison. closed? external? check corner cases
_logger.info('new branch %s found in %s', name, self.name)
if new_branch_values:
_logger.info('Creating new branches')
new_branches = self.env['runbot.branch'].create(new_branch_values)
for branch in new_branches:
ref_branches[branch.ref()] = branch
return ref_branches
def _find_new_commits(self, refs, ref_branches):
"""Find new commits in bare repo
:param refs: list of tuples returned by _get_refs()
:param ref_branches: dict structure {branch.name: branch.id}
described in _find_or_create_branches
"""
self.ensure_one()
for ref_name, sha, date, author, author_email, subject, committer, committer_email in refs:
branch = ref_branches[ref_name]
if branch.head_name != sha: # new push on branch
_logger.info('repo %s branch %s new commit found: %s', self.name, branch.name, sha)
commit = self.env['runbot.commit']._get(sha, self.id, {
'author': author,
'author_email': author_email,
'committer': committer,
'committer_email': committer_email,
'subject': subject,
'date': datetime.datetime.fromtimestamp(int(date)),
})
branch.head = commit
if not branch.alive:
if branch.is_pr:
_logger.info('Recomputing infos of dead pr %s', branch.name)
branch._compute_branch_infos()
else:
branch.alive = True
if branch.reference_name and branch.remote_id and branch.remote_id.repo_id._is_branch_forbidden(branch.reference_name):
message = "This branch name is incorrect. Branch name should be prefixed with a valid version"
message = branch.remote_id.repo_id.invalid_branch_message or message
branch.head._github_status(False, "Branch naming", 'failure', False, message)
bundle = branch.bundle_id
if bundle.no_build:
continue
if bundle.last_batch.state != 'preparing':
preparing = self.env['runbot.batch'].create({
'last_update': fields.Datetime.now(),
'bundle_id': bundle.id,
'state': 'preparing',
})
bundle.last_batch = preparing
if bundle.last_batch.state == 'preparing':
bundle.last_batch._new_commit(branch)
def _update_batches(self, force=False, ignore=None):
""" Find new commits in physical repos"""
updated = False
for repo in self:
if repo.remote_ids and self._update(poll_delay=30 if force else 60*5):
max_age = int(self.env['ir.config_parameter'].get_param('runbot.runbot_max_age', default=30))
ref = repo._get_refs(max_age, ignore=ignore)
ref_branches = repo._find_or_create_branches(ref)
repo._find_new_commits(ref, ref_branches)
updated = True
return updated
def _update_git_config(self):
""" Update repo git config file """
for repo in self:
if repo.mode == 'disabled':
_logger.info(f'skipping disabled repo {repo.name}')
continue
if os.path.isdir(os.path.join(repo.path, 'refs')):
git_config_path = os.path.join(repo.path, 'config')
template_params = {'repo': repo}
git_config = self.env['ir.ui.view']._render_template("runbot.git_config", template_params)
with open(git_config_path, 'w') as config_file:
config_file.write(str(git_config))
_logger.info('Config updated for repo %s' % repo.name)
else:
_logger.info('Repo not cloned, skiping config update for %s' % repo.name)
def _git_init(self):
""" Clone the remote repo if needed """
self.ensure_one()
repo = self
if not os.path.isdir(os.path.join(repo.path, 'refs')):
_logger.info("Initiating repository '%s' in '%s'" % (repo.name, repo.path))
git_init = subprocess.run(['git', 'init', '--bare', repo.path], stderr=subprocess.PIPE)
if git_init.returncode:
_logger.warning('Git init failed with code %s and message: "%s"', git_init.returncode, git_init.stderr)
return
self._update_git_config()
return True
def _update_git(self, force=False, poll_delay=5*60):
""" Update the git repo on FS """
self.ensure_one()
repo = self
if not repo.remote_ids:
return False
if not os.path.isdir(os.path.join(repo.path)):
os.makedirs(repo.path)
force = self._git_init() or force
fname_fetch_head = os.path.join(repo.path, 'FETCH_HEAD')
if not force and os.path.isfile(fname_fetch_head):
fetch_time = os.path.getmtime(fname_fetch_head)
if repo.mode == 'hook':
if not repo.hook_time or (repo.last_processed_hook_time and repo.hook_time <= repo.last_processed_hook_time):
return False
repo.last_processed_hook_time = repo.hook_time
if repo.mode == 'poll':
if (time.time() < fetch_time + poll_delay):
return False
_logger.info('Updating repo %s', repo.name)
return self._update_fetch_cmd()
def _update_fetch_cmd(self):
# Extracted from update_git to be easily overriden in external module
self.ensure_one()
try_count = 0
success = False
delay = 0
while not success and try_count < 5:
time.sleep(delay)
try:
self._git(['fetch', '-p', '--all', ])
success = True
except subprocess.CalledProcessError as e:
try_count += 1
delay = delay * 1.5 if delay else 0.5
if try_count > 4:
message = 'Failed to fetch repo %s: %s' % (self.name, e.output.decode())
host = self.env['runbot.host']._get_current()
host.message_post(body=message)
self.env['runbot.runbot'].warning('Host %s got reserved because of fetch failure' % host.name)
_logger.exception(message)
host.disable()
return success
def _update(self, force=False, poll_delay=5*60):
""" Update the physical git reposotories on FS"""
self.ensure_one()
try:
return self._update_git(force, poll_delay)
except Exception:
_logger.exception('Fail to update repo %s', self.name)
class RefTime(models.Model):
_name = 'runbot.repo.reftime'
_description = "Repo reftime"
_log_access = False
time = fields.Float('Time', index=True, required=True)
repo_id = fields.Many2one('runbot.repo', 'Repository', required=True, ondelete='cascade')
class HookTime(models.Model):
_name = 'runbot.repo.hooktime'
_description = "Repo hooktime"
_log_access = False
time = fields.Float('Time')
repo_id = fields.Many2one('runbot.repo', 'Repository', required=True, ondelete='cascade')