detached 76a5504

feat(image): binfmt_misc in the guest kernel (#37)

commit
76a5504f0cea61dde10c7d4fb9751907a31eaaed
author
brice · · 2026-10-16 14:12 UTC
committer
bnema · · 2026-10-16 14:12 UTC
parent
65c7d3d
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.4.0
4 files · +58 -0
M README.md +2 -0
@@ -382,6 +382,8 @@ scripts/build-image.sh tmp/image # builds the OCI image with docker or podman
382382
383383 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.
384384
385+`CONFIG_BINFMT_MISC` is built in (the guest has no modules) and the guest init mounts it at `/proc/sys/fs/binfmt_misc`, as systemd does on GitHub's image, so `docker/setup-qemu-action` (`tonistiigi/binfmt --install`) can register QEMU handlers and run foreign-architecture containers. The handlers belong to the job's VM and disappear with it. With a kernel built before this option, the init logs the failed mount and jobs run without it.
386+
385387 ## Tests
386388
387389 ```sh
@@ -382,6 +382,8 @@ scripts/build-image.sh tmp/image # builds the OCI image with docker or podman
382 382
383 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.383 The kernel configuration is `images/kernel/config-6.18` (+ per-architecture fragments), checked after `olddefconfig`: the build fails if a listed option did not survive. arm64 builds (`ARCH_NAME=arm64`) but is not tested in CI-1.
384 384
385+`CONFIG_BINFMT_MISC` is built in (the guest has no modules) and the guest init mounts it at `/proc/sys/fs/binfmt_misc`, as systemd does on GitHub's image, so `docker/setup-qemu-action` (`tonistiigi/binfmt --install`) can register QEMU handlers and run foreign-architecture containers. The handlers belong to the job's VM and disappear with it. With a kernel built before this option, the init logs the failed mount and jobs run without it.
386+
385 ## Tests387 ## Tests
386 388
387 ```sh389 ```sh
M images/kernel/config-6.18 +1 -0
@@ -31,6 +31,7 @@ CONFIG_HIGH_RES_TIMERS=y
3131 CONFIG_NO_HZ_IDLE=y
3232 CONFIG_BINFMT_ELF=y
3333 CONFIG_BINFMT_SCRIPT=y
34+CONFIG_BINFMT_MISC=y
3435 CONFIG_SYSVIPC=y
3536 CONFIG_POSIX_MQUEUE=y
3637 CONFIG_KEYS=y
@@ -31,6 +31,7 @@ CONFIG_HIGH_RES_TIMERS=y
31 CONFIG_NO_HZ_IDLE=y31 CONFIG_NO_HZ_IDLE=y
32 CONFIG_BINFMT_ELF=y32 CONFIG_BINFMT_ELF=y
33 CONFIG_BINFMT_SCRIPT=y33 CONFIG_BINFMT_SCRIPT=y
34+CONFIG_BINFMT_MISC=y
34 CONFIG_SYSVIPC=y35 CONFIG_SYSVIPC=y
35 CONFIG_POSIX_MQUEUE=y36 CONFIG_POSIX_MQUEUE=y
36 CONFIG_KEYS=y37 CONFIG_KEYS=y
M internal/guest/vmsetup.go +7 -0
@@ -140,6 +140,13 @@ func setupVM(log zerowrap.Logger, args map[string]string) error {
140140 log.Warn().Err(err).Str("target", m.dst).Msg("init: mount")
141141 }
142142 }
143+ // Mounted at boot on GitHub's image (systemd's
144+ // proc-sys-fs-binfmt_misc.automount): jobs read the handlers that
145+ // setup-qemu-action registers there. A kernel without BINFMT_MISC has no
146+ // such directory; the jobs run without it.
147+ if err := mount("binfmt_misc", "/proc/sys/fs/binfmt_misc", "binfmt_misc", unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil {
148+ log.Warn().Err(err).Msg("init: mount binfmt_misc")
149+ }
143150 _ = os.Chmod("/tmp", sandbox.TmpMode)
144151
145152 gw := args[sandbox.CmdlineGateway]
@@ -140,6 +140,13 @@ func setupVM(log zerowrap.Logger, args map[string]string) error {
140 log.Warn().Err(err).Str("target", m.dst).Msg("init: mount")140 log.Warn().Err(err).Str("target", m.dst).Msg("init: mount")
141 }141 }
142 }142 }
143+ // Mounted at boot on GitHub's image (systemd's
144+ // proc-sys-fs-binfmt_misc.automount): jobs read the handlers that
145+ // setup-qemu-action registers there. A kernel without BINFMT_MISC has no
146+ // such directory; the jobs run without it.
147+ if err := mount("binfmt_misc", "/proc/sys/fs/binfmt_misc", "binfmt_misc", unix.MS_NOSUID|unix.MS_NODEV|unix.MS_NOEXEC, ""); err != nil {
148+ log.Warn().Err(err).Msg("init: mount binfmt_misc")
149+ }
143 _ = os.Chmod("/tmp", sandbox.TmpMode)150 _ = os.Chmod("/tmp", sandbox.TmpMode)
144 151
145 gw := args[sandbox.CmdlineGateway]152 gw := args[sandbox.CmdlineGateway]
M internal/sandbox/integration/vm_test.go +48 -0
@@ -305,3 +305,51 @@ grep ' /home/runner/externals ' /proc/mounts
305305 t.Error("the externals are writable from the guest")
306306 }
307307 }
308+
309+// TestVMBinfmtMisc checks what docker/setup-qemu-action needs from the guest:
310+// binfmt_misc mounted at boot, as on GitHub's image, and a handler registered
311+// with the F (fix binary) flag that tonistiigi/binfmt uses. The handler here is
312+// cat on an extension, so the test needs neither QEMU nor the network.
313+func TestVMBinfmtMisc(t *testing.T) {
314+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
315+ t.Skip("set ERMITE_RUNNER_IT_VM=1")
316+ }
317+ need(t, "mkfs.ext4")
318+ needNetAdmin(t)
319+ ctx := ctxWithLog(t)
320+ cfg := loadConfig(t, sandbox.VM)
321+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ defer func() { _ = drv.Close() }()
326+ _ = drv.Reap(ctx)
327+ sb, err := drv.New()
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ defer func() { _ = sb.Stop(context.Background()) }()
332+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "binfmt"})
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ r, err := runJob(ctx, conn, `
337+S=; [ "$(id -u)" = 0 ] || S="sudo -n"
338+grep ' /proc/sys/fs/binfmt_misc ' /proc/mounts
339+echo "status: $(cat /proc/sys/fs/binfmt_misc/status)"
340+echo ':ermitetest:E::ermitetest::/usr/bin/cat:F' | $S tee /proc/sys/fs/binfmt_misc/register >/dev/null
341+grep -x 'flags: F' /proc/sys/fs/binfmt_misc/ermitetest
342+printf 'ran through binfmt_misc\n' > /tmp/probe.ermitetest
343+chmod +x /tmp/probe.ermitetest
344+/tmp/probe.ermitetest
345+`)
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ t.Log("\n" + r.out + r.errOut)
350+ for _, w := range []string{"binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc", "status: enabled\n", "flags: F\n", "ran through binfmt_misc\n"} {
351+ if !strings.Contains(r.out, w) {
352+ t.Errorf("output lacks %q", w)
353+ }
354+ }
355+}
@@ -305,3 +305,51 @@ grep ' /home/runner/externals ' /proc/mounts
305 t.Error("the externals are writable from the guest")305 t.Error("the externals are writable from the guest")
306 }306 }
307 }307 }
308+
309+// TestVMBinfmtMisc checks what docker/setup-qemu-action needs from the guest:
310+// binfmt_misc mounted at boot, as on GitHub's image, and a handler registered
311+// with the F (fix binary) flag that tonistiigi/binfmt uses. The handler here is
312+// cat on an extension, so the test needs neither QEMU nor the network.
313+func TestVMBinfmtMisc(t *testing.T) {
314+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
315+ t.Skip("set ERMITE_RUNNER_IT_VM=1")
316+ }
317+ need(t, "mkfs.ext4")
318+ needNetAdmin(t)
319+ ctx := ctxWithLog(t)
320+ cfg := loadConfig(t, sandbox.VM)
321+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ defer func() { _ = drv.Close() }()
326+ _ = drv.Reap(ctx)
327+ sb, err := drv.New()
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ defer func() { _ = sb.Stop(context.Background()) }()
332+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "binfmt"})
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ r, err := runJob(ctx, conn, `
337+S=; [ "$(id -u)" = 0 ] || S="sudo -n"
338+grep ' /proc/sys/fs/binfmt_misc ' /proc/mounts
339+echo "status: $(cat /proc/sys/fs/binfmt_misc/status)"
340+echo ':ermitetest:E::ermitetest::/usr/bin/cat:F' | $S tee /proc/sys/fs/binfmt_misc/register >/dev/null
341+grep -x 'flags: F' /proc/sys/fs/binfmt_misc/ermitetest
342+printf 'ran through binfmt_misc\n' > /tmp/probe.ermitetest
343+chmod +x /tmp/probe.ermitetest
344+/tmp/probe.ermitetest
345+`)
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ t.Log("\n" + r.out + r.errOut)
350+ for _, w := range []string{"binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc", "status: enabled\n", "flags: F\n", "ran through binfmt_misc\n"} {
351+ if !strings.Contains(r.out, w) {
352+ t.Errorf("output lacks %q", w)
353+ }
354+ }
355+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close