From 3202d12c508df1dbe5af056417ed009057eccbf0 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 18 Oct 2026 16:42:25 +0330 Subject: [PATCH] build: add image builder and image variants --- Makefile | 122 +++++++++++++++++++++++ build.sh | 191 +++++++++++++++++++++++++++++++++++++ images/base.sh | 61 ++++++++++++ images/basic.sh | 29 ++++++ images/cloud-image.sh | 25 +++++ images/libvirt-executor.sh | 27 ++++++ 6 files changed, 455 insertions(+) create mode 100644 Makefile create mode 100755 build.sh create mode 100644 images/base.sh create mode 100644 images/basic.sh create mode 100644 images/cloud-image.sh create mode 100644 images/libvirt-executor.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..48e394b --- /dev/null +++ b/Makefile @@ -0,0 +1,122 @@ +SHELL := /bin/bash +.DEFAULT_GOAL := help + +ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +OUTPUT_DIR := $(ROOT_DIR)/output +TMP_DIR := $(ROOT_DIR)/tmp +VERSION ?= +LIBVIRT_URI ?= qemu:///system +LIBVIRT_POOL ?= default +LIBVIRT_NETWORK ?= default +TERRAFORM_DIR := $(ROOT_DIR)/terraform +TERRAFORM_LOCK_TIMEOUT ?= 60s +VM_NAME ?= parch-cloud +SSH_KEY ?= $(HOME)/.ssh/id_ed25519 +TERRAFORM_VARS = -var="libvirt_uri=$(LIBVIRT_URI)" -var="libvirt_pool=$(LIBVIRT_POOL)" -var="libvirt_network=$(LIBVIRT_NETWORK)" \ + -var="vm_name=$(VM_NAME)" -var="ssh_public_key_path=$(SSH_KEY).pub" + +.PHONY: help check-deps install-deps install-qemu-deps build build-cloud list verify info clean clean-output clean-tmp \ + qemu-check qemu-init qemu-plan qemu-up qemu-down qemu-ip qemu-ssh + +help: + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +check-deps: + @missing=0; \ + for command in pacstrap arch-chroot pacman gpgconf losetup mount mountpoint umount \ + truncate sgdisk mkfs.fat mkfs.btrfs udevadm blockdev fstrim qemu-img sha256sum; do \ + if ! command -v "$$command" >/dev/null 2>&1; then \ + echo "Missing command: $$command"; \ + missing=1; \ + fi; \ + done; \ + if (( missing )); then \ + echo "Install the build dependencies with: make install-deps"; \ + exit 1; \ + fi; \ + echo "Build commands are available." + +install-deps: + @sudo pacman -S --needed --noconfirm arch-install-scripts btrfs-progs dosfstools gptfdisk gnupg qemu-img systemd util-linux + +install-qemu-deps: + @sudo pacman -S --needed --noconfirm qemu-full libvirt terraform + +build: check-deps + @if [[ $$(id -u) -eq 0 ]]; then \ + cd "$(ROOT_DIR)" && ./build.sh "$(VERSION)"; \ + else \ + cd "$(ROOT_DIR)" && sudo ./build.sh "$(VERSION)"; \ + fi + +build-cloud: check-deps + @if [[ $$(id -u) -eq 0 ]]; then \ + cd "$(ROOT_DIR)" && ./build.sh "$(VERSION)" cloud-image; \ + else \ + cd "$(ROOT_DIR)" && sudo ./build.sh "$(VERSION)" cloud-image; \ + fi + +list: + @if [[ -d "$(OUTPUT_DIR)" ]] && compgen -G "$(OUTPUT_DIR)/*.qcow2" >/dev/null; then \ + find "$(OUTPUT_DIR)" -maxdepth 1 -type f -name '*.qcow2' -printf '%f\t%s bytes\n'; \ + else \ + echo "No QCOW2 images found. Run 'make build' first."; \ + fi + +verify: + @if [[ ! -d "$(OUTPUT_DIR)" ]] || ! compgen -G "$(OUTPUT_DIR)/*.SHA256" >/dev/null; then \ + echo "No checksum files found in $(OUTPUT_DIR)."; \ + exit 1; \ + fi; \ + cd "$(OUTPUT_DIR)"; \ + for checksum in *.SHA256; do sha256sum --check "$$checksum"; done + +info: check-deps + @if [[ ! -d "$(OUTPUT_DIR)" ]] || ! compgen -G "$(OUTPUT_DIR)/*.qcow2" >/dev/null; then \ + echo "No QCOW2 images found in $(OUTPUT_DIR)."; \ + exit 1; \ + fi; \ + for image in "$(OUTPUT_DIR)"/*.qcow2; do qemu-img info "$$image"; done + +clean-output: + @rm -rf -- "$(OUTPUT_DIR)" + @echo "Removed $(OUTPUT_DIR)." + +clean-tmp: + @rm -rf -- "$(TMP_DIR)" + @echo "Removed $(TMP_DIR)." + +clean: clean-output clean-tmp + +qemu-check: + @command -v terraform >/dev/null || { echo "Missing command: terraform"; exit 1; } + @command -v virsh >/dev/null || { echo "Missing command: virsh (install libvirt)"; exit 1; } + @virsh -c "$(LIBVIRT_URI)" pool-info "$(LIBVIRT_POOL)" >/dev/null || { echo "Cannot access libvirt pool '$(LIBVIRT_POOL)' through $(LIBVIRT_URI)."; exit 1; } + @virsh -c "$(LIBVIRT_URI)" net-info "$(LIBVIRT_NETWORK)" >/dev/null || { echo "Libvirt network '$(LIBVIRT_NETWORK)' is unavailable."; exit 1; } + +qemu-init: + @terraform -chdir="$(TERRAFORM_DIR)" init -input=false + +qemu-plan: qemu-check + @terraform -chdir="$(TERRAFORM_DIR)" plan -input=false -lock-timeout=$(TERRAFORM_LOCK_TIMEOUT) $(TERRAFORM_VARS) + +qemu-up: qemu-check + @if ! compgen -G "$(OUTPUT_DIR)/Parch-Linux-x86_64-cloudimg-*.qcow2" >/dev/null; then $(MAKE) build-cloud; fi + @terraform -chdir="$(TERRAFORM_DIR)" init -input=false + @terraform -chdir="$(TERRAFORM_DIR)" apply -auto-approve -input=false -lock-timeout=$(TERRAFORM_LOCK_TIMEOUT) $(TERRAFORM_VARS) + +qemu-down: qemu-check + @terraform -chdir="$(TERRAFORM_DIR)" destroy -auto-approve -input=false -lock-timeout=$(TERRAFORM_LOCK_TIMEOUT) $(TERRAFORM_VARS) + +qemu-ip: qemu-check + @leases="$$(virsh -c "$(LIBVIRT_URI)" net-dhcp-leases "$(LIBVIRT_NETWORK)")"; \ + if printf '%s\n' "$$leases" | grep -Eq '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'; then \ + printf '%s\n' "$$leases"; \ + else \ + printf '%s\n' "No DHCP lease yet. Start the VM with 'make qemu-up', then run 'make qemu-ip' again."; \ + fi + +qemu-ssh: qemu-check + @ip="$$(virsh -c "$(LIBVIRT_URI)" domifaddr "$(VM_NAME)" --source lease | awk '/ipv4/ {sub(/\/.*/, "", $$4); print $$4; exit}')"; \ + if [[ -z "$$ip" ]]; then echo "No IP for $(VM_NAME) yet. Run 'make qemu-up', wait for boot, then retry."; exit 1; fi; \ + exec ssh -o IdentitiesOnly=yes -i "$(SSH_KEY)" arch@"$$ip" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7330691 --- /dev/null +++ b/build.sh @@ -0,0 +1,191 @@ +#!/bin/bash + +set -o nounset -o errexit +readonly DEFAULT_DISK_SIZE="2G" +readonly IMAGE="image.img" +readonly MIRROR='https://fastly.mirror.pkgbuild.com/$repo/os/$arch' + +function init() { + readonly ORIG_PWD="${PWD}" + readonly OUTPUT="${PWD}/output" + mkdir -p "${OUTPUT}" "${PWD}/tmp" + local tmpdir + tmpdir="$(mktemp --directory --tmpdir="${PWD}/tmp")" + readonly TMPDIR="${tmpdir}" + if [ -n "${SUDO_UID:-}" ] && [[ -n "${SUDO_GID:-}" ]]; then + chown "${SUDO_UID}:${SUDO_GID}" "${OUTPUT}" "${TMPDIR}" + fi + cd "${TMPDIR}" + + readonly MOUNT="${PWD}/mount" + mkdir "${MOUNT}" +} + +function cleanup() { + set +o errexit + if [ -n "${MOUNT:-}" ] && mountpoint -q "${MOUNT}"; then + umount --recursive "${MOUNT}" || exit 1 + fi + if [ -n "${LOOPDEV:-}" ]; then + losetup -d "${LOOPDEV}" + fi + if [ -n "${TMPDIR:-}" ]; then + rm -rf "${TMPDIR}" + fi +} +trap cleanup EXIT + +function setup_disk() { + truncate -s "${DEFAULT_DISK_SIZE}" "${IMAGE}" + sgdisk --align-end \ + --clear \ + --new 0:0:+1M --typecode=0:ef02 --change-name=0:'BIOS boot partition' \ + --new 0:0:+300M --typecode=0:ef00 --change-name=0:'EFI system partition' \ + --new 0:0:0 --typecode=0:8304 --change-name=0:'Parch Linux root' \ + "${IMAGE}" + + LOOPDEV=$(losetup --find --partscan --show "${IMAGE}") + wait_until_settled "${LOOPDEV}" + mkfs.fat -F 32 -S 4096 "${LOOPDEV}p2" + mkfs.btrfs "${LOOPDEV}p3" + mount -o compress=zstd:1 "${LOOPDEV}p3" "${MOUNT}" + mount --mkdir "${LOOPDEV}p2" "${MOUNT}/efi" +} + +function bootstrap() { + cat <pacman.conf +[options] +Architecture = auto +SigLevel = DatabaseOptional + +[world] +SigLevel = Never +Server = https://github.com/parchlinux/world/raw/refs/heads/main/\$arch/ + +[core] +Include = mirrorlist + +[extra] +Include = mirrorlist +EOF + echo "Server = ${MIRROR}" >mirrorlist + + pacstrap -c -C pacman.conf -M "${MOUNT}" base linux grub openssh sudo btrfs-progs dosfstools efibootmgr qemu-guest-agent parchlinux-keyring parch-branding lsb-release + gpgconf --homedir "${MOUNT}/etc/pacman.d/gnupg" --kill gpg-agent + cp -a --no-preserve=ownership "${ORIG_PWD}/branding/." "${MOUNT}/" + install -Dm 644 "${ORIG_PWD}/branding/usr/lib/os-release_parch" "${MOUNT}/usr/local/share/parch-branding/os-release" + install -Dm 644 "${ORIG_PWD}/branding/etc/issue_parch" "${MOUNT}/usr/local/share/parch-branding/issue" + install -Dm 644 "${ORIG_PWD}/branding/etc/lsb-release_parch" "${MOUNT}/usr/local/share/parch-branding/lsb-release" + install -Dm 644 "${ORIG_PWD}/branding/usr/share/pixmaps/parch-logo.svg" "${MOUNT}/usr/local/share/parch-branding/pixmaps/parch-logo.svg" + install -Dm 644 "${ORIG_PWD}/branding/usr/share/pixmaps/parch-logo-dark.svg" "${MOUNT}/usr/local/share/parch-branding/pixmaps/parch-logo-dark.svg" +} + +function image_cleanup() { + rm -rf "${MOUNT}/etc/pacman.d/gnupg/" + + printf '\nMODULES=(virtio_pci virtio_blk virtio_scsi btrfs sr_mod)\n' >>"${MOUNT}/etc/mkinitcpio.conf" + arch-chroot "${MOUNT}" /usr/bin/mkinitcpio -P + + sync -f "${MOUNT}/etc/os-release" + fstrim --verbose "${MOUNT}" + fstrim --verbose "${MOUNT}/efi" +} + +function wait_until_settled() { + udevadm settle + blockdev --flushbufs --rereadpt "${1}" + until test -e "${1}p3"; do + echo "${1}p3 doesn't exist yet..." + sleep 1 + done +} + +function mount_image() { + LOOPDEV=$(losetup --find --partscan --show "${1:-${IMAGE}}") + wait_until_settled "${LOOPDEV}" + mount -o compress=zstd:1 "${LOOPDEV}p3" "${MOUNT}" + mount --bind "/var/cache/pacman/pkg" "${MOUNT}/var/cache/pacman/pkg" +} + +function unmount_image() { + umount --recursive "${MOUNT}" + losetup -d "${LOOPDEV}" + LOOPDEV="" +} + +function mv_to_output() { + sha256sum "${1}" >"${1}.SHA256" + if [ -n "${SUDO_UID:-}" ]; then + chown "${SUDO_UID}:${SUDO_GID}" "${1}"{,.SHA256} + fi + mv "${1}"{,.SHA256} "${OUTPUT}/" +} + +function create_image() { + local tmp_image + tmp_image="$(basename "$(mktemp -u)")" + cp -a "${IMAGE}" "${tmp_image}" + if [ -n "${DISK_SIZE}" ]; then + truncate -s "${DISK_SIZE}" "${tmp_image}" + sgdisk --align-end --delete 3 "${tmp_image}" + sgdisk --align-end --move-second-header \ + --new 0:0:0 --typecode=0:8304 --change-name=0:'Parch Linux root' \ + "${tmp_image}" + fi + mount_image "${tmp_image}" + if [ -n "${DISK_SIZE}" ]; then + btrfs filesystem resize max "${MOUNT}" + fi + + if [ 0 -lt "${#PACKAGES[@]}" ]; then + arch-chroot "${MOUNT}" /usr/bin/pacman -S --noconfirm "${PACKAGES[@]}" + fi + if [ 0 -lt "${#SERVICES[@]}" ]; then + arch-chroot "${MOUNT}" /usr/bin/systemctl enable "${SERVICES[@]}" + fi + "${2}" + image_cleanup + unmount_image + "${3}" "${tmp_image}" "${1}" + mv_to_output "${1}" +} + +function main() { + if [ "$(id -u)" -ne 0 ]; then + echo "root is required" + exit 1 + fi + init + + if [ -n "${2:-}" ] && [ ! -f "${ORIG_PWD}/images/${2}.sh" ]; then + echo "Unknown image variant '${2}'. Available variants are: $(find "${ORIG_PWD}/images" -maxdepth 1 -type f -name '*.sh' ! -name base.sh -printf '%f\n' | sed 's/\.sh$//' | tr '\n' ' ')" + exit 1 + fi + + setup_disk + bootstrap + source "${ORIG_PWD}/images/base.sh" + pre + unmount_image + + local build_version + if [ -z "${1:-}" ]; then + build_version="$(date +%Y%m%d).0" + echo "WARNING: BUILD_VERSION wasn't set!" + echo "Falling back to $build_version" + else + build_version="${1}" + fi + + for image in "${ORIG_PWD}/images/"*.sh; do + if [ "$(basename "${image}")" = "base.sh" ]; then + continue + fi + if [ -n "${2:-}" ] && [ "$(basename "${image}" .sh)" != "${2}" ]; then + continue + fi + source "${image}" + create_image "${IMAGE_NAME}" pre post + done +} +main "$@" diff --git a/images/base.sh b/images/base.sh new file mode 100644 index 0000000..6462525 --- /dev/null +++ b/images/base.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +function pre() { + rm "${MOUNT}/etc/machine-id" + + arch-chroot "${MOUNT}" /usr/bin/btrfs subvolume create /swap + chattr +C "${MOUNT}/swap" + chmod 0700 "${MOUNT}/swap" + arch-chroot "${MOUNT}" /usr/bin/btrfs filesystem mkswapfile --size 512m --uuid clear /swap/swapfile + echo -e "/swap/swapfile none swap defaults 0 0" >>"${MOUNT}/etc/fstab" + + arch-chroot "${MOUNT}" /usr/bin/systemd-firstboot --locale=C.UTF-8 --timezone=UTC --hostname=parch --keymap=us + ln -sf /run/systemd/resolve/stub-resolv.conf "${MOUNT}/etc/resolv.conf" + + install -m 644 "${MOUNT}/usr/local/share/parch-branding/os-release" "${MOUNT}/usr/lib/os-release" + install -m 644 "${MOUNT}/usr/local/share/parch-branding/issue" "${MOUNT}/etc/issue" + install -m 644 "${MOUNT}/usr/local/share/parch-branding/lsb-release" "${MOUNT}/etc/lsb-release" + install -m 644 "${MOUNT}/usr/local/share/parch-branding/pixmaps/parch-logo.svg" "${MOUNT}/usr/share/pixmaps/parch-logo.svg" + install -m 644 "${MOUNT}/usr/local/share/parch-branding/pixmaps/parch-logo-dark.svg" "${MOUNT}/usr/share/pixmaps/parch-logo-dark.svg" + + cat <"${MOUNT}/etc/systemd/system/pacman-init.service" +[Unit] +Description=Initializes Pacman keyring +Before=sshd.service cloud-final.service archlinux-keyring-wkd-sync.service +After=time-sync.target +ConditionFirstBoot=yes + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/pacman-key --init +ExecStart=/usr/bin/pacman-key --populate archlinux +ExecStart=-/usr/bin/pacman-key --populate parchlinux +ExecStart=/usr/bin/gpg --homedir /etc/pacman.d/gnupg --batch --import-ownertrust /usr/share/pacman/keyrings/parchlinux-trusted + +[Install] +WantedBy=multi-user.target +EOF + + cat <<'EOF' >"${MOUNT}/etc/pacman.d/mirrorlist" +Server = https://fastly.mirror.pkgbuild.com/$repo/os/$arch +Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch +EOF + + arch-chroot "${MOUNT}" /bin/bash -e <"${MOUNT}/etc/sudoers.d/${NEWUSER}" + + cat <"${MOUNT}/etc/systemd/network/80-dhcp.network" +[Match] +Name=en* +Name=eth* + +[Link] +RequiredForOnline=routable + +[Network] +DHCP=yes +EOF +} + +function post() { + qemu-img convert -c -f raw -O qcow2 "${1}" "${2}" + rm "${1}" +} diff --git a/images/cloud-image.sh b/images/cloud-image.sh new file mode 100644 index 0000000..7e52d8a --- /dev/null +++ b/images/cloud-image.sh @@ -0,0 +1,25 @@ +#!/bin/bash +IMAGE_NAME="Parch-Linux-x86_64-cloudimg-${build_version}.qcow2" +DISK_SIZE="" +PACKAGES=(cloud-init cloud-guest-utils gptfdisk) +SERVICES=(cloud-init-main.service cloud-init-local.service cloud-init-network.service cloud-config.service cloud-final.service qemu-guest-agent.service) + +function pre() { + cat <<'EOF' >"${MOUNT}/etc/systemd/network/80-dhcp.network" +[Match] +Name=en* eth* + +[Network] +DHCP=yes +EOF + + sed -Ei 's/^(GRUB_CMDLINE_LINUX_DEFAULT=.*)"$/\1 console=tty0 console=ttyS0,115200"/' "${MOUNT}/etc/default/grub" + echo 'GRUB_TERMINAL="serial console"' >>"${MOUNT}/etc/default/grub" + echo 'GRUB_SERIAL_COMMAND="serial --speed=115200"' >>"${MOUNT}/etc/default/grub" + arch-chroot "${MOUNT}" /usr/bin/grub-mkconfig -o /boot/grub/grub.cfg +} + +function post() { + qemu-img convert -c -f raw -O qcow2 "${1}" "${2}" + rm "${1}" +} diff --git a/images/libvirt-executor.sh b/images/libvirt-executor.sh new file mode 100644 index 0000000..8b986cf --- /dev/null +++ b/images/libvirt-executor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +IMAGE_NAME="Parch-Linux-x86_64-libvirt-executor-${build_version}.qcow2" +DISK_SIZE="40G" +PACKAGES=(git git-lfs gitlab-runner) +SERVICES=() + +function pre() { + arch-chroot "${MOUNT}" /usr/bin/systemctl disable systemd-time-wait-sync + sed -E 's/^#(IgnorePkg *=)/\1 linux/' -i "${MOUNT}/etc/pacman.conf" + sed 's/^\(GRUB_CMDLINE_LINUX=".*\)"$/\1 lockdown=confidentiality"/' -i "${MOUNT}/etc/default/grub" + arch-chroot "${MOUNT}" /usr/bin/grub-mkconfig -o /boot/grub/grub.cfg + rm -f "${MOUNT}/etc/hostname" + + cat <"${MOUNT}/etc/systemd/network/80-dhcp.network" +[Match] +Name=en* +Name=eth* + +[Network] +DHCP=yes +EOF +} + +function post() { + qemu-img convert -c -f raw -O qcow2 "${1}" "${2}" + rm "${1}" +}