From 27216154974bf6b199c52b30a8575cc65e1520cd Mon Sep 17 00:00:00 2001 From: Noah Metz Date: Tue, 28 Jul 2026 20:10:51 -0600 Subject: [PATCH] Move channels.scm, scripts/, docker/ in from the separate system repo That repo (system) is being deprecated in favor of keeping deploy tooling alongside the channel it deploys -- cloning this repo alone is now enough to either build docker images for local testing (docker/, docker-compose.yml, scripts/guix-docker.sh) or reconfigure a real machine (channels.scm registers this channel plus nonguix/games/guix; scripts/qemu.sh and network.sh for the KVM-based workflow on real Linux hosts). docker-compose.yml and scripts/guix-docker.sh updated for the merge: no more local-channel indirection (docker/channels.local.scm, a file:// bind mount to a sibling repo) -- guix system docker-image now passes -L /workspace directly against this checkout, so uncommitted edits are picked up immediately without a commit+pull round-trip. --- channels.d/metznet.scm | 3 + channels.d/nonguix.scm | 8 +++ channels.d/nonguixgames.scm | 10 +++ channels.scm | 10 +++ docker-compose.yml | 21 ++++++ docker/Dockerfile | 38 ++++++++++ docker/README.md | 135 ++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 24 +++++++ scripts/guix-docker.sh | 76 ++++++++++++++++++++ scripts/network.sh | 62 +++++++++++++++++ scripts/qemu.sh | 44 ++++++++++++ 11 files changed, 431 insertions(+) create mode 100644 channels.d/metznet.scm create mode 100644 channels.d/nonguix.scm create mode 100644 channels.d/nonguixgames.scm create mode 100644 channels.scm create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100755 docker/entrypoint.sh create mode 100755 scripts/guix-docker.sh create mode 100755 scripts/network.sh create mode 100755 scripts/qemu.sh diff --git a/channels.d/metznet.scm b/channels.d/metznet.scm new file mode 100644 index 0000000..c18be1d --- /dev/null +++ b/channels.d/metznet.scm @@ -0,0 +1,3 @@ +(channel + (name 'metznet-channel) + (url "https://git.metznet.ca/MetzNet/metznet-channel")) diff --git a/channels.d/nonguix.scm b/channels.d/nonguix.scm new file mode 100644 index 0000000..5ee3277 --- /dev/null +++ b/channels.d/nonguix.scm @@ -0,0 +1,8 @@ +(channel + (name 'nonguix) + (url "https://gitlab.com/nonguix/nonguix") + (introduction + (make-channel-introduction + "897c1a470da759236cc11798f4e0a5f7d4d59fbc" + (openpgp-fingerprint + "2A39 3FFF 68F4 EF7A 3D29 12AF 6F51 20A0 22FB B2D5")))) diff --git a/channels.d/nonguixgames.scm b/channels.d/nonguixgames.scm new file mode 100644 index 0000000..94c50e3 --- /dev/null +++ b/channels.d/nonguixgames.scm @@ -0,0 +1,10 @@ +(channel + (name 'guix-gaming-games) + (url "https://gitlab.com/guix-gaming-channels/games.git") + ;; Enable signature verification: + (introduction + (make-channel-introduction + "c23d64f1b8cc086659f8781b27ab6c7314c5cca5" + (openpgp-fingerprint + "50F3 3E2E 5B0C 3D90 0424 ABE8 9BDC F497 A4BB CC7F")))) + diff --git a/channels.scm b/channels.scm new file mode 100644 index 0000000..f971588 --- /dev/null +++ b/channels.scm @@ -0,0 +1,10 @@ +;; channels.scm -- reads the channels.d dir to get a list of channel objects + +(use-modules (gnu) ((ice-9 ftw) #:select (scandir))) + +(define channel-files + (scandir "./channels.d" + (lambda (file) + (string-suffix? ".scm" file)))) + +(append (map (lambda (file) (load (string-append "./channels.d/" file))) channel-files) %default-channels) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87a7f12 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + guix: + platform: linux/amd64 + build: + context: docker + volumes: + - guix-store:/gnu + - guix-state:/var/guix + - guix-cache:/root/.cache + - .:/workspace + working_dir: /workspace + environment: + DOMAIN_CAPS: ${DOMAIN_CAPS:-METZNET.CA} + DOMAIN_NAME: ${DOMAIN_NAME:-metznet.ca} + tty: true + stdin_open: true + +volumes: + guix-store: + guix-state: + guix-cache: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c02da6e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,38 @@ +# Guix build environment for building `guix system docker-image` images +# from this repo on a machine (e.g. macOS) that can't run Guix natively. +# +# Guix state lives under /gnu and /var/guix, which the compose file mounts +# as named volumes so pulls/builds are cached across container runs. This +# image only bakes in the *initial* binary install; everything built via +# `guix pull` / `guix system` happens at `docker run` time against those +# volumes. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash gnupg dirmngr wget ca-certificates xz-utils tar locales procps sudo netbase \ + && rm -rf /var/lib/apt/lists/* \ + && sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen \ + && locale-gen + +# Official installer: extracts the binary tarball to /gnu and /var/guix, +# creates the guixbuild group/users, and (since no init system is present +# in the container) prints instructions to run guix-daemon manually, which +# entrypoint.sh does. `yes ''` answers every prompt with its default, +# which includes authorizing the ci.guix.gnu.org / bordeaux substitute +# servers so builds don't compile everything from source. +RUN wget -O /tmp/guix-install.sh https://guix.gnu.org/guix-install.sh \ + && chmod +x /tmp/guix-install.sh \ + && yes '' | /tmp/guix-install.sh \ + && rm -f /tmp/guix-install.sh \ + && test -x /root/.config/guix/current/bin/guix + +ENV PATH="/root/.config/guix/current/bin:/usr/local/bin:${PATH}" \ + GUIX_LOCPATH="/root/.config/guix/current/lib/locale" \ + LANG="en_US.UTF-8" + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["bash"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..5922404 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,135 @@ +# Guix-in-Docker build environment + +Guix has no native macOS build, so this runs a Debian container with Guix +installed (official binary tarball) as the build machine, and uses +`guix system docker-image` to turn `metznet/machines/*.scm` into Docker +images you can `docker load`/`docker run` directly in Docker Desktop. No +QEMU/KVM required, which is why it works on macOS where `scripts/qemu.sh` +(KVM-only) does not. + +The builder container runs as `linux/amd64` (via Rosetta on Apple +Silicon), matching real deployment hardware — nonguix substitutes are +x86_64-focused, so building on aarch64 forces a lot of expensive +from-source builds (and occasionally hits GCC/architecture edge cases) +that just don't happen on amd64. + +## Why this works without a bootloader/kernel/real disks + +`guix system docker-image` (and `guix system container`) transform the +`operating-system` value before building: the real `kernel`, `bootloader`, +and `file-systems` fields are all replaced/ignored (see +`containerized-operating-system` in Guix's `gnu/system/linux-container.scm`), +along with services that make no sense in a container (`mingetty`, +`agetty`, static networking). Nothing in `metznet/machines/*.scm` needs to +change for this to work. + +What *isn't* swapped out: services like `dhcpcd-service-type` and +`openvpn-client-service-type` (in `%metznet-server-services`) will still +try to run and will likely fail/hang inside a container (no real DHCP +server or VPN endpoint reachable) — that's expected. Pass `-N`/`--network` +(the `image` subcommand below always does) so a dummy `networking` +shepherd service is provided up front; other services that just +`(requirement '(networking ...))` (openssh, slapd, kdc) come up fine +regardless of whether the DHCP/VPN services themselves succeed. + +## Container internals (why the Dockerfile/entrypoint look the way they do) + +- `netbase` is installed explicitly: `debian:bookworm-slim` ships without + `/etc/services`, and Guile's `getaddrinfo` needs it to resolve `https`/ + `http` as service names. Without it, every network fetch inside Guix + (substitutes, source downloads, even channel git clones from the *guix + pull* subprocess) fails with a cryptic `In procedure getaddrinfo: + Servname not supported for ai_socktype` — nothing to do with DNS or + certs despite how it reads. +- `entrypoint.sh` starts `guix-daemon --disable-chroot`. Sandboxed builds + normally isolate via Linux namespaces (`unshare`/`clone`), which Docker + blocks by default even for root (`clone: Operation not permitted`). + We're already isolated by the outer container, so build isolation is + traded away here rather than running the whole thing `--privileged`. + One side effect: guix-daemon's store-path grafting (security-patch + rewriting) hits a permission error creating output files under + `--disable-chroot`, so `scripts/guix-docker.sh image` always passes + `--no-grafts` (uses un-grafted originals instead — fine for testing). +- It also always kills and restarts `guix-daemon` rather than checking + if its socket already exists: `/var/guix` is a persistent volume, so a + *stale* socket file from a previous (already-exited) container can + still be sitting there even though nothing is listening on it. +- `ps` (and possibly other procps tools) crash reliably under Rosetta + with `assertion failed [true_path_length_self >= 0]` — a Rosetta bug, + not a Guix issue. Doesn't affect the actual build; just don't rely on + `docker exec ps` to check progress. `du -sh /gnu/store` or + watching the log works fine. + +## One-time setup + +``` +scripts/guix-docker.sh build # builds the builder image (~1-2 min) +scripts/guix-docker.sh pull # guix pull -C channels.scm +``` + +`pull` needs network access to gitlab.com (nonguix, nonguix-games), +git.metznet.ca, and Savannah/git.guix.gnu.org. Re-run it whenever +`channels.scm`/`channels.d/*.scm` change. The main `guix` channel alone +is ~14.8k commits to authenticate the first time, which is genuinely slow +(several minutes of CPU) — after that it's cached (see below) and +re-pulls are fast. + +Guix's store (`/gnu`), daemon state (`/var/guix`), and git-checkout/ +authentication cache (`/root/.cache`, notably `~/.cache/guix/checkouts` +and `~/.cache/guix/authentication`) all live in named Docker volumes, so +none of that expensive work repeats across runs. `docker compose down -v` +wipes everything if you ever want a clean slate (necessary if you ever +switch the `platform:` in `docker-compose.yml` — a store built for one +architecture isn't usable from another). + +## Building a system as a docker image + +``` +scripts/guix-docker.sh image metznet/machines/ldap.scm +docker load < build/ldap.tar.gz +docker run --rm -it /run/current-system/profile/bin/bash --login +``` + +or do build+load+run in one step: + +``` +scripts/guix-docker.sh load-run metznet/machines/ldap.scm +``` + +`image`/`load-run` pass `-L /workspace` (this checkout, bind-mounted +live), so uncommitted local edits to any `metznet/**` module are picked +up directly — no commit/pull round-trip needed, unlike a real `guix +pull` on a deployed machine. + +`DOMAIN_CAPS`/`DOMAIN_NAME` default to `METZNET.CA`/`metznet.ca` in +`docker-compose.yml`, matching `scripts/qemu.sh`; override by exporting +them before calling `guix-docker.sh`, or in a `.env` file next to +`docker-compose.yml`. + +Once running, `herd status` inside the container shows shepherd service +state — useful for checking whether e.g. `slapd` actually came up. + +## Known gotchas + +- `channels.d/metznet.scm` has no `introduction`, so `guix pull` doesn't + authenticate it (unlike nonguix/games, which do). +- `metznet/machines/otto.scm` (Noah's laptop) has `firefox` and the ARM + embedded toolchain (`make-arm-none-eabi-toolchain-7-2018-q2-update`) + removed from its package list — both require building an entire + LLVM/Clang toolchain from scratch (no substitute currently available, + even on x86_64), which is impractical for routine testing. Add them + back with `(make-arm-none-eabi-toolchain-7-2018-q2-update)` (a + zero-arg constructor from `(gnu packages embedded)`, not a bare + package name — that changed upstream) and `firefox` (from `(nongnu + packages mozilla)`) if you need them for a real build/deploy. +- Package-level quirks fixed in this channel to build at all against + current Guix (all in `metznet/packages/`, not upstream Guix changes): + `metznet-sssd` disables tests (one fails only under Docker's emulated + filesystem/Rosetta, not a real regression) and strips the + `--install-layout=deb` flag setuptools no longer accepts; + `metznet-kitty` disables tests for the same reason (`O_TMPFILE` + atomicity isn't honored by Docker Desktop's virtualized filesystem on + macOS); `openldap-slapd` forces `-std=gnu17` plus several `-Wno-*` + flags because GCC 14 turned some of its 25-year-old autoconf checks + into hard errors, and adds `libxcrypt` as an input since glibc dropped + `crypt()`. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..cee01af --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +socket=/var/guix/daemon-socket/socket + +# /var/guix is a persistent volume, so a stale socket from a previous +# (now-dead) container can still be sitting there; always start a fresh +# daemon, which unlinks and rebinds the socket itself, rather than +# skipping startup just because the path exists. +# --disable-chroot: sandboxed builds normally isolate via Linux namespaces +# (unshare/clone), which Docker blocks by default even for root ("clone: +# Operation not permitted") and which also seems to cut off DNS for the +# substituter subprocess. We're already isolated by the outer container, +# so skip guix-daemon's own sandboxing rather than granting --privileged. +rm -f "$socket" +guix-daemon --build-users-group=guixbuild --disable-chroot & + +for _ in $(seq 1 30); do + [ -S "$socket" ] && break + sleep 1 +done +[ -S "$socket" ] || { echo "guix-daemon did not come up" >&2; exit 1; } + +exec "$@" diff --git a/scripts/guix-docker.sh b/scripts/guix-docker.sh new file mode 100755 index 0000000..1be5b2a --- /dev/null +++ b/scripts/guix-docker.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Drive the dockerized Guix build environment (see docker/) to build the +# operating-system definitions in this repo as `guix system docker-image` +# tarballs, loadable straight into Docker Desktop. +set -euo pipefail + +cd "$(dirname "$0")/.." + +compose() { + docker compose run --rm guix "$@" +} + +usage() { + cat < [args] + +commands: + build build/rebuild the guix builder image + pull guix pull -C channels.scm (do this first, and + again whenever channels.scm/channels.d/*.scm + change) + shell interactive shell inside the builder container + image FILE.scm [NAME] build FILE.scm (e.g. metznet/machines/ldap.scm) + as a docker image, writing build/NAME.tar.gz + (NAME defaults to FILE's basename). -L points + at /workspace (this checkout), so uncommitted + local edits are picked up directly -- no + commit/pull round-trip needed. Load with: + docker load < build/NAME.tar.gz + load-run FILE.scm build FILE.scm, docker load it into the host + Docker, and drop into a shell in a container + started from it +EOF +} + +cmd="${1:-}" +[ -n "$cmd" ] && shift || true + +case "$cmd" in + build) + docker compose build + ;; + pull) + compose guix pull -C channels.scm + ;; + shell) + compose bash + ;; + image) + scm="${1:?usage: $0 image FILE.scm [NAME]}" + name="${2:-$(basename "$scm" .scm)}" + compose bash -c " + set -e + # --no-grafts: with --disable-chroot (see docker/entrypoint.sh), + # guix-daemon's store-path grafting (security-patch rewriting) hits + # a permission error creating output files -- skipping grafts + # avoids it entirely and just uses the un-grafted originals. + out=\$(guix system docker-image -N --no-grafts -L /workspace '$scm') + mkdir -p /workspace/build + cp \"\$out\" '/workspace/build/$name.tar.gz' + echo \"wrote build/$name.tar.gz\" + " + ;; + load-run) + scm="${1:?usage: $0 load-run FILE.scm}" + name="$(basename "$scm" .scm)" + "$0" image "$scm" "$name" + image_id="$(docker load -q -i "build/$name.tar.gz" | sed 's/^Loaded image: //')" + echo "loaded $image_id, starting container..." + docker run --rm -it "$image_id" /run/current-system/profile/bin/bash --login + ;; + *) + usage + exit 1 + ;; +esac diff --git a/scripts/network.sh b/scripts/network.sh new file mode 100755 index 0000000..e4d54ab --- /dev/null +++ b/scripts/network.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +setup_bridge() { + if [[ $# -lt 1 ]]; then + echo "usage: start_bridge internet_interface" + else + echo "Setting up bridge with internet adapter $1" + fi + ip link add name virbr0 type bridge + ip link set virbr0 up + ip addr add 192.168.100.0/24 dev virbr0 + + sysctl -w net.ipv4.ip_forward=1 + nft add table inet nat + nft add chain inet nat postrouting '{ type nat hook postrouting priority 100 ; }' + nft add rule inet nat postrouting oifname $1 masquerade +} + +del_bridge() { + echo "Deleting virtual bridge" + ip link del virbr0 + nft delete table inet nat +} + +add_interface() { + echo "Adding interface $1" + ip tuntap add $1 mode tap + ip link set $1 master virbr0 + ip link set $1 up +} + +del_interface() { + echo "Deleteing inteface $1" + ip link set $1 down + ip link set $1 nomaster + ip tuntap del $1 mode tap +} + +if [[ $UID -ne 0 ]]; then + echo "Networking script must be run as root user" + exit 1 +fi + +CASE=$1 +shift +case $CASE in + start_bridge) + setup_bridge $@ + ;; + stop_bridge) + del_bridge $@ + ;; + add_tap) + add_interface $@ + ;; + del_tap) + del_interface $@ + ;; + *) + echo "usage:" $0 "[start_bridge|stop_bridge|add_tap|del_tap] [options]" + ;; +esac diff --git a/scripts/qemu.sh b/scripts/qemu.sh new file mode 100755 index 0000000..a916f56 --- /dev/null +++ b/scripts/qemu.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +start(){ + if [[ $# -lt 3 ]]; then + echo "usage: start [system].scm [netdev] [macaddr]" + exit 1 + fi + echo "building $1" + export DOMAIN_CAPS=METZNET.CA + export DOMAIN_NAME=metznet.ca + ro_image=$(guix system image --image-type=qcow2 -L `pwd` $1) + if [[ -z ${ro_image} ]]; then + echo "failed to build $1..." + exit 1 + else + echo "built ${ro_image}" + fi + image="${1%.*}.qcow2" + echo "copying ${ro_image} to ${image} and adding 5G to VM" + cp $ro_image $image + chmod +w $image + qemu-img resize $image +5G + echo "starting vm..." + qemu-system-x86_64 -enable-kvm -nographic -serial mon:stdio -hda $image -m 1000 -bios $(guix build ovmf)/share/firmware/ovmf_x64.bin -nic tap,ifname=$2,script=no,downscript=no,mac=$3 +} + +stop(){ + echo unimplemented +} + +case $1 in + start) + shift + start $@ + ;; + stop) + shift + stop $@ + ;; + *) + echo $0 "[start|stop] [system].scm" + ;; +esac +