Skip to content

ci: harden the kvm_x86_64 build/boot workflow and boot harness - #1137

Open
bhouse-nexthop wants to merge 1 commit into
opencomputeproject:onie-modernization-2026from
bhouse-nexthop:bhouse.ci-review-fixes
Open

ci: harden the kvm_x86_64 build/boot workflow and boot harness#1137
bhouse-nexthop wants to merge 1 commit into
opencomputeproject:onie-modernization-2026from
bhouse-nexthop:bhouse.ci-review-fixes

Conversation

@bhouse-nexthop

@bhouse-nexthop bhouse-nexthop commented Aug 18, 2026

Copy link
Copy Markdown

Follow-up fixes to the CI added in 1a24708f, all found in review by @mshych on #1126. They are here rather than in that PR because they fix already-merged code, which is how he asked for them to be split.

Every item was re-verified against the tree before being applied; the notes are his numbering.

Workflow

  1. Cross-toolchain cache key omitted inputs that change the toolchain. xtools.make:25 derives XTOOLS_VERSION = $(ONIE_ARCH)-g$(GCC_VERSION)-lnx$(LINUX_RELEASE)-..., but LINUX_RELEASE is defined in build-config/make/kernel-download.make:22 and ONIE_ARCH in machine/kvm_x86_64/machine.make:10 — neither was hashed. A kernel bump therefore keeps hitting the key while the build/x-tools/<XTOOLS_VERSION> directory name changes underneath it: the restored toolchain is the wrong one, make xtools rebuilds from scratch, and the save step (gated on a cache miss) skips it, so every later run pays the same rebuild. Silent and self-perpetuating. GCC_VERSION/XTOOLS_LIBC default in crosstool-ng.make, which is already hashed, so those two were the only gaps. Also adds his guard: a ::warning when a cache hit still had to build a toolchain.

    This is the one worth landing before the kernel PR (kernel: move to the 6.18 LTS kernel #1130) does exactly this bump.

  2. push: and pull_request: both unfiltered ran the pipeline twice for every push to a branch with an open PR; the concurrency group can't collapse them because the refs differ. Fixed, but not with the branch filter that was suggested — see the note at the bottom. A small gate job now skips the push run when an open PR in the same repository already covers the commit.

  3. --privileged dropped from both docker run invocations. The Dockerfile ends with USER build, so the build is unprivileged and can't use anything --privileged grants. Confirmed the build needs no loop mounts — it goes through fakeroot and mtools, and onie-mk-iso.sh hardcodes /sbin/mkdosfs.

  4. permissions: contents: read at workflow level plus timeout-minutes on each job (180/45/60).

Dockerfile

  1. groupadd -g $GID failed when the host GID already exists. Confirmed in debian:11: groupadd -g 30 buildrc=4, GID '30' already exists. The low range is full of system groups (30 dip, 50 staff, 100 users), which is common with central/NFS accounts and the norm on macOS (GID 20 = dialout there). CI never sees it because the runner's GID is free. Reuses the group when the GID is taken, and chowns by numeric id since build then isn't a group name. Test-built at GID 30 and GID 1001 — both work now.

  2. The /sbin:/usr/sbin PATH line never took effect. It was appended to ~/.bashrc, and Debian's default .bashrc returns early for non-interactive shells, so bash -lc never reaches it. As he noted, ENV alone isn't enough either: /etc/profile lines 5–9 replace PATH for non-root users with one that omits both sbin dirs. Verified in the image — bash -lc as non-root gets /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games and blkid is off PATH. Fixed with ENV plus an /etc/profile.d snippet (profile.d is sourced at line 27, after the reset). Verified both login and non-login shells now resolve /sbin/blkid.

emulation/ci-boot-test.sh

  1. The "ONIE userspace" milestone was matched by GRUB output. build-config/recovery/grub-iso.cfg:32 echoes "ONIE: Rescue Mode ..." and :24 echoes "Version : ...", both before the kernel loads — so the check duplicated "GRUB reached". Now asserts Info: BIOS mode:, printed unconditionally from rootconf/grub-arch/sysroot-lib-onie/init-arch:130.

  2. secureboot mode never checked that Secure Boot was enforced. Now asserts the guest's own Info: Secure Boot: Active. (init-arch:121, read from the SecureBoot EFI variable via efivar), and asserts its absence on the relaxed run.

  3. A failed virt-fw-vars was silently ignored — piped through | sed | grep ... || true, which dropped its status twice over. Enrolment moved into a helper that fails the run.

  4. A QEMU that never started could be scored as a successful SB rejection. QEMU's stderr is kept and an immediate exit is now a hard error. Verified with a stub QEMU that exits 1: the harness prints the stderr and exits 2, where before it reported FAIL no serial output captured / PASS ONIE did not boot without db. Also captures the OVMF debug console when isa-debugcon is present — belt-and-braces, since as he corrected himself the release OVMF does put its BdsDxe: ... Access Denied line on ttyS0.

  5. One vCPU when /dev/kvm is unusable, so the documented accel=kvm:tcg fallback works for local runs. CI has /dev/kvm and still gets two.

Smoke-tested locally against real OVMF: logs are written as expected, the firmware console is empty on Ubuntu's release OVMF (as he predicted), and the milestone assertions behave correctly on a non-ONIE ISO.


Note on the duplicate-run fix

@mshych suggested restricting push: to the long-lived branches, which is the usual answer. I took a different route because the filter has a blind spot that bites this exact patch series.

A fork's pull requests are opened against the upstream repository, so they raise no pull_request event in the fork; the fork's only trigger is push. Filtering push by branch name therefore leaves every topic branch in a fork with no CI at all, and workflow_dispatch cannot fill the gap unless the workflow also exists on the fork's default branch (it does not — this workflow lives on onie-modernization-2026, and forks default to master).

That is not hypothetical: with the branch filter applied, pushing this very branch to my fork produced zero runs, which is how I noticed.

The gate job targets the actual condition instead — "an open PR here already covers this commit" — so it drops the duplicate upstream while a fork, where the query finds nothing, keeps full push CI. Verified on the fork: No open pull request in bhouse-nexthop/onie for bhouse.ci-review-fixes; running. Only build depends on the gate; the other jobs chain from it and skip automatically.

If you would rather have the simple branch filter, say so and I will switch it — the tradeoff is that fork-based development loses pre-submit CI.

Follow-up fixes to the CI added in 1a24708, found in review by @mshych on
PR opencomputeproject#1126.  Grouped here because they all fix already-merged code rather than
anything that PR adds.

Workflow:

 - The cross-toolchain cache key omitted inputs that change the toolchain.
   xtools.make derives XTOOLS_VERSION from ONIE_ARCH and LINUX_RELEASE, which
   live in machine/kvm_x86_64/machine.make and kernel-download.make -- neither
   of which was hashed.  A kernel bump therefore kept HITTING the key while the
   build/x-tools/<XTOOLS_VERSION> directory name changed underneath it: the
   restored toolchain was the wrong one, "make xtools" rebuilt from scratch,
   and the save step (gated on a cache miss) skipped it, so every later run
   paid the same rebuild with no signal saying why.  Hash those inputs, and add
   a guard that warns when a cache hit still had to build a toolchain -- the
   symptom of a key that is missing an input.

 - push: and pull_request: were both unfiltered, running the whole pipeline
   twice for every push to a branch with an open PR.  The concurrency group
   cannot collapse them because the two events carry different refs.  Add a
   small "gate" job that skips the push run when an open PR in the same
   repository already covers the commit.

   This is a query rather than the simpler branch filter on push: because a
   branch filter also removes CI from every topic branch in a fork, which is
   where this workflow does most of its pre-submit work.  A fork's pull
   requests are opened against the upstream repository, so they raise no
   pull_request event in the fork, and workflow_dispatch is unavailable there
   unless the workflow is also on the fork's default branch -- a filtered
   branch would be left with no CI at all.  The query has no such blind spot:
   in a fork it finds no pull request and the push run proceeds.

 - Drop --privileged from both docker run invocations.  The Dockerfile ends
   with USER build, so the build is unprivileged and cannot use any capability
   --privileged grants; the image build uses fakeroot and mtools, not loop
   mounts.

 - Add a workflow-level "permissions: contents: read" and per-job
   timeout-minutes.  Nothing here writes to the repository, and a wedged build
   would otherwise burn the 6-hour default.

Dockerfile:

 - groupadd -g $GID failed when the host GID already exists in debian:11,
   which the low GID range is full of (30 dip, 50 staff, 100 users).  A
   developer whose primary group lands there -- common with central/NFS
   accounts, and the norm on macOS -- could not build the image at all, while
   CI never saw it because the runner's GID happens to be free.  Reuse the
   group when the GID is taken, and chown by numeric id since the "build"
   group then does not exist.

 - The /sbin:/usr/sbin PATH addition never took effect.  It was appended to
   ~/.bashrc, but the workflow runs "bash -lc" and Debian's default .bashrc
   returns early for non-interactive shells.  The root cause is a level
   deeper: /etc/profile *replaces* PATH for non-root users with one that omits
   both sbin dirs, so ENV alone would be undone under bash -l.  Set both ENV
   and an /etc/profile.d snippet, which is sourced after /etc/profile.

emulation/ci-boot-test.sh:

 - The "ONIE userspace" milestone matched strings GRUB itself echoes from
   grub-iso.cfg ("ONIE: Rescue Mode ...", "Version   :"), so it duplicated the
   "GRUB reached" check and proved nothing about userspace.  Assert
   "Info: BIOS mode:" from init-arch instead, which GRUB cannot produce.

 - secureboot mode never checked that Secure Boot was actually enforced: if the
   varstore failed to enrol the guest booted with SB off and the "must boot"
   assertion passed for the wrong reason.  Assert the guest's own
   "Info: Secure Boot: Active." (read from the SecureBoot EFI variable by
   init-arch), and assert its absence in the relaxed run.

 - A failed virt-fw-vars was silently ignored: it was piped through
   "| sed | grep ... || true", which dropped its exit status twice over.  Move
   enrolment into a helper that fails the run.

 - A QEMU that never started was indistinguishable from a firmware that
   refused the image -- both left an empty serial log -- so the negative
   control could score a broken harness as a successful rejection.  Keep
   QEMU's stderr and hard-fail when it exits immediately.  Also capture the
   OVMF debug console when the device is available, as a second evidence
   channel on debug OVMF builds.

 - The documented accel=kvm:tcg fallback did not work for secureboot at
   -smp 2: without KVM the boot dies in rcu_sched stalls and burns the whole
   timeout, where -smp 1 boots in ~31s.  Use one vCPU when /dev/kvm is not
   usable.  CI has /dev/kvm and is unaffected.

Signed-off-by: Brad House <bhouse@nexthop.ai>
@bhouse-nexthop
bhouse-nexthop force-pushed the bhouse.ci-review-fixes branch from a286b37 to 24eeb15 Compare August 18, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant