Skip to content
Draft

wip #1429

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 79 additions & 23 deletions runbot/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import re
import subprocess
import time
import yaml

from odoo.tools import file_path

Expand Down Expand Up @@ -235,7 +236,7 @@ def docker_run(*args, **kwargs):
return _docker_run(*args, **kwargs)


def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False, image_tag=False, exposed_ports=None, cpu_limit=None, cpu_period=100000, cpus=0, memory=None, preexec_fn=None, ro_volumes=None, env_variables=None, network_enabled=False):
def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False, image_tag=False, docker_compose_content=None, exposed_ports=None, cpu_limit=None, cpus=0, memory=None, preexec_fn=None, ro_volumes=None, env_variables=None, network_enabled=False):
"""Run tests in a docker container
:param run_cmd: command string to run in container
:param log_path: path to the logfile that will contain odoo stdout and stderr
Expand All @@ -244,8 +245,7 @@ def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False
:param container_name: used to give a name to the container for later reference
:param image_tag: Docker image tag name to select which docker image to use
:param exposed_ports: if not None, starting at 8069, ports will be exposed as exposed_ports numbers
:param cpu_period: Specify the CPU CFS scheduler period, which is used alongside cpu_quota
:param cpus: used to compute cpu_quota = cpu_period * cpus (equivalent of --cpus in docker CLI)
:param cpus: used to compute cpu_quota = 100000 * cpus (equivalent of --cpus in docker CLI)
:param memory: memory limit in bytes for the container
:params ro_volumes: dict of dest:source volumes to mount readonly in builddir
:params env_variables: list of environment variables
Expand Down Expand Up @@ -273,7 +273,6 @@ def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False
logs.write("Docker command:\n%s\n=================================================\n" % cmd_str)
# create start script
volumes = {
'/var/run/postgresql': {'bind': '/var/run/postgresql', 'mode': 'rw'},
f'{build_dir}': {'bind': '/data/build', 'mode': 'rw'},
f'{log_path}': {'bind': '/data/buildlogs.txt', 'mode': 'rw'}
}
Expand All @@ -294,25 +293,69 @@ def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False
ulimits.append(docker.types.Ulimit(name='cpu', soft=cpu_limit, hard=cpu_limit))

docker_client = docker.from_env()
container = docker_client.containers.run(
image_tag,
name=container_name,
volumes=volumes,
shm_size='128m',
mem_limit=memory,
ports=ports,
ulimits=ulimits,
cpu_period=cpu_period,
cpu_quota=int(cpus * cpu_period ) if cpus else None,
environment=env_variables,
init=True,
command=['/bin/bash', '-c',
f'exec &>> /data/buildlogs.txt ;{run_cmd}'],
auto_remove=True,
detach=True,
user=USERNAME,
network_mode=None if network_enabled else 'none'
)
if docker_compose_content:
docker_compose = yaml.safe_load(docker_compose_content)
for service_name, service in docker_compose['services'].items():
service["restart"] = "no"
if service_name == 'main':
service["container_name"] = container_name
else:
service["container_name"] = container_name + '-' + service_name
service = docker_compose['services']['main']
service['command'] = ['/bin/bash', '-c', f'exec &>> /data/buildlogs.txt ;{run_cmd}']
service['volumes'] = service.get('volumes', []) + [f'{source}:{volume["bind"]}:{volume["mode"]}'for source, volume in volumes.items()]
service["ports"] = [f"{hp}:{dp}/tcp" for dp, hp in enumerate(exposed_ports, start=8069)]
service["user"] = USERNAME
service["ulimits"] = {u["name"]: {"soft": u["soft"], "hard": u["hard"]} for u in ulimits}
service["environment"] = env_variables or {}
service["shm_size"] = '128m'
service["init"] = True
if not network_enabled:
service["network_mode"] = "none"

limits = {}
if memory:
limits["memory"] = memory
if cpus:
limits["cpus"] = str(cpus)
if limits:
service["deploy"] = {"resources": {"limits": limits}}

compose_path = os.path.join(build_dir, "docker-compose.yml")
with open(compose_path, 'w') as f:
yaml.dump(docker_compose, f, default_flow_style=False, sort_keys=False)
cmd = [
"docker", "compose",
"-f", compose_path,
"-p", container_name,
"up", "-d", "--remove-orphans",
]
subprocess.run(cmd, check=True)
container = docker_client.containers.get(container_name)

else:

volumes['/var/run/postgresql'] = {'bind': '/var/run/postgresql', 'mode': 'rw'}
cpu_period = 100000
container = docker_client.containers.run(
image_tag,
name=container_name,
volumes=volumes,
shm_size='128m',
mem_limit=memory,
ports=ports,
ulimits=ulimits,
cpu_period=cpu_period,
cpu_quota=int(cpus * cpu_period) if cpus else None,
environment=env_variables,
init=True,
command=['/bin/bash', '-c',
f'exec &>> /data/buildlogs.txt ;{run_cmd}'],
auto_remove=True,
detach=True,
user=USERNAME,
network_mode=None if network_enabled else 'none'
)
if container.status not in ('running', 'created') :
_logger.error('Container %s started but status is not running or created: %s', container_name, container.status)
else:
Expand All @@ -324,6 +367,17 @@ def docker_stop(container_name, build_dir=None):
return _docker_stop(container_name, build_dir)


def docker_compose_cleanup(container_name):
docker_client = docker.from_env()
for container in docker_client.containers.list(all=True, filters={'label': f'com.docker.compose.project={container_name}'}):
try:
container.stop(timeout=1)
container.remove(v=True)
except docker.errors.NotFound:
pass
except docker.errors.APIError as e:
_logger.warning('compose cleanup: %s on %s', e, container.name)

def _docker_stop(container_name, build_dir):
"""Stops the container named container_name"""
container_name = sanitize_container_name(container_name)
Expand All @@ -342,8 +396,10 @@ def _docker_stop(container_name, build_dir):
else:
_logger.info('Stopping docker without defined build_dir')
try:
docker_compose_cleanup(container_name)
container = docker_client.containers.get(container_name)
container.stop(timeout=1)
container.remove(v=True)
return
except docker.errors.NotFound:
_logger.error('Cannnot stop container %s. Container not found', container_name)
Expand Down
88 changes: 88 additions & 0 deletions runbot/data/dockerfile_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,67 @@ ADD --chown={USERNAME} https://raw.githubusercontent.com/odoo/odoo/{odoo_branch}
RUN python3 -m pip install --no-cache-dir -r /tmp/requirements.txt</field>
</record>

<record model="runbot.dockerfile" id="runbot.postgresql_docker_default">
<field name="name">PostgresqlDockerDefault</field>
<field name="to_build">True</field>
<field name="description">Default Postgresql Dockerfile for latest Odoo versions.</field>
</record>

<record id="runbot.docker_layer_from_postgresql" model="runbot.docker_layer">
<field name="sequence" eval="0"/>
<field name="dockerfile_id" ref="runbot.postgresql_docker_default"/>
<field name="layer_type">raw</field>
<field name="name">FROM postgres:18</field>
<field name="content">FROM postgres:18</field>
</record>

<record id="runbot.docker_layer_deb_postgresql" model="runbot.docker_layer">
<field name="sequence" eval="10"/>
<field name="dockerfile_id" ref="runbot.postgresql_docker_default"/>
<field name="layer_type">reference_layer</field>
<field name="name">Install postgresql extensions</field>
<field name="packages">postgresql-18-pgvector</field>
<field name="reference_docker_layer_id" ref="runbot.docker_layer_debian_packages_template"/>
</record>

<record id="runbot.docker_layer_create_user_postgresql" model="runbot.docker_layer">
<field name="sequence" eval="20"/>
<field name="dockerfile_id" ref="runbot.postgresql_docker_default"/>
<field name="layer_type">reference_layer</field>
<field name="name">Create user for postgresql docker</field>
<field name="reference_docker_layer_id" ref="runbot.docker_layer_create_user_template"/>
</record>

<record id="runbot.docker_layer_create_template_postgresql" model="runbot.docker_layer">
<field name="sequence" eval="30"/>
<field name="dockerfile_id" ref="runbot.postgresql_docker_default"/>
<field name="layer_type">raw</field>
<field name="name">Create template</field>
<field name="content">RUN printf '%s\n' \
'#!/bin/bash' \
'set -e' \
'createdb -U "$POSTGRES_USER" -O odoo template_runbot' \
'psql -U "$POSTGRES_USER" -d template_runbot &lt;&lt;-'"'"'SQL'"'"'' \
' SET statement_timeout = 0;' \
' SET lock_timeout = 0;' \
' SET idle_in_transaction_session_timeout = 0;' \
' SET client_encoding = '"'"'UTF8'"'"';' \
' SET standard_conforming_strings = on;' \
' SELECT pg_catalog.set_config('"'"'search_path'"'"', '"'"''"'"', false);' \
' SET check_function_bodies = false;' \
' SET xmloption = content;' \
' SET client_min_messages = warning;' \
' SET row_security = off;' \
' CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;' \
' CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;' \
' CREATE EXTENSION IF NOT EXISTS unaccent WITH SCHEMA public;' \
' CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public;' \
'SQL' \
'psql -U "$POSTGRES_USER" -c "UPDATE pg_database SET datistemplate = true WHERE datname = '"'"'template_runbot'"'"';"' \
&lt; /docker-entrypoint-initdb.d/10-template_runbot.sh

</field>
</record>
<record model="ir.actions.server" id="action_sync_docker_identifiers">
<field name="name">Sync Identifiers</field>
<field name="model_id" ref="runbot.model_runbot_dockerfile" />
Expand All @@ -212,4 +273,31 @@ RUN python3 -m pip install --no-cache-dir -r /tmp/requirements.txt</field>
</field>
</record>

<record model="runbot.docker_compose" id="runbot.base_odoo_docker_compose">
<field name="name">Base odoo docker compose</field>
<field name="content">
services:
postgres:
image: {postgres_image_tag}
environment:
POSTGRES_PASSWORD: odoo
POSTGRES_USER: odoo
POSTGRES_INITDB_ARGS: "--auth-local=peer --auth-host=scram-sha-256"
volumes:
- {build_dir}/pg_data:/var/lib/postgresql
- pg_socket:/var/run/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo -h /var/run/postgresql"]
interval: 2s
retries: 10

main:
image: {image_tag}
depends_on:
postgres:
condition: service_healthy
volumes:
- pg_socket:/var/run/postgresql
</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions runbot/models/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def _prepare(self, auto_rebase=False, use_base_commits=False):
_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
postgres_dockerfile_id = bundle.postgres_dockerfile_id or bundle.base_id.postgres_dockerfile_id or bundle.project_id.postgres_dockerfile_id or bundle.version_id.postgres_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?
Expand Down Expand Up @@ -425,6 +426,7 @@ def _fill_missing(branch_commits, match_type):
'commit_link_ids': [(6, 0, commits_links)],
'modules': bundle.modules,
'dockerfile_id': dockerfile_id,
'postgres_dockerfile_id': postgres_dockerfile_id,
'create_batch_id': self.id,
'used_custom_trigger': bool(trigger_custom.config_id or trigger_custom.extra_params or trigger_custom.config_data or trigger_custom.use_base_commits),
}
Expand Down
66 changes: 46 additions & 20 deletions runbot/models/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
transactioncache,
DEFAULT_MAX_FILE_SIZE,
)
from ..container import Command, docker_pull, docker_run, docker_state, docker_stop
from ..container import Command, docker_pull, docker_run, docker_state, docker_stop, docker_compose_cleanup
from ..fields import JsonDictField

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -90,6 +90,7 @@ class BuildParameters(models.Model):
create_batch_id = fields.Many2one('runbot.batch', index=True)
category = fields.Char('Category', index=True) # normal vs nightly vs weekly, ...
dockerfile_id = fields.Many2one('runbot.dockerfile', index=True, default=lambda self: self.env.ref('runbot.docker_default', raise_if_not_found=False))
postgres_dockerfile_id = fields.Many2one('runbot.dockerfile', index=True, default=lambda self: self.env.ref('runbot.postgresql_docker_default', raise_if_not_found=False))
skip_requirements = fields.Boolean('Skip requirements.txt auto install')
# other informations
extra_params = fields.Char('Extra cmd args')
Expand Down Expand Up @@ -162,6 +163,8 @@ def get_commit_links_ident(commit_link):
cleaned_vals['dynamic_config_position'] = param.dynamic_config_position
if param.dynamic_config.dict:
cleaned_vals['dynamic_config'] = param.dynamic_config.dict
if param.postgres_dockerfile_id:
cleaned_vals['postgres_dockerfile_id'] = param.postgres_dockerfile_id.id

param.fingerprint = hashlib.sha256(str(cleaned_vals).encode('utf8')).hexdigest()

Expand Down Expand Up @@ -909,6 +912,7 @@ def _schedule(self):
build._log('_schedule', 'Docker was likely killed, skipping%s' % details, level='ERROR')
if self.env['runbot.host']._fetch_local_logs(build_ids=build.ids):
return True # avoid to make results with remaining logs
docker_compose_cleanup(build._get_docker_name())
# No job running, make result and select next job
if build.docker_start:
docker_duration = int(time.time() - dt2time(build.docker_start))
Expand Down Expand Up @@ -1008,37 +1012,60 @@ def _run_job(self):
build._log("run", message, level='ERROR')
build._kill(result='ko')

def _docker_run(self, step, cmd=None, ro_volumes=None, env_variables=None, **kwargs):
def _docker_run(self, step, cmd=None, ro_volumes=None, env_variables=None, image_tag=None, postgres_image_tag=None, use_docker_compose=False, **kwargs):
self.ensure_one()
_ro_volumes = ro_volumes or {}
ro_volumes = {}
for dest, source in _ro_volumes.items():
ro_volumes[f'/data/build/{dest}'] = source
if 'image_tag' not in kwargs:
kwargs.update({'image_tag': step.dockerfile_id.image_tag or self.params_id.dockerfile_id.image_tag})
if image_tag is None:
image_tag = step.dockerfile_id.image_tag or self.params_id.dockerfile_id.image_tag

if postgres_image_tag is None:
postgres_image_tag = step.postgres_dockerfile_id.image_tag or self.params_id.postgres_dockerfile_id.image_tag or self.params_id.version_id.postgres_dockerfile_id.image_tag

dockerfile_variant = self.params_id.config_data.get('dockerfile_variant', step.dockerfile_variant)
if dockerfile_variant and f'.{dockerfile_variant.lower()}' not in kwargs['image_tag']:
kwargs['image_tag'] += f'.{dockerfile_variant.lower()}'
if dockerfile_variant and f'.{dockerfile_variant.lower()}' not in image_tag:
image_tag += f'.{dockerfile_variant.lower()}'
if self.params_id.config_data.get('docker_use_future') and not kwargs['image_tag'].endswith('.future'):
kwargs['image_tag'] += '.future'
image_tag += '.future'
if postgres_image_tag:
postgres_image_tag += '.future'
docker_registry_url = self.host_id._get_docker_registry_url()
image_id = None
if docker_registry_url and self.host_id.use_remote_docker_registry:
try:
result = docker_pull(f"{docker_registry_url}/{kwargs['image_tag']}")
if result['success']:
result['image'].tag(kwargs['image_tag'])
if result.get('log_progress'):
self._log('Docker Run', f'Docker image was pulled {"" if result["success"] else "with errors"}')
image_id = result.get('image_id')
except Exception:
_logger.exception('Failed to pull docker image %s', kwargs['image_tag'])
self._log('Docker Run', 'Failed to pull docker image')

self._log('Preparing', 'Using Dockerfile Tag [%s](/runbot/dockerfile_result/%s/%s)', kwargs['image_tag'], kwargs['image_tag'], image_id, log_type='markdown')
for pull_image_tag in (postgres_image_tag, image_tag):
try:
result = docker_pull(f"{docker_registry_url}/{pull_image_tag}")
if result['success']:
result['image'].tag(pull_image_tag)
if result.get('log_progress'):
self._log('Docker Run', f'Docker image was pulled {"" if result["success"] else "with errors"}')
image_id = result.get('image_id')
except Exception:
_logger.exception('Failed to pull docker image %s', pull_image_tag)
self._log('Docker Run', 'Failed to pull docker image')

self._log('Preparing', 'Using Dockerfile Tag [%s](/runbot/dockerfile_result/%s/%s)', pull_image_tag, pull_image_tag, image_id, log_type='markdown')

# network is disabled by default, can be enabled via kwargs['network_enabled'] (run, restore) or config_data['network_enabled'] (external, nightly,...)
kwargs['network_enabled'] = kwargs.get('network_enabled') or self.params_id.config_data.get('network_enabled') or self.params_id.trigger_id.network_enabled or False
use_docker_compose = use_docker_compose or self.params_id.config_data.get('use_docker_compose') or self.params_id.trigger_id.use_docker_compose or False
docker_compose_content = None
build_dir = self._path()
if use_docker_compose:
docker_compose_id = self.params_id.config_data.get('docker_compose_id') or step.docker_compose_id.id or self.params_id.project_id.docker_compose_id.id
docker_compose = self.env['runbot.docker_compose'].browse(docker_compose_id)
if docker_compose:
docker_compose_content = docker_compose._render({
'image_tag': image_tag,
'postgres_image_tag': postgres_image_tag,
'build_dir': build_dir,
})
if docker_compose_content:
kwargs['docker_compose_content'] = docker_compose_content
else:
kwargs['image_tag'] = image_tag

containers_memory_limit = self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_containers_memory', 0)
if containers_memory_limit and 'memory' not in kwargs:
Expand All @@ -1065,7 +1092,6 @@ def _docker_run(self, step, cmd=None, ro_volumes=None, env_variables=None, **kwa
kwargs.pop('log_path', False)
kwargs.pop('container_name', False)
log_path = self._path('logs', '%s.txt' % step.sanitized_name(self))
build_dir = self._path()
container_name = self._get_docker_name()
self.env.flush_all()
env_variables = env_variables or []
Expand Down
Loading