runbot/runbot/models/batch.py
2024-01-25 08:40:42 +01:00

479 lines
24 KiB
Python

import time
import logging
import datetime
import subprocess
from odoo import models, fields, api
from ..common import dt2time, s2human_long, pseudo_markdown
_logger = logging.getLogger(__name__)
class Batch(models.Model):
_name = 'runbot.batch'
_description = "Bundle batch"
last_update = fields.Datetime('Last ref update')
bundle_id = fields.Many2one('runbot.bundle', required=True, index=True, ondelete='cascade')
commit_link_ids = fields.Many2many('runbot.commit.link')
commit_ids = fields.Many2many('runbot.commit', compute='_compute_commit_ids')
slot_ids = fields.One2many('runbot.batch.slot', 'batch_id')
all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids', help="Recursive builds")
state = fields.Selection([('preparing', 'Preparing'), ('ready', 'Ready'), ('done', 'Done'), ('skipped', 'Skipped')])
hidden = fields.Boolean('Hidden', default=False)
age = fields.Integer(compute='_compute_age', string='Build age')
category_id = fields.Many2one('runbot.category', index=True, default=lambda self: self.env.ref('runbot.default_category', raise_if_not_found=False))
log_ids = fields.One2many('runbot.batch.log', 'batch_id')
has_warning = fields.Boolean("Has warning")
base_reference_batch_id = fields.Many2one('runbot.batch')
@api.depends('slot_ids.build_id')
def _compute_all_build_ids(self):
all_builds = self.env['runbot.build'].search([('id', 'child_of', self.slot_ids.build_id.ids)])
for batch in self:
batch.all_build_ids = all_builds.filtered_domain([('id', 'child_of', batch.slot_ids.build_id.ids)])
@api.depends('commit_link_ids')
def _compute_commit_ids(self):
for batch in self:
batch.commit_ids = batch.commit_link_ids.commit_id
@api.depends('create_date')
def _compute_age(self):
"""Return the time between job start and now"""
for batch in self:
if batch.create_date:
batch.age = int(time.time() - dt2time(batch.create_date))
else:
batch.buildage_age = 0
def _get_formated_age(self):
return s2human_long(self.age)
def _url(self):
self.ensure_one()
return "/runbot/batch/%s" % self.id
def _new_commit(self, branch, match_type='new'):
# if not the same hash for repo:
commit = branch.head
self.last_update = fields.Datetime.now()
for commit_link in self.commit_link_ids:
# case 1: a commit already exists for the repo (pr+branch, or fast push)
if commit_link.commit_id.repo_id == commit.repo_id:
if commit_link.commit_id.id != commit.id:
self._log('New head on branch %s during throttle phase: Replacing commit %s with %s', branch.name, commit_link.commit_id.name, commit.name)
commit_link.write({'commit_id': commit.id, 'branch_id': branch.id})
elif not commit_link.branch_id.is_pr and branch.is_pr:
commit_link.branch_id = branch # Try to have a pr instead of branch on commit if possible ?
break
else:
self.write({'commit_link_ids': [(0, 0, {
'commit_id': commit.id,
'match_type': match_type,
'branch_id': branch.id
})]})
def _skip(self):
for batch in self:
if batch.bundle_id.is_base or batch.state == 'done':
continue
batch.state = 'skipped' # done?
batch._log('Skipping batch')
for slot in batch.slot_ids:
slot.skipped = True
build = slot.build_id
if build.global_state in ('running', 'done'):
continue
testing_slots = build.slot_ids.filtered(lambda s: not s.skipped)
if not testing_slots:
if build.global_state == 'pending':
build._skip('Newer build found')
elif build.global_state in ('waiting', 'testing'):
if not build.killable:
build.killable = True
elif slot.link_type == 'created':
batches = testing_slots.mapped('batch_id')
_logger.info('Cannot skip build %s build is still in use in batches %s', build.id, batches.ids)
bundles = batches.mapped('bundle_id') - batch.bundle_id
if bundles:
batch._log('Cannot kill or skip build %s, build is used in another bundle: %s', build.id, bundles.mapped('name'))
def _process(self):
processed = self.browse()
for batch in self:
if batch.state == 'preparing' and batch.last_update < fields.Datetime.now() - datetime.timedelta(seconds=60):
batch._prepare()
processed |= batch
elif batch.state == 'ready' and all(slot.build_id.global_state in (False, 'running', 'done') for slot in batch.slot_ids):
_logger.info('Batch %s is done', self.id)
batch._log('Batch done')
batch.state = 'done'
processed |= batch
return processed
def _create_build(self, params):
"""
Create a build with given params_id if it does not already exists.
In the case that a very same build already exists that build is returned
"""
domain = [('params_id', '=', params.id), ('parent_id', '=', False)]
if self.bundle_id.host_id:
domain += [('host', '=', self.bundle_id.host_id.name), ('keep_host', '=', True)]
build = self.env['runbot.build'].search(domain, limit=1, order='id desc')
link_type = 'matched'
killed_states = ('skipped', 'killed', 'manually_killed')
if build and build.local_result not in killed_states and build.global_result not in killed_states:
if build.killable:
build.killable = False
else:
description = params.trigger_id.description if params.trigger_id.description else False
link_type = 'created'
build_type = 'normal'
if self.category_id != self.env.ref('runbot.default_category'):
build_type = 'scheduled'
elif self.bundle_id.priority:
build_type = 'priority'
build = self.env['runbot.build'].create({
'params_id': params.id,
'description': description,
'build_type': build_type,
'no_auto_run': self.bundle_id.no_auto_run,
})
if self.bundle_id.host_id:
build.host = self.bundle_id.host_id.name
build.keep_host = True
build._github_status()
return link_type, build
def _prepare(self, auto_rebase=False):
_logger.info('Preparing batch %s', self.id)
if not self.bundle_id.base_id:
# in some case the base can be detected lately. If a bundle has no base, recompute the base before preparing
self.bundle_id._compute_base_id()
for level, message in self.bundle_id._consistency_warning():
if level == "warning":
self._warning("Bundle warning: %s" % message)
self.state = 'ready'
bundle = self.bundle_id
project = bundle.project_id
if not bundle.version_id:
_logger.error('No version found on bundle %s in project %s', bundle.name, project.name)
dockerfile_id = bundle.dockerfile_id or bundle.base_id.dockerfile_id or bundle.project_id.dockerfile_id or bundle.version_id.dockerfile_id
if not dockerfile_id:
_logger.error('No dockerfile found !')
triggers = self.env['runbot.trigger'].search([ # could be optimised for multiple batches. Ormcached method?
('project_id', '=', project.id),
('category_id', '=', self.category_id.id)
]).filtered(
lambda t: not t.version_domain or \
self.bundle_id.version_id.filtered_domain(t._get_version_domain())
)
pushed_repo = self.commit_link_ids.mapped('commit_id.repo_id')
dependency_repos = triggers.mapped('dependency_ids')
all_repos = triggers.mapped('repo_ids') | dependency_repos
missing_repos = all_repos - pushed_repo
foreign_projects = dependency_repos.mapped('project_id') - project
######################################
# Find missing commits
######################################
def _fill_missing(branch_commits, match_type):
used_branches = []
if branch_commits:
for branch, commit in branch_commits.items(): # branch first in case pr is closed.
nonlocal missing_repos
if commit.repo_id in missing_repos:
if not branch.alive:
self._log("Skipping dead branch %s" % branch.name)
continue
values = {
'commit_id': commit.id,
'match_type': match_type,
'branch_id': branch.id,
}
if match_type.startswith('base'):
values['base_commit_id'] = commit.id
values['merge_base_commit_id'] = commit.id
self.write({'commit_link_ids': [(0, 0, values)]})
missing_repos -= commit.repo_id
used_branches.append(branch)
return used_branches
# CHECK branch heads consistency
branch_per_repo = {}
for branch in bundle.branch_ids.sorted(lambda b: (b.head.id, b.is_pr), reverse=True):
if branch.alive:
commit = branch.head
repo = commit.repo_id
if repo not in branch_per_repo:
branch_per_repo[repo] = branch
elif branch_per_repo[repo].head != branch.head and branch.alive:
obranch = branch_per_repo[repo]
self._log("Branch %s and branch %s in repo %s don't have the same head: %s%s", branch.dname, obranch.dname, repo.name, branch.head.name, obranch.head.name)
# 1.1 FIND missing commit in bundle heads
if missing_repos:
_fill_missing({branch: branch.head for branch in bundle.branch_ids.sorted(lambda b: (b.head.id, b.is_pr), reverse=True)}, 'head')
# 1.2 FIND merge_base info for those commits
# use last not preparing batch to define previous repos_heads instead of branches heads:
# Will allow to have a diff info on base bundle, compare with previous bundle
last_base_batch = self.env['runbot.batch'].search([('bundle_id', '=', bundle.base_id.id), ('state', '!=', 'preparing'), ('category_id', '=', self.category_id.id), ('id', '!=', self.id)], order='id desc', limit=1)
base_head_per_repo = {commit.repo_id.id: commit for commit in last_base_batch.commit_ids}
self._update_commits_infos(base_head_per_repo) # set base_commit, diff infos, ...
# 2. FIND missing commit in a compatible base bundle
if not bundle.is_base:
merge_base_commits = self.commit_link_ids.mapped('merge_base_commit_id')
if self.base_reference_batch_id:
self._log('Using a defined reference batch [%s](%s)', self.base_reference_batch_id.id, self.base_reference_batch_id._url())
elif auto_rebase or not bundle.branch_ids:
self.base_reference_batch_id = last_base_batch
else:
self.base_reference_batch_id = False
link_commit = self.env['runbot.commit.link'].search([
('commit_id', 'in', merge_base_commits.ids),
('match_type', 'in', ('new', 'head'))
])
batches = self.env['runbot.batch'].search([
('bundle_id', '=', bundle.base_id.id),
('commit_link_ids', 'in', link_commit.ids),
('state', '!=', 'preparing'),
('category_id', '=', self.category_id.id)
]).sorted(lambda b: (len(b.commit_ids & merge_base_commits), b.id), reverse=True)
if batches:
self.base_reference_batch_id = batches[0]
if missing_repos and bundle.always_use_foreign and foreign_projects:
self._log('Starting by filling foreign repo')
foreign_bundles = bundle.search([('name', '=', bundle.name), ('project_id', 'in', foreign_projects.ids)])
used_branches = _fill_missing({branch: branch.head for branch in foreign_bundles.mapped('branch_ids').sorted('is_pr', reverse=True)}, 'head')
if used_branches:
commits = ', '.join([f'[{branch.remote_id.repo_id.name}]({branch.bundle_id.name})' for branch in used_branches])
self._log(f'Found {len(used_branches)} commits in foreigh repo: {commits}')
batch = self.base_reference_batch_id
if batch:
if missing_repos:
self._log('Using batch [%s](%s) to define missing commits', batch.id, batch._url())
_fill_missing({link.branch_id: link.commit_id for link in batch.commit_link_ids}, 'base_match')
# check if all mergebase match reference batch
batch_exiting_commit = batch.commit_ids.filtered(lambda c: c.repo_id in merge_base_commits.repo_id)
not_matching = (batch_exiting_commit - merge_base_commits)
if not_matching and not auto_rebase:
message = 'Only %s out of %s merge base matched. You may want to rebase your branches to ensure compatibility' % (len(merge_base_commits)-len(not_matching), len(merge_base_commits))
suggestions = [('Tip: rebase %s to %s' % (commit.repo_id.name, commit.name)) for commit in not_matching]
self._warning('%s\n%s' % (message, '\n'.join(suggestions)))
else:
self._log('No reference batch found to fill missing commits')
# 3.1 FIND missing commit in base heads
if missing_repos:
if not bundle.is_base:
self._log('Not all commit found in bundle branches and base batch. Fallback on base branches heads.')
_fill_missing({branch: branch.head for branch in self.bundle_id.base_id.branch_ids}, 'base_head')
# 3.2 FIND missing commit in master base heads
if missing_repos: # this is to get an upgrade branch.
if not bundle.is_base:
self._log('Not all commit found in current version. Fallback on master branches heads.')
master_bundle = self.env['runbot.version']._get('master').with_context(project_id=self.bundle_id.project_id.id).base_bundle_id
_fill_missing({branch: branch.head for branch in master_bundle.branch_ids}, 'base_head')
# 4. FIND missing commit in foreign project
if missing_repos:
if foreign_projects:
base_foreign_bundles = bundle.search([('name', '=', bundle.base_id.name), ('project_id', 'in', foreign_projects.ids)])
_fill_missing({branch: branch.head for branch in base_foreign_bundles.mapped('branch_ids')}, 'base_head')
# CHECK missing commit
if missing_repos:
_logger.warning('Missing repo %s for batch %s', missing_repos.mapped('name'), self.id)
######################################
# Generate build params
######################################
if auto_rebase:
for commit_link in self.commit_link_ids:
commit_link.commit_id = commit_link.commit_id._rebase_on(commit_link.base_commit_id)
commit_link_by_repos = {commit_link.commit_id.repo_id.id: commit_link for commit_link in self.commit_link_ids}
bundle_repos = bundle.branch_ids.mapped('remote_id.repo_id')
version_id = self.bundle_id.version_id.id
project_id = self.bundle_id.project_id.id
trigger_customs = {}
for trigger_custom in self.bundle_id.trigger_custom_ids:
trigger_customs[trigger_custom.trigger_id] = trigger_custom
for trigger in triggers:
trigger_custom = trigger_customs.get(trigger, self.env['runbot.bundle.trigger.custom'])
trigger_repos = trigger.repo_ids | trigger.dependency_ids
if trigger_repos & missing_repos:
self._warning('Missing commit for repo %s for trigger %s', (trigger_repos & missing_repos).mapped('name'), trigger.name)
continue
# in any case, search for an existing build
config = trigger_custom.config_id or trigger.config_id
extra_params = trigger_custom.extra_params or ''
config_data = trigger_custom.config_data or {}
params_value = {
'version_id': version_id,
'extra_params': extra_params,
'config_id': config.id,
'project_id': project_id,
'trigger_id': trigger.id, # for future reference and access rights
'config_data': config_data,
'commit_link_ids': [(6, 0, [commit_link_by_repos[repo.id].id for repo in trigger_repos])],
'modules': bundle.modules,
'dockerfile_id': dockerfile_id,
'create_batch_id': self.id,
'used_custom_trigger': bool(trigger_custom),
}
params_value['builds_reference_ids'] = trigger._reference_builds(bundle)
params = self.env['runbot.build.params'].create(params_value)
build = self.env['runbot.build']
link_type = 'created'
force_trigger = trigger_custom and trigger_custom.start_mode == 'force'
skip_trigger = (trigger_custom and trigger_custom.start_mode == 'disabled') or trigger.manual
should_start = ((trigger.repo_ids & bundle_repos) or bundle.build_all or bundle.sticky)
if force_trigger or (should_start and not skip_trigger): # only auto link build if bundle has a branch for this trigger
link_type, build = self._create_build(params)
self.env['runbot.batch.slot'].create({
'batch_id': self.id,
'trigger_id': trigger.id,
'build_id': build.id,
'params_id': params.id,
'link_type': link_type,
})
######################################
# SKIP older batches
######################################
default_category = self.env.ref('runbot.default_category')
if not bundle.sticky and self.category_id == default_category:
skippable = self.env['runbot.batch'].search([
('bundle_id', '=', bundle.id),
('state', 'not in', ('done', 'skipped')),
('id', '<', self.id),
('category_id', '=', default_category.id)
])
skippable._skip()
def _update_commits_infos(self, base_head_per_repo):
for link_commit in self.commit_link_ids:
commit = link_commit.commit_id
base_head = base_head_per_repo.get(commit.repo_id.id)
if not base_head:
self._warning('No base head found for repo %s', commit.repo_id.name)
continue
link_commit.base_commit_id = base_head
merge_base_sha = False
try:
link_commit.base_ahead = link_commit.base_behind = 0
link_commit.file_changed = link_commit.diff_add = link_commit.diff_remove = 0
link_commit.merge_base_commit_id = commit.id
if commit.name == base_head.name:
continue
merge_base_sha = commit.repo_id._git(['merge-base', commit.name, base_head.name]).strip()
merge_base_commit = self.env['runbot.commit']._get(merge_base_sha, commit.repo_id.id)
link_commit.merge_base_commit_id = merge_base_commit.id
ahead, behind = commit.repo_id._git(['rev-list', '--left-right', '--count', '%s...%s' % (commit.name, base_head.name)]).strip().split('\t')
link_commit.base_ahead = int(ahead)
link_commit.base_behind = int(behind)
if merge_base_sha == commit.name:
continue
# diff. Iter on --numstat, easier to parse than --shortstat summary
diff = commit.repo_id._git(['diff', '--numstat', merge_base_sha, commit.name]).strip()
if diff:
for line in diff.split('\n'):
link_commit.file_changed += 1
add, remove, _ = line.split(None, 2)
try:
link_commit.diff_add += int(add)
link_commit.diff_remove += int(remove)
except ValueError: # binary files
pass
except subprocess.CalledProcessError:
self._warning('Commit info failed between %s and %s', commit.name, base_head.name)
def _warning(self, message, *args):
self.has_warning = True
_logger.warning('batch %s: ' + message, self.id, *args)
self._log(message, *args, level='WARNING')
def _log(self, message, *args, level='INFO'):
message = message % args if args else message
self.env['runbot.batch.log'].create({
'batch_id': self.id,
'message': message,
'level': level,
})
class BatchLog(models.Model):
_name = 'runbot.batch.log'
_description = 'Batch log'
batch_id = fields.Many2one('runbot.batch', index=True)
message = fields.Text('Message')
level = fields.Char()
def _markdown(self):
""" Apply pseudo markdown parser for message.
"""
self.ensure_one()
return pseudo_markdown(self.message)
fa_link_types = {'created': 'hashtag', 'matched': 'link', 'rebuild': 'refresh'}
class BatchSlot(models.Model):
_name = 'runbot.batch.slot'
_description = 'Link between a bundle batch and a build'
_order = 'trigger_id,id'
batch_id = fields.Many2one('runbot.batch', index=True)
trigger_id = fields.Many2one('runbot.trigger', index=True)
build_id = fields.Many2one('runbot.build', index=True)
all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids')
params_id = fields.Many2one('runbot.build.params', index=True, required=True)
link_type = fields.Selection([('created', 'Build created'), ('matched', 'Existing build matched'), ('rebuild', 'Rebuild')], required=True) # rebuild type?
active = fields.Boolean('Attached', default=True)
skipped = fields.Boolean('Skipped', default=False)
# rebuild, what to do: since build can be in multiple batch:
# - replace for all batch?
# - only available on batch and replace for batch only?
# - create a new bundle batch will new linked build?
@api.depends('build_id')
def _compute_all_build_ids(self):
all_builds = self.env['runbot.build'].search([('id', 'child_of', self.build_id.ids)])
for slot in self:
slot.all_build_ids = all_builds.filtered_domain([('id', 'child_of', slot.build_id.ids)])
def _fa_link_type(self):
return fa_link_types.get(self.link_type, 'exclamation-triangle')
def _create_missing_build(self):
"""Create a build when the slot does not have one"""
self.ensure_one()
if self.build_id:
return self.build_id
self.batch_id._log(f'Trigger {self.trigger_id.name} was started by {self.env.user.name}')
self.link_type, self.build_id = self.batch_id._create_build(self.params_id)
return self.build_id