From 5e41d1120d529666cffaa0b69af446f36ebac122 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Thu, 13 Mar 2025 16:21:30 +0100 Subject: [PATCH] WIP --- runbot/__manifest__.py | 2 +- runbot/container.py | 27 ++++++++++++ runbot/migrations/18.0.5.10/post-migration.py | 11 +++++ runbot/models/docker.py | 11 ++++- runbot/models/host.py | 30 +++++++------ runbot/models/res_config_settings.py | 3 +- runbot/tests/common.py | 6 ++- runbot/tests/test_host.py | 44 +++++++++++++++++++ runbot/views/dockerfile_views.xml | 2 + runbot/views/res_config_settings_views.xml | 3 ++ 10 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 runbot/migrations/18.0.5.10/post-migration.py diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 5085df56..84d15e5d 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -6,7 +6,7 @@ 'author': "Odoo SA", 'website': "http://runbot.odoo.com", 'category': 'Website', - 'version': '5.9', + 'version': '5.10', 'application': True, 'depends': ['base', 'base_automation', 'website'], 'data': [ diff --git a/runbot/container.py b/runbot/container.py index 4134c9d3..faabc2e0 100644 --- a/runbot/container.py +++ b/runbot/container.py @@ -137,6 +137,24 @@ def _docker_build(build_dir, image_tag): return dm.result +def docker_tag(identifier_or_tag, new_tag): + return _docker_tag(identifier_or_tag, new_tag) + +def _docker_tag(identifier_or_tag, new_tag): + if not identifier_or_tag: + return + _logger.info('Tagging image %s to "%s"', identifier_or_tag, new_tag) + docker_client = docker.from_env() + repo, tag = new_tag.split(':') # runbot DockerFile tags contains the repo part + try: + image = docker_client.images.get(identifier_or_tag) + image.tag(repo, tag) + except docker.errors.ImageNotFound: + _logger.warning('failed to find docker image with identifier %s', identifier_or_tag) + except docker.errors.APIError: + _logger.warning('failed to retag docker image with identifier %s', identifier_or_tag) + + def docker_push(image_tag, push_url='127.0.0.1:5001'): return _docker_push(image_tag, push_url) @@ -391,3 +409,12 @@ def sanitize_container_name(name): """Returns a container name with unallowed characters removed""" name = re.sub('^[^a-zA-Z0-9]+', '', name) return re.sub('[^a-zA-Z0-9_.-]', '', name) + + +def docker_get_identifier(tag_or_id): + docker_client = docker.from_env() + try: + image = docker_client.images.get(tag_or_id) + return image.short_id + except docker.errors.ImageNotFound: + return False diff --git a/runbot/migrations/18.0.5.10/post-migration.py b/runbot/migrations/18.0.5.10/post-migration.py new file mode 100644 index 00000000..f91d5ee9 --- /dev/null +++ b/runbot/migrations/18.0.5.10/post-migration.py @@ -0,0 +1,11 @@ +import logging + +_logger = logging.getLogger(__name__) + +def migrate(cr, version): + cr.execute(""" + UPDATE runbot_dockerfile + SET image_identifier = subq.identifier, image_future_identifier = subq.identifier + FROM (SELECT dockerfile_id, identifier FROM runbot_docker_build_result WHERE result='success' order by create_date desc) AS subq + WHERE runbot_dockerfile.id = subq.dockerfile_id; + """) diff --git a/runbot/models/docker.py b/runbot/models/docker.py index 6c407b2a..6221a833 100644 --- a/runbot/models/docker.py +++ b/runbot/models/docker.py @@ -124,7 +124,10 @@ class Dockerfile(models.Model): name = fields.Char('Dockerfile name', required=True, help="Name of Dockerfile") active = fields.Boolean('Active', default=True, tracking=True) + image_identifier = fields.Char('Identifier') + image_future_identifier = fields.Char('Future Identifier') image_tag = fields.Char(compute='_compute_image_tag', store=True) + image_future_tag = fields.Char(compute='_compute_image_future_tag') template_id = fields.Many2one('ir.ui.view', string='Docker Template', domain=[('type', '=', 'qweb')], context={'default_type': 'qweb', 'default_arch_base': ''}) arch_base = fields.Text(related='template_id.arch_base', readonly=False, related_sudo=True) dockerfile = fields.Text(compute='_compute_dockerfile', tracking=True) @@ -198,6 +201,11 @@ class Dockerfile(models.Model): if rec.name: rec.image_tag = 'odoo:%s' % re.sub(r'[ /:\(\)\[\]]', '', rec.name) + @api.depends('image_tag') + def _compute_image_future_tag(self): + for rec in self: + rec.image_future_tag = f'{rec.image_tag}.future' + @api.depends('template_id') def _compute_view_ids(self): for rec in self: @@ -332,7 +340,7 @@ class Dockerfile(models.Model): with open(self.env['runbot.runbot']._path('docker', self.image_tag, 'Dockerfile'), 'w') as Dockerfile: Dockerfile.write(content) - result = docker_build(docker_build_path, self.image_tag) + result = docker_build(docker_build_path, self.image_future_tag) duration = result['duration'] msg = result['msg'] success = image_id = result.get('image_id') @@ -372,6 +380,7 @@ class Dockerfile(models.Model): message = f'Build failure, check results for more info ({result.summary})' self.message_post(body=message) _logger.error(message) + return image_id class DockerBuildOutput(models.Model): diff --git a/runbot/models/host.py b/runbot/models/host.py index 8db11c3a..6f423ad9 100644 --- a/runbot/models/host.py +++ b/runbot/models/host.py @@ -6,7 +6,7 @@ from docker.errors import ImageNotFound from odoo import models, fields, api from odoo.tools import config, ormcache from ..common import fqdn, local_pgadmin_cursor, os, list_local_dbs, local_pg_cursor -from ..container import docker_push, docker_pull, docker_prune, docker_images, docker_remove +from ..container import docker_push, docker_pull, docker_prune, docker_images, docker_remove, docker_tag _logger = logging.getLogger(__name__) @@ -150,7 +150,7 @@ class Host(models.Model): # pull all images from the runbot docker registry is_registry = docker_registry_host == self all_docker_files = self.env['runbot.dockerfile'].search([]) - all_tags = set(all_docker_files.mapped('image_tag')) + all_tags = set(all_docker_files.mapped('image_tag')) | set(all_docker_files.mapped('image_future_tag')) if docker_registry_url and self.use_remote_docker_registry and not is_registry: _logger.info('Pulling docker images...') total_duration = 0 @@ -166,18 +166,22 @@ class Host(models.Model): else: _logger.info('Building docker images...') for dockerfile in self.env['runbot.dockerfile'].search([('to_build', '=', True)]): - dockerfile._build(self) + identifier = dockerfile._build(self) + dockerfile.image_future_identifier = identifier + docker_tag(dockerfile.image_identifier, dockerfile.image_tag) + docker_tag(dockerfile.image_future_identifier, dockerfile.image_future_tag) if is_registry: - try: - docker_push(dockerfile.image_tag) # for now, always push locally - if self.docker_registry_url: - docker_registry_url = self.docker_registry_url - else: - docker_registry_url = icp.get_param('runbot.docker_registry_url', default='').strip('/') - if docker_registry_url: - docker_push(dockerfile.image_tag, docker_registry_url) - except ImageNotFound: - _logger.warning("Image tag `%s` not found. Skipping push", dockerfile.image_tag) + for tag in [dockerfile.image_tag, dockerfile.image_future_tag]: + try: + docker_push(tag) # for now, always push locally + if self.docker_registry_url: + docker_registry_url = self.docker_registry_url + else: + docker_registry_url = icp.get_param('runbot.docker_registry_url', default='').strip('/') + if docker_registry_url: + docker_push(tag, docker_registry_url) + except ImageNotFound: + _logger.warning("Image tag `%s` not found. Skipping push", dockerfile.image_future_tag) _logger.info('Cleaning docker images...') for image in docker_images(): diff --git a/runbot/models/res_config_settings.py b/runbot/models/res_config_settings.py index 90a60aab..9d29f005 100644 --- a/runbot/models/res_config_settings.py +++ b/runbot/models/res_config_settings.py @@ -51,7 +51,8 @@ class ResConfigSettings(models.TransientModel): runbot_pending_warning = fields.Integer('Pending warning limit', default=5, config_parameter='runbot.pending.warning') runbot_pending_critical = fields.Integer('Pending critical limit', default=5, config_parameter='runbot.pending.critical') - runbot_docker_registry_host_id = fields.Many2one('runbot.host', 'Docker registry', help='Runbot host which handles Docker registry.', config_parameter='runbot.docker_registry_host_id') + runbot_docker_registry_host_id = fields.Many2one('runbot.host', 'Docker builder', help='Runbot host which handles Docker builds.', config_parameter='runbot.docker_registry_host_id') + runbot_docker_registry_url = fields.Char('Docker Registry url', help='Remote Registry Url', config_parameter='runbot.docker_registry_url') # TODO other icp # runbot.runbot_maxlogs 100 # migration db diff --git a/runbot/tests/common.py b/runbot/tests/common.py index abc7ed4f..cc981a76 100644 --- a/runbot/tests/common.py +++ b/runbot/tests/common.py @@ -181,6 +181,11 @@ class RunbotCase(TransactionCase): self.start_patcher('docker_run', 'odoo.addons.runbot.container._docker_run') self.start_patcher('docker_build', 'odoo.addons.runbot.container._docker_build') self.start_patcher('docker_push', 'odoo.addons.runbot.container._docker_push') + self.start_patcher('docker_prune', 'odoo.addons.runbot.container._docker_prune') + self.start_patcher('docker_pull', 'odoo.addons.runbot.container._docker_pull') + self.start_patcher('docker_tag', 'odoo.addons.runbot.container._docker_tag') + self.start_patcher('docker_images', 'odoo.addons.runbot.container._docker_images') + self.start_patcher('docker_remove', 'odoo.addons.runbot.container._docker_remove') self.start_patcher('docker_ps', 'odoo.addons.runbot.container._docker_ps', []) self.start_patcher('docker_stop', 'odoo.addons.runbot.container._docker_stop') self.start_patcher('docker_get_gateway_ip', 'odoo.addons.runbot.models.build_config.docker_get_gateway_ip', None) @@ -196,7 +201,6 @@ class RunbotCase(TransactionCase): self.start_patcher('getmtime', 'odoo.addons.runbot.common.os.path.getmtime', datetime.datetime.now().timestamp()) self.start_patcher('file_exist', 'odoo.tools.misc.os.path.exists', True) - self.start_patcher('_get_py_version', 'odoo.addons.runbot.models.build.BuildResult._get_py_version', 3) def no_commit(*_args, **_kwargs): diff --git a/runbot/tests/test_host.py b/runbot/tests/test_host.py index 2aeb24f5..6f119da5 100644 --- a/runbot/tests/test_host.py +++ b/runbot/tests/test_host.py @@ -1,5 +1,7 @@ import logging +from unittest.mock import call + from .common import RunbotCase from datetime import datetime, timedelta @@ -111,3 +113,45 @@ class TestHost(RunbotCase): self.patchers['fetch_local_logs'].return_value = logs self.test_host._process_logs() self.patchers['host_local_pg_cursor'].assert_called() + + def test_docker_builder(self): + self.start_patcher('build_patcher', 'odoo.addons.runbot.models.docker.Dockerfile._build') + + # deactivate DockerDefault to avoid test pollution + self.env.ref('runbot.docker_default').active = False + + icp = self.env['ir.config_parameter'] + icp.set_param('runbot.docker_registry_host_id', self.test_host.id) + icp.set_param('runbot.docker_registry_url', 'registryhost_nowhere') + dockerfile = self.env['runbot.dockerfile'].create({ + 'name': 'Docker Test', + 'to_build': True, + 'image_identifier': 'current', + 'image_future_identifier': 'current' + }) + + self.assertEqual(dockerfile.image_tag, 'odoo:DockerTest') + self.assertEqual(dockerfile.image_future_tag, 'odoo:DockerTest.future') + self.patchers['build_patcher'].side_effect = lambda x: 'future' + self.test_host._docker_update_images() + + self.assertEqual(dockerfile.image_future_identifier, 'future') + + expected_docker_tag_calls = [ + call('current', 'odoo:DockerTest'), + call('future', 'odoo:DockerTest.future') + ] + + self.patchers['docker_tag'].assert_has_calls(expected_docker_tag_calls) + + expected_push_calls = [ + call('odoo:DockerTest', '127.0.0.1:5001'), + call('odoo:DockerTest', 'registryhost_nowhere'), + call('odoo:DockerTest.future', '127.0.0.1:5001'), + call('odoo:DockerTest.future', 'registryhost_nowhere') + ] + + self.patchers['docker_push'].assert_has_calls(expected_push_calls) + self.patchers['docker_pull'].assert_not_called() + + diff --git a/runbot/views/dockerfile_views.xml b/runbot/views/dockerfile_views.xml index 62de7ce9..a75eeaf1 100644 --- a/runbot/views/dockerfile_views.xml +++ b/runbot/views/dockerfile_views.xml @@ -11,6 +11,8 @@ + + diff --git a/runbot/views/res_config_settings_views.xml b/runbot/views/res_config_settings_views.xml index 745fdf5f..f6d864b7 100644 --- a/runbot/views/res_config_settings_views.xml +++ b/runbot/views/res_config_settings_views.xml @@ -97,6 +97,9 @@ + + +