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.
master
noah metz 2026-07-28 20:10:51 -06:00
parent fc5771267a
commit 2721615497
11 changed files with 431 additions and 0 deletions

@ -0,0 +1,3 @@
(channel
(name 'metznet-channel)
(url "https://git.metznet.ca/MetzNet/metznet-channel"))

@ -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"))))

@ -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"))))

@ -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)

@ -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:

@ -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"]

@ -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 <container> 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 <image-id-or-name> /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()`.

@ -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 "$@"

@ -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 <<EOF
usage: $0 <command> [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

@ -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

@ -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