TLDR This post goes over how to use guix on a slow, embedded device, without actually running guix on it.
I recently found myself working on a embedded project based on a Raspberry Pi compute module. The goal of this project was to customize the software stack of the device, to provide a personalized experience, and make it easy to maintain.
As I already use guix on all other client machines, my first thought was to power this device with guix as well. The declarative system configuration, smooth remote updates and reconfiguration are just too good to pass up. Unfotunately there’s two problems:
- The device requires custom kernel and overlays, that need maintenance
- Computing guix changes, can be very resource intensive
Because some of the software that we were planning to run on the device, is already packaged for guix, and provided via private channel, I didn’t want to go through the effort to maintain separate Debian packages; After all, we already have a build pipeline for guix ARM/x86 packages.
Fortunately, guix is extremely flexible; The options are:
- Run guix as distribution (Originally GuixSD)
- Run guix, the package manager, on a foreign distro
- Use guix, to generate an image, which can be flashed to the device
- Use
guix pack
, to generate the store at a specific commit
Option 1. and 2. were out, because the device is too slow; 3. would make remote updates impossible, but 4. might just work…
I decided to come-up with a poor-mans guix experience:
- A current guix store at
/gnu.active
- A fallback (roll-back) store at
/gnu.inactive
- A guix store at an expected standard location
/gnu
which symlinks to/gnu.active
- Finally, a profile that applications refer to at
/srv/.guix-profile
which symlinks to/gnu/store/...-profile
Here’s what this looks like, when it’s done:
$ ls /srv/.guix-profile
bin etc include lib libexec manifest sbin share
Package
I generate the archive from a simple guix package, that includes all required applications and preferences:
(define-public bluetooth-terminal-assets
(package
(name "bluetooth-terminal-assets")
(version "2.0.0")
(source
(origin
(method url-fetch)
(uri (string-append "https://source.*.org/" name "_v" version ".tgz"))
(sha256
(base32 "0r8x5wj4asw6l0hrg4m0s9l6yf5b332gmrqaz8ba836bff85g537"))))
(build-system trivial-build-system)
(arguments
`(#:modules ((guix build utils))
#:builder
(begin
(use-modules (guix build utils))
(let ((tar (assoc-ref %build-inputs "tar"))
(gzip (assoc-ref %build-inputs "gzip"))
(src (assoc-ref %build-inputs "source"))
(out-dir (string-append %output "/etc/vhh")))
(mkdir-p out-dir)
(setenv "PATH" (string-append gzip "/bin"))
(invoke (string-append tar "/bin/tar") "xvf" src "-C" out-dir "--strip-components=1")
#t))))
(native-inputs
`(("tar" ,tar)
("gzip" ,gzip)))
(propagated-inputs (list polybar
sox
guix
bcms
bpms
dunst
svkbd
neovim
kdialog
libnotify
nss-certs
system-stats
glibc-locales
state-massage
usb-bpm-exporter
matrix-client-call-auto-accept
bluetooth-terminal-alerts
bluetooth-terminal-assigned-user
px-device-identity
px-device-identity-service))
(synopsis "Contains assets for bluetooth-terminal")
(description "Configuration, scripts and other assets")
(license license:expat)))
To pack the latest version to a tarball, the build server invokes:
guix pack -RR bluetooth-terminal-assets
Update Device
Here’s the script that takes the archive as input, and updates the guix store:
#!/usr/bin/bash
. /etc/profile
UPDATE_FILE_PATH=$1
# Check if the file exists
if [ ! -f "$UPDATE_FILE_PATH" ]; then
echo "Error: File '$UPDATE_FILE_PATH' does not exist."
exit 1
fi
# Check if the file has a .tar.gz extension
if [[ "$UPDATE_FILE_PATH" != *.tar.gz ]]; then
echo "Error: File '$UPDATE_FILE_PATH' is not a .tar.gz archive."
exit 1
fi
set -e # Exit on any error
ACTIVE_ROOT="/gnu.active"
INACTIVE_ROOT="/gnu.inactive"
GNU_LINK="/gnu"
# 1. Clean inactive directory
rm -rf "$INACTIVE_ROOT"
mkdir -p "$INACTIVE_ROOT"
# 2. Extract new archive to inactive directory using pv if available
if command -v pv >/dev/null 2>&1; then
pv "$UPDATE_FILE_PATH" | tar xzf - -C "$INACTIVE_ROOT" --strip-components=2
else
tar xzf "$UPDATE_FILE_PATH" -C "$INACTIVE_ROOT" --strip-components=2
fi
# 3. Atomic switch using symlink
ln -sfn "$INACTIVE_ROOT" "$GNU_LINK.tmp"
mv -Tf "$GNU_LINK.tmp" "$GNU_LINK"
# 4. Swap active/inactive directories for next update
mv "$ACTIVE_ROOT" "$ACTIVE_ROOT.old"
mv "$INACTIVE_ROOT" "$ACTIVE_ROOT"
mv "$ACTIVE_ROOT.old" "$INACTIVE_ROOT"
# 5. Update Guix profile symlink
GUIX_PROFILE=$(find "$GNU_LINK/store" -maxdepth 1 -type d -name '*-profile' | head -n1)
if [ -n "$GUIX_PROFILE" ]; then
ln -sfn "$GUIX_PROFILE" /srv/.guix-profile
source "$GUIX_PROFILE/etc/profile"
else
echo "Warning: No Guix profile found in $GNU_LINK/store"
fi
Additionally, to make applications available on the user terminal, I added these two lines to the .profile
of every user:
export GUIX_PROFILE=$(find /gnu/store -maxdepth 1 -type d -name '*-profile' | head -n1)
source $GUIX_PROFILE/etc/profile
Finally, to move some files into place, and interact with systemd, I wrote a small utility state-massage. It sources the changes from /srv/.guix-pofile
and makes sure everything else is in place. Here’s a excerpt of what this looks like:
[task.1]
title = Create Openbox configuration
opt = copy
src = files/rc.xml
dest = /home/default/.config/openbox/rc.xml
chown = default:default
mode = 0644
[task.2]
title = Set motd content
opt = copy
src = files/motd
dest = /etc/motd
[task.3]
title = Install System Stats Service
opt = copy
src = files/system-stats.service
dest = /etc/systemd/system/system-stats.service
[task.4]
title = Reload systemd and enable System Stats Service
opt = systemd
daemon_reload = yes
enabled = yes
state = active
name = system-stats.service
The application is executed like so:
state-massage --operation run --tasks-path "/srv/.guix-profile/etc/client/tasks.ini"
Remote Updates
I have an additional script in place, that checks the channel for package updates, downloads the new archive and executes the update script, and state-massage
. The whole process takes about ~10 minutes, which is acceptable here. It’s not as fast as relying on Debian packages, but 10x faster, than running a guix pull
on this device.
Conclusion
Using guix is not the optimal approach here, but it provides a predicable experience and saves me from having to maintain seperate packages and build pipeline for Debian.