diff options
| author | dd <dd@wx.tnyzeq.icu> | 2024-10-07 11:55:45 +0200 |
|---|---|---|
| committer | dd <dd@wx.tnyzeq.icu> | 2024-10-07 12:20:03 +0200 |
| commit | 7bc109ed9a701123bea0fbfecdd9fe09f2a525bc (patch) | |
| tree | 9ee462505be59dde59841938be17cd932b2aa8d1 | |
| parent | cf95baa691c82d21f0c78bb037a8ab9343db3a0a (diff) | |
| download | vyos-jenkins-7bc109ed9a701123bea0fbfecdd9fe09f2a525bc.tar.gz vyos-jenkins-7bc109ed9a701123bea0fbfecdd9fe09f2a525bc.zip | |
circinus refactoring: added shared docker logic and improved logging by whole lot
| -rwxr-xr-x | new/image_builder.py | 94 | ||||
| -rw-r--r-- | new/lib/docker.py | 72 | ||||
| -rw-r--r-- | new/lib/helpers.py | 128 | ||||
| -rwxr-xr-x | new/package_builder.py | 90 |
4 files changed, 254 insertions, 130 deletions
diff --git a/new/image_builder.py b/new/image_builder.py index df2e1be..817353a 100755 --- a/new/image_builder.py +++ b/new/image_builder.py @@ -12,8 +12,9 @@ from time import monotonic import netifaces +from lib.docker import Docker from lib.git import Git -from lib.helpers import setup_logging, execute, quote_all, refuse_root +from lib.helpers import setup_logging, refuse_root, project_dir, get_my_log_file class ImageBuilder: @@ -21,13 +22,14 @@ class ImageBuilder: "sagitta": "1.4.x", "circinus": "1.5.x", } - docker_image = None vyos_build_repo = None + docker = None - def __init__(self, branch, vyos_build_git, vyos_mirror, extra_options, flavor, build_by, version, bind_addr, - bind_port, keep_build): + def __init__(self, branch, vyos_build_git, vyos_build_docker, vyos_mirror, extra_options, + flavor, build_by, version, bind_addr, bind_port, keep_build): self.branch = branch self.vyos_build_git = vyos_build_git + self.vyos_build_docker = vyos_build_docker self.vyos_mirror = vyos_mirror self.extra_options = extra_options self.flavor = flavor @@ -37,32 +39,28 @@ class ImageBuilder: self.bind_port = bind_port self.keep_build = keep_build - self.project_dir: str = os.path.realpath(os.path.dirname(__file__)) self.cwd = os.getcwd() def build(self): begin = monotonic() if self.vyos_mirror == "local": - vyos_mirror = self.start_local_webserver() + vyos_mirror = self.start_local_apt_webserver() logging.info("Starting local APT repository at %s" % vyos_mirror) else: vyos_mirror = self.vyos_mirror logging.info("Using supplied APT repository at %s" % vyos_mirror) + self.vyos_build_repo = os.path.join(project_dir, "build", "%s-image-build" % self.branch) + logging.info("Pulling vyos-build docker image") - self.docker_image = "vyos/vyos-build:%s" % self.branch - execute("docker pull %s" % quote_all(self.docker_image), passthrough=True) + self.docker = Docker(self.vyos_build_docker, self.branch, self.vyos_build_repo) + self.docker.pull() - self.vyos_build_repo = os.path.join(self.project_dir, "build", "%s-image-build" % self.branch) git = Git(self.vyos_build_repo) if not self.keep_build: if git.exists(): - try: - shutil.rmtree(self.vyos_build_repo) - except PermissionError: - # Unfortunately the docker container creates some files as root, and thus we don't have choice... - self.docker_run("bash -c %s" % quote("sudo rm -rf /vyos/*"), log=False) - shutil.rmtree(self.vyos_build_repo) + # We want to delete original vyos-build repo and do fresh clone to clean cached build files. + self.docker.rmtree(self.vyos_build_repo) if not git.exists(): git.clone(self.vyos_build_git, self.branch) @@ -102,7 +100,18 @@ class ImageBuilder: logging.info("Using build image command: '%s'" % build_image_command) logging.info("Executing image build now...") - self.docker_run(build_image_command) + + extra_mounts = [] + if self.vyos_mirror == "local": + apt_key_path = os.path.join(project_dir, "apt", "apt.gpg.key") + extra_mounts.append((apt_key_path, "/opt/apt.gpg.key")) + + self.docker.run( + command=build_image_command, + work_dir="/vyos", + extra_mounts=extra_mounts, + log_command="IMAGE_BUILD_COMMAND" + ) image_path = None build_dir = os.path.join(self.vyos_build_repo, "build") @@ -117,7 +126,9 @@ class ImageBuilder: if not os.path.exists(image_path): logging.error( - "Build failed (image not found), see log above for reason why, inspect build here: %s" % build_dir + "Build failed (image not found), see log above for reason why" + ", inspect build here: %s" + ", log file: %s" % (build_dir, get_my_log_file()) ) exit(1) @@ -128,47 +139,16 @@ class ImageBuilder: elapsed = round(monotonic() - begin, 3) logging.info("Done in %s seconds, image is available here: %s" % (elapsed, new_image_path)) - def docker_run(self, command, log=True): - docker_pieces: list = [ - "docker run --rm -it", - "-v %s:/vyos" % quote(self.vyos_build_repo), - ] - - if self.vyos_mirror == "local": - apt_key_path = os.path.join(self.project_dir, "apt", "apt.gpg.key") - docker_pieces.extend([ - "-v %s:/opt/apt.gpg.key" % quote(apt_key_path), - ]) - - docker_pieces.extend([ - "-w /vyos --privileged --sysctl net.ipv6.conf.lo.disable_ipv6=0", - "-e GOSU_UID=%s -e GOSU_GID=%s" % (os.getuid(), os.getgid()), - self.docker_image, - ]) - - if log: - visual_docker_pieces = docker_pieces.copy() - visual_docker_pieces.append("IMAGE_BUILD_COMMAND") - logging.info("Using docker run command: '%s'" % " ".join(visual_docker_pieces)) - - docker_pieces.extend([ - command, - ]) - docker_command = " ".join(docker_pieces) - - execute(docker_command, passthrough=True) - - def start_local_webserver(self): + def start_local_apt_webserver(self): address = self.get_local_ip() if not self.bind_addr else self.bind_addr port = self.get_free_port(address) if not self.bind_port else self.bind_port - thread = Thread(target=self.serve_webserver, args=(address, port), name="LocalWebServer", daemon=True) + thread = Thread(target=self.serve_apt, args=(address, port), name="LocalWebServer", daemon=True) thread.start() return "http://%s:%s/%s" % (address, "" if port == 80 else port, self.branch) - def serve_webserver(self, address, port): - os.chdir(os.path.join(self.project_dir, "apt")) + def serve_apt(self, address, port): # noinspection PyTypeChecker - server = ThreadingHTTPServer((address, port), WebServerHandler) + server = ThreadingHTTPServer((address, port), AptWebServerHandler) server.serve_forever() def get_free_port(self, address): @@ -207,13 +187,16 @@ class ImageBuilder: return selected_address -class WebServerHandler(SimpleHTTPRequestHandler): +class AptWebServerHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=os.path.join(project_dir, "apt"), **kwargs) + def log_message(self, format, *args): pass if __name__ == "__main__": - setup_logging() + setup_logging(name="image_builder") try: refuse_root() @@ -223,6 +206,8 @@ if __name__ == "__main__": parser.add_argument("--vyos-build-git", default="https://github.com/vyos/vyos-build.git", help="Git URL of vyos-build") parser.add_argument("--vyos-mirror", default="local", help="VyOS package repository (URL or 'local')") + parser.add_argument("--vyos-build-docker", default="vyos/vyos-build", + help="Default option uses vyos/vyos-build from dockerhub") parser.add_argument("--extra-options", help="Extra options for the build-vyos-image") parser.add_argument("--flavor", default="generic") parser.add_argument("--build-by", default="myself@localhost") @@ -239,4 +224,5 @@ if __name__ == "__main__": exit(1) except Exception as e: logging.exception(e) + logging.error("Something went wrong, log file: %s" % get_my_log_file()) exit(1) diff --git a/new/lib/docker.py b/new/lib/docker.py new file mode 100644 index 0000000..916bb82 --- /dev/null +++ b/new/lib/docker.py @@ -0,0 +1,72 @@ +import logging +import os +from shlex import quote +import shutil + +from lib.helpers import execute, quote_all, project_dir + + +class Docker: + def __init__(self, image_name, branch, vyos_mount_dir): + self.image_name = image_name + self.branch = branch + self.vyos_mount_dir = vyos_mount_dir + + def get_full_image_name(self): + return "%s:%s" % (self.image_name, self.branch) + + def pull(self, passthrough=True): + docker_image = self.get_full_image_name() + execute("docker ") + execute("docker pull %s" % quote_all(docker_image), passthrough=passthrough) + + def rmtree(self, target): + # This is sanity check, we really don't want to rm -rf something that isn't ours by mistake. + target = os.path.realpath(target) + if not target.startswith(project_dir): + raise Exception("Delete of %s DENIED, target is outside project_dir (%s)" % (target, project_dir)) + + try: + shutil.rmtree(target) + except PermissionError: + # I know, this is privilege escalation, but there is no other way. + # Unfortunately the docker container creates some files as root, and thus we don't have a choice. + # What the container messes up, the container needs to clean up. + # Here you can see the inherent security issue if container has root privileges. + # Any regular user with docker access can leverage the container to do anything as root. + # But this container needs to run as root in order to do its job so this is necessary evil. + # Ideally the container should be made not to leave behind files owned by root, tell this to the VyOS team. + logging.info("Deleting '%s' by force (privilege escalation)" % target) + self.run("bash -c %s" % quote("sudo rm -rf /delete-me/*"), extra_mounts=[ + (target, "/delete-me") + ]) + shutil.rmtree(target) + + def run(self, command, work_dir="/vyos", extra_mounts=None, passthrough=True, log_command=None): + pieces: list = [ + "docker run --rm -it", + ] + + if os.path.exists(self.vyos_mount_dir): + pieces.append("-v %s:/vyos" % quote(self.vyos_mount_dir)) + + if extra_mounts is not None: + for mount in extra_mounts: + pieces.append("-v %s:%s" % quote_all(*mount)) + + pieces.extend([ + "-w %s --privileged --sysctl net.ipv6.conf.lo.disable_ipv6=0" % quote(work_dir), + "-e GOSU_UID=%s -e GOSU_GID=%s" % (os.getuid(), os.getgid()), + quote(self.get_full_image_name()), + ]) + + if log_command: + placeholder = command if log_command is True else log_command + visual_pieces = pieces.copy() + visual_pieces.append(placeholder) + logging.info("Using docker run command: '%s'" % " ".join(visual_pieces)) + + pieces.append(command) + + docker_run_command = " ".join(pieces) + return execute(docker_run_command, passthrough=passthrough, passthrough_prefix="DOCKER: ") diff --git a/new/lib/helpers.py b/new/lib/helpers.py index 327a88d..89237c0 100644 --- a/new/lib/helpers.py +++ b/new/lib/helpers.py @@ -1,15 +1,27 @@ import logging +from logging.handlers import RotatingFileHandler import os +import re import shlex import subprocess import sys +from time import monotonic +project_dir: str = os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) -def execute(command, passthrough=False, timeout=None, **kwargs): + +def quote_all(*args): + quoted = [] + for arg in args: + quoted.append(shlex.quote(arg)) + return tuple(quoted) + + +def execute(command, timeout: int = None, passthrough=False, passthrough_prefix=None, **kwargs): if passthrough: - kwargs["stdout"] = sys.stdout - kwargs["stderr"] = sys.stderr + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.STDOUT if "stdout" not in kwargs: kwargs["stdout"] = subprocess.PIPE @@ -19,7 +31,32 @@ def execute(command, passthrough=False, timeout=None, **kwargs): kwargs["shell"] = True process = subprocess.Popen(command, **kwargs) - process.wait(timeout) + if passthrough: + file_log_handler = find_file_log_handler() + buffer = TerminalLineBuffer() + stdout = process.stdout + deadline = monotonic() + timeout if timeout is not None else None + while process.poll() is None and (deadline is None or deadline < monotonic()): + # noinspection PyTypeChecker + value: bytes = stdout.read(1) + sys.stdout.buffer.write(value) + + if file_log_handler is not None: + buffer.feed(value) + if buffer.is_complete(): + line = buffer.get_line() + file_log_handler.handle(create_stdout_log_record(line, passthrough_prefix)) + + if file_log_handler is not None: + line = buffer.get_line() + if line: + file_log_handler.handle(create_stdout_log_record(line, passthrough_prefix)) + + if deadline is not None and deadline >= monotonic() and process.poll() is None: + process.kill() + raise subprocess.TimeoutExpired(process.args, timeout) + else: + process.wait(timeout) exit_code = process.returncode if exit_code != 0: @@ -40,11 +77,34 @@ class ProcessException(Exception): pass -def quote_all(*args): - quoted = [] - for arg in args: - quoted.append(shlex.quote(arg)) - return tuple(quoted) +class TerminalLineBuffer: + last_value: bytes + + def __init__(self): + self.line_buffer = b"" + # ANSI & carriage return + self.control_sequences_regex = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])|\x0D") + + def feed(self, value: bytes): + self.last_value = value + self.line_buffer += value + + def is_complete(self): + return self.last_value == b"\n" + + def get_line(self): + line = self.line_buffer.decode("utf-8") + self.line_buffer = b"" + line = line.replace("\r\n", "\n") + line = self.control_sequences_regex.sub("", line) + return line + + +def create_stdout_log_record(message, passthrough_prefix=None, level=logging.INFO): + message = message.rstrip() + if passthrough_prefix is not None: + message = "%s%s" % (passthrough_prefix, message) + return logging.LogRecord("root", level, "", 0, message, None, None, None) class LessThanLevelFilter(logging.Filter): @@ -56,23 +116,51 @@ class LessThanLevelFilter(logging.Filter): return 1 if record.levelno < self.maximum_level else 0 -def setup_logging(): +def setup_logging(name="test"): logger = logging.getLogger() logger.setLevel(logging.INFO) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") stderr_level = logging.WARNING - stdout = logging.StreamHandler(sys.stdout) - stdout.setLevel(logging.INFO) - stdout.addFilter(LessThanLevelFilter(stderr_level)) - stdout.setFormatter(formatter) - logger.addHandler(stdout) - - stderr = logging.StreamHandler(sys.stderr) - stderr.setLevel(stderr_level) - stderr.setFormatter(formatter) - logger.addHandler(stderr) + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.INFO) + stdout_handler.addFilter(LessThanLevelFilter(stderr_level)) + stdout_handler.setFormatter(formatter) + logger.addHandler(stdout_handler) + + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(stderr_level) + stderr_handler.setFormatter(formatter) + logger.addHandler(stderr_handler) + + log_file = os.path.join(project_dir, "build", "%s.log" % name) + file_handler = RotatingFileHandler( + log_file, + maxBytes=1048576 * 10, + backupCount=5, + encoding="utf-8", + ) + file_handler.log_file = log_file + file_handler.setFormatter(formatter) + file_handler.setLevel(logging.INFO) + logger.addHandler(file_handler) + + +def find_file_log_handler(): + file_log_handler = None + for handler in logging.getLogger().handlers: + if isinstance(handler, RotatingFileHandler): + file_log_handler = handler + break + return file_log_handler + + +def get_my_log_file(): + file_log_handler = find_file_log_handler() + if file_log_handler is not None and hasattr(file_log_handler, "log_file"): + return file_log_handler.log_file + return "can't find it" def refuse_root(): diff --git a/new/package_builder.py b/new/package_builder.py index 2721f09..be40de7 100755 --- a/new/package_builder.py +++ b/new/package_builder.py @@ -3,26 +3,27 @@ import argparse import logging import os.path from shlex import quote -import shutil from time import time, monotonic import pendulum from lib.apt import Apt from lib.cache import Cache +from lib.docker import Docker from lib.git import Git from lib.github import GitHub -from lib.helpers import setup_logging, quote_all, execute, ProcessException, refuse_root +from lib.helpers import setup_logging, ProcessException, refuse_root, project_dir, get_my_log_file class Builder: - directory = None + build_dir = None docker_image = None updated_repos = None apt = None + docker = None - def __init__(self, branch, single_package, dirty_build, ignore_missing_binaries, skip_build, - skip_apt, force_build): + def __init__(self, branch, single_package, dirty_build, ignore_missing_binaries, + skip_build, skip_apt, force_build, vyos_build_docker): self.branch = branch self.single_package = single_package self.dirty_build = dirty_build @@ -30,10 +31,10 @@ class Builder: self.skip_build = skip_build self.skip_apt = skip_apt self.force_build = force_build + self.vyos_build_docker = vyos_build_docker - self.project_dir: str = os.path.realpath(os.path.dirname(__file__)) self.github = GitHub() - self.cache = Cache(os.path.join(self.project_dir, "build", "builder-cache-%s.json" % self.branch), dict, {}) + self.cache = Cache(os.path.join(project_dir, "build", "builder-cache-%s.json" % self.branch), dict, {}) def build(self): begin = monotonic() @@ -43,15 +44,16 @@ class Builder: logging.info("Building packages for %s" % self.branch) packages = self.get_packages_metadata() - self.directory = os.path.join(self.project_dir, "build", self.branch) - if not os.path.exists(self.directory): - os.makedirs(self.directory) + self.build_dir = os.path.join(project_dir, "build", self.branch) + if not os.path.exists(self.build_dir): + os.makedirs(self.build_dir) - self.apt = Apt(self.project_dir, self.branch, self.directory) + self.apt = Apt(project_dir, self.branch, self.build_dir) logging.info("Pulling vyos-build docker image") - self.docker_image = "vyos/vyos-build:%s" % self.branch - execute("docker pull %s" % quote_all(self.docker_image), passthrough=True) + vyos_build_repo = os.path.join(os.path.join(self.build_dir, "vyos-build")) + self.docker = Docker(self.vyos_build_docker, self.branch, vyos_build_repo) + self.docker.pull() self.updated_repos = [] for package in packages.values(): @@ -71,7 +73,7 @@ class Builder: if "hash" not in my_state: my_state["hash"] = None - repo_path = os.path.join(self.directory, repo_name) + repo_path = os.path.join(self.build_dir, repo_name) parent_path = repo_path if package["build_type"] == "dpkg-buildpackage": @@ -87,7 +89,7 @@ class Builder: return except ProcessException as e: if "not a git repository" in str(e): - self.rmtree(parent_path) + self.docker.rmtree(parent_path) else: raise @@ -96,7 +98,8 @@ class Builder: self.updated_repos.append(repo_name) if os.path.exists(parent_path) and not self.dirty_build: - self.rmtree(parent_path) + # We want to delete original repo and do fresh clone to clean cached build files. + self.docker.rmtree(parent_path) if not os.path.exists(repo_path): logging.info("Cloning repository %s" % package["git_url"]) @@ -109,15 +112,17 @@ class Builder: logging.info("Using shared repository %s" % package["git_url"]) if package["build_type"] == "build.py": - my_directory = os.path.join(self.directory, "vyos-build", package["path"]) + my_directory = os.path.join(self.build_dir, "vyos-build", package["path"]) if not self.skip_build or new: - self.docker_run("bash -i -c 'python3 ./build.py'", "/vyos/%s" % package["path"]) + # It's important to run bash in interactive mode, non-interactive shell breaks dependency on .bashrc. + # It's also required to call python explicitly since some scripts don't have correct shebang. + self.docker.run("bash -i -c 'python3 ./build.py'", work_dir="/vyos/%s" % package["path"]) elif package["build_type"] == "dpkg-buildpackage": - my_directory = os.path.join(self.directory, repo_name) + my_directory = os.path.join(self.build_dir, repo_name) virtual_dir = "/vyos-%s" % package["package_name"] - scripts_dir = os.path.join(self.project_dir, "scripts") + scripts_dir = os.path.join(project_dir, "scripts") virtual_scripts = "%s-scripts" % virtual_dir build_script = "generic-build-script.sh" @@ -127,7 +132,9 @@ class Builder: sources_dir = os.path.join(virtual_dir, "sources") if not self.skip_build or new: - self.docker_run("bash -i %s/%s" % (virtual_scripts, build_script), sources_dir, extra_mounts=[ + # Again, interactive shell is essential. + virtual_build_script = os.path.join(virtual_scripts, build_script) + self.docker.run("bash -i %s" % quote(virtual_build_script), work_dir=sources_dir, extra_mounts=[ (my_directory, virtual_dir), (scripts_dir, virtual_scripts), ]) @@ -139,6 +146,8 @@ class Builder: dsc_files, binary_files = self.apt.scan_for_dist_files(my_directory) if len(binary_files) == 0: message = "%s: something is wrong, no binary files found" % package["package_name"] + message += ", build dir: %s," % my_directory + message += ", log file: %s" % get_my_log_file() if self.ignore_missing_binaries: logging.error(message) else: @@ -151,40 +160,6 @@ class Builder: self.cache.set(package["package_name"], my_state) - def docker_run(self, command, work_dir, extra_mounts=None): - pieces = [ - "docker run --rm -it", - "-v %s:/vyos" % quote(os.path.join(self.directory, "vyos-build")), - ] - - if extra_mounts is not None: - for mount in extra_mounts: - pieces.append("-v %s:%s" % quote_all(*mount)) - - pieces.extend([ - "-w %s --privileged --sysctl net.ipv6.conf.lo.disable_ipv6=0" % quote(work_dir), - "-e GOSU_UID=%s -e GOSU_GID=%s" % (os.getuid(), os.getgid()), - self.docker_image, - command, - ]) - - command = " ".join(pieces) - return execute(command, passthrough=True) - - def rmtree(self, directory): - # sanity check - if not directory.startswith(self.project_dir): - raise Exception("Delete of %s denied, target is outside project_dir (%s)" % (directory, self.project_dir)) - - try: - shutil.rmtree(directory) - except PermissionError: - # Unfortunately the docker container creates some files as root, and thus we don't have choice... - self.docker_run("bash -c %s" % quote("sudo rm -rf /delete-me/*"), "/vyos", extra_mounts=[ - (directory, "/delete-me") - ]) - shutil.rmtree(directory) - def get_packages_metadata(self): packages_timestamp = self.cache.get("packages_timestamp") packages = self.cache.get("packages") @@ -207,7 +182,7 @@ class Builder: if __name__ == "__main__": - setup_logging() + setup_logging(name="package_builder") try: refuse_root() @@ -221,6 +196,8 @@ if __name__ == "__main__": parser.add_argument("--skip-build", action="store_true") parser.add_argument("--skip-apt", action="store_true") parser.add_argument("--force-build", action="store_true") + parser.add_argument("--vyos-build-docker", default="vyos/vyos-build", + help="Default option uses vyos/vyos-build from dockerhub") args = parser.parse_args() builder = Builder(**vars(args)) @@ -230,4 +207,5 @@ if __name__ == "__main__": exit(1) except Exception as e: logging.exception(e) + logging.error("Something went wrong, log file: %s" % get_my_log_file()) exit(1) |
