From 4bd2ef5009ce4b484af579486efbe4927aa7e1be Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 5 Sep 2026 19:30:12 +0200 Subject: [PATCH 1/6] Map image partitions with per-partition loop devices build_img.sh relied on `losetup --partscan` and the /dev/loopNp1, /dev/loopNp2 nodes it is supposed to create. Those nodes are created by udev, which is not guaranteed to be running: inside containers they never appear, and on GitHub-hosted runners they are missing often enough to have an open bug report against them. The script then failed at mkfs with "No such file or directory". Attaching one loop device per partition with --offset/--sizelimit needs no udev at all and works the same way everywhere. The root PARTUUID, which used to come from `blkid` on the partition node, is now derived from the MBR disk identifier, which is exactly how the kernel builds it: -. --- scripts/build_img.sh | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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." From e6203a67f2e7f7d0d069ca0894a2d14466edf970 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 5 Sep 2026 19:35:21 +0200 Subject: [PATCH 2/6] Build the AstroArch image from scratch in CI The AstroArch image was still assembled by hand: build the rootfs locally, write it to a disk image, boot that image under QEMU and finish the job from inside the running system. Since the build script upstream stopped relying on systemctl, none of that needs a booted system any more, so the whole chain fits in one CI job. astroarch-image.yml runs `make prepare-rpi-img` end to end: Dockerfile.base, then Dockerfile.aarch64, then Dockerfile.astroarch, then build_img.sh. Each image is tagged with exactly the reference the next FROM line uses, so BuildKit takes it from the local image store and no intermediate image has to be pushed to a registry to make the chain work. It defaults to ubuntu-24.04-arm, which is aarch64 natively: no QEMU user-mode emulation, and free on public repositories. On an x86_64 runner it registers the arm64 binfmt handler instead and still works. Two things the runners force: the AstroArch rootfs plus the image on top of it come close to the free space on a runner, so the working files go on whichever filesystem has more room, Docker's storage is moved along with them when that helps, and every intermediate copy is deleted as soon as it is no longer needed; and pacman aborts its whole transaction when a single mirror response is slow, so the image builds are retried. verify_img.sh checks the result the way the QEMU boot used to: it mounts both partitions and asserts that the kernel, the users, the enabled units, the astrometry indexes and the rewritten root=PARTUUID are all in place. --- .github/workflows/astroarch-image.yml | 395 ++++++++++++++++++++++++++ README.md | 19 +- scripts/verify_img.sh | 213 ++++++++++++++ 3 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/astroarch-image.yml create mode 100755 scripts/verify_img.sh diff --git a/.github/workflows/astroarch-image.yml b/.github/workflows/astroarch-image.yml new file mode 100644 index 0000000..9b9e6fa --- /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 + 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/scripts/verify_img.sh b/scripts/verify_img.sh new file mode 100755 index 0000000..01fec02 --- /dev/null +++ b/scripts/verify_img.sh @@ -0,0 +1,213 @@ +#!/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)); } + +check() { # check + if sudo test -e "$ROOT_MNT/$2"; then pass "$1"; else fail "$1 (missing $2)"; fi +} + +check_warn() { + if sudo test -e "$ROOT_MNT/$2"; then pass "$1"; else warn "$1 (missing $2)"; fi +} + +cleanup() { + sudo umount "$BOOT_MNT" 2>/dev/null || true + sudo umount "$ROOT_MNT" 2>/dev/null || true +} +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 sudo test -e "$ROOT_MNT/home/astronaut/.astroarch.version"; then + version=$(sudo cat "$ROOT_MNT/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 || 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="$ROOT_MNT/etc/systemd/system/$unit" + if sudo test -L "$link"; then + target=$(sudo readlink "$link") + # These symlinks point at absolute paths inside the image, so they have to + # be resolved against the mount point and not against the host root. + case "$target" in + /*) resolved="$ROOT_MNT$target" ;; + *) resolved="$(dirname "$link")/$target" ;; + esac + if sudo test -e "$resolved"; 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 -> $target" + fi + else + warn "$unit is not enabled" + fi +done +if sudo test -e "$ROOT_MNT/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 + +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." From 4d079821ed214fb50f27cda63312cd9ff91656d1 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 5 Sep 2026 22:47:21 +0200 Subject: [PATCH 3/6] Take libgphoto2 from extra, not from astromatto The AstroArch package install cannot resolve at all right now: :: unable to satisfy dependency 'libjpeg' required by libgphoto2 :: unable to satisfy dependency 'libgphoto2' required by libindi :: unable to satisfy dependency 'libindi' required by kstars error: failed to prepare transaction (could not satisfy dependencies) The [astromatto] repo carries libgphoto2 2.5.30-1, built in May 2024, whose dependency list still names the bare `libjpeg` virtual. Current libjpeg-turbo only provides `libjpeg.so=8-64`, so nothing satisfies it. Since the Dockerfile inserts [astromatto] above [core] and [extra], pacman prefers that copy over extra's 2.5.34-1, which depends on libjpeg-turbo and resolves fine, and the whole transaction dies along with libindi, indi-3rdparty-* and kstars. Asking for extra/libgphoto2 explicitly is the smallest fix that unblocks the build. Removing libgphoto2 from [astromatto] would be the better one, and would make this line unnecessary. --- dockerfiles/Dockerfile.astroarch | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dockerfiles/Dockerfile.astroarch b/dockerfiles/Dockerfile.astroarch index 8191877..811b0ff 100644 --- a/dockerfiles/Dockerfile.astroarch +++ b/dockerfiles/Dockerfile.astroarch @@ -15,7 +15,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 \ From 129bbb7fb264f25731d85f1d7f1cfb168ea184b0 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 5 Sep 2026 23:09:01 +0200 Subject: [PATCH 4/6] Resolve image symlinks against the image, not the host Most of what AstroArch installs is a symlink into /home/astronaut/.astroarch, and those targets are absolute. Testing them from the host followed them to the host's own /home and /usr, so the checks reported the SDDM config as missing, sshd as disabled and novnc as dangling on an image where all three were fine. Paths are now walked link by link with absolute targets re-rooted at the mount point, which is how the booted system would resolve them. The default.target check also falls back to systemd's own /usr/lib/systemd/system/default.target, since an image that never overrides it still boots into whatever that points at. --- scripts/verify_img.sh | 47 +++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/scripts/verify_img.sh b/scripts/verify_img.sh index 01fec02..f72e491 100755 --- a/scripts/verify_img.sh +++ b/scripts/verify_img.sh @@ -25,12 +25,31 @@ 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 sudo test -e "$ROOT_MNT/$2"; then pass "$1"; else fail "$1 (missing $2)"; fi + if exists_in_root "/$2"; then pass "$1"; else fail "$1 (missing $2)"; fi } check_warn() { - if sudo test -e "$ROOT_MNT/$2"; then pass "$1"; else warn "$1 (missing $2)"; fi + if exists_in_root "/$2"; then pass "$1"; else warn "$1 (missing $2)"; fi } cleanup() { @@ -117,8 +136,8 @@ fi echo " fstab:"; sudo sed 's/^/ /' "$ROOT_MNT/etc/fstab" version="" -if sudo test -e "$ROOT_MNT/home/astronaut/.astroarch.version"; then - version=$(sudo cat "$ROOT_MNT/home/astronaut/.astroarch.version" 2>/dev/null | tr -d '[:space:]') +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 @@ -128,7 +147,8 @@ fi echo echo "== boot target ==" -default_target=$(sudo readlink "$ROOT_MNT/etc/systemd/system/default.target" 2>/dev/null || echo "") +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. @@ -150,27 +170,20 @@ for unit in graphical.target.wants/sddm.service \ multi-user.target.wants/chronyd.service \ multi-user.target.wants/novnc.service \ multi-user.target.wants/resize_once.service; do - link="$ROOT_MNT/etc/systemd/system/$unit" - if sudo test -L "$link"; then - target=$(sudo readlink "$link") - # These symlinks point at absolute paths inside the image, so they have to - # be resolved against the mount point and not against the host root. - case "$target" in - /*) resolved="$ROOT_MNT$target" ;; - *) resolved="$(dirname "$link")/$target" ;; - esac - if sudo test -e "$resolved"; then + 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 -> $target" + warn "$unit is enabled but dangling -> $(sudo readlink "$ROOT_MNT$link")" fi else warn "$unit is not enabled" fi done -if sudo test -e "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/sshd.service"; then +if exists_in_root /etc/systemd/system/multi-user.target.wants/sshd.service; then pass "sshd.service" else warn "sshd.service is not enabled" From 24c6083d5788ace562543c2b7aadfc70be3b8d13 Mon Sep 17 00:00:00 2001 From: teoteo Date: Mon, 7 Sep 2026 15:10:33 +0200 Subject: [PATCH 5/6] Build PHD2 from source with OpenCV unlinked The [astromatto] phd2 package is linked against opencv 4.13, which Arch has replaced with opencv 5. All 57 libopencv_*.so.413 it asks for are gone from every aarch64 repo, so the shipped binary cannot start. Nothing catches this: the package declares no `opencv` dependency, pacman resolves the transaction cleanly, and kstars pulls in opencv 5 anyway, so the image builds green and PHD2 only fails on the user's Pi. PHD2 does not need OpenCV. It references exactly four symbols (cv::VideoCapture, cv::Mat's ctor and dtor, cv::cvtColor), all from src/cam_opencv.cpp, which is entirely inside `#ifdef OPENCV_CAMERA` - the webcam driver, redundant next to the INDI V4L2 driver and the vendor SDKs. Deleting that define and the unconditional find_package(OpenCV) drops the OpenCV link, so the binary no longer tracks opencv sonames at all. PHD2 2.6.14 is therefore compiled in its own stage, which uses the same [astromatto] repo as the runtime stage so it links against the cfitsio and libindi the image actually ships. The result is 21 DT_NEEDED entries instead of 79, all resolvable, with the same camera coverage: the ZWO and QHY SDKs move from a shared library to the static archives upstream ships for armv8, and Player One, SVBONY, ToupTek and OGMA are still installed in /usr/lib/phd2. verify_img.sh gains the check that would have caught this. `check` only proves a file exists, which is why a dead PHD2 passed verification; the new check_links resolves every DT_NEEDED of the astrophotography binaries against the sonames present in the image. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HpwpeqWfJDvjfbWZJ8jKE6 --- .github/workflows/astroarch-image.yml | 2 +- dockerfiles/Dockerfile.astroarch | 93 ++++++++++++++++++++++++++- scripts/verify_img.sh | 76 ++++++++++++++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/.github/workflows/astroarch-image.yml b/.github/workflows/astroarch-image.yml index 9b9e6fa..76414d9 100644 --- a/.github/workflows/astroarch-image.yml +++ b/.github/workflows/astroarch-image.yml @@ -150,7 +150,7 @@ jobs: 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 + dosfstools e2fsprogs util-linux fdisk parted kpartx zstd rsync binutils df -h # ------------------------------------------------------------------ diff --git a/dockerfiles/Dockerfile.astroarch b/dockerfiles/Dockerfile.astroarch index 811b0ff..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 @@ -67,6 +152,7 @@ RUN pacman -Sy \ kstars \ ksystemlog \ kwalletmanager \ + libnova \ neofetch \ networkmanager \ network-manager-applet \ @@ -76,7 +162,6 @@ RUN pacman -Sy \ packagekit-qt6 \ pacman-contrib \ paru \ - phd2 \ pipewire-jack \ plasma-desktop \ plasma-nm \ @@ -98,6 +183,7 @@ RUN pacman -Sy \ usbutils \ websockify \ wireplumber \ + wxwidgets-gtk3 \ xf86-video-dummy \ xf86-video-fbdev \ xorg \ @@ -108,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/verify_img.sh b/scripts/verify_img.sh index f72e491..c44d7f8 100755 --- a/scripts/verify_img.sh +++ b/scripts/verify_img.sh @@ -52,9 +52,76 @@ 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() { + sudo find "$ROOT_MNT/usr/lib" "$ROOT_MNT/usr/lib64" "$ROOT_MNT/lib" \ + -name '*.so*' -printf '%f\n' 2>/dev/null | sort -u > "$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 + + needed=$(sudo readelf -d "$file" 2>/dev/null | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' | sort -u) + 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 @@ -198,6 +265,15 @@ 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) From 6ff665971b0bd4270ba6c4f08cd53384660f1ada Mon Sep 17 00:00:00 2001 From: teoteo Date: Mon, 7 Sep 2026 15:44:26 +0200 Subject: [PATCH 6/6] Do not let the soname index abort the verification ArchLinuxARM has no /usr/lib64, and find exits non-zero for a missing starting point even with its stderr discarded. Under `set -e -o pipefail` that killed verify_img.sh the moment it reached the new check, throwing away a three-hour image build over a directory that was never expected to exist. The index is now built one directory at a time, skipping the ones that are absent and tolerating find's exit status; the readelf call gets the same treatment, so a failure there reaches the warn branch instead of aborting the run. Checked against a real ArchLinuxARM aarch64 root rather than in CI: the index comes back with 2602 sonames, PHD2's 21 shared libraries all resolve, the non-ELF /usr/bin/phd2 wrapper is skipped with a warning and a missing binary still fails. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HpwpeqWfJDvjfbWZJ8jKE6 --- scripts/verify_img.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/verify_img.sh b/scripts/verify_img.sh index c44d7f8..cf3f96e 100755 --- a/scripts/verify_img.sh +++ b/scripts/verify_img.sh @@ -59,8 +59,17 @@ check_warn() { # to model the loader's search order to catch a library that is nowhere at all. SONAME_INDEX=${SONAME_INDEX:-$(mktemp)} build_soname_index() { - sudo find "$ROOT_MNT/usr/lib" "$ROOT_MNT/usr/lib64" "$ROOT_MNT/lib" \ - -name '*.so*' -printf '%f\n' 2>/dev/null | sort -u > "$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 @@ -90,7 +99,9 @@ check_links() { return fi - needed=$(sudo readelf -d "$file" 2>/dev/null | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' | sort -u) + # `|| 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.