diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8a0dce --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.bin diff --git a/Dockerfile b/Dockerfile index dfe8b0f..3cdc1f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,40 +17,28 @@ RUN --mount=type=bind,target=. \ yamllint -v && yamllint -s -f colored . # ========================================================= -FROM golang:1.25.5-alpine AS init +FROM golang:1.25.5-alpine AS imager-build +SHELL ["/bin/ash", "-euxo", "pipefail", "-c"] WORKDIR /go/src +COPY imager/go.mod imager/go.sum ./ +RUN go mod download +COPY imager . RUN \ ---mount=source=init,target=. \ --mount=type=cache,target=/root/.cache/go-build \ -CGO_ENABLED=0 go build -o /go/bin/init -v --ldflags '-s -w -extldflags=-static' +go generate -v ./...; \ +go test; \ +go build -o /go/bin/imager -v --ldflags "-s -w" cmd/main.go # ========================================================= FROM alpine:3.23.2 AS alpine-base # ========================================================= FROM alpine-base AS imager -SHELL ["/bin/ash", "-euxo", "pipefail", "-c"] -RUN \ -echo "@edge-community https://dl-cdn.alpinelinux.org/alpine/edge/community" >>/etc/apk/repositories && \ -apk add --no-cache \ -bash \ -binutils \ -coreutils \ -cpio \ -dosfstools \ -findutils \ -mtools \ -pigz \ -qemu-img \ -sfdisk \ -xorriso \ -zstd \ -xz \ -systemd-efistub@edge-community -COPY --from=init /go/bin/init /usr/share/claylinux/init -COPY build-image.sh /usr/bin/build-image +# TODO: bundle objcopy (or use a golang lib) and systemd-efistub into the imager binary +RUN apk add --no-cache binutils systemd-efistub +COPY --from=imager-build /go/bin/imager /bin/imager WORKDIR /out -ENTRYPOINT ["build-image"] +ENTRYPOINT ["/bin/imager"] # ========================================================= FROM alpine-base AS bootable-alpine-rootfs @@ -109,14 +97,14 @@ if [ "$UCODE" != "none" ]; then apk add --no-cache "${UCODE}-ucode"; fi; # hadolint ignore=DL3006 FROM imager AS test ARG FORMAT=efi -RUN --mount=from=test-rootfs,target=/system build-image --format "$FORMAT" +RUN --mount=from=test-rootfs,target=/system /bin/imager --format "$FORMAT" # ========================================================= # Generate a qemu image running our custom OS image FROM alpine-base AS emulator RUN apk add --no-cache bash qemu-system-x86_64 ovmf COPY emulator.sh /entrypoint -ENTRYPOINT ["/entrypoint"] COPY --from=test /out /images ARG FORMAT ENV FORMAT="$FORMAT" +ENTRYPOINT ["/entrypoint"] diff --git a/build-image.sh b/build-image.sh deleted file mode 100755 index bed3447..0000000 --- a/build-image.sh +++ /dev/null @@ -1,379 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# exit with an error message -die() { - echo "Error: $*" >&2 - exit 1 -} - -# get the size of the file in bytes -get_size() { - stat -c %s "$1" -} - -# convert a number of bytes into MiB (i.e. 1024 * 1024 bytes), rounded to the next value -in_mib() { - echo $(( ($1 + (1<<20) - 1) >> 20 )) -} - -# get the size of the file in mibytes -get_size_mib() { - in_mib "$(get_size "$1")" -} - -# round the value given in $1 to the next multiple of the value given in $2 -# e.g. align 9 4 -> 12, align 8 4 -> 8 -align() { - echo "$(( ($1 + $2 - 1) / $2 * $2 ))" -} - -# build the EFI executable -build_efi() { - local size kernel - - pushd "$build_dir" >/dev/null - - echo "Building the EFI executable" - build_initramfs - size=$(get_size_mib initramfs) - echo "The size of the initramfs is: $size MiB" - kernel=$(find /system/boot -name 'vmlinu*' -print) - space_separated cmdline - basename /system/lib/modules/* >kernel-release - - # build the EFI UKI file - # TODO: add .dtb section on ARM? - build_uki <<-EOF - .osrel /system/etc/os-release - .uname kernel-release - .cmdline cmdline - .initrd initramfs - .linux $kernel - EOF - - # delete all the temporary files - find . ! -name '*.efi' -delete - - popd >/dev/null -} - -# build the initramfs -build_initramfs() { - mkdir initramfs_files - - # copy the init script - cp /usr/share/claylinux/init initramfs_files - - # copy /etc/hosts.target as /etc/hosts - if [[ -f /system/etc/hosts.target ]]; then - mkdir -p initramfs_files/etc - cp /system/etc/hosts.target initramfs_files/etc/hosts - fi - - # copy etc/resolv.conf.target as etc/resolv.conf - if [[ -f /system/etc/resolv.conf.target ]]; then - mkdir -p initramfs_files/etc - cp /system/etc/resolv.conf.target initramfs_files/etc/resolv.conf - fi - - # create an initramfs with these files - find initramfs_files -mindepth 1 -printf '%P\0' \ - | cpio --quiet -o0H newc -D initramfs_files -F initramfs.img - - # append the system files into the initramfs image, except /boot, /etc/hosts.target and /etc/resolv.conf.target - find /system \ - -path /system/boot -prune -o \ - ! -path /system/init \ - ! -path /system/etc/hosts.target \ - ! -path /system/etc/resolv.conf.target \ - -mindepth 1 -printf '%P\0' \ - | cpio --quiet -o0AH newc -D /system -F initramfs.img - - # compress the initramfs - compress initramfs.img - - # build the final initramfs by concatenating the ucode images & our compressed initramfs image - # see https://docs.kernel.org/arch/x86/microcode.html - echo "$(find /system/boot/ -name '*-ucode.img') initramfs.img" | xargs cat >initramfs - - # remove the temporary files - find . ! -name initramfs -delete -} - -# create a Unified Kernel Image from the sections passed in the standard input -build_uki() { - # the sections addresses should be aligned to PAGE_ALIGN(), i.e. 2<<12 == 4096 bytes - local args=() alignment=4096 size offset - - # compute the start offset of the new sections - offset="$(objdump -h -w "$efi_stub" | awk 'END { offset=("0x"$4)+0; size=("0x"$3)+0; print offset + size }')" - offset=$(align "$offset" $alignment) - - # compute the objcopy arguments - while read -r section file - do - # add the section to the parameters - args+=( - --add-section - "$section=$file" - --change-section-vma - "$section=$offset" - ) - - # compute the offset for the next section - size="$(get_size "$file")" - size=$(align "$size" $alignment) - offset=$(( offset + size )) - done - - objcopy "${args[@]}" "$efi_stub" "$efi_file" -} - -# detect the current EFI architecture -get_efi_arch() { - local machine_arch - machine_arch="$(uname -m)" - case "$machine_arch" in - aarch64) - echo "aa64" - ;; - arm*) - echo "arm" - ;; - i686) - echo "ia32" - ;; - x86_64) - echo "x64" - ;; - *) - die "unsupported architecture: $machine_arch" - ;; - esac -} - -# convert a multi-line input into a space separated list -space_separated() { - paste -d' ' -s -} - -# compress the initramfs with the specified scheme -compress() { - case "$compression" in - none) - ;; - gz) - pigz -9 "$1" - mv "$1".gz "$1" - ;; - xz) - xz -C crc32 -9 -T0 "$1" - mv "$1".xz "$1" - ;; - zstd) - zstd -19 -T0 --rm "$1" - mv "$1".zstd "$1" - ;; - *) - die "invalid compression scheme '$compression'" - ;; - esac -} - -# just copy the build files to the output directory -generate_efi() { - echo "Copying the OS files to the output directory" - mv "$efi_file" "$output".efi -} - -# generate the EFI system partition -generate_esp() { - local size - - echo "Generating the EFI System Partition (ESP)" - - # compute the ESP size: - # - measure the apparent size of the files to copy, in bytes - # - add some headroom to account for the filesystem overhead (less than 2%) - # - convert the result into MiB - size=$(get_size "$efi_file") - size=$(in_mib $(( size * 102 / 100 ))) - echo "The size of the ESP is: $size MiB" - - # create the FAT32 filesystem - mkfs.vfat -n "$volume" -F 32 -C "$esp_file" "$(( size << 10 ))" >/dev/null - - # copy the EFI executable into the filesystem - mmd -i "$esp_file" ::/EFI - mmd -i "$esp_file" ::/EFI/boot - mcopy -i "$esp_file" "$efi_file" "::/EFI/boot/boot${efi_arch}.efi" - - rm "$efi_file" -} - -# generate an ISO image -generate_iso() { - generate_esp - - echo "Generating the ISO image" - - xorrisofs \ - -e "$(basename "$esp_file")" \ - -no-emul-boot \ - -joliet \ - -full-iso9660-filenames \ - -rational-rock \ - -sysid LINUX \ - -volid "$volume" \ - -output "$output".iso \ - "$esp_file" 2>/dev/null - - rm "$esp_file" -} - -# generate a raw disk image with a single whole disk FAT32 EFI partition on GPT -generate_raw() { - local disk_file esp_size disk_size - - generate_esp - - echo "Generating the disk image" - disk_file="$output".img - - # compute the disk size in bytes: get the ESP size and add 1MB at both ends for the partition tables - esp_size=$(get_size_mib "$esp_file") - disk_size=$(( esp_size + 2 )) - echo "The size of the disk image is: $disk_size MiB" - - # create a blank image file - truncate -s ${disk_size}M "$disk_file" - - # add a single full disk partition formatted as FAT32 with LBA - sfdisk --quiet "$disk_file" <<-EOF - label: gpt - first-lba: 34 - start=1MiB size=${esp_size}MiB name="EFI system partition" type=uefi - EOF - - # copy in our partition data into the first partition of the disk image - dd if="$esp_file" of="$disk_file" bs=1M seek=1 conv=notrunc status=none - - rm "$esp_file" -} - -# convert the raw disk image to another format -convert_image() { - local format="$1" - - generate_raw - - echo "Converting the disk image to $format format" - qemu-img convert -f raw -O "$format" "$output".img "$output"."$format" - - rm "$output".img -} - -# generate a disk image in qcow2 format -generate_qcow2() { - convert_image qcow2 -} - -# generate a disk image in vmdk format -generate_vmdk() { - convert_image vmdk -} - -# generate a disk image in vhdx format -generate_vhdx() { - convert_image vhdx -} - -# generate a disk image in vdi format -generate_vdi() { - convert_image vdi -} - -# validate and set the output format -set_format() { - case "$1" in - efi|iso|raw|qcow2|vmdk|vhdx|vdi) - format="$1" - ;; - *) - die "invalid format '$1'" - ;; - esac -} - -# defaults -output=/out/claylinux -format=raw -volume=CLAYLINUX -compression=gz -efi_arch=$(get_efi_arch) -efi_stub="/usr/lib/systemd/boot/efi/linux${efi_arch}.efi.stub" - -usage=$(cat <<-EOF - Usage: $(basename "$0") [OPTIONS ...] - - Build an OS image from the root filesystem found in /system - - Options: - -f, --format FORMAT Output format (default: $format) - -o, --output OUTPUT Output image path/name, without any extension (default: $output) - -v, --volume VOLUME Volume name/label of the boot partition (default: $volume) - -c, --compression COMP Compression format for the initramfs: none | gz | xz | zstd (default: $compression) - - Output formats: - - efi: EFI executable (saved as OUTPUT.efi), for use with a custom bootloader or with PXE boot - - iso: ISO9660 CD-ROM image (saved as OUTPUT.iso) - - raw: raw disk image with a single FAT32 boot partition (saved as OUTPUT.img) - - qcow2: disk image in QCOW2 format (saved as OUTPUT.qcow2) - - vmdk: disk image in VMDK format (saved as OUTPUT.vmdk) - - vhdx: disk image in VHDX format (saved as OUTPUT.vhdx) - - vdi: disk image in VDI format (saved as OUTPUT.vdi) - EOF -) - -# parse the command-line arguments -while [[ "$#" -gt 0 ]]; do - case "$1" in - -h|--help) - echo "$usage" - exit 0 - ;; - -f|--format) - set_format "$2" - shift 2 - ;; - -o|--output) - output="$2" - shift 2 - ;; - -v|--volume) - volume="$2" - shift 2 - ;; - -c|--compression) - compression="$2" - shift 2 - ;; - -*) - die "invalid option '$1'" - ;; - *) - die "invalid parameter '$1'" - ;; - esac -done - -[[ -d /system ]] || die "the /system directory does not exist, please copy/mount your root filesystem here" - -build_dir=$(mktemp -d) -efi_file="$build_dir"/claylinux.efi -esp_file="$build_dir"/claylinux.esp -build_efi -mkdir -p "$(dirname "$output")" -generate_"$format" -rmdir "$build_dir" diff --git a/imager/alignaddress.go b/imager/alignaddress.go new file mode 100644 index 0000000..b52dc07 --- /dev/null +++ b/imager/alignaddress.go @@ -0,0 +1,6 @@ +package imager + +func alignAddress(value, alignment uint64) uint64 { + offset := alignment - 1 + return (value + offset) &^ offset +} diff --git a/imager/alignadress_test.go b/imager/alignadress_test.go new file mode 100644 index 0000000..81dbc94 --- /dev/null +++ b/imager/alignadress_test.go @@ -0,0 +1,25 @@ +package imager + +import ( + "testing" +) + +func TestAlignAddress(t *testing.T) { + tests := []struct { + address uint64 + alignment uint64 + expected uint64 + }{ + {0, 512, 0}, + {1, 512, 512}, + {512, 512, 512}, + {513, 512, 1024}, + } + + for _, i := range tests { + result := alignAddress(i.address, i.alignment) + if result != i.expected { + t.Fatalf("Address %d, alignement %d: expected %d, got %d", i.address, i.alignment, i.expected, result) + } + } +} diff --git a/imager/build.go b/imager/build.go new file mode 100644 index 0000000..daaf066 --- /dev/null +++ b/imager/build.go @@ -0,0 +1,66 @@ +package imager + +import ( + "fmt" + "path/filepath" + "os" + + "github.com/sprat/claylinux/imager/efi" +) + +func (i Image) Build() error { + var err error + + // show the detected EFI architecture + fmt.Printf("EFI architecture: %s\n", efi.Arch) + + // ensure that the specified RootFsDir exists + if _, err := os.Stat(i.RootFsDir); err != nil { + return fmt.Errorf("the rootfs directory %s does not exist", i.RootFsDir) + } + + // make sure the output directory exists + if err := os.MkdirAll(filepath.Dir(i.Output), 0750); err != nil { + return err + } + + // create a temporary build directory + i.BuildDir, err = os.MkdirTemp("", "claylinux-") + if err != nil { + return err + } + defer os.RemoveAll(i.BuildDir) + + // build the Unified Kernel Image + fmt.Println("Building the Unified Kernel Image...") + efiFile, err := i.buildUKI() + if err != nil { + return err + } + + fmt.Println("Writing the output") + err = os.Rename(efiFile, i.Output + ".efi") + if err != nil { + return err + } + + /* + espFile = spec.output + ".esp" + + os.MkdirAll(filepath.Dir(output), 0755) + switch format { + case "efi": + generateEFI() + case "iso": + generateISO() + case "raw": + generateRaw() + case "qcow2", "vmdk", "vhdx", "vdi": + convertImage(format) + default: + die("invalid format: " + format) + } + */ + + return nil +} diff --git a/imager/cmd/main.go b/imager/cmd/main.go new file mode 100644 index 0000000..c7e7a65 --- /dev/null +++ b/imager/cmd/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/sprat/claylinux/imager" +) + +func main() { + image := imager.Image{} + + // parse command-line arguments + flag.StringVar(&image.RootFsDir, "rootfs", "/system", "Root filesystem directory") + flag.StringVar(&image.Output, "output", "/out/claylinux", "Output image name with path, without any extension") + flag.StringVar(&image.Format, "format", "efi", "Output format (efi, iso, raw, qcow2, vmdk, vhdx, vdi)") + // flag.StringVar(&volume, "volume", "CLAYLINUX", "Volume label for the boot partition") + // flag.StringVar(&compression, "compression", "gz", "Compression format for initramfs: none|gz|xz|zstd") + flag.Parse() + + // build the image + err := image.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) + os.Exit(1) + } +} diff --git a/imager/cmdline.go b/imager/cmdline.go new file mode 100644 index 0000000..8157bd7 --- /dev/null +++ b/imager/cmdline.go @@ -0,0 +1,8 @@ +package imager + +import "path/filepath" + +func (i Image) getCmdline() string { + // TODO: space-separated + return filepath.Join(i.RootFsDir, "boot", "cmdline") +} diff --git a/imager/efi/arch_386.go b/imager/efi/arch_386.go new file mode 100644 index 0000000..cf1c79b --- /dev/null +++ b/imager/efi/arch_386.go @@ -0,0 +1,5 @@ +//go:build 386 + +package efi + +const Arch = "IA32" diff --git a/imager/efi/arch_amd64.go b/imager/efi/arch_amd64.go new file mode 100644 index 0000000..aa85891 --- /dev/null +++ b/imager/efi/arch_amd64.go @@ -0,0 +1,5 @@ +//go:build amd64 + +package efi + +const Arch = "X64" diff --git a/imager/efi/arch_arm.go b/imager/efi/arch_arm.go new file mode 100644 index 0000000..4b07a1c --- /dev/null +++ b/imager/efi/arch_arm.go @@ -0,0 +1,5 @@ +//go:build arm + +package efi + +const Arch = "ARM" diff --git a/imager/efi/arch_arm64.go b/imager/efi/arch_arm64.go new file mode 100644 index 0000000..26b3749 --- /dev/null +++ b/imager/efi/arch_arm64.go @@ -0,0 +1,5 @@ +//go:build arm64 + +package efi + +const Arch = "AA64" diff --git a/imager/efi/stub.go b/imager/efi/stub.go new file mode 100644 index 0000000..344d4fc --- /dev/null +++ b/imager/efi/stub.go @@ -0,0 +1,10 @@ +package efi + +import ( + "fmt" + "strings" +) + +func GetStubPath() string { + return fmt.Sprintf("/usr/lib/systemd/boot/efi/linux%s.efi.stub", strings.ToLower(Arch)) +} diff --git a/imager/find.go b/imager/find.go new file mode 100644 index 0000000..1a2f74d --- /dev/null +++ b/imager/find.go @@ -0,0 +1,20 @@ +package imager + +import ( + "errors" + "path/filepath" +) + +func findSingleFile(pattern string) (string, error) { + files, err := filepath.Glob(pattern) + if err != nil { + return "", err + } + if len(files) > 1 { + return "", errors.New("more than one file found") + } + if len(files) == 0 { + return "", nil + } + return files[0], nil +} diff --git a/imager/formats.go b/imager/formats.go new file mode 100644 index 0000000..02bd3aa --- /dev/null +++ b/imager/formats.go @@ -0,0 +1,76 @@ +package imager + +/* +func CreateRawDisk(printf func(string, ...any), path string, diskSize int64) error { + printf("creating raw disk of size %s", humanize.Bytes(uint64(diskSize))) + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create raw disk: %w", err) + } + + defer f.Close() //nolint:errcheck + + if err = f.Truncate(diskSize); err != nil { + return fmt.Errorf("failed to create raw disk: %w", err) + } + + if err = syscall.Fallocate(int(f.Fd()), 0, 0, diskSize); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: failed to preallocate disk space for %q (size %d): %s", path, diskSize, err) + } + + return f.Close() +} + +func generateEFI() { + fmt.Println("Copying the OS files to the output directory") + run("mv", efiFile, output+".efi") +} + +func generateESP() { + fmt.Println("Generating the EFI System Partition (ESP)") + size := getFileSize(efiFile) + size = inMib(size * 102 / 100) + fmt.Printf("The size of the ESP is: %d MiB\n", size) + run("mkfs.vfat", "-n", volume, "-F", "32", "-C", espFile, strconv.FormatInt(size<<10, 10)) + run("mmd", "-i", espFile, "::/EFI") + run("mmd", "-i", espFile, "::/EFI/boot") + run("mcopy", "-i", espFile, efiFile, "::/EFI/boot/boot"+efiEfiArch+".efi") + run("rm", efiFile) +} + +func generateISO() { + generateESP() + fmt.Println("Generating the ISO image") + run("xorrisofs", "-e", filepath.Base(espFile), + "-no-emul-boot", "-joliet", "-full-iso9660-filenames", "-rational-rock", + "-sysid", "LINUX", "-volid", volume, + "-output", output+".iso", espFile) + run("rm", espFile) +} + +func generateRaw() { + generateESP() + fmt.Println("Generating the disk image") + diskFile := output + ".img" + espMiB := getFileSizeMib(espFile) + diskSize := espMiB + 2 + fmt.Printf("The size of the disk image is: %d MiB\n", diskSize) + run("truncate", "-s", fmt.Sprintf("%dM", diskSize), diskFile) + sfdiskCmd := fmt.Sprintf("label: gpt\nfirst-lba: 34\nstart=1MiB size=%dMiB name=\"EFI system partition\" type=uefi\n", espMiB) + cmd := exec.Command("sfdisk", "--quiet", diskFile) + cmd.Stdin = strings.NewReader(sfdiskCmd) + if err := cmd.Run(); err != nil { + die("sfdisk failed: " + err.Error()) + } + run("dd", "if="+espFile, "of="+diskFile, "bs=1M", "seek=1", "conv=notrunc", "status=none") + run("rm", espFile) +} + +func convertImage(format string) { + generateRaw() + fmt.Printf("Converting the disk image to %s format\n", format) + run("qemu-img", "convert", "-f", "raw", "-O", format, output+".img", output+"."+format) + run("rm", output+".img") +} +*/ diff --git a/init/go.mod b/imager/go.mod similarity index 50% rename from init/go.mod rename to imager/go.mod index 1e6b358..201d1d8 100644 --- a/init/go.mod +++ b/imager/go.mod @@ -1,13 +1,14 @@ -module github.com/sprat/claylinux/init +module github.com/sprat/claylinux/imager -go 1.24.0 +go 1.25.0 require ( + github.com/cavaliergopher/cpio v1.0.1 github.com/otiai10/copy v1.14.1 golang.org/x/sys v0.39.0 ) require ( github.com/otiai10/mint v1.6.3 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.17.0 // indirect ) diff --git a/init/go.sum b/imager/go.sum similarity index 70% rename from init/go.sum rename to imager/go.sum index 95fa603..5fb8997 100644 --- a/init/go.sum +++ b/imager/go.sum @@ -1,9 +1,11 @@ +github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/imager/image.go b/imager/image.go new file mode 100644 index 0000000..f3822e6 --- /dev/null +++ b/imager/image.go @@ -0,0 +1,11 @@ +package imager + +type Image struct { + RootFsDir string // root filesystem containing the files to bundle into the OS image + Output string // output directory/filename + Format string // output format + // Volume string + // Compression string + + BuildDir string // the build directory use to store the temporary files we need to build the image +} diff --git a/init/main.go b/imager/init/main.go similarity index 100% rename from init/main.go rename to imager/init/main.go diff --git a/imager/initramfs.go b/imager/initramfs.go new file mode 100644 index 0000000..e949cc0 --- /dev/null +++ b/imager/initramfs.go @@ -0,0 +1,153 @@ +package imager + +import ( + _ "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/cavaliergopher/cpio" +) + +// Build and embed the init binary which will be used to switch to the image userspace +//go:generate go build -o init.bin -v --ldflags "-s -w" ./init +//go:embed init.bin +var initProgram []byte + +// Build the initramfs of the image +func (i Image) buildInitRamFs() (string, error) { + initRamFsPath := filepath.Join(i.BuildDir, "initramfs.img") + + // create the initRamFs image file + file, err := os.Create(initRamFsPath) + if err != nil { + return "", err + } + + // remember to close the file + defer file.Close() + + // create a new cpio archive + writer := cpio.NewWriter(file) + + // add the rootfs files to the archive + err = filepath.WalkDir(i.RootFsDir, func(path string, dirEntry os.DirEntry, err error) error { + if err != nil { + return err + } + + if path == i.RootFsDir { // ignore the root entry + return nil + } + + fileInfo, err := dirEntry.Info() + if err != nil { + return err + } + + name := "." + filepath.ToSlash(strings.TrimPrefix(path, i.RootFsDir)) + + switch name { // handle some special cases + case "./init": + // ignore the file, because we'll add our own ./init binary later + return nil + case "./boot": + // store the directory (so that it can be mounted in the target system), but ignore its contents + // (kernel, cmdline, ...) since it will be used to build the UKI + err = addToCpioArchive(writer, path, fileInfo, name) + if err != nil { + return err + } + return fs.SkipDir + case "./etc/hosts.target", "./etc/resolv.conf.target": + // rename the files by removing the ".target" suffix + // we need alternate names for these files because the container runtime mount these files + // in order to provide internet access to our build container. We cannot use and store + // these files in the final image as the target system may not have the same network settings + // (and it's not possible anyway). + name = strings.TrimSuffix(name, ".target") + } + + return addToCpioArchive(writer, path, fileInfo, name) + }) + if err != nil { + return "", err + } + + // add our special ./init binary + // TODO: don't create an intermediary file + initPath := filepath.Join(i.BuildDir, "init") + err = os.WriteFile(initPath, initProgram, 0755) + if err != nil { + return "", err + } + + initFileInfo, err := os.Stat(initPath) + if err != nil { + return "", err + } + + err = addToCpioArchive(writer, initPath, initFileInfo, "./init") + if err != nil { + return "", err + } + + // check the errors on close + // TODO: can we use a defer instead? + if err := writer.Close(); err != nil { + return "", err + } + + // TODO: compress + + return initRamFsPath, nil +} + +// Add a file (in Linux sense) to a CPIO archive +func addToCpioArchive(writer *cpio.Writer, path string, fileInfo fs.FileInfo, name string) error { + var err error + var data []byte + + fmt.Printf("Adding %s\n", name) + + mode := fileInfo.Mode() + + targetLink := "" + if mode & os.ModeSymlink != 0 { + // find link target + targetLink, err = os.Readlink(path) + data = []byte(targetLink) + } else if mode.IsRegular() { + // TODO: don't read the whole file in RAM? + data, err = os.ReadFile(path) + } + if err != nil { + return err + } + + header, err := cpio.FileInfoHeader(fileInfo, targetLink) + if err != nil { + return err + } + + header.Name = name + + err = writer.WriteHeader(header) + if err != nil { + return err + } + + _, err = writer.Write(data) + if err != nil { + return err + } + + err = writer.Flush() + if err != nil { + return err + } + + return nil +} diff --git a/imager/kernel.go b/imager/kernel.go new file mode 100644 index 0000000..239db45 --- /dev/null +++ b/imager/kernel.go @@ -0,0 +1,19 @@ +package imager + +import "path/filepath" + +// Find the Linux kernel file in the root filesystem +func (i Image) findKernel() (string, error) { + pattern := filepath.Join(i.RootFsDir, "boot", "vmlinu*") + return findSingleFile(pattern) +} + +// Get the kernel release information +func (i Image) getKernelRelease() (string, error) { + pattern := filepath.Join(i.RootFsDir, "lib", "modules", "*") + modulesBase, err := findSingleFile(pattern) + if err != nil { + return "", err + } + return filepath.Base(modulesBase), nil +} diff --git a/imager/pefile.go b/imager/pefile.go new file mode 100644 index 0000000..32c2bf5 --- /dev/null +++ b/imager/pefile.go @@ -0,0 +1,89 @@ +package imager + +import ( + "debug/pe" + "errors" + "fmt" + "os" + "os/exec" +) + +type PEFile struct { + Path string + alignment uint64 + nextAddress uint64 + args []string +} + +func NewPEFile(path string) (*PEFile, error) { + peFile, err := pe.Open(path) + if err != nil { + return nil, err + } + + defer peFile.Close() + + var base uint64 // should be uint32 on 32-bits platforms, but it has no consequence here + var alignment uint64 + + switch optionalHeader := peFile.OptionalHeader.(type) { + case *pe.OptionalHeader32: + base = uint64(optionalHeader.ImageBase) + alignment = uint64(optionalHeader.SectionAlignment) + case *pe.OptionalHeader64: + base = uint64(optionalHeader.ImageBase) + alignment = uint64(optionalHeader.SectionAlignment) + default: + return nil, errors.New("optional header should be present in EFI files") + } + + // sections are sorted by increasing virtual address + // so we just have to take the last one to find the next available address + lastSection := peFile.Sections[len(peFile.Sections) - 1] + nextAddress := alignAddress(base + uint64(lastSection.VirtualAddress) + uint64(lastSection.VirtualSize), alignment) + + return &PEFile{ + Path: path, + alignment: alignment, + nextAddress: nextAddress, + args: []string{}, + }, nil +} + +func (p *PEFile) AddSection(name string, path string) error { + size, err := fileSize(path) + if err != nil { + return err + } + + p.args = append( + p.args, + "--add-section", + fmt.Sprintf("%s=%s", name, path), + "--change-section-vma", + fmt.Sprintf("%s=0x%x", name, p.nextAddress), + ) + p.nextAddress = alignAddress(p.nextAddress + uint64(size), p.alignment) + return nil +} + +func (p *PEFile) Finalize(path string) error { + p.args = append(p.args, p.Path, path) + cmd := exec.Command("objcopy", p.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func fileSize(path string) (int64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + fileInfo, err := file.Stat() + if err != nil { + return 0, err + } + return fileInfo.Size(), nil +} diff --git a/imager/uki.go b/imager/uki.go new file mode 100644 index 0000000..df75aef --- /dev/null +++ b/imager/uki.go @@ -0,0 +1,79 @@ +package imager + +import ( + "os" + "path/filepath" + + "github.com/sprat/claylinux/imager/efi" +) + +// Build the Unified Kernel Image +func (i Image) buildUKI() (string, error) { + stubPath := efi.GetStubPath() + peFile, err := NewPEFile(stubPath) + if err != nil { + return "", err + } + + // cmdline + cmdline := i.getCmdline() + err = peFile.AddSection(".cmdline", cmdline) + if err != nil { + return "", err + } + + // initramfs + initRamFsPath, err := i.buildInitRamFs() + if err != nil { + return "", err + } + err = peFile.AddSection(".initrd", initRamFsPath) + if err != nil { + return "", err + } + + // kernel release + kernelRelease, err := i.getKernelRelease() + if err != nil { + return "", err + } + kernelReleasePath := filepath.Join(i.BuildDir, "kernel-release") + os.WriteFile(kernelReleasePath, []byte(kernelRelease), 0644) + err = peFile.AddSection(".uname", kernelReleasePath) + if err != nil { + return "", err + } + + // os release + osReleasePath := filepath.Join(i.RootFsDir, "etc", "os-release") + err = peFile.AddSection(".osrel", osReleasePath) + if err != nil { + return "", err + } + + // TODO: include ucode + // ucodePath, err := findSingleFile(filepath.Join(i.RootFsDir, "boot", "*-ucode.img")) + // peFile.AddSection("".ucode", ucodePath) + + // peFile.AddSection("".dtb", ...) + // peFile.AddSection("".splash", ...) + + // kernel + // Should be the last section because everything after can be overwritten by in-place kernel decompression + kernelPath, err := i.findKernel() + if err != nil { + return "", err + } + err = peFile.AddSection(".linux", kernelPath) + if err != nil { + return "", err + } + + // run objcopy + outputPath := filepath.Join(i.BuildDir, "claylinux.efi") + err = peFile.Finalize(outputPath) + if err != nil { + return "", err + } + return outputPath, nil +}