GitHub action to use QEMU to run commands in a VM on a Raspberry Pi SD card image
Unless you need to start and interact with the Docker daemon on your Raspberry Pi SD card image,
you probably don't need to use this action; instead, you can perform those operations inside a
systemd-nspawn
container (or even just a chroot) attached to your image, which will be much
simpler, easier, and faster, e.g. using the
ethanjli/pinspawn-action
GitHub action (which you can use as a very similar substitute for this action) or the
Nature40/pimod
GitHub action.
- name: Analyze systemd boot process
uses: ethanjli/piqemu-action@v0.1.1
with:
image: rpi-os-image.img
machine: rpi-3b+
run: |
while ! systemd-analyze 2>/dev/null; do
echo "Waiting for boot to finish..."
sleep 5
done
systemd-analyze critical-chain | cat
systemd-analyze blame | cat
systemd-analyze plot > /run/external/bootup-timeline.svg
echo "Done!"
- name: Extract the bootup timeline from the VM
uses: ethanjli/pinspawn-action@v0.1.1
with:
image: rpi-os-image.img
args: --bind "$(pwd)":/run/external
run: |
mv /var/lib/bootup-timeline.svg /run/external/bootup-timeline.svg
- name: Upload the bootup timeline to Job Artifacts
uses: actions/upload-artifact@v4
with:
name: bootup-timeline
path: bootup-timeline.svg
if-no-files-found: error
overwrite: true
- name: Run as user pi in booted container
uses: ethanjli/piqemu-action@v0.1.1
with:
image: rpi-os-image.img
machine: rpi-3b+
user: pi
run: |
sudo apt-get update
sudo apt-get install -y cowsay
/usr/games/cowsay "I am $USER!"
- name: Make a script on the host
uses: 1arp/create-a-file-action@0.4.5
with:
file: setup-figlet.sh
content: |
#!/usr/bin/env -S bash -eux
sudo apt-get update
sudo apt-get install -y figlet
figlet -f digital "I am $USER in $SHELL!"
- name: Copy the script into the image
uses: ethanjli/pinspawn-action@v0.1.1
with:
image: rpi-os-image.img
args: --bind "$(pwd)":/run/external
run: |
sudo cp /run/external/setup-figlet.sh /usr/bin/setup-figlet.sh
sudo chmod a+x /usr/bin/setup-figlet.sh
- name: Run script directly
uses: ./
with:
image: rpi-os-image.img
machine: rpi-3b+
user: pi
shell: /usr/bin/setup-figlet.sh
- name: Delete the script from the image
uses: ethanjli/pinspawn-action@v0.1.1
with:
image: rpi-os-image.img
run: rm /usr/bin/setup-figlet.sh
Note: we use systemd-nspawn (via ethanjli/pinspawn-action) instead of QEMU to install Docker because the installation process is much slower on a QEMU VM!
- name: Install Docker
uses: ethanjli/pinspawn-action@v0.1.1
with:
image: rpi-os-image.img
run: |
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- name: Pull a container with Docker
uses: ethanjli/piqemu-action@v0.1.1
with:
image: rpi-os-image.img
machine: rpi-3b+
run: docker pull cgr.dev/chainguard/crane:latest
- name: Run pre-downloaded container
uses: ethanjli/piqemu-action@v0.1.1
with:
image: rpi-os-image.img
machine: rpi-3b+
run: |
docker images cgr.dev/chainguard/crane
docker run --pull=never --rm cgr.dev/chainguard/crane:latest \
manifest cgr.dev/chainguard/crane:latest --platform=linux/amd64
Inputs:
Input | Allowed values | Required? | Description |
---|---|---|---|
image |
file path | yes | Path of the image to use for the VM. |
machine |
rpi-3b+ |
yes | The type of machine to emulate. |
args |
qemu-system-aarch64 options/args |
no (default ``) | Options/args to pass to qemu-system-aarch64 . |
shell |
``, bash , `sh`, `python`, etc. |
no (default ``) | The shell to use for running commands. |
run |
shell commands | no (default ``) | Commands to run in the shell. |
user |
name of user in image | no (default root ) |
The user to run commands as. |
run-service |
file path | no (default ``) | systemd service to run shell with the run commands. |
-
image
must be the path of an unmounted raw disk image (such as a Raspberry Pi OS SD card image), where partition 2 should be mounted as the root filesystem (i.e./
) and partition 1 should be mounted to/boot
. -
machine
controls the hardware which the VM will emulate. Currently only the Raspberry Pi 3B+ (rpi-3b+
) is supported; I can add support for other options depending on what people are interested in - so if you want some other machine type, please file a feature request!-
Raspberry Pi 4 emulation is possible in QEMU but is not yet supported by this action, because I haven't been able to get internet access from the Raspberry Pi 4 machine type; this is because QEMU support for the RPi 4B is still very new and only partially implemented.
-
Generic ARM64 emulation is possible with QEMU's
virt
machine type and is probably faster than RPi-specific emulation, but then we have to compile a Linux kernel (which I have attempted successfully, and which is described in this guide). The problem is that then the Linux kernel modules on the Raspberry Pi image won't work with a custom kernel - and such kernel modules are required by Docker. Then we'll need to distribute and supply the kernel modules in some other way, and I am not (yet) experienced enough with the Linux kernel to understand how to do that. This matters to me because Docker depends on various kernel modules (e.g. for iptables/nftables) to start - and I'm only using QEMU to interact with the Docker daemon, so I have no use for use thevirt
machine type myself.
-
-
args
can be a list of command-line options/arguments forqemu-system-aarch64
. These arguments will be added to arguments automatically generated by this action. -
If
run
is not left empty,shell
will be used to execute commands specified in therun
input. You can use built-inshell
keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the commands to run, like in GitHub Actions. Please refer to the GitHub Actions semantics of theshell
keyword of job steps for details about the behavior of this action'sshell
input.If you just want to run a single script, you can leave
run
empty and provide that script as theshell
input. However, you will need to set the appropriate permissions on the script file. -
The provided
run
commands will be triggered by a temporary system service defined with the following template (unless you specify a different service file template using therun-service
input):[Unit] Description=Run commands in booted OS After=getty.target [Service] Type=exec ExecStart=bash -c "\ su - {user} -c '{command}; echo $? | tee {result}'; \ echo Shutting down...; \ shutdown now \ " & StandardOutput=tty [Install] WantedBy=getty.target
This service file template has string interpolation applied to the following strings:
{user}
will be replaced with the value of the action'suser
input.{command}
will be replaced with a command to run your specifiedrun
commands using your specifiedshell
{result}
will be replaced with the path of a temporary file whose contents will be checked after the VM finishes running to determine whether the command finished successfully (in which case the file should be the string0
); this file is interpreted as holding a return code.