Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

OE5XRX Linux Image

CI License: GPL-3.0 Yocto

Yocto-based Linux image for the OE5XRX Amateurfunkclub für Remote Stationen (Austria) remote amateur radio station fleet. Each station runs a Raspberry Pi Compute Module 4 connected to a custom STM32 mainboard plus pluggable RF/audio modules.

The image is paired with the station-manager server which handles fleet management, OTA rollouts, live monitoring, and a browser-based remote terminal.


Design highlights

  • A/B root filesystems with bootcount + automatic rollback. A bad update reverts to the previous known-good slot after three failed boot attempts.
  • Read-only rootfs with a dedicated persistent data partition. /var, /home, /root and /etc/station-agent are bind-mounted onto it so application state survives rootfs swaps.
  • Bootloader abstraction — U-Boot on the Raspberry Pi target, GRUB-EFI on the x86-64 target. Both expose the same three env vars (boot_part, bootcount, upgrade_available) so the station agent doesn't care which board it's running on.
  • Station-agent integrated as a Yocto recipe. Pulls directly from the station-manager repo's station_agent/ subdir at build time.
  • On-demand CI builds on Hetzner Cloud — a fresh CCX43 server is spun up, builds the image into a persistent sstate-cache volume, uploads the artifact, and is deleted. About €0.02 per build after the cache is warm.

Architecture

A/B boot with automatic rollback

Both targets run the same logic — GRUB on x86-64, U-Boot on the Pi — around three shared env variables: boot_part, bootcount, upgrade_available.

flowchart TD
    Boot[Power-on] --> BL[Bootloader reads env]
    BL --> Check{bootcount < 3?}
    Check -->|yes| Inc[bootcount++]
    Inc --> Load[Boot rootfs<br/>from boot_part slot]
    Check -->|no| Flip[Flip boot_part A↔B<br/>reset bootcount]
    Flip --> Load
    Load --> US[systemd + station-agent]
    US --> HC{Health check OK?}
    HC -->|yes| Commit[Agent clears bootcount<br/>= update committed]
    HC -->|no| Reboot[Reboot — bootcount stays set]
    Reboot --> Boot
Loading

After three bad boots of the new slot, the bootloader flips back to the previous known-good slot on its own. No network, no server, no agent required for rollback to work.

Storage layout

Active slot is read-only; everything that changes lives on a dedicated data partition, bind-mounted into the running rootfs.

flowchart LR
    subgraph disk[" on-disk partitions "]
        EFI[EFI / boot]
        A[rootfs A<br/>read-only]
        B[rootfs B<br/>read-only]
        DATA[data<br/>read-write]
    end
    subgraph runtime[" active runtime view "]
        root[/ read-only/]
        var[/var/]
        home[/home/]
        agent[/etc/station-agent/]
    end
    A -.active slot.-> root
    DATA --> var
    DATA --> home
    DATA --> agent
Loading

CM4 boot stages + device-tree overlays

The Raspberry Pi target boots in two U-Boot script stages so that boot logic and our own device-tree overlays travel with the A/B slot (and therefore with OTA) instead of living on the shared FAT partition.

  • Stage 1 — boot.scr on the FAT (frozen). Built from u-boot-ab/files/boot.cmd. Owns everything that must never depend on an update: env defaults, bootcount increment + saveenv, the watchdog, the rollback decision, finding root_${boot_part}. It then sources /boot/boot-stage2.scr from the slot. If stage 2 is missing or returns without booting, stage 1 boots the slot kernel with the firmware DTB itself (frozen fallback bootargs). Stage 1 changes only by reflashing.
  • Stage 2 — /boot/boot-stage2.scr in the slot rootfs (per OTA). Built from boot-stage2.cmd.in. Loads the kernel, copies the firmware DTB, applies every overlay from /boot/overlays/<name>.dtbo (installed by oe5xrx-dt-overlays) and boots. If any overlay fails to load or apply, it boots with no overlays (never a half-patched tree).
  • Overlay list: OE5XRX_DT_OVERLAYS in conf/oe5xrx-boot.inc, shared by u-boot-ab and oe5xrx-dt-overlays so stage 2 and the rootfs always agree. Each name needs files/<name>-overlay.dts in oe5xrx-dt-overlays. Never machine-override the variable.
  • Cmdline markers: oe5xrx.stage2=ok|missing|failed and oe5xrx.overlays=ok:<a,b>|failed:<name> end up in /proc/cmdline. During an OTA trial the station-agent refuses to commit when they signal a failure (stage 2 not ok or overlays=failed:…) and rolls back like a failed health check. No marker at all (old stage 1) is treated as the legacy boot path and does not block.

Rule: our own overlays live only in the slot (this list). config.txt is only for things the firmware itself needs (otg_mode, dtparam=…) — never dtoverlay=oe5xrx-…. The release gate enforces this on the built artifact.

Migration: a station needs one reflash with an image containing stage 1. Until then its old boot.scr ignores boot-stage2.scr and boots as before. After that, overlays and boot logic arrive by OTA.

Fault-injection builds (HW boot tests, dev builds only):

kas build raspberrypi4-64.yml:include/boot-test-marker.yml          # extra harmless test overlay
kas build raspberrypi4-64.yml:include/boot-test-overlay-broken.yml  # overlay fails -> overlays=failed:…
kas build raspberrypi4-64.yml:include/boot-test-stage2-broken.yml   # stage 2 returns -> stage2=failed

Never for releases: the build bbfatals if a test switch or an oe5xrx-test-* overlay meets OE5XRX_RELEASE_TAG != dev, and the release artifact gate rejects test overlays/faults in the image.

Sandbox tests (CI job uboot-sandbox) run the real stage 1/2 scripts in a U-Boot v2026.01 sandbox against the real CM4 firmware DTB:

scripts/build-uboot-sandbox.sh /tmp/sbx/u-boot     # host build, idempotent
curl -fsSL -o /tmp/sbx/cm4.dtb \
  https://raw.githubusercontent.com/raspberrypi/firmware/<RPI_FW_REV>/boot/bcm2711-rpi-cm4.dtb
UBOOT_SANDBOX=/tmp/sbx/u-boot CM4_BASE_DTB=/tmp/sbx/cm4.dtb \
  python3 -m pytest tests/ota-integration -m sandbox

(RPI_FW_REV + checksum: see .github/workflows/ci.yml.)

Release pipeline

flowchart LR
    PR[Pull Request] --> CI[ci.yml<br/>parse + lint]
    CI --> Merge[Merge to main]
    Merge --> TR[tag-release.yml<br/>dispatch on main]
    TR --> Tag[compute version<br/>push tag]
    Tag --> Rel[release.yml<br/>dispatched @ tag]
    Rel --> B1[build.yml<br/>qemux86-64]
    Rel --> B2[build.yml<br/>raspberrypi4-64]
    B1 --> Gate[boot + OTA gate]
    Gate --> Sign[cosign keyless<br/>@refs/tags/{tag}]
    B2 --> Sign
    Sign --> GH[GitHub Release<br/>.wic.bz2 · .sha256 · .bundle]
Loading

Each build.yml invocation creates a fresh Hetzner CCX43, attaches the persistent sstate-cache volume, builds, uploads the artifact, and is deleted. The two calls run serially because they share that cache volume.


Targets

Machine Config Purpose
qemux86-64 qemux86-64.yml Development image, bootable in QEMU. GRUB-EFI + full A/B layout for offline testing.
raspberrypi4-64 raspberrypi4-64.yml Production image for Raspberry Pi Compute Module 4. U-Boot + A/B + read-only rootfs.

Both targets share everything via oe5xrx.yml; only machine-specific bits differ.


Building locally

Prerequisites:

# QEMU x86-64 (fast, useful for iterating)
kas build qemux86-64.yml

# Raspberry Pi CM4 (production)
kas build raspberrypi4-64.yml

Outputs land in build/tmp/deploy/images/<machine>/.

Booting the qemux86-64 image in QEMU

A helper script is included: scripts/run-qemu.sh.

# Boot a local build, or whatever's already in the cache
./scripts/run-qemu.sh

# Pull the latest published release (verifies sha256 + decompresses)
./scripts/run-qemu.sh --release

# Pick a specific release tag
./scripts/run-qemu.sh --release v1-alpha

# Pull the latest CI artifact instead (unreleased, last 7 days)
./scripts/run-qemu.sh --fetch

# Or a specific GitHub Actions run
./scripts/run-qemu.sh --fetch 24543210987

The script handles OVMF UEFI firmware, KVM acceleration, user-mode networking with SSH port-forward on 2222, and A/B rootfs layout.

SSH in once booted:

ssh -p 2222 root@localhost

Test the A/B boot-switch / rollback from inside the guest:

grub-editenv /boot/EFI/BOOT/grubenv list
grub-editenv /boot/EFI/BOOT/grubenv set boot_part=b upgrade_available=1 bootcount=0
reboot
# After 3 failed boots of the (empty) slot B, GRUB rolls back to slot A

Prerequisites on the host: qemu-system-x86, ovmf, membership in the kvm group. On Debian/Ubuntu: sudo apt install qemu-system-x86 ovmf && sudo usermod -aG kvm "$USER" (log out and back in).

Only the qemux86-64 target is supported by the script. The raspberrypi4-64 image is designed to be flashed to real CM4 hardware — QEMU's raspi4b emulation lacks working Ethernet and has no serial-getty path in our image, so it's not practical for end-to-end testing.


CI

Three workflows:

  • ci.yml — every pull request and push to main. Parses all kas configs with kas dump, shellchecks the scripts, yamllints the YAML, sanity-checks the wks files, runs the unit tests and the U-Boot sandbox boot-script tests. No Hetzner, no artifact.
  • build.yml — reusable + manually dispatchable. Full Yocto build on an on-demand Hetzner CCX43 for a single machine. Uploads the image artifact with 7-day retention. Used ad-hoc or called by release.yml.
  • tag-release.yml — the one-button release entrypoint (workflow_dispatch on main). Runs preflight.yml, computes the next YYYY.MM.DD-HH[a-z] version, pushes the tag, then dispatches release.yml against that tag ref (gh workflow run release.yml --ref <tag> — a documented GITHUB_TOKEN exception, so no PAT is needed).
  • release.yml — build / gate / sign / publish, run on the tag ref so cosign signs under the tag identity (…/release.yml@refs/tags/<tag>, which station-manager verifies). Validates the tag format, calls build.yml for both targets in parallel, gates on the boot + cross-build OTA test and the rpi artifact gate (test_rpi_artifact.py: stage 2 + overlays in the slot, FAT clean, sandbox boot of the built scripts), signs each image with cosign keyless, and publishes the GitHub Release. Deletes the tag if a build/gate/sign fails, so a failed attempt leaves nothing behind. dry_run=true (manual branch dispatch) does build + gate only.
  • preflight.yml — reusable cheap gate (pinned station-agent SRCREV + cosign-verified FM artifacts); called by both release workflows.

Required GitHub Secrets

Secret Used by Description
HCLOUD_TOKEN build.yml Hetzner Cloud API token
HCLOUD_SSH_KEY_NAME build.yml Name of an SSH key registered in Hetzner Cloud (for logging into the build server)
HCLOUD_SSH_PRIVATE_KEY build.yml Private half of the above key, used by GitHub's runner
GH_PAT build.yml Personal access token with repo scope — used to fetch a short-lived runner registration token

The build server is disposable; only sstate-cache and download caches are persisted via per-target named Hetzner volumes (oe5xrx-yocto-cache-qemux86-64, oe5xrx-yocto-cache-raspberrypi4-64). Each target has its own volume so both builds can run in parallel on a release.


Image contents

Each image ships with:

  • station-agent — management agent (heartbeat, OTA, remote terminal). Authenticates via Ed25519 signatures.
  • Python 3 + agent runtime dependencies
  • OpenSSH, dfu-util (STM32 flashing), i2c-tools, htop
  • systemd as init
  • GRUB-EFI (x86) or U-Boot (RPi) with A/B boot logic

Repository layout

.
├── oe5xrx.yml                           shared kas config
├── qemux86-64.yml                       x86-64 target
├── raspberrypi4-64.yml                  RPi CM4 target
├── include/raspberrypi.yml              meta-raspberrypi glue
├── include/boot-test-*.yml              dev-only boot fault-injection overlays
├── meta-oe5xrx-remotestation/
│   ├── conf/layer.conf
│   ├── conf/oe5xrx-boot.inc             CM4 boot knobs: DT overlay list + test switch
│   ├── recipes-bsp/
│   │   ├── grub/                        grub-efi bbappend + embedded cfg
│   │   ├── grub-ab/                     seed grubenv with A/B defaults
│   │   ├── oe5xrx-dt-overlays/          our DT overlays -> /boot/overlays in the slot
│   │   └── u-boot-ab/                   stage 1 boot.scr (FAT) + slot boot-stage2.scr + fw_env.config
│   ├── recipes-core/
│   │   ├── ab-layout/                   systemd mount units + first-boot init
│   │   ├── base-files/                  fstab tweaks (RPi)
│   │   ├── images/                      production + development images
│   │   └── station-agent/               agent recipe
│   └── wic/                             partition layouts (x64 + RPi)
├── scripts/
│   └── run-qemu.sh                      local QEMU launcher for qemux86-64
└── .github/
    ├── workflows/
    │   ├── ci.yml                       fast PR / main checks
    │   ├── build.yml                    reusable Hetzner build (single target)
    │   ├── preflight.yml                reusable release preflight (pins + signatures)
    │   ├── tag-release.yml              one-button entrypoint: version + tag + dispatch
    │   └── release.yml                  runs @ tag: build both + gate + cosign + GH Release
    ├── ISSUE_TEMPLATE/bug.yml
    ├── PULL_REQUEST_TEMPLATE.md
    ├── CODEOWNERS
    └── dependabot.yml

Contributing

  1. Fork the repo
  2. Create a feature branch (git checkout -b fix/my-thing)
  3. Commit, push, open a PR
  4. Wait for ci.yml to go green — it enforces recipe parsing, shellcheck, yamllint
  5. A maintainer reviews + merges (squash or rebase; no merge commits)

The main branch is protected — direct pushes are blocked, every change goes through PR review.

Releases

Rolling releases are tagged as YYYY.MM.DD-HH in UTC (e.g. 2026.04.19-14), with a lowercase suffix on a same-hour collision (…-14a). Cutting a release is one button — run the Tag release workflow on main:

gh workflow run tag-release.yml --repo OE5XRX/linux-image --ref main

tag-release.yml runs preflight.yml, computes the next timestamp version, pushes the tag, then dispatches release.yml against that tag. release.yml builds both machine images (qemux86-64 + raspberrypi4-64), gates on the boot + cross-build OTA test, signs each with cosign keyless (Sigstore + GitHub Actions OIDC) — under the tag identity …/release.yml@refs/tags/<tag> — and publishes a GitHub Release with the images, SHA256 checksums, and signature bundles.

If any real build step fails after the tag is validated (preflight, resolve-slot-a, build, gate or sign), release.yml deletes the tag so a failed attempt leaves nothing behind. To retry, re-run tag-release.yml (it computes a fresh tag) — do not re-run release.yml, whose tag ref was deleted. See SECURITY.md for how to verify a release before flashing it.

Note: scripts/release.sh (local tag + push) predates this two-step flow and no longer triggers a release on its own; use the workflow dispatch above.

The helper only emits tags in the form YYYY.MM.DD-HH or YYYY.MM.DD-HH<suffix> where <suffix> is a single lowercase letter a–z; this is also the exact set the release workflow triggers on. Legacy v* releases (v1-alpha … v1-delta) stay on GitHub as historical artifacts — scripts/run-qemu.sh --release v1-delta can still fetch them — but pushing a new v* tag no longer triggers a build.


License

GPL-3.0-or-later. Same spirit as the Linux kernel + GNU userland that the image is built from — improvements flow back to the community.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages