Skip to content

Build igloo.ko with nix, against kernel derivations - #100

Merged
lacraig2 merged 4 commits into
mainfrom
nixify-driver
Aug 15, 2026
Merged

Build igloo.ko with nix, against kernel derivations#100
lacraig2 merged 4 commits into
mainfrom
nixify-driver

Conversation

@lacraig2

Copy link
Copy Markdown
Contributor

Moves igloo_driver to nix. build.sh and the Docker workflows are untouched and still work — this is additive while both release paths exist.

Why

A module is loadable only by the exact kernel build it was compiled against. Its modversion CRCs come from that kernel's headers, and the kernel's .config itself holds options Kconfig resolves by probing the compiler (CONFIG_STACKPROTECTOR_PER_TASK, CONFIG_INIT_STACK_*, the CC_HAS_* family). The ABI moves when the toolchain moves, with byte-identical sources and byte-identical config files.

The Docker path downloads kernel-devel-all.tar.gz from a linux_builder release, unpacks it, and builds against whatever is in that directory. Two problems, only one obvious:

  1. Nothing ties the unpacked tree to the kernel the module is inserted into. The pin was a release tag — a label, not an identity. Pairing v0.0.96 with linux_builder's nix kernels put 129 of 221 CRCs out of agreement; the only symptom was igloo: disagrees about version of symbol module_layout and a guest panic, and nothing in either release recorded what it was built against.

  2. A nix-built kernel-devel tree is not consumable from a non-nix container at all. It ships prebuilt host tools linked against the Nix store — scripts/basic/fixdep requests an interpreter under /nix/store — so inside embedded-toolchains every target died within seconds of make starting (run 31767370803). That is not a bug in the tarball; it is what "prebuilt host tools" means.

Taking linux_builder as a flake input makes the kernel derivation the build input. ARCH, CROSS_COMPILE and the compiler all come out of the kernel's passthru via kernelsmith's buildModule, so a mismatched (kernel, module) pair is not representable — "built against a different kernel" is a different store path, not a runtime panic.

What lands

File
flake.nix linux_builder as an input. The 19-cell matrix is derived from its kernel outputs, not restated — build.sh's hand-maintained TARGETS/VERSIONS lists can no longer drift into silent SKIPPED (No kernel config) lines. Also exposes lib.<system>.buildFor (below).
nix/module.nix the buildModule call. entry = "wrapper" because src/Makefile runs python3 codegen before re-entering kbuild.
nix/artifacts.nix ISF and stripped .ko as separate derivations off the unstripped module, so the extract-then-strip ordering _in_container_build.sh relies on cannot be reordered into a bug. dwarf2json is linux_builder's pinned fork, shared rather than re-derived — the kernel ISFs and the module ISFs in a release are now produced by one identical tool, which was previously true only by coincidence.
nix/release.nix the tarball, reproducible, plus BUILT_AGAINST.txt — which names each kernel's store path, i.e. the ABI itself, not a tag two different ABIs can share.
nix/shape.nix every module's ELF class / byte order / machine must match its target name. The Docker build has no such check and collapses every powerpc* target onto ARCH=powerpc; linux_builder shipped two bugs of exactly this shape before adding the equivalent.
nix/modversions.nix every module's CRCs must agree with its kernel's Module.symvers (below).
.github/workflows/nix.yml PR gate (one cell per version, through to the shipped artifacts), full matrix on demand and on a nixdev_* tag, which cuts a prerelease.
build.sh refuses a nix-built kernel-devel tree up front, with a message pointing at the nix path. Plus a set -u fix — see below.

Verification

nix build .#igloo_driver produces all 19 cells (7.1 MB): kernels/<version>/igloo.ko.<target> + .json.xz, the same layout the Docker build produces, so penguin's mk-igloo-static.nix consumes it unchanged.

nix flake checkall 38 checks pass:

igloo-driver-shape-check> 6.13/powerpc      ok  ELF 32-bit MSB relocatable, PowerPC ...
igloo-driver-shape-check> 6.13/powerpc64le  ok  ELF 64-bit LSB relocatable, 64-bit PowerPC ...
igloo-driver-modversions-check> ok   4.10/armel:  209 symbols, all CRCs agree
igloo-driver-modversions-check> ok   6.13/armel:  222 symbols, all CRCs agree
igloo-driver-modversions-check> ok   6.13/x86_64: 225 symbols, all CRCs agree
    ... 19 cells, 198-225 symbols each, zero mismatches, zero absent
all checks passed!

That last check is the point of the exercise: 222 of 222 CRCs agree where the old pairing had 129 of 221 disagreeing.

Building against a kernel derivation makes the right inputs structural. It does not prove the output is right — kbuild happily emits a module with no __versions section at all if CONFIG_MODVERSIONS is off in a config, and nothing else here would notice. nix/modversions.nix closes that, and is the check that would have caught the incident that motivated this PR.

Two implementation notes worth a reviewer's eye:

  • The section is read by parsing the ELF directly rather than via objcopy: nixpkgs' binutils is built for the host target and objcopy --dump-section fails outright on several machines in this matrix.
  • MODULE_NAME_LEN is (64 - sizeof(Elf_Addr)), so a modversion entry is 64 bytes on every arch. Parsing it as crc + 56 misreads every 32-bit module (found the hard way).

Also in here

build.sh's rewrite_mount referenced $PENGUIN_HOST_MOUNT_FROM unguarded under set -u, so it aborted with unbound variable whenever that variable was unset — every local build, i.e. exactly the case its own comment says is a no-op. ./build.sh has been unusable locally as a result. Fixed with ${VAR:-}.

Where this is going

lib.<system>.buildFor { kernel, version, target } builds igloo.ko against an arbitrary kernelsmith-built kernel, not just one of linux_builder's cells:

igloo-driver.lib.x86_64-linux.buildFor {
  kernel = kernelsmith.buildKernel { /* ... */ };
  version = "6.6"; target = "armel";
}

That is the seam for the direction this is heading — fewer changes carried as kernel patches, more of the delta in the module, eventually igloo.ko loading into a stock upstream kernel. Anything that can hand over a buildKernel derivation can build a matching module today, without this repo enumerating that kernel anywhere.

Follow-ups (not in this PR)

Adds a nix build path alongside the Docker one. Same artifact, same
igloo_driver.tar.gz layout; the difference is what a module is built
*against*.

The Docker path downloads kernel-devel-all.tar.gz from a linux_builder
release, unpacks it, and builds against whatever is in that directory.
Two problems, only one of them obvious:

1. Nothing ties the unpacked tree to the kernel the module is inserted
   into. The pin was a release TAG -- a label, not an identity. A module
   is loadable only by the exact kernel build it was compiled against:
   modversion CRCs come from that kernel's headers, and the kernel's
   .config itself holds options Kconfig resolves BY PROBING THE COMPILER
   (CONFIG_STACKPROTECTOR_PER_TASK, CONFIG_INIT_STACK_*, the CC_HAS_*
   family). The ABI moves when the toolchain moves, with byte-identical
   sources and byte-identical configs.

   Not hypothetical: pairing v0.0.96 with linux_builder's nix kernels put
   129 of 221 CRCs out of agreement. The only symptom was "igloo:
   disagrees about version of symbol module_layout" and a guest panic,
   and nothing in either release recorded what it was built against.

2. A nix-built kernel-devel tree is not consumable from a non-nix
   container at all. It ships prebuilt host tools linked against the Nix
   store -- scripts/basic/fixdep requests an interpreter under
   /nix/store -- so inside embedded-toolchains every target died within
   seconds of make starting. That is not a bug in the tarball; it is what
   "prebuilt host tools" means.

Taking linux_builder as a flake input makes the kernel derivation the
build input. ARCH, CROSS_COMPILE and the compiler all come out of the
kernel's passthru via kernelsmith's buildModule, so a mismatched
(kernel, module) pair is not representable -- "built against a different
kernel" is a different store path, not a runtime panic.

What lands:

  flake.nix          linux_builder as an input; the 19-cell matrix is
                     DERIVED from its kernel outputs rather than restated,
                     so build.sh's hand-maintained TARGETS/VERSIONS lists
                     can no longer drift into silent "SKIPPED (No kernel
                     config)" lines
  nix/module.nix     the buildModule call (wrapper entry: src/Makefile
                     does python3 codegen before re-entering kbuild)
  nix/artifacts.nix  ISF and stripped .ko as separate derivations off the
                     unstripped module, so the extract-then-strip ordering
                     _in_container_build.sh relies on cannot be reordered
                     into a bug. dwarf2json is linux_builder's pinned fork,
                     shared rather than re-derived
  nix/release.nix    the tarball, reproducible, plus BUILT_AGAINST.txt --
                     which now names each kernel's STORE PATH, i.e. the ABI
                     itself, not a tag that two different ABIs can share
  nix/shape.nix      every module's ELF class/byte order/machine must match
                     what its target name claims. The Docker build has no
                     such check and collapses every powerpc* target onto
                     ARCH=powerpc; linux_builder shipped two bugs of this
                     exact shape before adding the equivalent
  .github/workflows/nix.yml
                     PR gate (one cell per version, through to the shipped
                     artifacts), full matrix on demand and on a nixdev_*
                     tag, which cuts a PRERELEASE. Deliberately not dev_*:
                     that already triggers build.yml, and both pipelines
                     would race version-increment for the same vX.Y.Z

build.sh and the Docker workflows are untouched and still work; this is
additive while both release paths exist.
Three additions on top of the nix build path.

nix/modversions.nix -- assert every module's modversion CRCs agree with
its kernel's Module.symvers.

Building against a kernel derivation makes the right INPUTS structural.
It does not prove the OUTPUT is right, which is a different claim: kbuild
happily emits a module with no __versions section at all if
CONFIG_MODVERSIONS is off in a config, and nothing else here would
notice. Either way the symptom is identical and arrives inside a guest --
"igloo: disagrees about version of symbol module_layout", then a panic.
That shipped once (129 of 221 CRCs out of agreement) and was
undiscoverable until boot.

All 19 cells pass, 198-225 symbols each, zero mismatches and zero
symbols absent.

The section is read by parsing the ELF directly rather than shelling out
to objcopy: nixpkgs' binutils is built for the host target and
`objcopy --dump-section` fails outright on several machines in this
matrix, and it also writes its temp file beside the input, which is in
the read-only store. Note MODULE_NAME_LEN is (64 - sizeof(Elf_Addr)), so
a modversion entry is 64 bytes on every arch -- parsing it as crc + 56
misreads every 32-bit module.

flake.nix -- `lib.<system>.buildFor { kernel, version, target }`.

The seam for where the driver is going: fewer changes carried as kernel
patches, more of the delta in the module, eventually igloo.ko loading
into a stock upstream kernel. Anything that can hand over a buildKernel
derivation can build a matching module without this repo enumerating that
kernel anywhere.

Also fixes a real bug in the emptiness guard added in the previous
commit: it was a standalone `_ = lib.assertMsg ...` let-binding, and an
unreferenced binding is never forced, so it would never have fired.
Folded into kernelCells itself.

build.sh -- refuse a NIX-BUILT kernel-devel tree, loudly and up front.

Its prebuilt host tools are linked against the Nix store
(scripts/basic/fixdep asks for an interpreter under /nix/store), which
does not exist in the Ubuntu toolchain image, so every target dies within
seconds of make starting with an error that names fixdep and explains
nothing. Detect it and point at `nix build .#igloo_driver` instead.

And while in there: rewrite_mount referenced $PENGUIN_HOST_MOUNT_FROM
unguarded under `set -u`, so it aborted with "unbound variable" whenever
the variable was unset -- every local build, i.e. exactly the case its own
comment says is a no-op. `./build.sh` has been unusable locally as a
result.
nixdev_0.1.0's 4.10/x86_64 kernel did not boot -- binutils >= 2.31 emits
R_X86_64_PLT32, which Linux only learned in 4.16. A module is only as
good as the kernel it is paired with, so this pin has to move with the
kernels rather than lag them.

Also picks up kernelsmith's $dev slimming (kernelsmith#4), which changes
what an `M=` build reads out of the kernel-devel tree. This repo is the
consumer that exercises that path, and its matrix is what confirms it:
all 19 cells build, modversions and shape checks pass.
Three changes, and the second forces the third.

1. linux-builder: nixdev_0.1.1 -> v4.0.1. A real release rather than a
   prerelease: v4.0.1 is the first linux_builder release cut by its nix
   path and the first whose kernels are all boot-tested.

2. The Docker path is removed -- build.yml, test.yml, build.sh,
   _in_container_build.sh. It did not fall out of favour, it became
   UNRUNNABLE. It consumed linux_builder's
   `releases/latest/download/kernel-devel-all.tar.gz`, and as of v4.0.1
   that asset is nix-built: its prebuilt host tools are linked against
   /nix/store, so they cannot execute in the Ubuntu toolchain image. Every
   target fails within seconds of `make` starting. build.sh's own guard
   detected exactly this and refused, which is the only reason the failure
   was legible. Not a bug in the tarball -- it is what "prebuilt host
   tools" means, and it is why a nix linux_builder forces a nix
   igloo_driver.

3. So release duty moves to nix.yml, which is now the only pipeline: the
   full matrix also runs on a push to main, then version-increment and a
   real vX.Y.Z release. Releases are therefore cut by the job that already
   proved every module built, the shapes match, and the CRCs agree.

That last part is the point of the whole exercise. v0.0.96 was Docker-built
against different kernels and disagreed on 129 of 221 modversion CRCs when
paired with nix kernels -- which a guest reports as "igloo: disagrees about
version of symbol module_layout", at load time, in a running rehost. Modules
are now compiled against the kernel DERIVATIONS they will ship beside, so
that pairing is not expressible.

nix.yml carried a comment warning that its release must NEVER be a full one,
since `releases/latest` is the newest non-prerelease and a full release here
"would silently become the driver everything picks up". That is now the
intent rather than the hazard, and the comment says so instead of being
deleted.

Verified against v4.0.1: 19 cells build, modversions and shape checks pass,
and BUILT_AGAINST.txt records linux_builder b87fad4 -- the v4.0.1 commit.

README and docs/building.md rewritten around the nix path, both recording
why the Docker path is gone rather than quietly dropping it.

NOTE: the deleted test.yml contained a plaintext registry credential. It is
still in git history and should be rotated independently of this change.
@lacraig2
lacraig2 merged commit 73c8474 into main Aug 15, 2026
6 checks passed
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