├── .dockerignore ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── Makefile ├── README.md ├── conftest.py ├── docker ├── admin-user.dockerfile ├── ext4-generator.dockerfile ├── livecd-generator.dockerfile ├── ubuntu-livecd.dockerfile └── ubuntu-netplan.dockerfile ├── etc ├── all-dhcp.yaml ├── grub.cfg ├── initramfs.conf └── isolinux.cfg ├── metallize.py ├── profiles ├── ubuntu20-ext4.yaml └── ubuntu20-livecd.iso.yaml ├── pytest.ini ├── requirements.txt ├── scripts ├── ext4.py ├── legacy-boot.sh ├── livecd.py └── uefi-boot.sh └── tests ├── README.md ├── metallize_helpers ├── __init__.py └── cmd.py ├── test_ext4_legacy └── test_ext4_legacy.py ├── test_ext_dir ├── etc │ ├── hostname │ └── hosts ├── test.dockerfile └── test_ext_dir.py ├── test_legacy └── test_legacy_build.py └── test_uefi └── test_uefi_build.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/** 2 | .venv/** 3 | __pycache__/ 4 | *_logs.txt -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "metallize.py", 12 | "console": "integratedTerminal", 13 | "args": ["profiles/ubuntu20-livecd.iso"] 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, the respective contributors, as shown by the AUTHORS file. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export COMPRESS=lz4 2 | export OPTS="-comp lz4 -b 1024K -always-use-fragments -keep-as-directory -no-recovery -exit-on-error" 3 | WORKDIR=build 4 | USER=$(shell id -u):$(shell id -g) 5 | ISO_SRC_DIR=$(abspath $(WORKDIR)/iso_src) 6 | KERNEL_DIR=$(WORKDIR)/kernel 7 | ISO_BOOT_DIR=$(ISO_SRC_DIR)/isolinux 8 | STRIP_DIR=--transform='s:.*/::' 9 | LIVE_DIR=$(ISO_SRC_DIR)/live 10 | SQUASHFS_ROOT=$(LIVE_DIR)/rootfs.squashfs 11 | 12 | build: 13 | mkdir -p $@ 14 | 15 | $(WORKDIR)/container.tar: docker/ubuntu-livecd.dockerfile build 16 | DOCKER_BUILDKIT=1 docker build --build-arg "SRC_IMG=ubuntu:20.04" -t my-ubuntu -f $< . 17 | # ridiculous dance to get rid of /etc/resolv.conf that leaks from docker 18 | DOCKER_BUILDKIT=1 docker build --build-arg "SRC_IMG=ubuntu:20.04" -t my-ubuntu -f $< . --output type=tar,dest=- | tar --delete etc/resolv.conf > $@ 19 | cd build && mkdir -p etc && ln -sf /run/systemd/resolve/resolv.conf etc/resolv.conf 20 | tar -rvf $@ -C build etc/resolv.conf 21 | 22 | $(SQUASHFS_ROOT): $(WORKDIR)/container.tar 23 | mkdir -p $(LIVE_DIR) 24 | docker run -i -v $(LIVE_DIR):/tmp --user $(USER) squashfs-and-syslinux.image mksquashfs - /tmp/$(shell basename $(SQUASHFS_ROOT)) -comp $(COMPRESS) -b 1024K -always-use-fragments -keep-as-directory -no-recovery -exit-on-error -tar < $< 25 | 26 | $(WORKDIR)/livecd.iso: $(WORKDIR)/container.tar squashfs-and-syslinux.image $(SQUASHFS_ROOT) 27 | mkdir -p $(KERNEL_DIR) $(ISO_SRC_DIR) 28 | tar --show-transformed-names --transform='s:-.*::' $(STRIP_DIR) -xvf $< -C $(KERNEL_DIR) \ 29 | --wildcards "boot/vmlinuz-*" \ 30 | --wildcards "boot/initrd*-*" 31 | docker run --user $(USER) \ 32 | -v $(abspath $(KERNEL_DIR)):/boot \ 33 | -v $(ISO_SRC_DIR):/iso_src \ 34 | -v $(abspath $(WORKDIR)):/out \ 35 | squashfs-and-syslinux.image /build.sh 36 | 37 | squashfs-and-syslinux.image: 38 | DOCKER_BUILDKIT=1 docker build -t $@ -f docker/squashfs-and-syslinux.dockerfile . 39 | 40 | run: #$(WORKDIR)/livecd.iso 41 | # following arguments are handy for testing in console 42 | kvm -hda $(WORKDIR)/livecd.iso -netdev user,id=user.0 -device e1000,netdev=user.0,mac=52:54:00:12:34:56 -m 4096 -serial stdio -nographic -monitor null 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Metallize 2 | Generate bootable disk images from Dockerfiles. 3 | 4 | ## Problem 5 | 6 | If you don't know what an OS image is, you probably don't need this tool, can stop reading now. 7 | 8 | >I want to be able to robustly generate OS images with minimum headache. 9 | 10 | In particular, I'd like to control exactly what software goes into the image and how the image behaves, eg overlayfs livecd vs mutable ext4. I'd like to be able to target high-performance $100K bare-metal servers, $30 raspberry PIs, $5 cloud VMs and everything inbetween with same tooling. My approach is inspired by how much less painful embedded development is with [esphome](https://esphome.io/). 11 | 12 | > For example at home, if I want to deploy a simple raspberry-pi temp logger, I get to define the high-level app in a pleasant dockerfile and then perform a crapton of work in order to deploy that image to a robust bare-metal Linux install. 13 | 14 | > I hit the same problem (the tooling hasn't improved much since dawn of Linux) at work at [Pure Storage](https://www.purestorage.com/) when wanting to quickly evolve a high-performance server image(custom kernel, custom nic tuning, custom networking) and deploy it on 10-20 servers. 15 | 16 | Existing approaches suck: 17 | 18 | 1) Operating system image generation is usually done by taking some vendor boot image, booting it, then applying some automation to it (kickstart, ansible, vargant) and freezing into some sort of golden image. The problem is that this is not cleanly reproducible/automateable. Eg nobody uses `docker commit` it in the container world. 19 | 20 | 2) Alternatively there are custom solutions like [Yocto](https://www.yoctoproject.org/software-overview/) which constitute a wholy custom solution. This requires learning a whole new ecosystem, including a new distribution, that's too much. 21 | 22 | > Idea Behind Metallize: Docker is a common existing skill, lets augment it with bare metal bits 23 | 24 | ## Quickstart 25 | 26 | Install modern docker. 27 | 28 | wget -qO- https://get.docker.com/ | sh 29 | 30 | Setup python env: 31 | 32 | python3 -m virtualenv .venv 33 | ./.venv/bin/python3 -m pip install -r requirements.txt 34 | 35 | Generate an ubuntu-based livecd image: 36 | 37 | ./.venv/bin/python3 metallize.py profiles/ubuntu20-livecd.iso.yaml |sh 38 | 39 | > Note that if you don't pipe to sh, you can preview the commands that will run. This is also handy for debugging metallize. 40 | 41 | Now you have a livecd image in `build/livecd.iso` 42 | 43 | You can now upload that image into the cloud, burn it onto a cd, write it to usb stick and boot. 44 | 45 | Alternatively you can test it with KVM: 46 | 47 | ./scripts/uefi-boot.sh build/livecd.iso 48 | 49 | 50 | ## Metallize architecture: profile yaml 51 | 52 | You can inspect profiles/[ubuntu20-livecd.iso.yaml](profiles/ubuntu20-livecd.iso.yaml) to see how this works. 53 | 54 | The yaml defines a list of docker files which have a parameterized FROM section. You can stack: 55 | * base image(ubuntu:20.04) 56 | * networking choice([ubuntu-netplan.dockerfile](docker/ubuntu-netplan.dockerfile)) 57 | * login info([admin-user.dockerfile](docker/admin-user.dockerfile)) 58 | * etc 59 | 60 | The yaml also defines a `generator` which is another dockerfile like [livecd-generator.dockerfile](docker/livecd-generator.dockerfile). 61 | 62 | 63 | ## Metallize mechanics: profile yaml 64 | The build process looks like: 65 | 66 | 1) [metallize.py](metallize.py) converts the yaml build profile definition into a sequence (of mostly docker) commands. One usually pipes that into `bash`. 67 | 2) Most of the image is build using a sequence of docker build steps, the final steps instead of outputting the image, outputs a tar file. 68 | 3) There are some complications in producing above tar file without a problematic replacing `/etc/resolv.conf`. 69 | 4) The tar file is then *passed* into a docker file that knows how to build a particular filesystem, setup bootloaders, etc. 70 | 71 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | sys.path.append(os.path.join(os.path.dirname(__file__))) -------------------------------------------------------------------------------- /docker/admin-user.dockerfile: -------------------------------------------------------------------------------- 1 | ARG METALLIZE_SRC_IMG 2 | FROM ${METALLIZE_SRC_IMG} 3 | 4 | ENV USER admin 5 | RUN useradd -m $USER -s /bin/bash -G sudo && echo "$USER:$USER" | chpasswd 6 | -------------------------------------------------------------------------------- /docker/ext4-generator.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 as common_base 2 | 3 | # facilitate apt cache 4 | RUN --mount=type=cache,target=/var/cache/apt,id=ext4-generator \ 5 | apt-get update && apt-get install --no-install-recommends -y \ 6 | extlinux python3 e2fsprogs 7 | 8 | COPY etc/isolinux.cfg /etc 9 | COPY scripts/ext4.py /build.cmd -------------------------------------------------------------------------------- /docker/livecd-generator.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 as common_base 2 | # facilitate apt cache 3 | RUN rm -f /etc/apt/apt.conf.d/docker-clean 4 | RUN echo deb http://archive.ubuntu.com/ubuntu bionic-backports main restricted universe multiverse >> /etc/apt/sources.list 5 | ARG DEBIAN_FRONTEND=noninteractive 6 | ENV KERNEL 5.4.0-52-generic 7 | RUN --mount=type=cache,target=/var/cache/apt,id=squashfs-and-syslinux-2 \ 8 | apt-get update && apt-get install --no-install-recommends -y \ 9 | mkisofs syslinux syslinux-utils syslinux-common isolinux p7zip-full curl ca-certificates wget extlinux python3 10 | RUN mkdir -p /build 11 | WORKDIR /build 12 | 13 | # This is an intermediate image for building things without bloating resulting image 14 | FROM common_base as builder 15 | RUN --mount=type=cache,target=/var/cache/apt,id=squashfs-and-syslinux-4 \ 16 | apt-get update && apt-get install -y build-essential liblzma-dev liblz4-dev zlib1g-dev 17 | ENV SQUASHFS_TAR 4.5.tar.gz 18 | RUN curl -L https://github.com/plougher/squashfs-tools/archive/refs/tags/$SQUASHFS_TAR | tar -zxv 19 | RUN cd squashfs-tools*/squashfs-tools && make LZ4_SUPPORT=1 LZMA_XZ_SUPPORT=1 XZ_SUPPORT=1 -j`nproc` && make install 20 | 21 | FROM common_base 22 | 23 | RUN wget -O debian.iso -q --show-progress https://cdimage.debian.org/cdimage/archive/11.1.0/amd64/iso-cd/debian-11.1.0-amd64-netinst.iso && \ 24 | 7z x debian.iso -odebian_files && \ 25 | mkdir -p /etc/grub && \ 26 | mv 'debian_files/[BOOT]/2-Boot-NoEmul.img' /etc/grub/debian.efi.stub && \ 27 | mv debian_files/EFI /etc/grub/EFI && \ 28 | chmod -R a+rx /etc/grub/EFI && \ 29 | rm -fR debian_files debian.iso 30 | 31 | # this controls compression settings for initrd and squashfs 32 | COPY etc/* /etc 33 | COPY scripts/livecd.py /build.cmd 34 | # copy squashfs tools 35 | COPY --from=builder /usr/local/bin/*s*fs* /usr/local/bin/ -------------------------------------------------------------------------------- /docker/ubuntu-livecd.dockerfile: -------------------------------------------------------------------------------- 1 | ARG METALLIZE_SRC_IMG 2 | FROM ${METALLIZE_SRC_IMG} 3 | 4 | ENV KERNEL 5.4.0-52-generic 5 | ARG METALLIZE_COMPRESSION 6 | # COPY etc/initramfs.conf /etc/initramfs-tools/initramfs.conf 7 | # RUN sed -i "s:lz4:$METALLIZE_COMPRESSION:" /etc/initramfs-tools/initramfs.conf 8 | RUN --mount=type=cache,target=/var/cache/apt,id=dummy \ 9 | apt-get update && apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ 10 | live-boot live-boot-initramfs-tools linux-image-$KERNEL linux-modules-extra-$KERNEL systemd-sysv sudo 11 | -------------------------------------------------------------------------------- /docker/ubuntu-netplan.dockerfile: -------------------------------------------------------------------------------- 1 | ARG METALLIZE_SRC_IMG 2 | FROM ${METALLIZE_SRC_IMG} 3 | 4 | RUN --mount=type=cache,target=/var/cache/apt \ 5 | apt update && apt install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ 6 | netplan.io 7 | COPY etc/all-dhcp.yaml /etc/netplan 8 | -------------------------------------------------------------------------------- /etc/all-dhcp.yaml: -------------------------------------------------------------------------------- 1 | network: 2 | version: 2 3 | renderer: networkd 4 | ethernets: 5 | alleths: 6 | match: 7 | name: e* 8 | dhcp4: true -------------------------------------------------------------------------------- /etc/grub.cfg: -------------------------------------------------------------------------------- 1 | linux /isolinux/vmlinuz METALLIZE_LINUX_CMDLINE 2 | initrd /isolinux/initrd.img 3 | boot -------------------------------------------------------------------------------- /etc/initramfs.conf: -------------------------------------------------------------------------------- 1 | # 2 | # initramfs.conf 3 | # Configuration file for mkinitramfs(8). See initramfs.conf(5). 4 | # 5 | # Note that configuration options from this file can be overridden 6 | # by config files in the /etc/initramfs-tools/conf.d directory. 7 | 8 | # 9 | # MODULES: [ most | netboot | dep | list ] 10 | # 11 | # most - Add most filesystem and all harddrive drivers. 12 | # 13 | # dep - Try and guess which modules to load. 14 | # 15 | # netboot - Add the base modules, network modules, but skip block devices. 16 | # 17 | # list - Only include modules from the 'additional modules' list 18 | # 19 | 20 | MODULES=most 21 | 22 | # 23 | # BUSYBOX: [ y | n | auto ] 24 | # 25 | # Use busybox shell and utilities. If set to n, klibc utilities will be used. 26 | # If set to auto (or unset), busybox will be used if installed and klibc will 27 | # be used otherwise. 28 | # 29 | 30 | BUSYBOX=auto 31 | 32 | # 33 | # COMPCACHE_SIZE: [ "x K" | "x M" | "x G" | "x %" ] 34 | # 35 | # Amount of RAM to use for RAM-based compressed swap space. 36 | # 37 | # An empty value - compcache isn't used, or added to the initramfs at all. 38 | # An integer and K (e.g. 65536 K) - use a number of kilobytes. 39 | # An integer and M (e.g. 256 M) - use a number of megabytes. 40 | # An integer and G (e.g. 1 G) - use a number of gigabytes. 41 | # An integer and % (e.g. 50 %) - use a percentage of the amount of RAM. 42 | # 43 | # You can optionally install the compcache package to configure this setting 44 | # via debconf and have userspace scripts to load and unload compcache. 45 | # 46 | 47 | COMPCACHE_SIZE="" 48 | 49 | # 50 | # COMPRESS: [ gzip | bzip2 | lz4 | lzma | lzop | xz ] 51 | # 52 | 53 | COMPRESS=lz4 54 | 55 | # 56 | # NFS Section of the config. 57 | # 58 | 59 | # 60 | # DEVICE: ... 61 | # 62 | # Specify a specific network interface, like eth0 63 | # Overridden by optional ip= or BOOTIF= bootarg 64 | # 65 | 66 | DEVICE= 67 | 68 | # 69 | # NFSROOT: [ auto | HOST:MOUNT ] 70 | # 71 | 72 | NFSROOT=auto 73 | 74 | # 75 | # RUNSIZE: ... 76 | # 77 | # The size of the /run tmpfs mount point, like 256M or 10% 78 | # Overridden by optional initramfs.runsize= bootarg 79 | # 80 | 81 | RUNSIZE=10% 82 | -------------------------------------------------------------------------------- /etc/isolinux.cfg: -------------------------------------------------------------------------------- 1 | default linux 2 | label linux 3 | kernel vmlinuz 4 | append initrd=initrd.img METALLIZE_LINUX_CMDLINE -------------------------------------------------------------------------------- /metallize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | metallize.py config.yaml 4 | """ 5 | import os 6 | import yaml 7 | import sys 8 | import argparse 9 | from pathlib import Path 10 | 11 | def fail(msg): 12 | print(msg, file=sys.stderr) 13 | sys.exit(1) 14 | 15 | """ 16 | Takes list of docker file and produces a tar file rootfs 17 | does some magic for /etc/resolv.conf, as the one from docker build will almost-certainly be wrong 18 | """ 19 | def build_tar(config, images_path, build_path, extensions_path, project_path, tar_file): 20 | images_ls = config['images'] 21 | cmds = [ 22 | "#" + str(config), 23 | "mkdir -p " + str(build_path) 24 | ] 25 | config_args = config['args'] 26 | build_args = " ".join([f"--build-arg METALLIZE_{key.upper()}={value}" for (key, value) in config_args.items()]) 27 | prev_img = None 28 | for i, image in enumerate(images_ls): 29 | src_file = extensions_path / image if (extensions_path / image).exists() else images_path / image 30 | context = extensions_path if (extensions_path / image).exists() else project_path 31 | if not src_file.exists(): 32 | if i == 0: 33 | cmds.append("docker pull " + image) 34 | prev_img = image 35 | continue 36 | else: 37 | fail(f"No file named '{src_file}' exists") 38 | prev_img_str = f"--build-arg METALLIZE_SRC_IMG='{prev_img}'" if prev_img else "" 39 | tag = image 40 | # make for a pretty name of the final docker image 41 | if i == len(images_ls) - 1: 42 | tag = Path(tar_file).stem.split('.')[0] 43 | cmds.append(f"DOCKER_BUILDKIT=1 docker build {prev_img_str} {build_args} -t {tag} -f {src_file} {context}") 44 | prev_img = tag 45 | cmds.append(cmds[-1] + f" --output type=tar,dest=- | tar --delete etc/resolv.conf > {tar_file}" ) 46 | cmds.append(f"(cd {build_path} && mkdir -p etc && ln -sf /run/systemd/resolve/resolv.conf etc/resolv.conf)") 47 | cmds.append(f"tar -rvf {tar_file} -C {build_path} etc/resolv.conf") 48 | return cmds 49 | 50 | def extract_kernel_files(boot_path: Path, tar_file: Path): 51 | cmds = [ 52 | f"mkdir -p {boot_path}", 53 | ( 54 | f"tar --show-transformed-names --transform='s:-.*::' --transform='s:.*/::' -xvf {tar_file} -C {boot_path} " 55 | '--wildcards "boot/vmlinuz-*" ' 56 | '--wildcards "boot/initrd*-*"' 57 | ) 58 | ] 59 | return cmds 60 | 61 | def generate_generic(config, generator_name:str, generator_docker_path:Path, tar_file:Path, output_file_path:Path, project_path: Path): 62 | output_file = output_file_path.absolute() 63 | cmds = [ 64 | f"DOCKER_BUILDKIT=1 docker build -t {generator_name} -f {generator_docker_path} {project_path}", 65 | f"rm -f {output_file}", 66 | f"touch {output_file}", 67 | ( 68 | f"docker run " 69 | f"-v {tar_file.absolute()}:/input.tar " 70 | f"-v {output_file}:/output.file " 71 | f"--privileged " 72 | f"{generator_name} /build.cmd /input.tar /output.file {config['output']['kernel_boot_params']}" 73 | ) 74 | ] 75 | return cmds 76 | 77 | def load_config(project_path, config_file_path, default_built_dir='build'): 78 | config = yaml.load(open(config_file_path), Loader=yaml.SafeLoader) 79 | config_metallize = config['metallize'] = config.get('metallize', {}) 80 | config_metallize['dockerfile_dir'] = config_metallize.get('dockerfile_dir', str((project_path / 'docker'))) 81 | config_metallize['build_dir'] = config_metallize.get('build_dir', default_built_dir) 82 | config_args = config['args'] = config.get('args', {}) 83 | config_args['compression'] = config_args.get('compression', 'lzma') 84 | config_output = config['output'] 85 | config_output['kernel_boot_params'] = config_output.get('kernel_boot_params', 86 | 'nomodeset console=ttyS0,115200 console=tty0' ) 87 | return config 88 | 89 | def main(config_file, extension_dir): 90 | project_path = Path(os.path.dirname(os.path.realpath(__file__))) 91 | config_file_path = Path(config_file) 92 | config = load_config(project_path, config_file_path) 93 | config_metallize = config['metallize'] 94 | build_path = Path(config_metallize['build_dir']) 95 | extension_path = Path(extension_dir) 96 | images_path = Path(config_metallize['dockerfile_dir']) 97 | tar_file = build_path / f"{config_file_path.name}.tar" 98 | output_file = build_path / config['output']['file'] 99 | generators = { 100 | "ext4": images_path / "ext4-generator.dockerfile", 101 | "livecd": images_path / "livecd-generator.dockerfile", 102 | } 103 | generator_name = config['output']['generator'] 104 | generator_docker_path = generators[generator_name] 105 | cmds = ( 106 | [ 107 | "#!/bin/bash", 108 | "set -x -e" 109 | ] 110 | + build_tar(config, images_path, build_path, extension_path, project_path, tar_file) 111 | + generate_generic(config, generator_name, generator_docker_path, tar_file, output_file, project_path) 112 | ) 113 | print("\n".join(cmds)) 114 | 115 | 116 | def is_docker_installed(): 117 | import subprocess 118 | try: 119 | version = subprocess.check_output("docker version --format '{{.Server.Version}}'", shell=True) 120 | except subprocess.CalledProcessError: 121 | return False 122 | 123 | version = version.decode('ascii') 124 | version = version.split('.') 125 | 126 | if int(version[0]) > 18: 127 | return True 128 | elif int(version[0]) == 18 and int(version[1]) >= 9: 129 | return True 130 | 131 | return False 132 | 133 | 134 | if __name__ == '__main__': 135 | parser = argparse.ArgumentParser() 136 | parser.add_argument('config_file', help='yaml file with configs') 137 | parser.add_argument('--extensions_dir', help='path to dir with extension', default='metallize') 138 | args = parser.parse_args() 139 | 140 | if sys.platform != "linux" and sys.platform != "linux2": 141 | print("Now metallize works only on linux platform", file=sys.stderr) 142 | sys.exit(1) 143 | 144 | if not is_docker_installed(): 145 | print("You should have docker with version 18.09 or more", file=sys.stderr) 146 | sys.exit(1) 147 | 148 | main(args.config_file, args.extensions_dir) 149 | -------------------------------------------------------------------------------- /profiles/ubuntu20-ext4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # args are passed to every docker build command 3 | args: 4 | compression: lz4 5 | # images are chained together using dockerfile FROM statement 6 | # first image can be pulled from registry 7 | images: 8 | - ubuntu:20.04 9 | - ubuntu-livecd.dockerfile 10 | - ubuntu-netplan.dockerfile 11 | - admin-user.dockerfile 12 | output: 13 | generator: ext4 14 | file: image.ext4 15 | -------------------------------------------------------------------------------- /profiles/ubuntu20-livecd.iso.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # args are passed to every docker build command 3 | args: 4 | compression: lz4 5 | # images are chained together using dockerfile FROM statement 6 | # first image can be pulled from registry 7 | images: 8 | - ubuntu:20.04 9 | - ubuntu-livecd.dockerfile 10 | - ubuntu-netplan.dockerfile 11 | - admin-user.dockerfile 12 | output: 13 | generator: livecd 14 | file: livecd.iso 15 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli = 1 3 | log_cli_level = INFO 4 | log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) 5 | log_cli_date_format=%Y-%m-%d %H:%M:%S 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil==5.8.0 2 | PyYAML==5.3.1 3 | pytest==6.2.5 4 | psutil==5.8.0 5 | -------------------------------------------------------------------------------- /scripts/ext4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | from pathlib import Path 4 | from subprocess import Popen, PIPE 5 | 6 | def main(input_tar, output_diskimage, kernel_boot_params): 7 | input_tar_path = Path(input_tar) 8 | tar_size = input_tar_path.stat().st_size 9 | 10 | fudge_factor = 1.50 11 | mnt_dir = "/mnt" 12 | cmds = ([ 13 | f"truncate -s 0 {output_diskimage} # erase any potential leftovers", 14 | f"truncate -s {int(tar_size * fudge_factor)} {output_diskimage}", 15 | f"mkfs.ext4 -L METALLIZE_ROOT {output_diskimage}", 16 | f"mkdir -p {mnt_dir}", 17 | f"mount {output_diskimage} {mnt_dir} -o loop", 18 | f"tar -C {mnt_dir} -xf {input_tar}", 19 | f"cp etc/isolinux.cfg {mnt_dir}/boot/extlinux.conf", 20 | f"sed -i 's|METALLIZE_LINUX_CMDLINE|root=LABEL=METALLIZE_ROOT rw {kernel_boot_params}|' {mnt_dir}/boot/extlinux.conf", 21 | f"extlinux --install /mnt/boot", 22 | f"umount {mnt_dir}", 23 | ]) 24 | p = Popen(['/bin/sh', '-x', '-e'], stdin=PIPE) 25 | p.communicate(input="\n".join(cmds).encode("utf-8")) 26 | sys.exit(p.returncode) 27 | 28 | if __name__ == '__main__': 29 | main(sys.argv[1], sys.argv[2], ' '.join(sys.argv[3:])) -------------------------------------------------------------------------------- /scripts/legacy-boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | kvm -netdev user,id=user.0 -device e1000,netdev=user.0 -m 4096 -serial stdio -nographic -monitor null -hda $@ -------------------------------------------------------------------------------- /scripts/livecd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import argparse 4 | from pathlib import Path 5 | from subprocess import Popen, PIPE, STDOUT 6 | 7 | def main(dry_run:bool, input_tar, output_diskimage, kernel_boot_params): 8 | iso_src_path = Path("/tmp") / "iso_src" 9 | squashfs_file = iso_src_path / "live" / f"rootfs.squashfs" 10 | isolinux_path = iso_src_path / "isolinux" 11 | grub_path = iso_src_path / "boot/grub/x86_64-efi/" 12 | grub_search = iso_src_path / ".disk/info" 13 | 14 | compression = "lz4" 15 | cmds = [ 16 | f"sed -i 's|METALLIZE_LINUX_CMDLINE|boot=live {kernel_boot_params}|' /etc/grub.cfg /etc/isolinux.cfg", 17 | f"mkdir -p {squashfs_file.parent} {isolinux_path} {grub_path} {grub_search.parent}", 18 | f"echo MetallizeOS > {grub_search}", 19 | f"cp /etc/grub.cfg {grub_path}", 20 | f"cp -rT /etc/grub/EFI {iso_src_path}/EFI/", 21 | f"cp /etc/grub/debian.efi.stub {iso_src_path}/efi.img", 22 | f"cp /etc/isolinux.cfg /usr/lib/ISOLINUX/isolinux.bin /usr/lib/syslinux/modules/bios/ldlinux.c32 {isolinux_path}", 23 | 24 | f"tar --wildcards --delete 'boot/*' < {input_tar} | mksquashfs - {squashfs_file} -comp {compression} -b 1024K -always-use-fragments -keep-as-directory -no-recovery -exit-on-error -tar", 25 | ( 26 | f"tar --show-transformed-names --transform='s:-.*::' --transform='s:.*/::' -xvf {input_tar} -C {isolinux_path} " 27 | '--wildcards "boot/vmlinuz-*" ' 28 | '--wildcards "boot/initrd*-*"' 29 | ), 30 | f"mkisofs -o {output_diskimage} -J -b isolinux/isolinux.bin -c isolinux/boot.cat -no-emul-boot -boot-load-size 4 -boot-info-table -eltorito-alt-boot -eltorito-boot efi.img -no-emul-boot {iso_src_path}", 31 | f"isohybrid {output_diskimage}", 32 | ] 33 | script = "\n".join(cmds) 34 | if dry_run: 35 | print(script) 36 | return 37 | p = Popen(['/bin/sh', '-x', '-e'], stdin=PIPE) 38 | p.communicate(input=script.encode("utf-8")) 39 | sys.exit(p.returncode) 40 | 41 | if __name__ == '__main__': 42 | parser = argparse.ArgumentParser() 43 | parser.add_argument('--dry-run', action='store_true') 44 | args, rest_of_args = parser.parse_known_args() 45 | main(args.dry_run, rest_of_args[0], rest_of_args[1], ' '.join(rest_of_args[2:])) -------------------------------------------------------------------------------- /scripts/uefi-boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | kvm -netdev user,id=user.0 -device e1000,netdev=user.0 -m 4096 -serial stdio -nographic -monitor null -bios /usr/share/ovmf/OVMF.fd -snapshot -hda $@ -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | You must be in the root directory of the project to run the tests. 2 | Use command 3 | ``` 4 | sudo apt-get install -y qemu-kvm 5 | sudo usermod $USER -G docker,kvm -a 6 | python3 -m virtualenv .venv 7 | . .venv/bin/activate 8 | pip install -r requirements.txt 9 | pytest . 10 | ``` 11 | -------------------------------------------------------------------------------- /tests/metallize_helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarasglek/metallize/1c4620a7af93150b789bb0cc87cefa0d5cdfb388/tests/metallize_helpers/__init__.py -------------------------------------------------------------------------------- /tests/metallize_helpers/cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import subprocess 4 | import time 5 | import psutil 6 | import json 7 | from pathlib import Path 8 | from metallize import load_config 9 | 10 | def run(cmd): 11 | logging.info(cmd) 12 | os.system(cmd) 13 | 14 | def popen(cmd, log_file): 15 | logging.info(cmd) 16 | return subprocess.Popen(cmd, start_new_session=True, stdout=log_file, stderr=log_file, shell=True) 17 | 18 | class MetallizeTest: 19 | def __init__(self, test_file, src_config_name): 20 | test_path = Path(test_file) 21 | self.test_path = test_path.parent 22 | self.project_path = self.test_path.parent.parent 23 | self.src_config = self.project_path / 'profiles' / src_config_name 24 | self.metallize_logs = self.test_path / "metallize_logs.txt" 25 | self.uefi_logs = self.test_path / "build_uefi_logs.txt" 26 | self.build_path = self.test_path / 'build' 27 | self.output_image = self.build_path / 'filesystem.img' 28 | self.modified_config = self.build_path / (test_path.stem + '.json') 29 | 30 | def setup(self): 31 | run(f'rm -rf {self.uefi_logs} {self.metallize_logs}') 32 | os.makedirs(self.build_path, exist_ok=True) 33 | 34 | def cleanup(self): 35 | run(f'rm -rf {self.build_path}') 36 | 37 | def generate_config_from_template(self, modifier_f=lambda _ : ()): 38 | cfg = load_config(self.project_path, self.src_config, default_built_dir=str(self.build_path)) 39 | cfg['output']['file'] = str(self.output_image) 40 | modifier_f(cfg) 41 | cfg_str = json.dumps(cfg, indent=4, sort_keys=True) 42 | logging.info(cfg_str) 43 | with open(self.modified_config, 'w') as cfg_out: 44 | cfg_out.write(cfg_str) 45 | return self.modified_config 46 | 47 | def run_metallize(self, extra_args = ''): 48 | run(f'cd {self.test_path} | {self.project_path}/metallize.py {self.modified_config} {extra_args} ' 49 | f'| bash > {self.metallize_logs} 2>&1') 50 | assert(self.output_image.exists()) 51 | 52 | def run_qemu(self, checker_f, legacy_or_uefi="legacy", timeout = 3 * 60): 53 | with open(self.uefi_logs, 'w') as log_file: 54 | p = popen(f"{self.project_path}/scripts/{legacy_or_uefi}-boot.sh {self.output_image}", log_file) 55 | self.pid = p.pid 56 | start_time = time.time() 57 | success = False 58 | while p.poll() is None and time.time() - start_time < timeout: 59 | time.sleep(5) 60 | success = checker_f(open(self.uefi_logs, "r").read()) 61 | if success: 62 | break 63 | self.kill() 64 | assert success, "qemu failed or test timed out without expected output" 65 | 66 | def kill(self): 67 | process = psutil.Process(self.pid) 68 | for proc in process.children(recursive=True): 69 | proc.kill() 70 | process.kill() -------------------------------------------------------------------------------- /tests/test_ext4_legacy/test_ext4_legacy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.metallize_helpers.cmd import MetallizeTest 3 | 4 | test = MetallizeTest(__file__, 'ubuntu20-ext4.yaml') 5 | 6 | @pytest.fixture 7 | def cleanup(): 8 | test.setup() 9 | yield 10 | test.cleanup() 11 | 12 | def test_build_from_ext_dir(cleanup): 13 | test.generate_config_from_template() 14 | test.run_metallize() 15 | test.run_qemu(checker_f=lambda output: output.find('localhost login') != -1, legacy_or_uefi="legacy") -------------------------------------------------------------------------------- /tests/test_ext_dir/etc/hostname: -------------------------------------------------------------------------------- 1 | test-host -------------------------------------------------------------------------------- /tests/test_ext_dir/etc/hosts: -------------------------------------------------------------------------------- 1 | 127.0.0.1 localhost 2 | 127.0.1.1 test-host 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/test_ext_dir/test.dockerfile: -------------------------------------------------------------------------------- 1 | ARG METALLIZE_SRC_IMG 2 | FROM ${METALLIZE_SRC_IMG} 3 | 4 | COPY etc/hosts /etc/ 5 | COPY etc/hostname /etc/ 6 | -------------------------------------------------------------------------------- /tests/test_ext_dir/test_ext_dir.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.metallize_helpers.cmd import MetallizeTest 3 | 4 | test = MetallizeTest(__file__, 'ubuntu20-livecd.iso.yaml') 5 | 6 | @pytest.fixture 7 | def cleanup(): 8 | test.setup() 9 | yield 10 | test.cleanup() 11 | 12 | def test_build_from_ext_dir(cleanup): 13 | test.generate_config_from_template(lambda cfg: cfg['images'].append('test.dockerfile')) 14 | test.run_metallize(extra_args=f"--extensions_dir={test.test_path}") 15 | test.run_qemu(checker_f=lambda output: output.find('test-host login') != -1) -------------------------------------------------------------------------------- /tests/test_legacy/test_legacy_build.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.metallize_helpers.cmd import MetallizeTest 3 | 4 | test = MetallizeTest(__file__, 'ubuntu20-livecd.iso.yaml') 5 | 6 | @pytest.fixture 7 | def cleanup(): 8 | test.setup() 9 | yield 10 | test.cleanup() 11 | 12 | def test_build_from_ext_dir(cleanup): 13 | test.generate_config_from_template() 14 | test.run_metallize(extra_args=f"--extensions_dir={test.test_path}") 15 | test.run_qemu(checker_f=lambda output: output.find('localhost login') != -1, legacy_or_uefi="legacy") -------------------------------------------------------------------------------- /tests/test_uefi/test_uefi_build.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.metallize_helpers.cmd import MetallizeTest 3 | 4 | test = MetallizeTest(__file__, 'ubuntu20-livecd.iso.yaml') 5 | 6 | @pytest.fixture 7 | def cleanup(): 8 | test.setup() 9 | yield 10 | test.cleanup() 11 | 12 | def test_build_from_ext_dir(cleanup): 13 | test.generate_config_from_template() 14 | test.run_metallize(extra_args=f"--extensions_dir={test.test_path}") 15 | test.run_qemu(checker_f=lambda output: output.find('localhost login') != -1, legacy_or_uefi="uefi") --------------------------------------------------------------------------------