Skip to content
Snippets Groups Projects
docker_dev_start.py 20.9 KiB
Newer Older
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Launch a docker image but bind the local directory inside the container
and define the module path automatically.
"""
import argparse
import logging
from subprocess import call
import sys

import ConfigParser

import docker  # apt python-docker (1.9) or pip install docker
from psycopg2 import connect, OperationalError  # apt python-psycopg2
from requests.exceptions import ConnectionError
if docker.__version__ > '3.0.0':
    from docker import APIClient as docker_api
else:
    from docker import Client as docker_api

# TODO auto create list of module

_logger = logging.getLogger(__name__)

Florent Aide's avatar
Florent Aide committed

def which(program):
    """Return path of program if it exists
https://stackoverflow.com/questions/377017/test-if-executable-exists-in-python/377028#377028
    """
    def is_exe(fpath):
        return os.path.isfile(fpath) and os.access(fpath, os.X_OK)

    fpath, fname = os.path.split(program)
    if fpath:
        if is_exe(program):
            return program
    else:
        for path in os.environ["PATH"].split(os.pathsep):
            exe_file = os.path.join(path, program)
            if is_exe(exe_file):
                return exe_file

    return None


def main(argv=None):  # IGNORE:C0111
    """Parse arguments and launch conversion
    """
    program_version = __version__
    program_build_date = str(__updated__)
    program_version_message = '%%(prog)s %s (%s)' % (
        program_version, program_build_date)
    program_shortdesc = __doc__.split(".")[0]
    program_license = '''%s

  Created by Vincent Hatakeyama on %s.
  Copyright 2017 XCG Consulting. All rights reserved.

  Licensed under the MIT License

  Distributed on an "AS IS" basis without warranties
  or conditions of any kind, either express or implied.

USAGE
Florent Aide's avatar
Florent Aide committed
''' % (program_shortdesc, str(__date__))

    project_path = os.path.realpath('.')
    project_name = os.path.basename(project_path)
    if project_name == 'odoo_scripts':
        logging.fatal(
            "You must run this script from the super project"
            " (./odoo_scripts/docker_dev_start.py)")
        return

Florent Aide's avatar
Florent Aide committed
    # TODO add a way to store configuration options in a project file
    # Argument parsing
    parser = argparse.ArgumentParser(
        description=program_license,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument(
        '-V', '--version', action='version',
        version=program_version_message)
    parser.add_argument(
        '-v', '--verbose', dest='verbose', action='count',
        help="set verbosity level [default: %(default)s]")
    parser.add_argument(
        '--db_user',
        help="Database user [default: %(default)s]",
    )
    parser.add_argument(
        '--db_password',
        help="Database user password [default: %(default)s]",
    parser.add_argument(
        '-d',
        '--database',
        help="Database [default: %(default)s]",
        default=None,
    )
    parser.add_argument(
        '-p',
        '--dbport',
        help="Database port [default: %(default)s]",
oury.balde's avatar
oury.balde committed
        default='5432',
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        '-u',
        '--update',
        help="Module to update (will also set --i18n-overwrite)"
             " [default: %(default)s]\n"
             "Options --update, --test and --test-default"
             " cannot be used at the same time",
    group.add_argument(
        '-t',
        '--test',
        help="Modules to test (will also set --log-level=test)"
             " [default: %(default)s]\n"
             "Options --update, --test and --test-default"
             " cannot be used at the same time",
    )
    group.add_argument(
        '--test-default',
        help="Test all modules in module_list_test"
             " [default: %(default)s]\n"
             "Options --update, --test and --test-default"
             " cannot be used at the same time",
        action='store_true',
    )
    parser.add_argument(
        '-i',
        '--install',
        help="Module to install [default: %(default)s]",
        default=None,
    )
    parser.add_argument(
        '--without-demo',
        help="Module to avoid including demo data [default: %(default)s]",
        default=None,
    )
        '--create-user',
Florent Aide's avatar
Florent Aide committed
        help="Create user in database (using $user) [default: %(default)s]",
Florent Aide's avatar
Florent Aide committed
        help=("Append the module_list from setup.cfg to the modules to install"
              " [default: %(default)s]"),
    parser.add_argument(
        '--start-postgresql',
        help="Start a Postgresql docker",
        action='store_true',
    )
    parser.add_argument(
        '--no-flake8',
        help="Do not run flake8 [default: %(default)s]",
        action='store_true',
    )
    parser.add_argument(
        '--odoo-help',
        help="Pass --help to odoo binary [default: %(default)s]",
        action='store_true',
    )
    build_group = parser.add_mutually_exclusive_group()
    build_group.add_argument(
        '--no-build',
        help="Do not build locally [default: %(default)s]",
        action='store_true',
    )
    build_group.add_argument(
        '--force-build',
        help="Force new local build [default: %(default)s]",
    network_group = parser.add_mutually_exclusive_group()
    network_group.add_argument(
        '--host-network',
        help="Use host network [default: %(default)s]",
        action='store_false',
    )
    network_group.add_argument(
        '--docker-network',
Florent Aide's avatar
Florent Aide committed
        help=("Use docker network (works better with"
              " python-netifaces installed) [default: %(default)s]"),
        action='store_true',
    )

    # TODO detect that user is member of docker group

    # TODO add a way to add options to docker
    # TODO add a way to add arg to odoo

    verbose = nmspc.verbose
    if not verbose:
        logging.basicConfig(level=logging.WARN)
    elif verbose == 1:
        logging.basicConfig(level=logging.INFO)
        logging.basicConfig(level=logging.DEBUG)
    db_user = nmspc.db_user
    db_password = nmspc.db_password
    no_flake8 = nmspc.no_flake8

    c = ConfigParser.ConfigParser()
    logging.debug('setup file path %s', setup_path)
    # TODO test that setup file exists
    c.read(setup_path)

    if c.has_option('odoo_scripts', 'addon_dirs'):
        addon_dirs = c.get('odoo_scripts', 'addon_dirs', '').split()
    else:
        addon_dirs = ''
    logging.debug("addon directories: %s", addon_dirs)
    registry = (
        c.has_section('odoo_scripts') and
        c.has_option('odoo_scripts', 'registry') and
        c.get('odoo_scripts', 'registry')) or 'dockerhub.xcg.io'
    project = (
        c.has_section('odoo_scripts') and
        c.has_option('odoo_scripts', 'image') and
        c.get('odoo_scripts', 'image')) or os.path.basename(project_path)
    odoo_type = (
        c.has_section('odoo_scripts') and
        c.has_option('odoo_scripts', 'odoo_type') and
        c.get('odoo_scripts', 'odoo_type')) or 'odoo7'
    image = "%s/%s:latest" % (registry, project)
    postgresql_version = (
        c.has_section('odoo_scripts') and
        c.has_option('odoo_scripts', 'postgresql_version') and
        c.get('odoo_scripts', 'postgresql_version')) or '9.6'
    logging.debug("Docker image: %s", image)
    # detect if docker image already exists
    docker_client = docker_api(base_url='unix://var/run/docker.sock')
    image_list = docker_client.images(name=image, quiet=True)
    if not image_list:
        logging.info("Image %s does not exist", image)
        logging.info("Image %s exists", image)
    if (not image_list and not nmspc.no_build) or nmspc.force_build:
        logging.info("Building image %s", image)
        import docker_build
        arguments = [
            '--dev'
        ]
        if verbose == 1:
            arguments.append('-v')
        elif verbose and verbose > 1:
            arguments.append('-vv')
        result = docker_build.main(arguments)
        if result:
            exit(result)
    # options is only used with subprocess call
    options = [
        '--name',
        project_name,
        '--rm',
        '--tty',
        '--interactive',
    ]
    if not use_host_network:
        options.append('--publish')
        options.append('8069:8069')
    arg = [
        'start',
    ]
    if db_user:
        arg.append('--db_user %s' % db_user)
    if db_password:
        arg.append('--db_password %s' % db_user)
    if nmspc.update:
        arg.append('-u %s' % nmspc.update)
        arg.append('--i18n-overwrite')
    if nmspc.test:
        arg.append('-u %s' % nmspc.test)
        arg.append('--test-enable')
        if odoo_type == 'odoo7':
            arg.append('--log-level=test')
        arg.append('--stop-after-init')
    if nmspc.test_default:
        test_modules = c.get('odoo_scripts', 'module_list_tests').split() or []
        str_modules = ','.join(test_modules)
        arg.append('-u %s' % str_modules)
        arg.append('--test-enable')
        if odoo_type == 'odoo7':
            arg.append('--log-level=test')
        arg.append('--stop-after-init')
    if nmspc.database:
        arg.append('-d %s' % nmspc.database)
    if nmspc.install or nmspc.install_default:
        modules_to_install = []
        if nmspc.install:
            modules_to_install.extend(nmspc.install.split(','))
        if nmspc.install_default:
            modules_to_install.extend((
                c.has_section('odoo_scripts') and
                c.has_option('odoo_scripts', 'module_list') and
                c.get('odoo_scripts', 'module_list').split()) or [])
        if modules_to_install:
            arg.append('-i')
            arg.append(','.join(modules_to_install))
        arg.append('--without-demo %s' % nmspc.database)
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
            from netifaces import ifaddresses, AF_INET  # apt python-netifaces
Florent Aide's avatar
Florent Aide committed
            iface_name = 'docker0'
            addresses = [
                i['addr']
                for i in ifaddresses(
                    iface_name
                ).setdefault(AF_INET, [{'addr': 'No IP addr'}])
            ]
            if addresses:
                local_ip = addresses[0]
        except ImportError:
Florent Aide's avatar
Florent Aide committed
            logging.warn("Consider installing python netifaces"
                         " to ease local IP detection")
            logging.info('Contacting Google Public DNS to find our IP')
Florent Aide's avatar
Florent Aide committed
            local_ip = [
                (
                    s.connect(('8.8.8.8', 53)),
                    s.getsockname()[0],
                    s.close()
                ) for s in [
                    socket.socket(socket.AF_INET, socket.SOCK_DGRAM)]
            ][0][1]

Florent Aide's avatar
Florent Aide committed

    arg.append('--db_host')
    arg.append(local_ip)
    if nmspc.dbport:
        arg.append("--db_port")
oury.balde's avatar
oury.balde committed
        arg.append('%s' % nmspc.dbport)
    local_conf_dir = os.path.join('conf', 'dev')
    local_conf_path = os.path.join(local_conf_dir, 'odoo.conf')
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
    user = db_user
    if os.path.isfile(local_conf_path):
        logging.info('Local configuration file found: %s' % local_conf_path)
        binds.append('%s:/opt/odoo/etc' % os.path.join(
            project_path, local_conf_dir))
        cp_local = ConfigParser.ConfigParser()
        cp_local.read(local_conf_path)
        if not user:
            user = cp_local.get('options', 'db_user', 'pg')
        if not password:
Florent Aide's avatar
Florent Aide committed
            password = cp_local.get(
                'options',
                'db_password',
                'THIS-IS-NOT-USED-DONOT-CHANGE',
            )
    # data volume handling
    if odoo_type != 'odoo7':
        data_volume_name = '{}_data'.format(project_name)
        logging.debug('Using data volume %s', data_volume_name)
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
        createVolume(docker_client, data_volume_name)
        # make sure the permission in the volume are correct
        mount_opts = '{}:/mnt/data'.format(data_volume_name)
        # TODO replace by something cleaner if possible
Florent Aide's avatar
Florent Aide committed
        call([
            'docker', 'run', '--rm', '-v',
            mount_opts, 'busybox', 'chmod', '777', '/mnt/data',
        ])

        arg.append('--data-dir /mnt/data')
    else:
        logging.debug('No data volume for this odoo version')

    # avoid the duplication of unbind volumes with all addons
    # additionnal_addons only in odoo < 11
    for extra_volume in ['additional_addons', 'var', 'data']:
        volume_name = '{}_opt_odoo_{}'.format(project_name, extra_volume)
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
        # volume = createVolume(docker_client, volume_name)
        binds.append('{}:/opt/odoo/{}'.format(volume_name, extra_volume))
        pg_repository = 'xcgd/postgresql'
        pg_image = '{}:{}'.format(pg_repository, postgresql_version)
        name = 'pg{}'.format(postgresql_version)
        docker_client.pull(repository=pg_repository, tag=postgresql_version)
Florent Aide's avatar
Florent Aide committed
        pg_data_volume_name = 'postgresql_{}-{}'.format(
            postgresql_version, project_name)

Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
        # volume = createVolume(docker_client, pg_data_volume_name)
        host_config = docker_client.create_host_config(
            binds=['{}:/var/lib/postgresql'.format(pg_data_volume_name)],
            port_bindings={5432: host_pg_port},
        )
        logging.debug('Creating postgresql container')
Florent Aide's avatar
Florent Aide committed
        pg = docker_client.create_container(
            image=pg_image,
            host_config=host_config,
            name=name,
        )

        logging.debug('Starting postgresql container')
        docker_client.start(pg.get('Id'))
        # give pg the time to start up
        import time
        time.sleep(5)
        arg.append('--db_port')
        arg.append(str(host_pg_port))
Florent Aide's avatar
Florent Aide committed
        # Check that connection can be done, try to create user if asked to
        # TODO: handle the case where the database is still starting up
        # TODO: (and remove the sleep)
        try:
            if local_ip or start_postgresql:
                port = host_pg_port if start_postgresql else None
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
                connect(
Florent Aide's avatar
Florent Aide committed
                    user=user,
                    password=password,
                    database='postgres',
                    host=local_ip,
                    port=port,
                )
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
                connect(
Florent Aide's avatar
Florent Aide committed
                    user=user,
                    password=password,
                    database='postgres',
                )

        except OperationalError as exception:
            if nmspc.create_user:
                logging.info('Cannot connect to database with user %s', user)
                logging.info(exception)
                logging.info('Creating user %s', user)
                if start_postgresql:
                    connection = connect(
                        user='pg',
                        database='postgres',
                        host=local_ip,
                        port=host_pg_port,
                    )
                else:
                    loginname = pwd.getpwuid(os.getuid())[0]
                    connection = connect(user=loginname, database='postgres')
                with connection.cursor() as cursor:
                    # not injection safe but you are on your own machine
                    # with already full access to db
                    cursor.execute(
                        'CREATE ROLE %s LOGIN CREATEDB PASSWORD %%s' % user,
                        (password,))
                connection.commit()
                connection.close()
            else:
                logging.warn('Cannot connect to database with user %s', user)
                logging.warn(exception)
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
        binds.append(
            '%s:/mnt/addons/%s' % (
                os.path.realpath(os.path.join(project_path, addons_dir)),
                addons_dir))
        # also check directory for symbolic links
        for subdir, dirs, files in os.walk(
            os.path.realpath(os.path.join(project_path, addons_dir))
        ):
            for directory in dirs:
                if os.path.islink(os.path.join(subdir, directory)):
                    binds.append(
                        '%s:/mnt/addons/%s' % (
                            os.path.realpath(os.path.join(
                                project_path, addons_dir, directory)),
                            os.path.join(addons_dir, directory))
                    )
    all_addons_dir = []
    if odoo_type != 'odoo11':
        all_addons_dir.append('/opt/odoo/sources/odoo/addons')
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
    all_addons_dir.extend(
        '/mnt/addons/%s' % addons_dir for addons_dir in addon_dirs)
    if all_addons_dir:
        arg.append('--addons-path')
        arg.append(','.join(all_addons_dir))
    if not no_flake8:
        if which('flake8'):
            logging.info('Running flake8')
            call(['flake8'])
        else:
            logging.warning('No flake8 in PATH')
        # ipdb should not be run if not interactive
        arg.append('--dev=xml,reload,ipdb')
    if odoo_type in ('odoo8', 'odoo7'):
        arg.append('--auto-reload')
    if odoo_help:
        arg.append('--help')

    if start_postgresql and not odoo_help:
        # use docker-py to be able to stop both the database and odoo
        # add volumes
        odoo_host_config = docker_client.create_host_config(
            binds=binds,
            port_bindings={8069: 8069},
            network_mode='host' if use_host_network else 'bridge',
        )
        logging.debug('Creating odoo container')
        odoo = docker_client.create_container(
                name=project_name, host_config=odoo_host_config,
                image=image, command=arg, tty=True)
        logging.debug('Starting odoo container')
        docker_client.start(odoo.get('Id'))
        def signal_handler(code, frame):
            if code == signal.SIGINT:
                logging.debug('You pressed Ctrl+C!')
                if isRunning(docker_client, odoo.get('Id')):
                    logging.info('Stopping odoo')
                    docker_client.stop(odoo.get('Id'))
                logging.info('Removing container odoo')
                docker_client.remove_container(odoo.get('Id'))
                if start_postgresql:
                    logging.info('Stopping postgresql')
                    docker_client.stop(pg.get('Id'))
                    logging.info('Removing container postgresql')
                    docker_client.remove_container(pg.get('Id'))
                sys.exit(0)
        # TODO add a kill of pg when crashing
        signal.signal(signal.SIGINT, signal_handler)
        logging.info('Press Ctrl+C to quit')
        # print docker logs of odoo
        stream = docker_client.logs(odoo.get('Id'), stream=True, follow=True)
        while isRunning(docker_client, odoo.get('Id')):
            try:
                for log in stream:
                    sys.stdout.write(log)
            except ConnectionError:
                # If there is no log for some time requests throw some errors
                # we ignore them
                pass
        # Clean up, just in case
        signal_handler(signal.SIGINT, None)
        # TODO add handling of signal to restart odoo
    else:
        # use call to allow usage of pdb
        for bind in binds:
            options.append('--volume')
            options.append(bind)

        cmd = ['docker', 'run']
        cmd.extend(options)
        cmd.append(image)
        cmd.extend(arg)
        logging.debug(' '.join(cmd))
        result = call(cmd)
        return result
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed

def createVolume(docker_client, data_volume_name):
    """Return the volume passed in parameter, creating it if it does not exists
    """
    volumes = docker_client.volumes(filters={'name': data_volume_name})
    if volumes['Volumes']:
        logging.debug('Volume %s already exist', data_volume_name)
        return volumes['Volumes'][0]
    else:
        logging.debug('Creating volume %s', data_volume_name)
        return docker_client.create_volume(name=data_volume_name)
def isRunning(docker_client, container_id):
    """Return true if the container is still running
    """
Vincent Hatakeyama's avatar
Vincent Hatakeyama committed
    return len(docker_client.containers(
        filters={'id': container_id}, quiet=True))