diff --git a/.github/workflows/astroarch-image.yml b/.github/workflows/astroarch-image.yml new file mode 100644 index 0000000..76414d9 --- /dev/null +++ b/.github/workflows/astroarch-image.yml @@ -0,0 +1,395 @@ +name: Build AstroArch RPi image + +# Builds the whole AstroArch chain from scratch on a GitHub-hosted runner and +# produces a bootable Raspberry Pi .img, mirroring `make prepare-rpi-img`: +# +# Dockerfile.base -> ghcr.io/devducks/archlinuxarm-basic:latest (minimal ALARM rootfs) +# Dockerfile.aarch64 -> ghcr.io/devducks/archlinuxarm:latest (kernel, ssh, network) +# Dockerfile.astroarch -> rootfs.tar (KDE + INDI stack) +# scripts/build_img.sh -> archarm-rpi-aarch64.img (partitioned disk image) +# +# The default runner is `ubuntu-24.04-arm`, which is aarch64 natively: no QEMU +# user-mode emulation is involved and the build runs at native speed. On an +# x86_64 runner the workflow registers the arm64 binfmt handler instead +# (same as `make binfmt`) and everything still works, just much slower. +# +# Nothing is pushed anywhere by default: the image is only uploaded as a +# workflow artifact. Publishing to a GitHub Release is opt-in. + +on: + workflow_dispatch: + inputs: + runner: + description: "Runner to build on" + type: choice + default: ubuntu-24.04-arm + options: + - ubuntu-24.04-arm + - ubuntu-latest + from_scratch: + description: "Rebuild base + aarch64 images from scratch (off = pull them from GHCR)" + type: boolean + default: true + image_size: + description: "Total .img size (sparse), e.g. 20G" + type: string + default: "20G" + boot_mb: + description: "FAT32 /boot partition size in MiB" + type: string + default: "768" + publish_release: + description: "Publish the image to a GitHub Release (requires a tag)" + type: boolean + default: false + # Nightly/weekly builds are intentionally NOT enabled: a full run downloads + # several GB and takes a long time. Uncomment if you want a scheduled build. + # schedule: + # - cron: '0 3 1 * *' # 03:00 on the 1st of every month + +permissions: + contents: read + +concurrency: + group: astroarch-image-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build AstroArch image + runs-on: ${{ inputs.runner || 'ubuntu-24.04-arm' }} + timeout-minutes: 350 + + env: + IMG_NAME: archarm-rpi-aarch64.img + SIZE: ${{ inputs.image_size || '20G' }} + BOOT_MB: ${{ inputs.boot_mb || '768' }} + BASIC_IMAGE: ghcr.io/devducks/archlinuxarm-basic:latest + ALARM_IMAGE: ghcr.io/devducks/archlinuxarm:latest + # Largest single part uploaded as an artifact / release asset (bytes). + # GitHub release assets top out around 2 GB, so stay below that. + SPLIT_BYTES: "1900000000" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ------------------------------------------------------------------ + # 1. Make room. A full AstroArch rootfs is ~10 GB and the .img on top + # of it another ~11 GB, so we need every gigabyte the runner has. + # ------------------------------------------------------------------ + - name: Report runner resources + run: | + set -x + uname -a + nproc + free -h || true + df -h + lsblk || true + docker version || true + + - name: Reclaim disk space + run: | + set -euo pipefail + before=$(df --output=avail -k / | tail -1) + # These are only present on the x86_64 images; guarded so the step is + # a no-op on the arm64 runner, where they do not exist. + for d in /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /usr/local/share/boost /usr/local/share/powershell \ + /usr/share/swift "${AGENT_TOOLSDIRECTORY:-}"; do + [ -n "$d" ] && [ -e "$d" ] && sudo rm -rf "$d" || true + done + sudo apt-get clean || true + after=$(df --output=avail -k / | tail -1) + echo "Reclaimed $(( (after - before) / 1024 )) MiB on /" + df -h / + + - name: Pick the working filesystem and relocate Docker if useful + id: work + run: | + set -euo pipefail + root_avail=$(df --output=avail -k / | tail -1) + mnt_avail=0 + if mountpoint -q /mnt; then + mnt_avail=$(df --output=avail -k /mnt | tail -1) + fi + echo "free on / : $((root_avail / 1024)) MiB" + echo "free on /mnt: $((mnt_avail / 1024)) MiB" + + # The ephemeral /mnt disk is not present on every runner SKU, so both + # branches have to work. When it exists and is roomier than /, move + # Docker's storage there. Docker 29 defaults to the containerd image + # store, where `data-root` alone does NOT move image layers, so + # /var/lib/containerd has to be relocated as well. + if [ "$mnt_avail" -gt "$root_avail" ] && [ "$mnt_avail" -gt 20971520 ]; then + echo "Relocating Docker storage to /mnt" + sudo systemctl stop docker.socket docker containerd || true + sudo mkdir -p /mnt/docker-data /mnt/containerd + sudo rm -rf /var/lib/docker /var/lib/containerd + sudo ln -s /mnt/containerd /var/lib/containerd + echo '{"data-root": "/mnt/docker-data"}' | sudo tee /etc/docker/daemon.json + sudo systemctl daemon-reload + sudo systemctl start containerd docker || true + if ! timeout 60 docker info >/dev/null 2>&1; then + echo "::warning::Docker did not come back after relocation, reverting" + sudo rm -f /etc/docker/daemon.json + sudo rm -f /var/lib/containerd + sudo mkdir -p /var/lib/containerd + sudo systemctl restart containerd docker + fi + docker info | grep -i "docker root dir" || true + echo "dir=/mnt/work" >> "$GITHUB_OUTPUT" + else + echo "dir=${GITHUB_WORKSPACE}/work" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare working directory and host tools + run: | + set -euo pipefail + sudo mkdir -p "${{ steps.work.outputs.dir }}" + sudo chown "$(id -u):$(id -g)" "${{ steps.work.outputs.dir }}" + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends \ + dosfstools e2fsprogs util-linux fdisk parted kpartx zstd rsync binutils + df -h + + # ------------------------------------------------------------------ + # 2. Emulation. Only needed when the runner is not already aarch64; + # this is the CI equivalent of `make binfmt`. + # ------------------------------------------------------------------ + - name: Register arm64 binfmt handler (x86_64 runners only) + if: ${{ runner.arch != 'ARM64' }} + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + # ------------------------------------------------------------------ + # 3. The build chain. Each image is tagged with exactly the reference + # the next Dockerfile has in its FROM line, so BuildKit resolves it + # from the local image store instead of pulling from GHCR. That is + # what makes the "from scratch" chain work in a single job without + # pushing anything to a registry in between. + # ------------------------------------------------------------------ + - name: Build minimal ArchLinuxARM rootfs (Dockerfile.base) + if: ${{ inputs.from_scratch != false }} + run: | + set -euo pipefail + # Retried because pacman aborts the whole transaction on a single slow + # mirror response ("Operation too slow"), which is a transient failure + # that a rerun clears; BuildKit resumes from the last good layer. + retry() { local n=0; until [ "$n" -ge 3 ]; do "$@" && return 0; n=$((n+1)); echo "::warning::attempt $n failed, retrying in 30s"; sleep 30; done; return 1; } + retry docker build \ + --platform linux/arm64 \ + -f dockerfiles/Dockerfile.base \ + --target archarm \ + -t "$BASIC_IMAGE" \ + . + docker images "$BASIC_IMAGE" + df -h / + + - name: Pull published base images instead + if: ${{ inputs.from_scratch == false }} + run: | + set -euo pipefail + docker pull --platform linux/arm64 "$BASIC_IMAGE" + + - name: Build ArchLinuxARM aarch64 image (Dockerfile.aarch64) + if: ${{ inputs.from_scratch != false }} + run: | + set -euo pipefail + retry() { local n=0; until [ "$n" -ge 3 ]; do "$@" && return 0; n=$((n+1)); echo "::warning::attempt $n failed, retrying in 30s"; sleep 30; done; return 1; } + retry docker build \ + --platform linux/arm64 \ + -f dockerfiles/Dockerfile.aarch64 \ + --target builder \ + -t "$ALARM_IMAGE" \ + . + docker images "$ALARM_IMAGE" + df -h / + + - name: Pull published aarch64 image instead + if: ${{ inputs.from_scratch == false }} + run: | + set -euo pipefail + docker pull --platform linux/arm64 "$ALARM_IMAGE" + + - name: Build AstroArch rootfs (Dockerfile.astroarch) + run: | + set -euo pipefail + retry() { local n=0; until [ "$n" -ge 2 ]; do "$@" && return 0; n=$((n+1)); echo "::warning::attempt $n failed, retrying in 60s"; sleep 60; done; return 1; } + # The `astroarch-rootfs` stage is `FROM scratch` and contains nothing + # but the tarball, so exporting it with `--output type=local` writes + # rootfs.tar straight to disk. This replaces the Makefile's + # create-rootfs-container + copy-rootfs-tar dance and avoids keeping + # a throwaway container around. + retry docker build \ + --platform linux/arm64 \ + -f dockerfiles/Dockerfile.astroarch \ + --target astroarch-rootfs \ + --output "type=local,dest=${{ steps.work.outputs.dir }}/export" \ + . + mv "${{ steps.work.outputs.dir }}/export/astroarch-rootfs.tar" \ + "${{ steps.work.outputs.dir }}/rootfs.tar" + rmdir "${{ steps.work.outputs.dir }}/export" || true + ls -lh "${{ steps.work.outputs.dir }}/rootfs.tar" + df -h + + - name: Free the Docker image store + run: | + set -euo pipefail + # rootfs.tar is on disk now; the images and the build cache hold a + # second and third copy of the same ~10 GB and are no longer needed. + docker system prune -af || true + docker builder prune -af || true + df -h + + # ------------------------------------------------------------------ + # 4. Turn the rootfs into a partitioned, bootable disk image. + # ------------------------------------------------------------------ + - name: Build the Raspberry Pi disk image + working-directory: ${{ steps.work.outputs.dir }} + run: | + set -euo pipefail + env IMG="$IMG_NAME" \ + SIZE="$SIZE" \ + BOOT_MB="$BOOT_MB" \ + ROOTFS_TAR=rootfs.tar \ + bash "$GITHUB_WORKSPACE/scripts/build_img.sh" + ls -lh "$IMG_NAME" + echo "apparent size: $(du -h --apparent-size "$IMG_NAME" | cut -f1)" + echo "on-disk size : $(du -h "$IMG_NAME" | cut -f1)" + df -h + + - name: Drop the rootfs tarball + working-directory: ${{ steps.work.outputs.dir }} + run: | + rm -f rootfs.tar + df -h + + # ------------------------------------------------------------------ + # 5. Verify the image before spending time compressing it. These are + # the checks that used to be done by hand after booting under QEMU. + # ------------------------------------------------------------------ + - name: Verify the built image + working-directory: ${{ steps.work.outputs.dir }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/scripts/verify_img.sh" "$IMG_NAME" | tee verify.txt + + # ------------------------------------------------------------------ + # 6. Compress and publish as an artifact. + # ------------------------------------------------------------------ + - name: Compress the image + id: compress + working-directory: ${{ steps.work.outputs.dir }} + run: | + set -euo pipefail + # astroarch.version is written by scripts/verify_img.sh, read out of + # the image itself (/home/astronaut/.astroarch.version). + version=$(cat astroarch.version 2>/dev/null || echo "dev") + stamp=$(date -u +%Y%m%d) + out="astroarch-${version}-${stamp}-aarch64.img.zst" + # zstd rather than xz: comparable ratio on a mostly-empty sparse image, + # but minutes instead of hours, which matters against the 6h job limit. + # No --long: it would push the decompression window past what + # rpi-imager and other flashing tools accept by default. + zstd -T0 -12 -o "$out" "$IMG_NAME" + rm -f "$IMG_NAME" + sha256sum "$out" > "$out.sha256" + ls -lh "$out" + echo "name=$out" >> "$GITHUB_OUTPUT" + echo "size=$(stat -c %s "$out")" >> "$GITHUB_OUTPUT" + df -h + + - name: Split the image if it exceeds the per-asset limit + id: split + working-directory: ${{ steps.work.outputs.dir }} + run: | + set -euo pipefail + out="${{ steps.compress.outputs.name }}" + size="${{ steps.compress.outputs.size }}" + if [ "$size" -gt "$SPLIT_BYTES" ]; then + split -b "$SPLIT_BYTES" -d --additional-suffix=.part "$out" "$out." + rm -f "$out" + { + echo "The image was split because it exceeds the GitHub asset size limit." + echo "Reassemble it with:" + echo + echo " cat ${out}.*.part > ${out}" + echo " sha256sum -c ${out}.sha256" + echo " zstd -d ${out}" + } > REASSEMBLE.txt + echo "split=true" >> "$GITHUB_OUTPUT" + else + echo "split=false" >> "$GITHUB_OUTPUT" + fi + ls -lh + + - name: Upload the image + uses: actions/upload-artifact@v4 + with: + name: astroarch-image + path: | + ${{ steps.work.outputs.dir }}/*.img.zst + ${{ steps.work.outputs.dir }}/*.part + ${{ steps.work.outputs.dir }}/*.sha256 + ${{ steps.work.outputs.dir }}/REASSEMBLE.txt + ${{ steps.work.outputs.dir }}/verify.txt + if-no-files-found: error + retention-days: 14 + # The payload is already zstd-compressed; re-zipping it only burns CPU. + compression-level: 0 + + - name: Job summary + if: always() + run: | + cd "${{ steps.work.outputs.dir }}" 2>/dev/null || true + { + echo "## AstroArch image build" + echo + echo "| | |" + echo "|---|---|" + echo "| Runner | \`${{ runner.os }}/${{ runner.arch }}\` (\`${{ inputs.runner || 'ubuntu-24.04-arm' }}\`) |" + echo "| From scratch | ${{ inputs.from_scratch != false }} |" + echo "| Image size | ${SIZE} (boot ${BOOT_MB} MiB) |" + echo "| Artifact | \`${{ steps.compress.outputs.name }}\` |" + echo "| Split | ${{ steps.split.outputs.split }} |" + echo + if [ -f verify.txt ]; then + echo "### Verification" + echo '```' + cat verify.txt + echo '```' + fi + echo "### Disk" + echo '```' + df -h + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish release + needs: build + if: ${{ inputs.publish_release == true && startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download the image + uses: actions/download-artifact@v4 + with: + name: astroarch-image + path: dist + + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + # Created as a draft on purpose: someone has to look at the image + # before it goes out to users. + gh release create "$tag" dist/* \ + --draft \ + --generate-notes \ + --title "AstroArch $tag" diff --git a/README.md b/README.md index 49e63aa..4e62a57 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,22 @@ Adjust mirrors by editing the relevant Dockerfile. `.github/workflows/buildx.yml` builds and pushes the minimal and aarch64 images to GHCR (`ghcr.io//archlinuxarm-basic` and `ghcr.io//archlinuxarm`) on pushes to `main`, on version tags, weekly on a schedule, and on manual dispatch. Pull requests build without pushing. +`.github/workflows/astroarch-image.yml` builds the **whole chain from scratch** in a single job and produces the bootable Raspberry Pi image. It is the CI equivalent of `make prepare-rpi-img`, with no manual QEMU step: `Dockerfile.base` → `Dockerfile.aarch64` → `Dockerfile.astroarch` → `scripts/build_img.sh` → `archarm-rpi-aarch64.img`, verified by `scripts/verify_img.sh` and uploaded as a zstd-compressed workflow artifact. + +Run it from the Actions tab (**Build AstroArch RPi image** → *Run workflow*). Inputs: + +| Input | Default | Description | +|---|---|---| +| `runner` | `ubuntu-24.04-arm` | Build host. The arm64 runner is aarch64 natively, so no QEMU emulation is involved; it is free and unlimited on public repositories. Picking `ubuntu-latest` falls back to binfmt emulation, which works but is several times slower. | +| `from_scratch` | `true` | Rebuild the base and aarch64 images in the same run. Set to `false` to pull them from GHCR and only rebuild AstroArch, which is much faster when iterating. | +| `image_size` | `20G` | Total (sparse) size of the `.img`. | +| `boot_mb` | `768` | Size of the FAT32 `/boot` partition, in MiB. | +| `publish_release` | `false` | Attach the image to a **draft** GitHub Release. Only takes effect on a tag. | + +Nothing is pushed to a registry and no release is published unless you ask for it. Intermediate images are never pushed: each one is tagged locally with exactly the reference the next `FROM` line uses, so BuildKit resolves it from the local image store. + +The image is compressed with `zstd` and split into <1.9 GB parts if needed; reassemble with `cat .*.part > ` before decompressing. + ## Project layout ``` @@ -154,7 +170,8 @@ Adjust mirrors by editing the relevant Dockerfile. │ └── Dockerfile.astroarch ├── scripts/ │ ├── build_img.sh -│ └── start_qemu.sh +│ ├── start_qemu.sh +│ └── verify_img.sh ├── Makefile └── README.md ``` diff --git a/dockerfiles/Dockerfile.astroarch b/dockerfiles/Dockerfile.astroarch index 8191877..65ffc07 100644 --- a/dockerfiles/Dockerfile.astroarch +++ b/dockerfiles/Dockerfile.astroarch @@ -1,3 +1,88 @@ +############################################### +# PHD2, built from source with OpenCV unlinked +############################################### +# The [astromatto] phd2 package is linked against opencv 4.13. Arch has since +# replaced opencv 4 with opencv 5, so the 57 libopencv_*.so.413 that binary +# asks for exist in no aarch64 repo any more and it dies on startup. The +# package does not declare an `opencv` dependency either, so pacman resolves +# it cleanly and the breakage only surfaces the first time a user launches +# PHD2. +# +# PHD2 does not actually need OpenCV: it pulls in exactly four symbols +# (cv::VideoCapture, cv::Mat's ctor/dtor, cv::cvtColor), all from +# src/cam_opencv.cpp, which is entirely inside `#ifdef OPENCV_CAMERA` - the +# webcam driver, redundant on AstroArch next to the INDI V4L2 driver and the +# vendor SDKs. Deleting that one define and the unconditional +# find_package(OpenCV) drops the OpenCV link completely, so the binary stops +# tracking opencv sonames for good. +# +# Built in its own stage so the toolchain never reaches the rootfs; only the +# install tree is copied over. The trade-off is that these files are not owned +# by any pacman package, so `pacman -Syu` on the device will not update them. +FROM ghcr.io/devducks/archlinuxarm:latest AS phd2-builder + +ARG PHD2_VERSION=2.6.14 + +# Dockerfile.aarch64 removes /etc/pacman.d/gnupg at the end of its build, so +# the keyring has to be set up again before anything can be installed here. +RUN pacman-key --init && pacman-key --populate archlinuxarm + +# The same [astromatto] repo the runtime stage uses. PHD2 has to be compiled +# against the exact cfitsio and libindi the image will ship, not against the +# newer copies in [extra]: linking to a library the target system does not +# have is the whole bug this stage exists to fix. +RUN sed -i 's|\[core\]|\[astromatto\]\nSigLevel = Optional TrustAll\nServer = http://astroarch.astromatto.com:9000/$arch\n\n\[core\]|' /etc/pacman.conf + +# Same reason as in the runtime stage: pacman aborts a whole transaction when a +# single mirror stalls, which on these long downloads happens often enough to +# matter. +RUN sed -i 's|ParallelDownloads = 5|ParallelDownloads = 5\nDisableDownloadTimeout|g' /etc/pacman.conf + +# extra/libgphoto2 is pinned for the same reason as in the runtime stage +# below: [astromatto]'s copy still depends on the bare `libjpeg` virtual that +# libjpeg-turbo no longer provides, and it drags libindi down with it. +RUN pacman -Syu --noconfirm && \ + pacman -S --needed --noconfirm \ + extra/libgphoto2 \ + cfitsio cmake curl eigen gcc gettext git gtest libindi libnova libusb \ + libx11 make pkgconf wxwidgets-gtk3 zlib + +ADD https://github.com/OpenPHDGuiding/phd2/archive/refs/tags/v${PHD2_VERSION}.tar.gz /tmp/phd2.tar.gz +RUN mkdir -p /tmp/phd2 && tar -xzf /tmp/phd2.tar.gz -C /tmp/phd2 --strip-components=1 + +# The patch is asserted before it is applied: if upstream moves these lines the +# build has to stop here, rather than quietly producing another opencv-linked +# binary that only fails on the user's Pi. cameras.h defines OPENCV_CAMERA +# twice, once per platform; only the __linux__ branch is touched. +RUN cd /tmp/phd2 && \ + grep -q '^# elif defined(__linux__)' src/cameras.h && \ + grep -q '^ find_package( OpenCV REQUIRED )$' CMakeLists.txt && \ + grep -q '^ target_link_libraries(phd2 X11 ${OpenCV_LIBS})$' CMakeLists.txt && \ + sed -i '/^# elif defined(__linux__)/,$ { /^# define OPENCV_CAMERA$/d }' src/cameras.h && \ + sed -i 's/^ find_package( OpenCV REQUIRED )$//; s/^ target_link_libraries(phd2 X11 ${OpenCV_LIBS})$/ target_link_libraries(phd2 X11)/' CMakeLists.txt && \ + ! grep -q 'OpenCV' CMakeLists.txt + +# USE_SYSTEM_* keeps cmake from downloading and building its own copies of +# libraries the image already has. CMAKE_POLICY_VERSION_MINIMUM is needed +# because PHD2 still declares an older cmake_minimum_required than current +# cmake accepts by default. +RUN mkdir -p /tmp/phd2/build && cd /tmp/phd2/build && \ + cmake -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DUSE_SYSTEM_LIBINDI=ON \ + -DUSE_SYSTEM_LIBUSB=ON \ + -DUSE_SYSTEM_GTEST=ON \ + -DEIGEN_SRC=/usr/include/eigen3 \ + .. && \ + make -j"$(nproc)" && \ + make install DESTDIR=/phd2-root && \ + rm -rf /phd2-root/usr/include + +# The whole point of this stage, checked rather than assumed. +RUN readelf -d /phd2-root/usr/bin/phd2.bin | grep -q '(NEEDED)' && \ + ! readelf -d /phd2-root/usr/bin/phd2.bin | grep -i opencv + FROM ghcr.io/devducks/archlinuxarm:latest AS builder # Push the astromatto repo so we can pull whatever package into docker immediately @@ -15,7 +100,15 @@ RUN pacman -Syu --noconfirm RUN mkdir -p /run/dbus && dbus-daemon --system --fork +# libgphoto2 is pinned to the official package on purpose. The copy in the +# [astromatto] repo is from May 2024 and still depends on the bare `libjpeg` +# virtual, which libjpeg-turbo stopped providing; because [astromatto] is +# ordered above [extra], pacman picks that stale copy and the whole +# transaction fails to resolve, taking libindi, indi-3rdparty-* and kstars +# with it. Dropping libgphoto2 from [astromatto] would make this line +# unnecessary. RUN pacman -Sy \ + extra/libgphoto2 \ arandr \ astroarch-bridge \ astroarch-onboarding \ @@ -59,6 +152,7 @@ RUN pacman -Sy \ kstars \ ksystemlog \ kwalletmanager \ + libnova \ neofetch \ networkmanager \ network-manager-applet \ @@ -68,7 +162,6 @@ RUN pacman -Sy \ packagekit-qt6 \ pacman-contrib \ paru \ - phd2 \ pipewire-jack \ plasma-desktop \ plasma-nm \ @@ -90,6 +183,7 @@ RUN pacman -Sy \ usbutils \ websockify \ wireplumber \ + wxwidgets-gtk3 \ xf86-video-dummy \ xf86-video-fbdev \ xorg \ @@ -100,6 +194,11 @@ RUN pacman -Sy \ zsh \ --noconfirm +# PHD2 from the stage above: /usr/bin/phd2 (wrapper), /usr/bin/phd2.bin and the +# vendor camera SDKs in /usr/lib/phd2. libnova and wxwidgets-gtk3 are in the +# list above for it, since no package declares them any more. +COPY --from=phd2-builder /phd2-root/ / + RUN curl -O https://raw.githubusercontent.com/devDucks/astroarch/refs/heads/main/astroarch_build.sh && \ sed -i '/\/etc\/hosts/d; /\/etc\/hostname/d;' astroarch_build.sh diff --git a/scripts/build_img.sh b/scripts/build_img.sh index 81145ff..72cb4f3 100755 --- a/scripts/build_img.sh +++ b/scripts/build_img.sh @@ -29,11 +29,19 @@ ${IMG}1 : start=${BOOT_START}, size=${BOOT_SIZE}, type=c ${IMG}2 : start=$((BOOT_START+BOOT_SIZE)), type=83 EOF -# --- map loop with partitions --- -LOOP=$(sudo losetup --find --show --partscan "$IMG") -BOOT_DEV=${LOOP}p1 -ROOT_DEV=${LOOP}p2 -PARTUUID=$(sudo blkid -s PARTUUID -o value "$ROOT_DEV") +# --- map each partition to its own loop device --- +# One loop device per partition (--offset/--sizelimit) rather than a single +# --partscan device: /dev/loopNp* nodes depend on udev and are not created +# reliably inside containers or on GitHub-hosted runners. +BOOT_DEV=$(sudo losetup --find --show \ + --offset $(( BOOT_START * 512 )) --sizelimit $(( BOOT_SIZE * 512 )) "$IMG") +ROOT_DEV=$(sudo losetup --find --show \ + --offset $(( (BOOT_START + BOOT_SIZE) * 512 )) "$IMG") + +# With no whole-disk device there is nothing for blkid to read the PARTUUID +# from, but on an MBR disk the kernel derives it from the disk identifier: +# -<2-digit partition number>. Partition 2 is the root filesystem. +PARTUUID="$(sudo sfdisk --disk-id "$IMG" | tr -d '\n' | sed 's/^0[xX]//' | tr '[:upper:]' '[:lower:]')-02" # --- mkfs --- sudo mkfs.vfat -F 32 -n "$BOOT_LABEL" "$BOOT_DEV" @@ -109,6 +117,6 @@ sync # --- unmount & detach --- sudo umount /mnt/arch-boot || true sudo umount /mnt/arch-root || true -sudo losetup -d "$LOOP" +sudo losetup -d "$BOOT_DEV" "$ROOT_DEV" echo "OK: ${IMG} is ready." diff --git a/scripts/verify_img.sh b/scripts/verify_img.sh new file mode 100755 index 0000000..cf3f96e --- /dev/null +++ b/scripts/verify_img.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# Sanity-check a built AstroArch .img without booting it. +# +# The partitions are mounted read-only by byte offset rather than through +# `losetup --partscan`, because partition device nodes (/dev/loopNp1) are not +# reliably created on GitHub-hosted runners. +# +# Usage: ./scripts/verify_img.sh [image] +# +# Exits non-zero if a check marked FAIL does not pass. Checks marked WARN are +# reported but do not fail the run. + +set -euo pipefail + +IMG=${1:-archarm-rpi-aarch64.img} +ROOT_MNT=${ROOT_MNT:-/mnt/verify-root} +BOOT_MNT=${BOOT_MNT:-/mnt/verify-boot} + +[ -f "$IMG" ] || { echo "Missing $IMG"; exit 1; } + +failures=0 +warnings=0 + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); } +warn() { printf ' warn %s\n' "$1"; warnings=$((warnings + 1)); } + +# Resolve a path the way the booted system would. Much of what AstroArch +# installs is a symlink into /home/astronaut/.astroarch, and those targets are +# absolute: following them from the host would resolve against the host root +# and report everything as missing. +resolve_in_root() { + local p="$1" target i=0 + while [ "$i" -lt 40 ] && sudo test -L "$ROOT_MNT$p"; do + target=$(sudo readlink "$ROOT_MNT$p") + case "$target" in + /*) p="$target" ;; + *) p="$(dirname "$p")/$target" ;; + esac + i=$((i + 1)) + done + printf '%s' "$p" +} + +exists_in_root() { sudo test -e "$ROOT_MNT$(resolve_in_root "$1")"; } + +check() { # check + if exists_in_root "/$2"; then pass "$1"; else fail "$1 (missing $2)"; fi +} + +check_warn() { + if exists_in_root "/$2"; then pass "$1"; else warn "$1 (missing $2)"; fi +} + +# Every soname the image actually ships, indexed once. /usr/lib is searched +# recursively on purpose: some packages keep private libraries in a +# subdirectory and put it on LD_LIBRARY_PATH from a wrapper script (PHD2 does +# this for its camera SDKs in /usr/lib/phd2), and a sanity check does not need +# to model the loader's search order to catch a library that is nowhere at all. +SONAME_INDEX=${SONAME_INDEX:-$(mktemp)} +build_soname_index() { + local d + : > "$SONAME_INDEX" + # One directory at a time, and never fatal: ArchLinuxARM has no /usr/lib64, + # and find exits non-zero for a missing starting point even with its stderr + # discarded, which under `set -e -o pipefail` would kill the whole script. + for d in /usr/lib /usr/lib64 /lib; do + sudo test -d "$ROOT_MNT$d" || continue + sudo find "$ROOT_MNT$d" -name '*.so*' -printf '%f\n' 2>/dev/null \ + >> "$SONAME_INDEX" || true + done + sort -u -o "$SONAME_INDEX" "$SONAME_INDEX" +} + +# check_links +# +# `check` only proves that a file is there. A binary can be present and still +# refuse to start, because the repo it was built against has moved on to a new +# library soname: when Arch went from opencv 4 to opencv 5 every +# libopencv_*.so.413 became .so.500, and any prebuilt package that was not +# rebuilt keeps asking for the old name. pacman does not catch it either when +# the package fails to declare the dependency, so the build succeeds and the +# breakage only shows up the first time a user launches the program. +# +# readelf parses aarch64 ELF headers on any host, so this needs neither a +# chroot nor QEMU. +check_links() { + local desc="$1" rel file needed missing="" n=0 soname + rel=$(resolve_in_root "/$2") + file="$ROOT_MNT$rel" + + if ! sudo test -f "$file"; then + fail "$desc links (missing $2)" + return + fi + if [ "$(sudo dd if="$file" bs=4 count=1 status=none | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then + # /usr/bin/phd2 is a shell wrapper around /usr/bin/phd2.bin, not an ELF. + warn "$desc links: $2 is not an ELF binary, skipped" + return + fi + + # `|| true` for the same reason as above: a readelf failure has to reach the + # warn below, not abort the run. + needed=$(sudo readelf -d "$file" 2>/dev/null | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' | sort -u || true) + if [ -z "$needed" ]; then + # Either readelf is missing from the runner or the file is statically + # linked. Both are worth a look, and neither is something to pass silently. + warn "$desc links: no DT_NEEDED entries read from $2" + return + fi + + for soname in $needed; do + n=$((n + 1)) + grep -qxF "$soname" "$SONAME_INDEX" || missing="$missing $soname" + done + + if [ -z "$missing" ]; then + pass "$desc links: $n shared libraries all resolve" + else + # Truncated, because a single stale opencv link accounts for 57 entries and + # would bury every other check in the report. + local count + count=$(printf '%s\n' $missing | wc -l | tr -d ' ') + fail "$desc links: $count of $n shared libraries are not in the image" + printf '%s\n' $missing | sort | sed -n '1,8s/^/ /p' + if [ "$count" -gt 8 ]; then + printf ' ... and %s more\n' "$((count - 8))" + fi + fi +} + +cleanup() { + sudo umount "$BOOT_MNT" 2>/dev/null || true + sudo umount "$ROOT_MNT" 2>/dev/null || true + rm -f "$SONAME_INDEX" +} +trap cleanup EXIT + +# --- partition geometry, straight from the partition table --- +# partx prints plain sector numbers, which avoids parsing sfdisk's padded +# `start= 2048,` syntax. +geom=$(partx -g -o NR,START,SECTORS "$IMG") +BOOT_START=$(echo "$geom" | awk '$1 == 1 { print $2 }') +BOOT_SIZE=$(echo "$geom" | awk '$1 == 1 { print $3 }') +ROOT_START=$(echo "$geom" | awk '$1 == 2 { print $2 }') + +if [ -z "$BOOT_START" ] || [ -z "$BOOT_SIZE" ] || [ -z "$ROOT_START" ]; then + echo "Could not read the partition table of $IMG" + partx -o NR,START,SECTORS "$IMG" || true + exit 1 +fi + +echo "== partition table ==" +echo " boot: start=${BOOT_START}s size=${BOOT_SIZE}s ($(( BOOT_SIZE / 2048 )) MiB)" +echo " root: start=${ROOT_START}s" + +sudo mkdir -p "$ROOT_MNT" "$BOOT_MNT" +sudo mount -o ro,loop,offset=$(( ROOT_START * 512 )) "$IMG" "$ROOT_MNT" +sudo mount -o ro,loop,offset=$(( BOOT_START * 512 )),sizelimit=$(( BOOT_SIZE * 512 )) "$IMG" "$BOOT_MNT" + +echo +echo "== filesystem usage ==" +df -h "$ROOT_MNT" "$BOOT_MNT" | sed 's/^/ /' + +echo +echo "== boot partition ==" +if sudo test -e "$BOOT_MNT/kernel8.img" || sudo test -e "$BOOT_MNT/Image"; then + pass "a kernel is present" +else + fail "no kernel8.img or Image on the boot partition" +fi +if sudo test -e "$BOOT_MNT/config.txt"; then pass "config.txt"; else fail "config.txt"; fi +if sudo test -e "$BOOT_MNT/cmdline.txt"; then + cmdline=$(sudo cat "$BOOT_MNT/cmdline.txt") + echo " cmdline: $cmdline" + # build_img.sh rewrites root= with the real PARTUUID; astroarch_build.sh + # leaves the QEMU /dev/vda2 UUID behind, which would not boot on a Pi. + part_uuid=$(printf '%s' "$cmdline" | sed -n 's/.*root=PARTUUID=\([^ ]*\).*/\1/p') + if [ -n "$part_uuid" ]; then + pass "cmdline.txt uses root=PARTUUID=$part_uuid" + else + fail "cmdline.txt does not set root=PARTUUID= (found: $cmdline)" + fi +else + fail "cmdline.txt" +fi +if sudo test -d "$BOOT_MNT/overlays"; then pass "device tree overlays"; else warn "no overlays/ directory"; fi + +echo +echo "== users ==" +for u in astronaut astronaut-kiosk; do + if sudo grep -q "^$u:" "$ROOT_MNT/etc/passwd"; then pass "user $u"; else fail "user $u"; fi +done +if sudo grep -q "^astronaut:" "$ROOT_MNT/etc/shadow" && \ + [ "$(sudo awk -F: '/^astronaut:/ {print $2}' "$ROOT_MNT/etc/shadow")" != "" ]; then + pass "astronaut has a password set" +else + fail "astronaut has no password" +fi +check "astronaut home" home/astronaut +check "oh-my-zsh" home/astronaut/.oh-my-zsh +check "astroarch checkout" home/astronaut/.astroarch + +echo +echo "== system identity ==" +hostname=$(sudo cat "$ROOT_MNT/etc/hostname" 2>/dev/null || echo "") +if [ "$hostname" = "astroarch" ]; then pass "hostname=astroarch"; else fail "hostname is '$hostname'"; fi +if sudo grep -q astroarch "$ROOT_MNT/etc/hosts"; then pass "/etc/hosts"; else fail "/etc/hosts has no astroarch entry"; fi +if sudo grep -q " / " "$ROOT_MNT/etc/fstab" && sudo grep -q " /boot " "$ROOT_MNT/etc/fstab"; then + pass "fstab has / and /boot" +else + fail "fstab is incomplete" +fi +echo " fstab:"; sudo sed 's/^/ /' "$ROOT_MNT/etc/fstab" + +version="" +if exists_in_root /home/astronaut/.astroarch.version; then + version=$(sudo cat "$ROOT_MNT$(resolve_in_root /home/astronaut/.astroarch.version)" 2>/dev/null | tr -d '[:space:]') + pass "AstroArch version ${version:-unknown}" + printf '%s' "${version:-dev}" > astroarch.version +else + warn "no .astroarch.version" + printf 'dev' > astroarch.version +fi + +echo +echo "== boot target ==" +default_target=$(sudo readlink "$ROOT_MNT/etc/systemd/system/default.target" 2>/dev/null || \ + sudo readlink "$ROOT_MNT/usr/lib/systemd/system/default.target" 2>/dev/null || echo "") +if [ -z "$default_target" ]; then + # No override means systemd falls back to its compiled-in default, which is + # graphical.target on Arch. Worth flagging rather than failing. + warn "default.target is not overridden; systemd's built-in default applies" +else + case "$default_target" in + *graphical.target) pass "default.target -> graphical.target" ;; + *) warn "default.target -> $default_target (SDDM will not start)" ;; + esac +fi + +echo +echo "== enabled units ==" +for unit in graphical.target.wants/sddm.service \ + multi-user.target.wants/NetworkManager.service \ + multi-user.target.wants/xrdp.service \ + multi-user.target.wants/xrdp-sesman.service \ + multi-user.target.wants/smb.service \ + multi-user.target.wants/chronyd.service \ + multi-user.target.wants/novnc.service \ + multi-user.target.wants/resize_once.service; do + link="/etc/systemd/system/$unit" + if sudo test -L "$ROOT_MNT$link"; then + if exists_in_root "$link"; then + pass "$unit" + else + # An enabled unit whose target file does not exist means the package + # providing it was never installed; systemd will log a failure at boot. + warn "$unit is enabled but dangling -> $(sudo readlink "$ROOT_MNT$link")" + fi + else + warn "$unit is not enabled" + fi +done +if exists_in_root /etc/systemd/system/multi-user.target.wants/sshd.service; then + pass "sshd.service" +else + warn "sshd.service is not enabled" +fi + +echo +echo "== astrophotography stack ==" +check "KStars" usr/bin/kstars +check "PHD2" usr/bin/phd2 +check "indiserver" usr/bin/indiserver +check "solve-field" usr/bin/solve-field +check_warn "noVNC" usr/share/webapps/novnc +check_warn "x0vncserver" usr/bin/x0vncserver + +echo +echo "== shared library resolution ==" +build_soname_index +echo " $(wc -l < "$SONAME_INDEX" | tr -d ' ') sonames indexed under /usr/lib, /usr/lib64 and /lib" +check_links "KStars" usr/bin/kstars +check_links "PHD2" usr/bin/phd2.bin +check_links "indiserver" usr/bin/indiserver +check_links "solve-field" usr/bin/solve-field + +idx_dir="$ROOT_MNT/home/astronaut/.local/share/kstars/astrometry" +if sudo test -d "$idx_dir"; then + n=$(sudo find "$idx_dir" -name 'index-*.fits' | wc -l) + bytes=$(sudo du -sm "$idx_dir" | cut -f1) + if [ "$n" -ge 100 ]; then + pass "astrometry indexes: $n files, ${bytes} MiB" + else + fail "only $n astrometry index files (expected >= 100)" + fi +else + fail "no astrometry index directory" +fi + +echo +echo "== desktop ==" +check "SDDM config" etc/sddm.conf.d/kde_settings.conf +check_warn "Plasma X11" usr/share/xsessions/plasmax11.desktop +check_warn "xorg.conf" etc/X11/xorg.conf +check_warn "AstroArch look-and-feel" usr/share/plasma/look-and-feel/astroarch + +echo +echo "== summary ==" +echo " failures: $failures" +echo " warnings: $warnings" +[ "$failures" -eq 0 ] || { echo "Image verification FAILED"; exit 1; } +echo "Image verification passed."