#!/usr/bin/env python3 # Copyright (C) 2018-2019 Internet Systems Consortium, Inc. ("ISC") # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. """Hammer - Kea development environment management tool.""" from __future__ import print_function import os import sys import glob import time import json import logging import datetime import platform import binascii import argparse import textwrap import functools import subprocess import multiprocessing import xml.etree.ElementTree as ET # TODO: # - add docker provider # https://developer.fedoraproject.org/tools/docker/docker-installation.html # - add CCACHE support # - improve building from tarball # - improve native-pkg builds # - avoid using network if possible (e.g. check first if pkgs are installed) SYSTEMS = { 'fedora': ['27', '28', '29'], 'centos': ['7'], 'rhel': ['8'], 'ubuntu': ['16.04', '18.04', '18.10'], 'debian': ['8', '9'], 'freebsd': ['11.2', '12.0'], } # pylint: disable=C0326 IMAGE_TEMPLATES = { 'fedora-27-lxc': {'bare': 'lxc-fedora-27', 'kea': 'godfryd/kea-fedora-27'}, 'fedora-27-virtualbox': {'bare': 'generic/fedora27', 'kea': 'godfryd/kea-fedora-27'}, 'fedora-28-lxc': {'bare': 'lxc-fedora-28', 'kea': 'godfryd/kea-fedora-28'}, 'fedora-28-virtualbox': {'bare': 'generic/fedora28', 'kea': 'godfryd/kea-fedora-28'}, 'fedora-29-lxc': {'bare': 'godfryd/lxc-fedora-29', 'kea': 'godfryd/kea-fedora-29'}, 'fedora-29-virtualbox': {'bare': 'generic/fedora29', 'kea': 'godfryd/kea-fedora-29'}, 'centos-7-lxc': {'bare': 'godfryd/lxc-centos-7', 'kea': 'godfryd/kea-centos-7'}, 'centos-7-virtualbox': {'bare': 'generic/centos7', 'kea': 'godfryd/kea-centos-7'}, 'rhel-8-virtualbox': {'bare': 'generic/rhel8', 'kea': 'generic/rhel8'}, 'ubuntu-16.04-lxc': {'bare': 'godfryd/lxc-ubuntu-16.04', 'kea': 'godfryd/kea-ubuntu-16.04'}, 'ubuntu-16.04-virtualbox': {'bare': 'ubuntu/xenial64', 'kea': 'godfryd/kea-ubuntu-16.04'}, 'ubuntu-18.04-lxc': {'bare': 'godfryd/lxc-ubuntu-18.04', 'kea': 'godfryd/kea-ubuntu-18.04'}, 'ubuntu-18.04-virtualbox': {'bare': 'ubuntu/bionic64', 'kea': 'godfryd/kea-ubuntu-18.04'}, 'ubuntu-18.10-lxc': {'bare': 'godfryd/lxc-ubuntu-18.10', 'kea': 'godfryd/kea-ubuntu-18.10'}, 'ubuntu-18.10-virtualbox': {'bare': 'ubuntu/cosmic64', 'kea': 'godfryd/kea-ubuntu-18.10'}, 'debian-8-lxc': {'bare': 'godfryd/lxc-debian-8', 'kea': 'godfryd/kea-debian-8'}, 'debian-8-virtualbox': {'bare': 'debian/jessie64', 'kea': 'godfryd/kea-debian-8'}, 'debian-9-lxc': {'bare': 'godfryd/lxc-debian-9', 'kea': 'godfryd/kea-debian-9'}, 'debian-9-virtualbox': {'bare': 'debian/stretch64', 'kea': 'godfryd/kea-debian-9'}, 'freebsd-11.2-virtualbox': {'bare': 'generic/freebsd11', 'kea': 'godfryd/kea-freebsd-11.2'}, 'freebsd-12.0-virtualbox': {'bare': 'generic/freebsd12', 'kea': 'godfryd/kea-freebsd-12.0'}, } LXC_VAGRANTFILE_TPL = """# -*- mode: ruby -*- # vi: set ft=ruby : Vagrant.configure("2") do |config| config.vm.hostname = "{name}" config.vm.box = "{image_tpl}" config.vm.provider "lxc" do |lxc| lxc.container_name = "{name}" lxc.customize 'rootfs.path', "/var/lib/lxc/{name}/rootfs" end config.vm.synced_folder '.', '/vagrant', disabled: true end """ VBOX_VAGRANTFILE_TPL = """# -*- mode: ruby -*- # vi: set ft=ruby : Vagrant.configure("2") do |config| config.vm.hostname = "{name}" config.vm.box = "{image_tpl}" config.vm.provider "virtualbox" do |v| v.name = "{name}" v.memory = 8192 nproc = Etc.nprocessors if nproc > 8 nproc -= 2 elsif nproc > 1 nproc -= 1 end v.cpus = nproc end config.vm.synced_folder '.', '/vagrant', disabled: true end """ log = logging.getLogger() def red(txt): if sys.stdout.isatty(): return '\033[1;31m%s\033[0;0m' % txt return txt def green(txt): if sys.stdout.isatty(): return '\033[0;32m%s\033[0;0m' % txt return txt def blue(txt): if sys.stdout.isatty(): return '\033[0;34m%s\033[0;0m' % txt return txt def get_system_revision(): """Return tuple containing system name and its revision.""" system = platform.system() if system == 'Linux': system, revision, _ = platform.dist() # pylit: disable=deprecated-method if system == 'debian': if revision.startswith('8.'): revision = '8' if revision.startswith('9.'): revision = '9' elif system == 'redhat': system = 'rhel' if revision.startswith('8.'): revision = '8' elif system == 'FreeBSD': system = system.lower() revision = platform.release() return system.lower(), revision class ExecutionError(Exception): pass def execute(cmd, timeout=60, cwd=None, env=None, raise_error=True, dry_run=False, log_file_path=None, quiet=False, check_times=False, capture=False, interactive=False): """Execute a command in shell. :param str cmd: a command to be executed :param int timeout: timeout in number of seconds, after that time the command is terminated but only if check_times is True :param str cwd: current working directory for the command :param dict env: dictionary with environment variables :param bool raise_error: if False then in case of error exception is not raised, default: True ie exception is raise :param bool dry_run: if True then the command is not executed :param str log_file_path: if provided then all traces from the command are stored in indicated file :param bool quiet: if True then the command's traces are not printed to stdout :param bool check_times: if True then timeout is taken into account :param bool capture: if True then the command's traces are captured and returned by the function :param bool interactive: if True then stdin and stdout are not redirected, traces handling is disabled, used for e.g. SSH """ log.info('>>>>> Executing %s in %s', cmd, cwd if cwd else os.getcwd()) if not check_times: timeout = None if dry_run: return 0 if interactive: p = subprocess.Popen(cmd, cwd=cwd, env=env, shell=True) exitcode = p.wait() else: if log_file_path: log_file = open(log_file_path, "wb") p = subprocess.Popen(cmd, cwd=cwd, env=env, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if capture: output = '' t0 = time.time() t1 = time.time() # repeat until process is running or timeout not occured while p.poll() is None and (timeout is None or t1 - t0 < timeout): line = p.stdout.readline() if line: line_decoded = line.decode(errors='ignore').rstrip() + '\r' if not quiet: print(line_decoded) if capture: output += line_decoded if log_file_path: log_file.write(line) t1 = time.time() if log_file_path: log_file.close() # If no exitcode yet, ie. process is still running then it means that timeout occured. # In such case terminate the process and raise an exception. if p.poll() is None: p.terminate() raise ExecutionError('Execution timeout') exitcode = p.returncode if exitcode != 0 and raise_error: raise ExecutionError("The command return non-zero exitcode %s, cmd: '%s'" % (exitcode, cmd)) if capture: return exitcode, output return exitcode def install_yum(pkgs, env=None, check_times=False): if isinstance(pkgs, list): pkgs = ' '.join(pkgs) # skip_missing_names_on_install used to detect case when one packet is not found and no error is returned # but we want an error cmd = 'sudo yum install -y --setopt=skip_missing_names_on_install=False %s' % pkgs execute(cmd, env=env, check_times=check_times) class VagrantEnv(object): """Helper class that makes interacting with Vagrant easier. It creates Vagrantfile according to specified system. It exposes basic Vagrant functions like up, upload, destro, ssh. It also provides more complex function for preparing system for Kea build and building Kea. """ def __init__(self, provider, system, sys_revision, features, image_template_variant, dry_run, quiet=False, check_times=False): self.provider = provider self.system = system self.sys_revision = sys_revision self.features = features self.dry_run = dry_run self.quiet = quiet self.check_times = check_times # set properly later self.features_arg = None self.nofeatures_arg = None self.python = None if provider == "virtualbox": vagrantfile_tpl = VBOX_VAGRANTFILE_TPL elif provider == "lxc": vagrantfile_tpl = LXC_VAGRANTFILE_TPL image_tpl = IMAGE_TEMPLATES["%s-%s-%s" % (system, sys_revision, provider)][image_template_variant] self.repo_dir = os.getcwd() sys_dir = "%s-%s" % (system, sys_revision) if provider == "virtualbox": self.vagrant_dir = os.path.join(self.repo_dir, 'hammer', sys_dir, 'vbox') elif provider == "lxc": self.vagrant_dir = os.path.join(self.repo_dir, 'hammer', sys_dir, 'lxc') if dry_run: return if not os.path.exists(self.vagrant_dir): os.makedirs(self.vagrant_dir) vagrantfile_path = os.path.join(self.vagrant_dir, "Vagrantfile") if os.path.exists(vagrantfile_path): # TODO: destroy any existing VM pass crc = binascii.crc32(self.vagrant_dir.encode()) self.name = "hmr-%s-%s-kea-srv-%08d" % (system, sys_revision.replace('.', '-'), crc) vagrantfile = vagrantfile_tpl.format(image_tpl=image_tpl, name=self.name) with open(vagrantfile_path, "w") as f: f.write(vagrantfile) log.info('Prepared vagrant system %s in %s', self.name, self.vagrant_dir) def up(self): execute("vagrant box update", cwd=self.vagrant_dir, timeout=20 * 60, dry_run=self.dry_run) execute("vagrant up --no-provision --provider %s" % self.provider, cwd=self.vagrant_dir, timeout=15 * 60, dry_run=self.dry_run) def package(self): """Package Vagrant system into Vagrant box.""" if self.provider == 'virtualbox': cmd = "vagrant package --output kea-%s-%s.box" % (self.system, self.sys_revision) execute(cmd, cwd=self.vagrant_dir, timeout=4 * 60, dry_run=self.dry_run) elif self.provider == 'lxc': execute('vagrant halt', cwd=self.vagrant_dir, dry_run=self.dry_run, raise_error=False) box_path = os.path.join(self.vagrant_dir, 'kea-%s-%s.box' % (self.system, self.sys_revision)) if os.path.exists(box_path): os.unlink(box_path) lxc_box_dir = os.path.join(self.vagrant_dir, 'lxc-box') if os.path.exists(lxc_box_dir): execute('sudo rm -rf %s' % lxc_box_dir) os.mkdir(lxc_box_dir) lxc_container_path = os.path.join('/var/lib/lxc', self.name) execute('sudo bash -c \'echo "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+' 'kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo' '3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2M' 'WZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEg' 'E98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== vagrant insecure public key" > %s/rootfs/home/vagrant/.ssh/authorized_keys\'' % lxc_container_path) cmd = 'sudo bash -c "cd %s && tar --numeric-owner --anchored --exclude=./rootfs/dev/log -czf %s/rootfs.tar.gz ./rootfs/*"' execute(cmd % (lxc_container_path, lxc_box_dir)) execute('sudo cp %s/config %s/lxc-config' % (lxc_container_path, lxc_box_dir)) execute('sudo chown `id -un`:`id -gn` *', cwd=lxc_box_dir) with open(os.path.join(lxc_box_dir, 'metadata.json'), 'w') as f: now = datetime.datetime.now() f.write('{\n') f.write(' "provider": "lxc",\n') f.write(' "version": "1.0.0",\n') f.write(' "built-on": "%s"\n' % now.strftime('%c')) f.write('}\n') execute('tar -czf %s ./*' % box_path, cwd=lxc_box_dir) execute('sudo rm -rf %s' % lxc_box_dir) def upload(self, src): """Upload src to Vagrant system, home folder.""" attempt = 4 while attempt > 0: exitcode = execute('vagrant upload %s' % src, cwd=self.vagrant_dir, dry_run=self.dry_run, raise_error=False) if exitcode == 0: break attempt -= 1 if exitcode != 0: msg = 'cannot upload %s' % src log.error(msg) raise ExecutionError(msg) def run_build_and_test(self, tarball_path, jobs): """Run build and unit tests inside Vagrant system.""" if self.dry_run: return 0, 0 # prepare tarball if needed and upload it to vagrant system if not tarball_path: name_ver = 'kea-1.5.0' cmd = 'tar --transform "flags=r;s|^|%s/|" --exclude hammer ' % name_ver cmd += ' --exclude "*~" --exclude .git --exclude .libs --exclude .deps --exclude \'*.o\' --exclude \'*.lo\' ' cmd += ' -zcf /tmp/%s.tar.gz .' % name_ver execute(cmd) tarball_path = '/tmp/%s.tar.gz' % name_ver self.upload(tarball_path) log_file_path = os.path.join(self.vagrant_dir, 'build.log') log.info('Build log file stored to %s', log_file_path) t0 = time.time() # run build command bld_cmd = "%s hammer.py build -p local -t %s.tar.gz -j %d" % (self.python, name_ver, jobs) if self.features_arg: bld_cmd += ' ' + self.features_arg if self.nofeatures_arg: bld_cmd += ' ' + self.nofeatures_arg if self.check_times: bld_cmd += ' -i' self.execute(bld_cmd, timeout=40 * 60, log_file_path=log_file_path, quiet=self.quiet) # timeout: 40 minutes ssh_cfg_path = self.dump_ssh_config() if 'native-pkg' in self.features: execute('scp -F %s -r default:/home/vagrant/rpm-root/RPMS/x86_64/ .' % ssh_cfg_path) t1 = time.time() dt = int(t1 - t0) log.info('Build log file stored to %s', log_file_path) log.info("") log.info(">>>>>> Build time %s:%s", dt // 60, dt % 60) log.info("") # run unit tests if requested total = 0 passed = 0 try: if 'unittest' in self.features: execute('scp -F %s -r default:/home/vagrant/unit-test-results.json .' % ssh_cfg_path, cwd=self.vagrant_dir) results_file = os.path.join(self.vagrant_dir, 'unit-test-results.json') if os.path.exists(results_file): with open(results_file) as f: txt = f.read() results = json.loads(txt) total = results['grand_total'] passed = results['grand_passed'] except: # pylint: disable=bare-except log.exception('ignored issue with parsing unit test results') return total, passed def destroy(self): cmd = 'vagrant destroy --force' execute(cmd, cwd=self.vagrant_dir, timeout=3 * 60, dry_run=self.dry_run) # timeout: 3 minutes def ssh(self): execute('vagrant ssh', cwd=self.vagrant_dir, timeout=None, dry_run=self.dry_run, interactive=True) def dump_ssh_config(self): """Dump ssh config that allows getting into Vagrant system via SSH.""" ssh_cfg_path = os.path.join(self.vagrant_dir, 'ssh.cfg') execute('vagrant ssh-config > %s' % ssh_cfg_path, cwd=self.vagrant_dir) return ssh_cfg_path def execute(self, cmd, timeout=None, raise_error=True, log_file_path=None, quiet=False, env=None): """Execute provided command inside Vagrant system.""" if not env: env = os.environ.copy() env['LANGUAGE'] = env['LANG'] = env['LC_ALL'] = 'C' return execute('vagrant ssh -c "%s"' % cmd, env=env, cwd=self.vagrant_dir, timeout=timeout, raise_error=raise_error, dry_run=self.dry_run, log_file_path=log_file_path, quiet=quiet, check_times=self.check_times) def prepare_system(self): """Prepare Vagrant system for building Kea.""" if self.features: self.features_arg = '--with ' + ' '.join(self.features) else: self.features_arg = '' nofeatures = set(DEFAULT_FEATURES) - self.features if nofeatures: self.nofeatures_arg = '--without ' + ' '.join(nofeatures) else: self.nofeatures_arg = '' # select proper python version for running Hammer inside Vagrant system if self.system == 'centos' and self.sys_revision == '7' or (self.system == 'debian' and self.sys_revision == '8' and self.provider != 'lxc'): self.python = 'python' elif self.system == 'freebsd': self.python = 'python3.6' else: self.python = 'python3' # to get python in RHEL 8 beta it is required first register machine in RHEL account if self.system == 'rhel' and self.sys_revision == '8': exitcode = self.execute("sudo subscription-manager repos --list-enabled | grep rhel-8-for-x86_64-baseos-beta-rpms", raise_error=False) if exitcode != 0: env = os.environ.copy() with open(os.path.expanduser('~/rhel-creds.txt')) as f: env['RHEL_USER'] = f.readline().strip() env['RHEL_PASSWD'] = f.readline().strip() self.execute('sudo subscription-manager register --user $RHEL_USER --password "$RHEL_PASSWD"', env=env) self.execute("sudo subscription-manager refresh") self.execute("sudo subscription-manager attach --pool 8a85f99a67cdc3e70167e45c85f47429") self.execute("sudo subscription-manager repos --enable rhel-8-for-x86_64-baseos-beta-rpms") self.execute("sudo dnf install -y python36") # upload Hammer to Vagrant system hmr_py_path = os.path.join(self.repo_dir, 'hammer.py') self.upload(hmr_py_path) log_file_path = os.path.join(self.vagrant_dir, 'prepare.log') log.info('Prepare log file stored to %s', log_file_path) # run prepare-system inside Vagrant system cmd = "{python} hammer.py prepare-system -p local {features} {nofeatures} {check_times}" cmd = cmd.format(features=self.features_arg, nofeatures=self.nofeatures_arg, python=self.python, check_times='-i' if self.check_times else '') self.execute(cmd, timeout=40 * 60, log_file_path=log_file_path, quiet=self.quiet) def _install_gtest_sources(): # download gtest sources only if it is not present as native package if not os.path.exists('/usr/src/googletest-release-1.8.0/googletest'): execute('wget --no-verbose -O /tmp/gtest.tar.gz https://github.com/google/googletest/archive/release-1.8.0.tar.gz') execute('sudo tar -C /usr/src -zxf /tmp/gtest.tar.gz') os.unlink('/tmp/gtest.tar.gz') def _configure_mysql(system, revision, features): if system in ['fedora', 'centos']: execute('sudo systemctl enable mariadb.service') execute('sudo systemctl start mariadb.service') time.sleep(5) cmd = "echo 'DROP DATABASE IF EXISTS keatest;' | sudo mysql -u root" execute(cmd) cmd = "echo 'DROP USER 'keatest'@'localhost';' | sudo mysql -u root" execute(cmd, raise_error=False) cmd = "echo 'DROP USER 'keatest_readonly'@'localhost';' | sudo mysql -u root" execute(cmd, raise_error=False) cmd = "bash -c \"cat < 0 or grand_total == 0: result = red(result) else: result = green(result) log.info('Unit test results: %s', result) with open('unit-test-results.json', 'w') as f: f.write(json.dumps(results)) if 'install' in features: execute('sudo make install', cwd=src_path, env=env, check_times=check_times, dry_run=dry_run) execute('sudo ldconfig', dry_run=dry_run) # TODO: this shouldn't be needed if 'forge' in features: if 'mysql' in features: execute('kea-admin lease-init mysql -u keauser -p keapass -n keadb', dry_run=dry_run) if 'pgsql' in features: execute('kea-admin lease-init pgsql -u keauser -p keapass -n keadb', dry_run=dry_run) def _build_native_pkg(distro, features, tarball_path, env, check_times, dry_run): if distro in ['fedora', 'centos', 'rhel']: # prepare RPM environment execute('rm -rf rpm-root', dry_run=dry_run) os.mkdir('rpm-root') os.mkdir('rpm-root/BUILD') os.mkdir('rpm-root/BUILDROOT') os.mkdir('rpm-root/RPMS') os.mkdir('rpm-root/SOURCES') os.mkdir('rpm-root/SPECS') os.mkdir('rpm-root/SRPMS') # get rpm.spec from tarball execute('rm -rf kea-src', dry_run=dry_run) os.mkdir('kea-src') execute('tar -zxf %s' % tarball_path, cwd='kea-src', check_times=check_times, dry_run=dry_run) src_path = glob.glob('kea-src/*')[0] rpm_dir = os.path.join(src_path, 'rpm') for f in os.listdir(rpm_dir): if f == 'kea.spec': continue execute('cp %s rpm-root/SOURCES' % os.path.join(rpm_dir, f), check_times=check_times, dry_run=dry_run) execute('cp %s rpm-root/SPECS' % os.path.join(rpm_dir, 'kea.spec'), check_times=check_times, dry_run=dry_run) execute('cp %s rpm-root/SOURCES' % tarball_path, check_times=check_times, dry_run=dry_run) # do rpm build cmd = "rpmbuild -ba rpm-root/SPECS/kea.spec -D'_topdir /home/vagrant/rpm-root'" execute(cmd, env=env, timeout=60 * 40, check_times=check_times, dry_run=dry_run) if 'install' in features: execute('sudo rpm -i rpm-root/RPMS/x86_64/*rpm', check_times=check_times, dry_run=dry_run) elif distro in ['ubuntu', 'debian']: # unpack tarball execute('rm -rf kea-src', check_times=check_times, dry_run=dry_run) os.mkdir('kea-src') execute('tar -zxf %s' % tarball_path, cwd='kea-src', check_times=check_times, dry_run=dry_run) src_path = glob.glob('kea-src/*')[0] # do deb build execute('debuild -i -us -uc -b', env=env, cwd=src_path, timeout=60 * 40, check_times=check_times, dry_run=dry_run) if 'install' in features: execute('sudo dpkg -i kea-src/*deb', check_times=check_times, dry_run=dry_run) else: raise NotImplementedError def build_local(features, tarball_path, check_times, jobs, dry_run): """Prepare local system for Kea development based on requested features. If tarball_path is provided then instead of Kea sources from current directory use provided tarball. """ env = os.environ.copy() env['LANGUAGE'] = env['LANG'] = env['LC_ALL'] = 'C' distro, revision = get_system_revision() execute('df -h', dry_run=dry_run) if tarball_path: tarball_path = os.path.abspath(tarball_path) if 'native-pkg' in features: # native pkg build _build_native_pkg(distro, features, tarball_path, env, check_times, dry_run) else: # build straight from sources _build_just_binaries(distro, revision, features, tarball_path, env, check_times, jobs, dry_run) execute('df -h', dry_run=dry_run) def build_in_vagrant(provider, system, sys_revision, features, leave_system, tarball_path, dry_run, quiet, clean_start, check_times, jobs): """Build Kea via Vagrant in specified system with specified features.""" log.info('') log.info(">>> Building %s, %s, %s", provider, system, sys_revision) log.info('') t0 = time.time() ve = None error = None total = 0 passed = 0 try: ve = VagrantEnv(provider, system, sys_revision, features, 'kea', dry_run, quiet, check_times) if clean_start: ve.destroy() ve.up() ve.prepare_system() total, passed = ve.run_build_and_test(tarball_path, jobs) msg = ' - ' + green('all ok') except KeyboardInterrupt as e: error = e msg = ' - keyboard interrupt' except ExecutionError as e: error = e msg = ' - ' + red(str(e)) except Exception as e: # pylint: disable=broad-except log.exception('Building erred') error = e msg = ' - ' + red(str(e)) finally: if not leave_system and ve: ve.destroy() t1 = time.time() dt = int(t1 - t0) log.info('') log.info(">>> Building %s, %s, %s completed in %s:%s%s", provider, system, sys_revision, dt // 60, dt % 60, msg) log.info('') return dt, error, total, passed def package_box(provider, system, sys_revision, features, dry_run, check_times): """Prepare Vagrant box of specified system.""" ve = VagrantEnv(provider, system, sys_revision, features, 'bare', dry_run, check_times=check_times) ve.destroy() ve.up() ve.prepare_system() # TODO cleanup ve.package() def ssh(provider, system, sys_revision): ve = VagrantEnv(provider, system, sys_revision, [], 'kea', False) ve.up() ve.ssh() def ensure_hammer_deps(): """Install Hammer dependencies onto current, host system.""" distro, _ = get_system_revision() exitcode = execute('vagrant version', raise_error=False) if exitcode != 0: if distro in ['fedora', 'centos', 'rhel']: execute('wget --no-verbose -O /tmp/vagrant_2.2.2_x86_64.rpm https://releases.hashicorp.com/vagrant/2.2.2/vagrant_2.2.2_x86_64.rpm') execute('sudo rpm -i /tmp/vagrant_2.2.2_x86_64.rpm') os.unlink('/tmp/vagrant_2.2.2_x86_64.rpm') elif distro in ['debian', 'ubuntu']: execute('wget --no-verbose -O /tmp/vagrant_2.2.2_x86_64.deb https://releases.hashicorp.com/vagrant/2.2.2/vagrant_2.2.2_x86_64.deb') execute('sudo dpkg -i /tmp/vagrant_2.2.2_x86_64.deb') os.unlink('/tmp/vagrant_2.2.2_x86_64.deb') else: # TODO: check for packages here: https://www.vagrantup.com/downloads.html raise NotImplementedError exitcode = execute('vagrant plugin list | grep vagrant-lxc', raise_error=False) if exitcode != 0: execute('vagrant plugin install vagrant-lxc') class CollectCommaSeparatedArgsAction(argparse.Action): """Helper argparse action class that can split multi-argument options by space and by comma.""" def __call__(self, parser, namespace, values, option_string=None): values2 = [] for v1 in values: for v2 in v1.split(): values2.extend(v2.split(',')) for v in values2: if v not in ALL_FEATURES: raise argparse.ArgumentError(self, "feature '%s' is not supported. List of supported features: %s." % (v, ", ".join(ALL_FEATURES))) setattr(namespace, self.dest, values2) DEFAULT_FEATURES = ['install', 'unittest', 'docs'] ALL_FEATURES = ['install', 'unittest', 'docs', 'mysql', 'pgsql', 'cql', 'native-pkg', 'radius', 'shell', 'forge'] def parse_args(): fl = functools.partial(lambda w, t: textwrap.fill(t, w), 80) # used lambda to change args order and able to substitute width description = [ "Hammer - Kea development environment management tool.\n", fl("At first it is required to install Hammer dependencies which is Vagrant and either " "VirtualBox or LXC. To make life easier Hammer can install Vagrant and required " "Vagrant plugins using the command:"), "\n ./hammer.py ensure-hammer-deps\n", "Still VirtualBox and LXC need to be installed manually.", fl("Basic functionality provided by Hammer is preparing building environment and " "performing actual build and running unit tests locally, in current system. " "This can be achieved by running the command:"), "\n ./hammer.py build -p local\n", fl("The scope of the process can be defined using --with (-w) and --without (-x) options. " "By default the build command will build Kea with documentation, install it locally " "and run unit tests."), "To exclude installation and generating docs do:", "\n ./hammer.py build -p local -x install docs\n", fl("The whole list of available features is: %s." % ", ".join(ALL_FEATURES)), fl("Hammer can be told to set up a new virtual machine with specified operating system " "and not running the build:"), "\n ./hammer.py prepare-system -p virtualbox -s freebsd -r 12.0\n", fl("This way we can prepare a system for our own use. To get to such system using SSH invoke:"), "\n ./hammer.py ssh -p virtualbox -s freebsd -r 12.0\n", "To list all created system on a host invoke:", "\n ./hammer.py created-systems\n", "And then to destroy a given system run:", "\n ./hammer.py destroy -d /path/to/dir/with/Vagrantfile\n", ] description = "\n".join(description) main_parser = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter) main_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose mode.') main_parser.add_argument('-q', '--quiet', action='store_true', help='Enable quiet mode.') subparsers = main_parser.add_subparsers(dest='command', title="Hammer commands", description=fl("The following commands are provided by Hammer. " "To get more information about particular command invoke: ./hammer.py -h.")) parent_parser1 = argparse.ArgumentParser(add_help=False) parent_parser1.add_argument('-p', '--provider', default='virtualbox', choices=['lxc', 'virtualbox', 'local', 'all'], help="Backend build executor. If 'all' then build is executed several times on all providers. " "If 'local' then build is executed on current system. Default is 'virtualbox'.") parent_parser1.add_argument('-s', '--system', default='all', choices=list(SYSTEMS.keys()) + ['all'], help="Build is executed on selected system. If 'all' then build is executed several times on all systems. " "If provider is 'local' then this option is ignored. Default is 'all'.") parent_parser1.add_argument('-r', '--revision', default='all', help="Revision of selected system. If 'all' then build is executed several times " "on all revisions of selected system. To list supported systems and their revisions invoke 'supported-systems'. " "Default is 'all'.") parent_parser2 = argparse.ArgumentParser(add_help=False) hlp = "Enable features. Separate them by space or comma. List of available features: %s. Default is '%s'." hlp = hlp % (", ".join(ALL_FEATURES), ' '.join(DEFAULT_FEATURES)) parent_parser2.add_argument('-w', '--with', metavar='FEATURE', nargs='+', default=set(), action=CollectCommaSeparatedArgsAction, help=hlp) hlp = "Disable features. Separate them by space or comma. List of available features: %s. Default is ''." % ", ".join(ALL_FEATURES) parent_parser2.add_argument('-x', '--without', metavar='FEATURE', nargs='+', default=set(), action=CollectCommaSeparatedArgsAction, help=hlp) parent_parser2.add_argument('-l', '--leave-system', action='store_true', help='At the end of the command do not destroy vagrant system. Default behavior is destroing the system.') parent_parser2.add_argument('-c', '--clean-start', action='store_true', help='If there is pre-existing system then it is destroyed first.') parent_parser2.add_argument('-i', '--check-times', action='store_true', help='Do not allow executing commands infinitelly.') parent_parser2.add_argument('-n', '--dry-run', action='store_true', help='Print only what would be done.') parser = subparsers.add_parser('ensure-hammer-deps', help="Install Hammer dependencies on current, host system.") parser = subparsers.add_parser('supported-systems', help="List system supported by Hammer for doing Kea development.") parser = subparsers.add_parser('build', help="Prepare system and run Kea build in indicated system.", parents=[parent_parser1, parent_parser2]) parser.add_argument('-j', '--jobs', default=0, help='Number of processes used in compilation. Override make -j default value.') parser.add_argument('-t', '--from-tarball', metavar='TARBALL_PATH', help='Instead of building sources in current folder use provided tarball package (e.g. tar.gz).') parser = subparsers.add_parser('prepare-system', help="Prepare system for doing Kea development i.e. install all required dependencies " "and pre-configure the system. build command always first calls prepare-system internally.", parents=[parent_parser1, parent_parser2]) parser = subparsers.add_parser('ssh', help="SSH to indicated system.", formatter_class=argparse.RawDescriptionHelpFormatter, description="Allows getting into the system using SSH. If the system is not present then it will be created first " "but not prepared. The command can be run in 2 way: " "\n1) ./hammer.py ssh -p -s -r \n2) ./hammer.py ssh -d ", parents=[parent_parser1]) parser.add_argument('-d', '--directory', help='Path to directory with Vagrantfile.') parser = subparsers.add_parser('created-systems', help="List ALL systems created by Hammer.") parser = subparsers.add_parser('destroy', help="Destroy indicated system.", description="Destroys system indicated by a path to directory with Vagrantfile. " "To get the list of created systems run: ./hammer.py created-systems.") parser.add_argument('-d', '--directory', help='Path to directory with Vagrantfile.') parser = subparsers.add_parser('package-box', help="Package currently running system into Vagrant Box. Prepared box can be later deployed to Vagrant Cloud.", parents=[parent_parser1, parent_parser2]) args = main_parser.parse_args() return args def list_supported_systems(): for system, revisions in SYSTEMS.items(): print('%s:' % system) for r in revisions: providers = [] for p in ['lxc', 'virtualbox']: k = '%s-%s-%s' % (system, r, p) if k in IMAGE_TEMPLATES: providers.append(p) providers = ', '.join(providers) print(' - %s: %s' % (r, providers)) def list_created_systems(): _, output = execute('vagrant global-status', quiet=True, capture=True) systems = [] for line in output.splitlines(): if 'hammer' not in line: continue elems = line.split() state = elems[3] path = elems[4] systems.append([path, state]) print('') print('%-10s %s' % ('State', 'Path')) print('-' * 80) for path, state, in sorted(systems): print('%-10s %s' % (state, path)) print('-' * 80) print('To destroy a system run: ./hammer.py destroy -d ') print('') def destroy_system(path): execute('vagrant destroy', cwd=path, interactive=True) def _what_features(args): features = set(vars(args)['with']) features = features.union(DEFAULT_FEATURES) nofeatures = set(args.without) features = features.difference(nofeatures) return features def _print_summary(results, features): print("") print("+===== Hammer Summary ====================================================+") print("| provider | system | revision | duration | status | unit tests |") print("+------------+------------+----------+-----------+---------+--------------+") total_dt = 0 for key, result in results.items(): provider, system, revision = key dt, error, ut_total, ut_passed = result total_dt += dt if error is None: status = ' %s' % green('ok') elif error == 'not run': status = blue('not run') else: status = ' %s' % red('error') if 'unittest' in features: ut_results = '%s/%s' % (ut_passed, ut_total) padding = ' ' * (12 - len(ut_results)) if ut_passed < ut_total or ut_total == 0: ut_results = padding + red(ut_results) else: ut_results = padding + green(ut_results) else: ut_results = ' not planned' print('| %10s | %10s | %8s | %6d:%02d | %s | %s |' % (provider, system, revision, dt // 60, dt % 60, status, ut_results)) print("+------------+------------+----------+-----------+---------+--------------+") print("| Total: %6d:%02d | |" % (total_dt // 60, total_dt % 60)) print("+=========================================================================+") def _check_system_revision(system, revision): if revision == 'all': return revs = SYSTEMS[system] if revision not in revs: msg = "hammer.py error: argument -r/--revision: invalid choice: '%s' (choose from '%s')" msg = msg % (revision, "', '".join(revs)) print(msg) sys.exit(1) def prepare_system_cmd(args): if args.provider != 'local' and (args.system == 'all' or args.revision == 'all'): print('Please provide required system and its version.') print('Example: ./hammer.py prepare-system -s fedora -r 28.') print('To get list of supported systems run: ./hammer.py supported-systems.') sys.exit(1) _check_system_revision(args.system, args.revision) features = _what_features(args) log.info('Enabled features: %s', ' '.join(features)) if args.provider == 'local': prepare_system_local(features, args.check_times) return prepare_system_in_vagrant(args.provider, args.system, args.revision, features, args.dry_run, args.check_times, args.clean_start) def build_cmd(args): features = _what_features(args) log.info('Enabled features: %s', ' '.join(features)) if args.provider == 'local': build_local(features, args.from_tarball, args.check_times, int(args.jobs), args.dry_run) return _check_system_revision(args.system, args.revision) if args.provider == 'all': providers = ['lxc', 'virtualbox'] else: providers = [args.provider] if args.system == 'all': systems = SYSTEMS.keys() else: systems = [args.system] plan = [] results = {} log.info('Build plan:') for provider in providers: for system in systems: if args.revision == 'all': revisions = SYSTEMS[system] else: revisions = [args.revision] for revision in revisions: if args.revision == 'all': key = '%s-%s-%s' % (system, revision, provider) if key not in IMAGE_TEMPLATES: continue plan.append((provider, system, revision)) log.info(' - %s, %s, %s', provider, system, revision) results[(provider, system, revision)] = (0, 'not run') fail = False for provider, system, revision in plan: result = build_in_vagrant(provider, system, revision, features, args.leave_system, args.from_tarball, args.dry_run, args.quiet, args.clean_start, args.check_times, int(args.jobs)) results[(provider, system, revision)] = result error = result[1] if error: fail = True if isinstance(error, KeyboardInterrupt): break _print_summary(results, features) if fail: sys.exit(1) def main(): args = parse_args() # prepare logging level = logging.INFO if args.verbose: level = logging.DEBUG fmt = '[HAMMER] %(asctime)-15s %(message)s' logging.basicConfig(format=fmt, level=level) # dispatch command if args.command == 'supported-systems': list_supported_systems() elif args.command == 'created-systems': list_created_systems() elif args.command == "package-box": _check_system_revision(args.system, args.revision) features = _what_features(args) log.info('Enabled features: %s', ' '.join(features)) package_box(args.provider, args.system, args.revision, features, args.dry_run, args.check_times) elif args.command == "prepare-system": prepare_system_cmd(args) elif args.command == "build": build_cmd(args) elif args.command == "ssh": _check_system_revision(args.system, args.revision) ssh(args.provider, args.system, args.revision) elif args.command == "ensure-hammer-deps": ensure_hammer_deps() elif args.command == "destroy": destroy_system(args.directory) if __name__ == '__main__': main()