Files
netris-nestri/build/Containerfile
T
DatCaptainHorseandClaude Sonnet 5.5 85e6f19977 feat: build umu-launcher into the guest image
`umu-run` is how a title that is not Steam's is launched, so the game fixes
Proton carries apply outside Steam. It is built from a pinned tag in its own
stage rather than installed from the distribution, whose package declares a
long list of lib32 libraries and desktop packages for the Steam Linux Runtime
container this image never runs.

It is built against the distribution's urllib3 and pyzstd instead of the copies
it can vendor: those are built from source with pinned build tooling, and a
rolling distribution moved past a pin (setuptools-scm 10 against a <10
ceiling) and failed the build. The image adds python-xlib, python-urllib3 and
python-pyzstd, none of which pull in anything 32-bit.

Two checks keep it honest. umu-run has to start, because a missing Python module
otherwise shows up as a game that never starts and reads like a script failure;
and no lib32 package may be installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:09:15 +03:00

716 lines
39 KiB
Docker

# ═══════════════════════════════════════════════════════════
# nestri guest rootfs — the open half
#
# Builds a bootable Arch image containing Mesa (virtio-gpu native context)
# and the six open guest components: nesinit, nescope, neshub, neswire,
# nescapture, nesgamepad. Two leaf targets, selected with `--target`:
#
# runtime_prod stripped, root locked (default: `make build`)
# runtime_debug debug tools, autologin root (`make build-debug`)
#
# There is no service manager, no init scripts and no udev. `nesinit` is PID 1
# and brings the box's services up from a table compiled into it, which is why
# this image is plain Arch rather than a distribution chosen for its init.
# ref(d-0064)
#
# Proton is NOT in this image any more. It reaches a box as a read-only
# image the host attaches, so a different one can be tried without
# rebuilding this. What stays here is everything Proton needs *around* it —
# its interpreter and its libraries — and the `compat_check` stage below
# proves a given Proton still has them. Valve's steamclient.so is a
# different thing and is NOT here either — that one is closed, and
# nestri/CLAUDE.md is explicit that nothing closed enters this repo.
# Whatever layers it on top of runtime_prod is a closed build outside this
# repo — see build/README.md.
#
# Build from the repo root, not from build/:
# docker build -f build/Containerfile --target runtime_prod -t nestri-guest .
# (`make build` in this directory does exactly that.)
# ═══════════════════════════════════════════════════════════
# Declared here and not beside the stage that uses it, because an ARG a FROM
# expands has to precede the *first* FROM in the file. Anywhere else it is
# scoped to one stage instead, `FROM ${PROTON_IMAGE}` expands to nothing, and
# the build fails with "no FROM statement found" — which says nothing about
# the actual mistake. See the Proton stage below for what this is.
ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-cachyos-native-wow64:11.0-20260703
# ───────────────────────────────────────────────────────────
# initial / builder
#
# The same distribution the guest is now, which it did not use to be: the
# guest was Artix, chosen for an init system this image no longer contains.
# Only build artifacts leave these stages.
# ───────────────────────────────────────────────────────────
FROM docker.io/cachyos/cachyos-v3:latest AS initial
RUN pacman -Syu --noconfirm
FROM initial AS builder
RUN pacman -S --noconfirm --needed \
cmake meson ninja git pkgconf mold \
python python-mako python-yaml python-packaging python-ply \
bison flex \
libpciaccess libepoxy libglvnd \
libx11 libxext libxrandr libxshmfence libxfixes libxxf86vm libxcb \
xcb-util-keysyms xorgproto \
wayland wayland-protocols \
expat zlib-ng-compat zstd libxml2 lm_sensors \
llvm clang libclc spirv-tools spirv-llvm-translator glslang shader-slang \
elfutils libva libdrm directx-headers \
rust rust-bindgen cbindgen \
curl openssl \
pixman libxkbcommon \
vulkan-headers vulkan-icd-loader \
pipewire shaderc opus \
libinput \
&& pacman -Scc --noconfirm
WORKDIR /build
ENV ARTIFACTS=/artifacts
ENV RUSTFLAGS="-C link-arg=-fuse-ld=mold"
# ───────────────────────────────────────────────────────────
# Mesa — the only piece still fetched from outside this tree
# ───────────────────────────────────────────────────────────
FROM builder AS mesa-build
ARG MESA_GIT=https://gitlab.freedesktop.org/nowrep/mesa.git
ARG MESA_COMMIT=49162a0ba8f3647917bc4c94d13eea6b51a1e49f
ARG JOBS=
# Our patches to the amdgpu native-context path, applied on top of the pinned
# commit. They are not cosmetic: upstream's winsys re-queries device-static
# facts on a path that costs a synchronous round trip to the host under
# virtio, and it was most of a frame's time. See each patch's own message.
COPY build/patches/mesa /build/patches/mesa
RUN test -n "$JOBS" || JOBS=$(nproc) && \
git clone --depth=1 --revision="${MESA_COMMIT}" "${MESA_GIT}" /build/mesa-src && \
cd /build/mesa-src && \
git apply --whitespace=nowarn /build/patches/mesa/*.patch && \
meson setup builddir \
-Dprefix=/usr \
-Dbuildtype=release \
-Dplatforms=wayland \
-Dgallium-drivers=zink \
-Dvulkan-drivers=amd,intel \
-Damdgpu-virtio=true \
-Dintel-virtio-experimental=true \
-Dvideo-codecs=all \
-Degl=disabled \
-Dglx=disabled \
-Dgles1=disabled \
-Dgles2=disabled \
-Dgbm=disabled \
-Dgallium-va=disabled \
-Db_ndebug=true && \
mold -run ninja -C builddir -j${JOBS:-$(nproc)} && \
DESTDIR=/artifacts/mesa ninja -C builddir install && \
rm -rf /build/mesa-src && \
find /artifacts/mesa -type f -printf '/%P\n' > /artifacts/mesa/.manifest
# ───────────────────────────────────────────────────────────
# nestri workspace — same repo now, so this is COPY, not a private clone
#
# One `cargo build --release` over the guest members rather than one stage per
# binary:
# that per-repo splitting existed because nescope/neswire/nescapture/the hub
# were four separate private repos and a stage boundary was the only way to
# stop bumping one from invalidating the others' build cache. They are one
# Cargo workspace with one Cargo.lock now, so a BuildKit cache mount on
# target/ gives the same isolation — cargo's own incremental compiler
# already knows nescope changing does not touch nesprotocol's .rlib — without
# four copies of every shared dependency getting compiled once per stage.
#
# The members are named rather than `--workspace`, because the workspace holds
# one crate that is not part of a guest — `nesdoctor` runs on a stranger's own
# machine — and building it here would compile something this image will never
# contain. Naming them also means adding a member does not silently add a
# binary to the image.
# ───────────────────────────────────────────────────────────
FROM builder AS nestri-src
WORKDIR /build/nestri
COPY Cargo.toml Cargo.lock ./
COPY crates/nesprotocol crates/nesprotocol
COPY crates/nespyro crates/nespyro
# Not a guest component and not installed below — it runs on a stranger's own
# machine. It is here because `cargo` loads every workspace member's manifest
# before it builds anything, so a member missing from the context fails the
# build outright with `failed to read .../Cargo.toml`. Copying it costs a few
# files; the member list is the thing that decides, not this build.
COPY apps/nesdoctor apps/nesdoctor
COPY apps/nesinit apps/nesinit
COPY apps/nescope apps/nescope
COPY apps/neshub apps/neshub
COPY apps/neswire apps/neswire
COPY apps/nescapture apps/nescapture
COPY apps/nesgamepad apps/nesgamepad
FROM nestri-src AS nestri-build
RUN --mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/build/nestri/target \
cargo build --release \
-p nesinit -p nescope -p neshub -p neswire -p nescapture -p nesgamepad && \
mkdir -p /artifacts/nestri/usr/bin /artifacts/nestri/usr/lib \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d && \
install -Dm755 target/release/nesinit /artifacts/nestri/usr/bin/nesinit && \
install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \
install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \
install -Dm755 target/release/neswire /artifacts/nestri/usr/bin/neswire && \
install -Dm755 target/release/nesgamepad /artifacts/nestri/usr/bin/nesgamepad && \
install -Dm755 target/release/libnescapture_layer.so \
/artifacts/nestri/usr/lib/libnescapture_layer.so && \
install -Dm644 apps/nescapture/manifest/VK_LAYER_nescapture.json \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d/VK_LAYER_nescapture.json && \
find /artifacts/nestri -type f -printf '/%P\n' > /artifacts/nestri/.manifest
# ───────────────────────────────────────────────────────────
# umu-launcher — built here, not installed from the distribution
#
# `umu-run` launches every title that is not Steam's, applying the game fixes
# Proton carries outside Steam the way launchers such as Heroic do.
#
# The distribution's package is not used because of what it declares: it
# depends on a long list of `lib32-*` libraries and desktop packages that exist
# for the Steam Linux Runtime container, which this image never runs — Proton
# runs on the libraries in here (see the compat_check stage). Building from
# source costs the package and none of that.
#
# Built against the distribution's own `urllib3` and `pyzstd` rather than the
# copies it can vendor: vendoring builds both from source with pinned build
# tooling, and a rolling distribution moves past those pins (a
# `setuptools-scm` too new for them failed the build). The image installs the
# two as ordinary Python packages, with no 32-bit anything.
#
# Pinned by tag; it moves deliberately.
# ───────────────────────────────────────────────────────────
FROM builder AS umu-build
ARG UMU_GIT=https://github.com/Open-Wine-Components/umu-launcher.git
ARG UMU_TAG=1.4.4
RUN pacman -S --noconfirm --needed \
make scdoc gcc \
python-build python-hatchling python-installer python-pip \
&& pacman -Scc --noconfirm
RUN git clone --depth 1 --branch "${UMU_TAG}" --recurse-submodules --shallow-submodules \
"${UMU_GIT}" /umu && \
cd /umu && \
./configure.sh --prefix=/usr --use-system-pyzstd --use-system-urllib && \
make && \
make DESTDIR=/artifacts/umu install
# ───────────────────────────────────────────────────────────
# Proton — pulled to be checked, not shipped
#
# Building it takes hours and it changes only when its own tag moves, so it
# has a cadence of its own and an image of its own. The published image is
# `FROM scratch` over the tree, so its root *is* the tree and there is nothing
# in it to run — only something to copy from.
#
# It is copied into the `compat_check` stage and nowhere else. A box gets its
# Proton from the host, as an image attached at run time; this is only the
# Proton this rootfs is verified against, and any other can be checked the same
# way by overriding it.
#
# Built wow64-only, which is the whole reason it is a build of ours rather
# than a released one at the time. wow64 runs 32-bit Windows code inside a
# 64-bit unix process, so a box needs no lib32 anything: no 32-bit glibc, no
# second Mesa for i686, and — the one that matters most here — no second
# capture layer, because the game is a 64-bit process and loads the 64-bit
# Vulkan loader the existing layer already sits in. Upstream Wine defaults to
# it from Proton 11, so a released build needs no more than ours does.
#
# Override to check another; the tag is a version and moves deliberately.
# ───────────────────────────────────────────────────────────
FROM ${PROTON_IMAGE} AS proton
# ───────────────────────────────────────────────────────────
# os-base — the Arch rootfs itself
#
# `FROM archlinux:base` directly, and `pacman -S` as plain RUN steps — not a
# privileged host `chroot` into a hand-extracted tarball, which would need
# /proc, /sys and /dev bind-mounted in first (they don't exist inside a chroot
# target until something puts them there). A Containerfile RUN step already
# executes inside a real container with its own /proc, /sys, /dev, so there is
# no bind-mount step to write at all.
#
# # systemd goes; systemd-libs stays
#
# Nothing in the package list below depends on `systemd`, and two things in it
# — dbus-daemon and wireplumber — link `libsystemd.so.0`, which comes from the
# separate `systemd-libs` package. So the removal is `-Rdd` of `systemd` and
# `systemd-sysvcompat` only, which is normal rather than a compromise: keeping
# the library while having no PID 1 from it is exactly how a distribution
# without systemd ships these same programs.
#
# Removing the package also removes its pacman hooks, which is the point. The
# hooks call `systemd-tmpfiles`, `systemd-sysusers` and `udevadm`; leaving them
# behind while deleting what they call is how a later transaction fails
# obscurely, and a previous attempt at this image lost two services to exactly
# that.
# ───────────────────────────────────────────────────────────
FROM docker.io/cachyos/cachyos-v3:latest AS os-base
# The base image ships an unsigned local keyring, so upgrading
# `archlinux-keyring` runs a hook that reports `There is no secret key
# available to sign with` and then `error: command failed to execute
# correctly`. It is cosmetic and it is also every Arch container's build log.
# One line fixes it, and it is worth the seconds: a build that always prints an
# error is a build nobody reads an error out of.
RUN pacman-key --init
# Installed first and removed second, so every dependency resolves normally
# before anything is taken out from under it.
#
# Gone with the init system: `openrc`, `udev`, `dbus-openrc`. `udev` is not
# replaced by anything — `devtmpfs` creates the nodes and init sets the two
# modes that matter, because the compositor takes input through Wayland and
# opens nothing udev provides. ref(d-0064)
#
# `logrotate` is also gone, and that one is not about the init system: a box
# keeps no logs to rotate. What is worth reading leaves over the control
# channel, and `/var/log` is a small tmpfs that is discarded with the box.
# `mesa` is not in this list, and the two `--assume-installed` flags are why.
#
# The distribution's Mesa used to be installed so that every runtime dependency
# of *a* Mesa was present and correctly versioned, and ours was then overlaid
# on top. That worked for the unversioned filenames and not for the versioned
# one: `libgallium-<version>.so` from the package sat beside ours, 53 MB of it,
# referenced by nothing. Installing it to overwrite most of it was always the
# roundabout way round; telling pacman the dependency is already satisfied is
# the direct one.
#
# Exactly two flags are needed and both were checked by dropping each in turn:
# `mesa` is the name two packages depend on, and `opengl-driver` is a virtual
# provide `libglvnd` requires that only a real driver package satisfies. The
# other three names Mesa provides — `mesa-libgl`, `libva-driver`,
# `libva-mesa-driver` — change nothing here, so they are not listed.
#
# What makes this safe is that our Mesa is a superset for this image's
# purposes: it builds the drivers a box can actually use and the package's
# other ones (apple, asahi, armada, d3d12) are for hardware no box has. What it
# does *not* build is a software rasteriser, so there is no llvmpipe fallback —
# a box with no working GPU path now fails instead of rendering slowly, which
# is the honest outcome for something that exists to stream frames.
#
# Two packages below are explicit *because* Mesa is gone, and both used to
# arrive as its dependencies: `llvm-libs`, which the radeonsi driver links for
# shader compilation, and `lm_sensors`, which it links for `libsensors.so.5`.
# The second was found by the check further down rather than by reading the
# list — dropping a package takes its dependency tree with it, and the part of
# that tree something else was quietly using is not visible from here.
RUN pacman -Syu --noconfirm --needed \
--assume-installed mesa --assume-installed opengl-driver \
dbus \
iptables iproute2 \
libglvnd libdrm libepoxy libxxf86vm libinput wayland \
expat zlib-ng-compat llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
pipewire pipewire-audio wireplumber opus \
python libunwind sdl2-compat \
python-xlib python-urllib3 python-pyzstd \
&& rm -f /usr/share/libalpm/hooks/dbus-reload.hook \
&& pacman -Rdd --noconfirm systemd systemd-sysvcompat openssh \
&& pacman -Scc --noconfirm
# `libunwind` is Wine's, not ours. `ntdll.so` links it, so without it every
# process Wine starts dies at `could not load ntdll.so`, which is the first
# thing it loads and reads like Wine itself being broken. Found 2026-09-12,
# after the prefix had already been created -- so the session got past every
# check that Proton was present and usable.
#
# `python` is not a build dependency here -- the builder stage has its own for
# Mesa -- it is a *runtime* one. The compatibility tool's entry point is a
# Python script, so a box without an interpreter starts a game and the launch
# ends with `env: 'python3': No such file or directory` and an exit status that
# reads like an ordinary finish. Found 2026-09-12, on the first session that
# got as far as launching one.
# `sdl2-compat` is Wine's controller support, and the only one there is for a
# controller that is not a Steam Input one. Proton's device bus reads evdev
# controllers through SDL2 and nothing else -- its udev backend defers every
# evdev device to the SDL one on purpose -- and it loads the library with
# `dlopen`, so its absence fails nothing at build time or at launch. A game
# simply sees no controller, while `nesgamepad` reports it plugged in. Found
# 2026-09-27, on the first session with one. Real Steam brings SDL in its
# runtime, which is why this is never missed anywhere else. On Arch the
# library is SDL2's API over SDL3, which arrives as its dependency.
# `dbus-reload.hook` is deleted above, before the removal rather than after,
# and it is the whole reason that line is there: the hook runs
# `/usr/share/libalpm/scripts/systemd-hook`, which systemd owns, so the
# transaction that removes systemd trips its own leftover on the way out —
# `call to execv failed`, then `error: command failed to execute correctly`.
# pacman treats a post-transaction hook failure as non-fatal, so the build
# survives it and the image is fine; what it leaves is an error message in
# every future transaction and a reader with no way to tell it from a real
# one. Removing the hook first means the error never happens.
# Nothing left may point at a program that is not here.
#
# The specific case above is fixed; this is the general one, and it exists
# because a hook calling a deleted binary is the exact shape of the failure
# that took two services off a previous version of this image. A build error
# is a much better place to find the next one than a log.
RUN for hook in /usr/share/libalpm/hooks/*.hook; do \
exec_line="$(awk -F'= *' '/^Exec/ { print $2; exit }' "$hook")"; \
program="${exec_line%% *}"; \
case "$program" in /*) ;; *) continue ;; esac; \
test -e "$program" \
|| { echo "$(basename "$hook") runs $program, which is not in the image" >&2; exit 1; }; \
done
# The check, because the removal above is the kind of thing a later `pacman
# -Syu` undoes quietly. A box with systemd's PID 1 back in it boots something
# other than `nesinit`, and the symptom is a guest that never dials out.
RUN test ! -e /usr/lib/systemd/systemd \
|| { echo "systemd's PID 1 is back in the image" >&2; exit 1; }
# `groupadd -f`, because some of these already exist in the base image and the
# rest have to. Nothing creates them at runtime any more: udev's rules did that
# for device nodes, and with udev gone init sets the two modes that matter
# directly. ref(d-0064)
#
# **Two users, and they must stay two.** `nestri` runs the services that come
# with this image; `nesplay` is who a workload runs as. Sharing one user between
# them is what lets workload code impersonate a service — it can replace the
# socket a service listens on and answer in its place, and the answer that
# matters is the address a client is told to connect to. Init refuses an address
# served by the workload's own user, so a single shared user does not merely
# weaken that check, it makes every session fail it.
#
# The uid a workload actually runs as is chosen by whoever asks for the box, not
# here; this account exists so that the number has a home, a shell and a name in
# `ps`, and so the separation has somewhere to be written down.
RUN groupadd -f audio && groupadd -f video && groupadd -f input && groupadd -f render && \
useradd -m -u 1000 -s /bin/bash nestri && \
for g in audio video input render; do gpasswd -a nestri "$g" >/dev/null; done && \
useradd -m -u 1001 -s /bin/bash nesplay && \
for g in audio video input render; do gpasswd -a nesplay "$g" >/dev/null; done
# ───────────────────────────────────────────────────────────
# runtime — everything common to debug and prod
# ───────────────────────────────────────────────────────────
FROM os-base AS runtime
# This is what GHCR actually uses to connect a pushed image back to its
# repo — not a setting to toggle after the fact, a label the image has to
# carry. Without it a manually-pushed image shows no "used by" repo on its
# package page even though this Containerfile is exactly what built it.
LABEL org.opencontainers.image.source="https://github.com/nestrilabs/nestri"
# Our own builds, overlaid on the distro's mesa. The distro package landed
# first (above) so every runtime dependency of *a* Mesa is present and
# correctly versioned; this overwrites its .so files with ours.
#
# COPY --from runs as root inside this build with no invoking-user uid to
# stamp onto / or /usr/bin, unlike a host-side `podman cp` + `cp -a` — so
# there is no ownership-sanity-check to write here. Nothing to catch, on
# purpose, not an oversight.
COPY --from=mesa-build /artifacts/mesa/.manifest /tmp/mesa.manifest
COPY --from=nestri-build /artifacts/nestri/.manifest /tmp/nestri.manifest
RUN cat /tmp/mesa.manifest /tmp/nestri.manifest > /tmp/.strip-manifest && \
rm -f /tmp/mesa.manifest /tmp/nestri.manifest
COPY --from=mesa-build /artifacts/mesa /
COPY --from=nestri-build /artifacts/nestri /
COPY --from=umu-build /artifacts/umu /
# **No Proton here.** It is attached to a box at run time, mounted at
# /nestri/compat, so this image has no Proton path for a launch to fall back to
# and a box started without one fails saying where it looked.
RUN ldconfig
COPY build/etc/ /etc/
# `nesinit` is PID 1, and `/usr/bin/init` is the fallback for a kernel started
# without an explicit `init=`. `systemd-sysvcompat` used to own that path and
# was removed with the rest of systemd, so nothing else claims it.
RUN ln -sf nesinit /usr/bin/init
# One id per boot, not one per image.
#
# `dbus-uuidgen --ensure=/etc/machine-id` used to run here, which baked one id
# into the image and made every box built from it the same machine. Init writes
# a fresh one to /run at boot instead, so both of these are symlinks into a
# tmpfs — which is also the only place they could be, with a read-only root.
RUN rm -f /etc/machine-id /var/lib/dbus/machine-id && \
mkdir -p /var/lib/dbus && \
ln -sf /run/machine-id /etc/machine-id && \
ln -sf /run/machine-id /var/lib/dbus/machine-id
# Session mount points. The guest root is read-only at runtime, so a runtime
# mkdir gets EROFS and takes a service down before it starts — these have to
# already exist in the image.
#
# `/nestri/logs` is a mount point and nothing mounts it from in here any more:
# a share arrives because the caller named it in the boot descriptor, which is
# the same rule every other share follows. The directory stays so that naming
# it works.
RUN mkdir -p /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
chmod 0755 /nestri /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
mkdir -p /dev/shm && chmod 1777 /dev/shm && \
mkdir -p /run/user/1000 /var/log && \
# The distribution's own empty `fstab` goes with ours. Nothing in a box
# reads either: init mounts what a box always needs, and every share comes
# from the boot descriptor. A file that looks like it configures mounts and
# is read by nothing is a file somebody edits expecting an effect.
rm -f /etc/network/interfaces /etc/inittab /etc/fstab
# Nothing in this image may be an init system except `nesinit`.
#
# A service manager arriving as a dependency of something innocuous is the
# failure this catches, and it is silent otherwise: the extra init does not run
# — the kernel is told which one to start — it just sits there with its own
# ideas about what the box should be doing, waiting for somebody to wire it in.
RUN for intruder in /usr/lib/systemd/systemd /sbin/openrc-init /usr/bin/openrc-init \
/sbin/runit-init /usr/bin/runit-init /sbin/dinit /usr/bin/dinit; do \
test ! -e "$intruder" || { echo "a second init is in the image: $intruder" >&2; exit 1; }; \
done
# What `nesinit` will look for at runtime, checked while there is somebody to
# read the failure.
#
# It is a table compiled into a binary, so a missing program is not a build
# error — it is a service that does not come up in a box somebody is waiting
# on, reported over the control channel and read hours later. Checking here
# turns that into a failed build.
# Everything this image promises must resolve the libraries it links.
#
# This is the check the Mesa change needs: dropping a package that provided
# libraries is how a binary ends up resolving nothing, and the symptom is not a
# build failure — it is a service that will not start in a box somebody is
# waiting on, or a render path that is missing rather than slow. It caught
# exactly that on the first run, and the missing library was two levels down a
# dependency tree nobody had reason to read.
#
# **Named rather than swept, and that is deliberate.** A sweep over everything
# in /usr/lib fails on a stock image: a distribution ships optional plugins
# whose optional dependencies are not installed — pinentry's Qt build, mpg123's
# JACK output, libdecor's GTK backend — and every one of those was already
# unresolved before this stage existed. A check that reports a dozen things
# nobody intends to load is a check the next person deletes. This list is what
# the image is *for*: the components, the services init starts, and the chain
# between a workload and the GPU. Wine's own core is checked by the
# `compat_check` stage, against the Proton it is actually going to run.
#
# **Wine was added after it was missed**, and then narrowed twice, which is
# worth recording so nobody widens it again.
#
# It was missed because the list covered everything this image ships *of ours*
# and nothing of the compatibility tool's, so an unresolved `libunwind.so.8`
# behind `ntdll.so` survived a build whose whole purpose is catching that, and
# surfaced as a session that created a prefix and could not start one process
# in it.
#
# The obvious fix -- sweep every `*-unix/*.so` -- is wrong in both directions.
# It is noisy: those objects are Wine's optional backends, and their
# dependencies are a camera library, a media stack, a VR loader, a smartcard
# daemon and OpenCL, none of which belong in a box. And it cannot see what it
# is checking: Wine's unix objects **link each other by soname** and are
# resolved by Wine's own loader rather than by `ld.so`, so `ldd` reports
# `ntdll.so` and `win32u.so` themselves as missing while they sit in the same
# directory. Forty files, every one a false positive, hiding the one real
# entry.
#
# So: the programs in `bin/`, which are ordinary ELF and resolve normally,
# `ntdll.so`, which is the first thing Wine loads and the one that linked the
# missing library, and `winepulse.so`, which is audio. That is exactly the
# failure that got through, with none of the noise. The Windows-side DLLs beside
# them are not ELF and `ldd` skips them anyway.
#
# Each file is resolved with its own directory on the library path. That is
# where Wine's loader finds the siblings a unix object links by soname, so
# `winepulse.so` naming `ntdll.so` resolves the way it does at run time, and
# what is left is a library the image really lacks. For every other file in
# the list it changes nothing.
# The output is one file per line with its own missing libraries under it, and
# then every missing library once at the end. That last list is what somebody
# acts on -- it is the set of packages to add -- and forty files each naming the
# same two libraries is not that list. An earlier version printed one
# comma-joined line and cut the wrong field out of `ldd`, so it named no
# libraries at all: `ldd` indents with a tab, which `tr -s ' '` does not
# collapse, so the second space-separated field is `=>`.
RUN failed=0; \
: > /tmp/missing-libs; \
for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/bin/nesgamepad /usr/lib/libnescapture_layer.so /usr/lib/libSDL2-2.0.so.0 \
/usr/bin/dbus-daemon /usr/bin/pipewire \
/usr/bin/wireplumber /usr/bin/ip \
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
/usr/lib/libvulkan_*.so /usr/lib/dri/*.so /usr/lib/gbm/*.so; do \
[ -e "$f" ] || continue; \
libs="$(LD_LIBRARY_PATH="${f%/*}" ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \
[ -n "$libs" ] || continue; \
failed=1; \
printf ' %s\n' "$f" >&2; \
printf ' %s\n' $libs >&2; \
printf '%s\n' $libs >> /tmp/missing-libs; \
done; \
if [ "$failed" != 0 ]; then \
echo "" >&2; \
echo " every library above, once each -- this is the list to install:" >&2; \
sort -u /tmp/missing-libs | sed 's/^/ /' >&2; \
exit 1; \
fi
RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/bin/nesgamepad /usr/lib/libSDL2-2.0.so.0 \
/usr/bin/dbus-daemon /usr/bin/pipewire \
/usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 /usr/bin/umu-run; do \
test -x "$required" || { echo "the image is missing $required" >&2; exit 1; }; \
done
# `umu-run` has to start, not only exist. It is a Python program built in another
# stage: a module it imports that this image lacks, or an interpreter that moved
# a minor version between that stage and this one, is a game that never starts —
# and the launch reports a failure to run a script, not a missing module.
RUN umu-run --help >/dev/null 2>&1 \
|| { echo "umu-run does not start; run it by hand to see the module or interpreter it lacks" >&2; \
umu-run --help >&2 || true; exit 1; }
# No 32-bit libraries came with it. The distribution's umu package would have
# brought a long list of them for the container runtime this image never runs,
# which is why it is built instead of installed; this is the check that stays
# true if somebody swaps it back.
RUN ! pacman -Qq | grep -q '^lib32-' \
|| { echo "a lib32 package is installed:" >&2; pacman -Qq | grep '^lib32-' >&2; exit 1; }
# ───────────────────────────────────────────────────────────
# compat_check — does this rootfs still carry what a Proton needs?
#
# Proton is not part of the image, so what used to be checked by having it
# there is checked here, against the Proton named by PROTON_IMAGE: that every
# library its programs and Wine's core link is present, and that its entry
# point has an interpreter. A rootfs that loses one of them otherwise finds out
# as a game that starts and never draws.
#
# Not a stage anything runs. The leaf stages copy a one-line marker out of it
# so that a build cannot skip it as unused, which is also what records which
# Proton the image was verified against.
#
# Check any other Proton the same way:
# make PROTON_IMAGE=<ref> build
# where <ref> is an image whose root is a Proton tree, or a tree under
# `usr/share/steam/compatibilitytools.d/<name>/`.
# ───────────────────────────────────────────────────────────
FROM runtime AS compat_check
ARG PROTON_IMAGE
COPY --from=proton / /opt/compat-check/
# The Proton root is the directory that holds `proton`, wherever the image put
# it: a tree directly, or under compatibilitytools.d as the published one does.
# Exactly one is expected, and anything else is refused -- picking the first of
# several is how the wrong Proton gets verified and the right one never is.
RUN set -eu; \
found="$(find /opt/compat-check -maxdepth 6 -name proton -type f)"; \
[ "$(printf '%s\n' "$found" | grep -c .)" = 1 ] \
|| { echo "expected one Proton in ${PROTON_IMAGE}, found:" >&2; echo "$found" >&2; exit 1; }; \
dirname "$found" > /tmp/proton-root; \
root="$(cat /tmp/proton-root)"; \
test -x "$root/proton" || { echo "$root/proton is not executable" >&2; exit 1; }; \
test -d "$root/files" || { echo "$root has no files/ directory" >&2; exit 1; }
# The libraries, by the method the runtime stage documents: the programs in
# `bin/`, which resolve normally, and `ntdll.so`, which is Wine's first load and
# the one that linked the library once found missing. Each file is resolved with
# its own directory on the library path, because Wine's unix objects link each
# other by soname.
RUN root="$(cat /tmp/proton-root)"; failed=0; : > /tmp/missing-libs; \
for f in "$root"/files/bin/* "$root"/files/lib*/wine/*-unix/ntdll.so; do \
[ -e "$f" ] || continue; \
libs="$(LD_LIBRARY_PATH="${f%/*}" ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \
[ -n "$libs" ] || continue; \
failed=1; \
printf ' %s\n' "$f" >&2; \
printf ' %s\n' $libs >&2; \
printf '%s\n' $libs >> /tmp/missing-libs; \
done; \
if [ "$failed" != 0 ]; then \
echo "" >&2; \
echo " every library above, once each -- this is the list to install:" >&2; \
sort -u /tmp/missing-libs | sed 's/^/ /' >&2; \
exit 1; \
fi
# An entry point that is executable is not an entry point that runs.
#
# A check that only asked `test -x` passed on an image whose compatibility tool
# was a Python script with no interpreter behind it: the kernel then fails to
# find what the shebang names, and a session got as far as launching a game and
# ended with `env: 'python3': No such file or directory`.
#
# So the entry point resolves its own interpreter. `env` is unwrapped where it
# is used, because a shebang of `#!/usr/bin/env python3` names `env` and the
# thing that is actually missing is the argument.
RUN script="$(cat /tmp/proton-root)/proton"; \
head -c 2 "$script" | grep -q '#!' || exit 0; \
shebang="$(head -1 "$script" | sed 's/^#!//')"; \
interpreter="${shebang%% *}"; \
case "$interpreter" in \
*/env) argument="${shebang#* }"; interpreter="$(command -v "${argument%% *}" || true)";; \
esac; \
test -n "$interpreter" && test -x "$interpreter" \
|| { echo "$script needs an interpreter the image does not have: $shebang" >&2; exit 1; }
RUN echo "${PROTON_IMAGE}" > /compat-verified
# ───────────────────────────────────────────────────────────
# runtime_prod — the default: `make build`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_prod
# Which Proton this image was verified against, and the reason the check stage
# is built at all. See `compat_check`.
COPY --from=compat_check /compat-verified /usr/share/nestri/compat-verified
# No console is offered by either flavour: `nesinit` spawns no getty, because
# the way into a guest that will not boot is `init=/bin/bash` on the kernel
# command line, which needs nothing from the image but a shell. So the locked
# root account is belt and braces rather than the only thing standing between
# a box and a login prompt.
RUN passwd -l root
RUN while IFS= read -r f; do \
[ -f "$f" ] && strip --strip-unneeded "$f" 2>/dev/null || true; \
done < /tmp/.strip-manifest; \
rm -rf /tmp/.strip-manifest /var/cache/pacman/pkg/* /tmp/* /root/.cache \
/usr/share/man /usr/share/doc /usr/share/locale \
/usr/lib/cmake /usr/lib/pkgconfig /usr/share/pkgconfig /usr/include \
/usr/share/gir-1.0 /usr/lib/udev; \
find /usr/lib -name '*.a' -delete
RUN echo "NESTRI_STAGE=runtime_prod" >> /etc/os-release
# ───────────────────────────────────────────────────────────
# runtime_debug — `make build-debug`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_debug
COPY --from=compat_check /compat-verified /usr/share/nestri/compat-verified
# `-Sy` and not `-Syu`: a full upgrade here can pull a package back in as
# somebody's dependency, and the one that matters is systemd. The check below
# catches it either way, but a debug image that fails to build is worse than
# one that is a few days behind on versions it only uses for `vulkaninfo`.
RUN pacman -Sy --noconfirm --needed vulkan-tools mesa-utils libva-utils && \
pacman -Scc --noconfirm
# The same guard as the runtime stage, because the transaction above is exactly
# the kind that quietly reinstates an init system.
RUN test ! -e /usr/lib/systemd/systemd \
|| { echo "systemd's PID 1 came back with the debug tools" >&2; exit 1; }
# Root has a password here and nothing offers a login prompt to type it into.
# It is for `su` from an `init=/bin/bash` shell, which is the whole debug route.
RUN echo 'root:nestri' | chpasswd
RUN echo "NESTRI_STAGE=runtime_debug" >> /etc/os-release