| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2017, 2021 Igalia S.L. |
| # Copyright (C) 2020 Apple Inc. All rights reserved. |
| # |
| # Redistribution and use in source and binary forms, with or without |
| # modification, are permitted provided that the following conditions are |
| # met: |
| # |
| # * Redistributions of source code must retain the above copyright |
| # notice, this list of conditions and the following disclaimer. |
| # |
| # * Redistributions in binary form must reproduce the above |
| # copyright notice, this list of conditions and the following disclaimer |
| # in the documentation and/or other materials provided with the |
| # distribution. |
| # |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| # |
| import sys |
| import signal |
| import os |
| import argparse |
| import subprocess |
| import tempfile |
| import shutil |
| import socket |
| import json |
| import traceback |
| import multiprocessing |
| from time import sleep |
| |
| buildbot_server_tac = """ |
| import os |
| from twisted.application import service |
| from buildbot.master import BuildMaster |
| |
| basedir = os.path.dirname(os.path.realpath(__file__)) |
| configfile = r'%(config_file)s' |
| |
| application = service.Application('buildmaster') |
| BuildMaster(basedir, configfile).setServiceParent(application) |
| """ |
| |
| buildbot_worker_tac = """ |
| import os |
| from buildbot_worker.bot import Worker |
| from twisted.application import service |
| |
| basedir = os.path.dirname(os.path.realpath(__file__)) |
| application = service.Application('buildbot-worker') |
| |
| buildmaster_host = 'localhost' |
| port = %(server_pb_port)s |
| workername = '%(worker_name)s' |
| passwd = 'password' |
| keepalive = 600 |
| |
| s = Worker(buildmaster_host, port, workername, passwd, basedir, keepalive) |
| s.setServiceParent(application) |
| """ |
| |
| |
| def check_tcp_port_open(address, port): |
| s = socket.socket() |
| try: |
| s.connect((address, port)) |
| return True |
| except ConnectionRefusedError: |
| return False |
| |
| |
| def create_tempdir(tmpdir=None): |
| if tmpdir is not None: |
| if not os.path.isdir(tmpdir): |
| raise ValueError('{} is not a directory'.format(tmpdir)) |
| return tempfile.mkdtemp(prefix=os.path.join(os.path.abspath(tmpdir), 'tmp')) |
| return tempfile.mkdtemp() |
| |
| |
| def print_if_error_stdout_stderr(cmd, retcode, stdout=None, stderr=None, extramsg=None): |
| if retcode != 0: |
| if type(cmd) is list: |
| cmd = ' '.join(cmd) |
| print('WARNING: "{cmd}" returned {retcode} status code'.format(cmd=cmd, retcode=retcode)) |
| if stdout is not None: |
| print('STDOUT:\n' + stdout.decode('utf-8')) |
| if stderr is not None: |
| print('STDERR:\n' + stderr.decode('utf-8')) |
| if extramsg is not None: |
| print(extramsg) |
| |
| |
| def cmd_exists(cmd): |
| return any(os.access(os.path.join(path, cmd), os.X_OK) |
| for path in os.environ['PATH'].split(os.pathsep)) |
| |
| |
| class BuildbotTestRunner(object): |
| |
| def __init__(self, configdir): |
| self._configdir = os.path.abspath(os.path.realpath(configdir)) |
| if not os.path.isdir(self._configdir): |
| raise RuntimeError('The configdir {} is not a directory'.format(self._configdir)) |
| if not os.path.isfile(os.path.join(self._configdir, 'config.json')): |
| raise RuntimeError('The configdir {} does not contain a config.json file'.format(self._configdir)) |
| |
| number_config_files = 0 |
| for file in os.listdir(self._configdir): |
| if file.endswith('.cfg'): |
| self._server_config_file_name = file |
| number_config_files += 1 |
| if number_config_files == 0: |
| raise RuntimeError('The configdir {} does not contain a .cfg file'.format(self._configdir)) |
| if number_config_files != 1: |
| raise RuntimeError('The configdir {} has more than one .cfg file'.format(self._configdir)) |
| |
| self._server_http_port, self._server_pb_port = self._get_config_tcp_ports() |
| |
| def _get_config_tcp_ports(self): |
| pb_port = 17000 |
| http_port = 8010 |
| try: |
| with open(os.path.join(self._configdir, self._server_config_file_name)) as f: |
| for line in f: |
| if '=' in line: |
| if 'protocols' in line and 'pb' in line and 'port' in line: |
| pb = eval(line.split('=', 1)[1]) |
| pb_port = int((pb['pb']['port'])) |
| if 'www' in line and 'port' in line: |
| http = eval(line.split('=', 1)[1]) |
| http_port = int((http['port'])) |
| except: |
| print("Unable to detect http and pb ports from config. Using defaults") |
| return http_port, pb_port |
| |
| def start(self, basetempdir=None, no_clean=False, number_workers=1, use_system_version=False): |
| try: |
| self._base_workdir_temp = os.path.abspath(os.path.realpath(create_tempdir(basetempdir))) |
| if self._base_workdir_temp.startswith(self._configdir): |
| raise ValueError('The temporal working directory {} cant be located inside configdir {}'.format(self._base_workdir_temp, self._configdir)) |
| if not use_system_version: |
| self._setup_virtualenv() |
| if not (cmd_exists('twistd') and cmd_exists('buildbot')): |
| raise RuntimeError('Buildbot is not installed.') |
| self._setup_server_workdir() |
| server_runner = multiprocessing.Process(target=self._start_server) |
| server_runner.start() |
| self._wait_for_server_ready() |
| if number_workers == 0: |
| print(' - To manually attach a build worker use this info:\n' |
| + ' TCP port for the worker-to-server connection: {}\n'.format(self._server_pb_port) |
| + ' worker-id: the one defined at {}\n'.format(os.path.join(self._server_wordir, 'passwords.json')) |
| + ' password: password\n') |
| elif number_workers == 1: |
| worker = 'local-worker' |
| worker_runner = multiprocessing.Process(target=self._start_worker, args=(worker,)) |
| worker_runner.start() |
| print(' - Worker started!.\n' |
| + ' Check the log for at {}/local-worker/worker.log\n'.format(self._base_workdir_temp) |
| + ' tail -f {}/local-worker/worker.log\n'.format(self._base_workdir_temp)) |
| worker_runner.join() |
| else: |
| worker_runners = [] |
| for worker in self._get_list_workers(): |
| worker_runner = multiprocessing.Process(target=self._start_worker, args=(worker,)) |
| worker_runner.start() |
| worker_runners.append(worker_runner) |
| print(' - Workers started!.\n' |
| + ' Check the log for each one at {}/WORKERNAMEID/worker.log\n'.format(self._base_workdir_temp) |
| + ' tail -f {}/*/worker.log\n'.format(self._base_workdir_temp)) |
| for worker_runner in worker_runners: |
| worker_runner.join() |
| server_runner.join() |
| except KeyboardInterrupt: |
| pass # no print the exception |
| except: |
| traceback.print_exc() |
| finally: |
| try: |
| # The children may exit between the check and the kill call. |
| # Ignore any exception raised here. |
| for c in multiprocessing.active_children(): |
| # Send the signal to the whole process group. |
| # Otherwise some twistd sub-childs can remain alive. |
| os.killpg(os.getpgid(c.pid), signal.SIGKILL) |
| except: |
| pass |
| if not no_clean: |
| self._clean() |
| sys.exit(0) |
| |
| def _wait_for_server_ready(self): |
| server_ready_check_counter = 0 |
| while True: |
| if os.path.isfile(self._server_ready_fd): |
| return |
| if server_ready_check_counter > 60: |
| print('ERROR: buildbot server has not started after waiting 60 seconds for it.') |
| |
| if os.path.isfile(self._server_log): |
| print('The server.log file contains the following:') |
| with open(self._server_log, 'r') as f: |
| print(f.read()) |
| else: |
| print('There is no server.log on the directory. That means a general failure starting buildbot.') |
| |
| raise RuntimeError('buildbot server has not started after waiting 60 seconds for it.') |
| sleep(1) |
| server_ready_check_counter += 1 |
| |
| def _create_mock_worker_passwords_dict(self): |
| with open(os.path.join(self._server_wordir, 'config.json'), 'r') as config_json: |
| config_dict = json.load(config_json) |
| result = dict([(worker['name'], 'password') for worker in config_dict['workers']]) |
| return result |
| |
| def _setup_server_workdir(self): |
| self._server_wordir = os.path.join(self._base_workdir_temp, 'buildbot-server') |
| assert(not os.path.exists(self._server_wordir)) |
| self._server_log = os.path.join(self._server_wordir, 'server.log') |
| self._server_ready_fd = os.path.join(self._server_wordir, '.server-is-ready') |
| print('Copying files from {} to {} ...'.format(self._configdir, self._server_wordir)) |
| shutil.copytree(self._configdir, self._server_wordir) |
| print('Generating buildbot files at {} ...'.format(self._server_wordir)) |
| with open(os.path.join(self._server_wordir, 'buildbot.tac'), 'w') as f: |
| f.write(buildbot_server_tac % {'config_file': self._server_config_file_name}) |
| with open(os.path.join(self._server_wordir, 'passwords.json'), 'w') as passwords_file: |
| passwords_file.write(json.dumps(self._create_mock_worker_passwords_dict(), indent=4, sort_keys=True)) |
| |
| def _setup_virtualenv(self): |
| if cmd_exists('virtualenv'): |
| print('Setting up virtualenv at {} ... '.format(self._base_workdir_temp)) |
| virtualenv_cmd = ['virtualenv', '-p', 'python3', 'venv'] |
| virtualenv_process = subprocess.Popen(virtualenv_cmd, cwd=self._base_workdir_temp, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| stdout, stderr = virtualenv_process.communicate() |
| print_if_error_stdout_stderr(virtualenv_cmd, virtualenv_process.returncode, stdout, stderr) |
| virtualenv_bindir = os.path.join(self._base_workdir_temp, 'venv', 'bin') |
| virtualenv_pip = os.path.join(virtualenv_bindir, 'pip') |
| if not os.access(virtualenv_pip, os.X_OK): |
| print('Something went wrong setting up virtualenv' |
| 'Trying to continue using the system version of buildbot') |
| return |
| print('Setting up buildbot dependencies on the virtualenv ... ') |
| # The idea is to install the very same version of buildbot and its |
| # dependencies than the ones used for running https://build.webkit.org/about |
| buildbot_version = '2.10.5' |
| pip_cmd = [virtualenv_pip, 'install', |
| 'buildbot=={}'.format(buildbot_version), |
| 'buildbot-console-view=={}'.format(buildbot_version), |
| 'buildbot-grid-view=={}'.format(buildbot_version), |
| 'buildbot-waterfall-view=={}'.format(buildbot_version), |
| 'buildbot-worker=={}'.format(buildbot_version), |
| 'buildbot-www=={}'.format(buildbot_version), |
| 'twisted==21.2.0', |
| 'requests==2.21.0', |
| 'lz4==1.1.0'] |
| pip_process = subprocess.Popen(pip_cmd, cwd=self._base_workdir_temp, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| stdout, stderr = pip_process.communicate() |
| print_if_error_stdout_stderr(pip_cmd, pip_process.returncode, stdout, stderr) |
| os.environ['PATH'] = virtualenv_bindir + ':' + os.environ['PATH'] |
| return |
| print('WARNING: virtualenv not installed. ' |
| 'Trying to continue using the system version of buildbot') |
| |
| def _upgrade_db_needed(self): |
| with open(self._server_log) as f: |
| for l in f: |
| if 'upgrade the database' in l: |
| return True |
| return False |
| |
| def _start_server(self): |
| # This is started via multiprocessing. We set a new process group here |
| # to be able to reliably kill this subprocess and all of its child on clean. |
| os.setsid() |
| dbupgraded = False |
| retry = True |
| if check_tcp_port_open('localhost', self._server_pb_port): |
| print('ERROR: There is some process already listening in port {}'.format(self._server_pb_port)) |
| return 1 |
| while retry: |
| retry = False |
| print('Starting the buildbot server process ...') |
| twistd_cmd = ['twistd', '-l', self._server_log, '-noy', 'buildbot.tac'] |
| twistd_server_process = subprocess.Popen(twistd_cmd, cwd=self._server_wordir, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| while twistd_server_process.poll() is None: |
| if check_tcp_port_open('localhost', self._server_pb_port): |
| print('Test buildbot server ready!\n' |
| + 'Press CTRL-C to stop\n\n' |
| + ' - See buildbot server log:\n' |
| + ' tail -f {}\n\n'.format(self._server_log) |
| + ' - Open a browser to:\n' |
| + ' http://localhost:{}\n'.format(self._server_http_port)) |
| with open(self._server_ready_fd, 'w') as f: |
| f.write('ready') |
| twistd_server_process.wait() |
| return 0 |
| sleep(1) |
| stdout, stderr = twistd_server_process.communicate() |
| if twistd_server_process.returncode == 0 and self._upgrade_db_needed() and not dbupgraded: |
| retry = True |
| dbupgraded = True |
| print('Upgrading the database ...') |
| upgrade_cmd = ['buildbot', 'upgrade-master', self._server_wordir] |
| upgrade_process = subprocess.Popen(upgrade_cmd, cwd=self._server_wordir, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| stdout, stderr = upgrade_process.communicate() |
| print_if_error_stdout_stderr(upgrade_cmd, upgrade_process.returncode, stdout, stderr) |
| else: |
| print_if_error_stdout_stderr(twistd_cmd, twistd_server_process.returncode, stdout, stderr, |
| 'Check the log at {}'.format(self._server_log)) |
| return twistd_server_process.returncode |
| |
| def _get_list_workers(self): |
| password_list = os.path.join(self._server_wordir, 'passwords.json') |
| with open(password_list) as f: |
| passwords = json.load(f) |
| list_workers = [] |
| for worker in passwords: |
| list_workers.append(str(worker)) |
| return list_workers |
| |
| def _start_worker(self, worker): |
| # This is started via multiprocessing. We set a new process group here |
| # to be able to reliably kill this subprocess and all of its child on clean. |
| os.setsid() |
| worker_workdir = os.path.join(self._base_workdir_temp, worker) |
| os.mkdir(worker_workdir) |
| with open(os.path.join(worker_workdir, 'buildbot.tac'), 'w') as f: |
| f.write(buildbot_worker_tac % {'worker_name': worker, 'server_pb_port': self._server_pb_port}) |
| twistd_cmd = ['twistd', '-l', 'worker.log', '-noy', 'buildbot.tac'] |
| twistd_worker_process = subprocess.Popen(twistd_cmd, cwd=worker_workdir, |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| stdout, stderr = twistd_worker_process.communicate() |
| print_if_error_stdout_stderr(twistd_cmd, twistd_worker_process.returncode, stdout, stderr, |
| 'Check the log at {}'.format(os.path.join(worker_workdir, 'worker.log'))) |
| return twistd_worker_process.returncode |
| |
| def _clean(self): |
| if os.path.isdir(self._base_workdir_temp): |
| print('\n\nCleaning {} ... \n'.format(self._base_workdir_temp)) |
| # shutil.rmtree can fail if we hold an open file descriptor on temp_dir |
| # (which is very likely when cleaning) or if temp_dir is a NFS mount. |
| # Use rm instead that always works. |
| rm = subprocess.Popen(['rm', '-fr', self._base_workdir_temp]) |
| rm.wait() |
| |
| |
| if __name__ == '__main__': |
| if sys.version_info < (3, 5): |
| print('ERROR: Please use Python 3. This script is not compatible with Python 2.') |
| sys.exit(1) |
| |
| parser = argparse.ArgumentParser() |
| configuration = parser.add_mutually_exclusive_group(required=True) |
| configuration.add_argument('--ews', action='store_const', const='ews', dest='configuration', |
| help='Simulate the EWS buildbot server (ews-build.webkit.org)') |
| configuration.add_argument('--post-commit', action='store_const', const='post_commit', dest='configuration', |
| help='Simulate the post-commit buildbot server (build.webkit.org)') |
| configuration.add_argument('--config-dir', default=None, dest='configdir', type=str, |
| help='Specify the directory that contains the buildbot config files') |
| parser.add_argument('--base-temp-dir', help='Path where the temporal working directory will be created. ' |
| 'Note: To trigger test builds with the test workers you need enough free space on that path.', |
| dest='basetempdir', default=None, type=str) |
| parser.add_argument('--no-clean', help='Do not clean the temporal working dir on exit.', |
| dest='no_clean', action='store_true') |
| workers = parser.add_mutually_exclusive_group(required=False) |
| workers.add_argument('--no-workers', help='Do not start any workers.', |
| dest='number_workers', action='store_const', const=0) |
| workers.add_argument('--all-workers', help='Instead of starting only one worker that round-robins between all the queues, ' |
| 'start multiple parallel workers as defined on the server config.', |
| dest='number_workers', action='store_const', const=float('inf')) |
| parser.add_argument('--use-system-version', help='Instead of setting up a virtualenv with the buildbot version ' |
| 'used by build.webkit.org, use the buildbot version installed on this system.', |
| dest='use_system_version', action='store_true') |
| args = parser.parse_args() |
| |
| if args.configuration == "ews": |
| configdir = os.path.join(os.path.dirname(__file__), 'ews-build') |
| elif args.configuration == "post_commit": |
| configdir = os.path.join(os.path.dirname(__file__), 'build-webkit-org') |
| else: |
| configdir = args.configdir |
| |
| if args.number_workers is None: |
| args.number_workers = 1 |
| |
| buildbot_test_runner = BuildbotTestRunner(configdir) |
| buildbot_test_runner.start(args.basetempdir, args.no_clean, args.number_workers, args.use_system_version) |