Today we’re going to look at a slightly unorthodox usage of Quadlets. Often Quadlets are just used as a way to run long running server processes in containers, but there’s nothing stopping you from treating them like individual units of work.
For this post, I’ll be showing off a little static site deployment pipeline I wrote recently that’s installed with Materia and uses Quadlets and Systemd to orchestrate the individual steps.
The basic premise is this: I have a static website stored in a private Github repository. The repo uses git-lfs to store some particularly large blobs, and thus I need that on whatever host I want to deploy on the site on. However, most of my servers run OpenSUSE MicroOS and I don’t want to have to add git-lfs to the base image of every single one just for this. So instead, I’m going to automatically build an image on the host that has the Git dependencies and secrets I need and use that to download the site automatically.
The Premise
The quick bullet list:
- I have a static website in a private repo on Github
- I need git-lfs to clone it
- I don’t want to install anything on my MicroOS server since that would require a transactional upgrade
- BONUS I already have caddy running on the server and it has
/srv/www1 already bind mounted.
That last part about Caddy is optional; I could very easily deploy it as part of this pipeline as well but I happened to have it already running for my other static sites.
Building on the host
To download the website onto the host I need two things: a script that runs the git commands and an image with git and git-lfs in it.
Fortunately, there’s a Quadlet type that fits my exact need: .build units are Quadlets that build a Podman image on the host. You can think of them as declarative podman build commands in a way.
We need an environment to grab the site in. We need two tools: git and git-lfs, plus an SSH key to clone it with.
First off, here’s the script that I’m going to use to clone the website:
Sync script sync_sh2:
#!/bin/sh
set -eu
: "${REPO_URL:?REPO_URL environment variable is required}"
: "${SYNC_DEST:?SYNC_DEST environment variable is requires}"
if [ -d "$SYNC_DEST/.git" ]; then
echo "Updating"
git -C "$SYNC_DEST" fetch --all
git -C "$SYNC_DEST" reset --hard "@{u}"
git -C "$SYNC_DEST" lfs pull
else
echo "Cloning"
git clone "$REPO_URL" "$SYNC_DEST"
git -C "$SYNC_DEST" lfs pull
fi
echo "Sync complete: $(date -u)"Since this is running fully automated I’m using git fetch and git reset to ignore any local changes that might get made by accident. This script gets added to the final image below.
Next we have the Containerfile:
FROM alpine:latest
RUN apk add --no-cache \
git \
git-lfs \
openssh-client
RUN git lfs install
RUN mkdir -p /srv/www
RUN mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
COPY sync_sh /usr/local/bin/sync.sh
RUN chmod +x /usr/local/bin/sync.sh
ENTRYPOINT ["/usr/local/bin/sync.sh"]A very simple image! We could even do everything in the container and not embed a separate script, but this is easier to maintain. The one adjustment I’d like to make later is to not set up known_hosts in the image since that could get out of date quickly, but this seemed like an easy duck-tape for now.
Finally, we need a .build Quadlet to automate the image building process.
site-builder.build.gotmpl
[Build]
File=Containerfile
ImageTag=localhost/site-builder:latest
SetWorkingDirectory={{ m_dataDir "staticsite" }} # evaluates to /var/lib/materia/components/staticsiteSince this is being deployed with Materia I’m using the m_dataDir macro to keep things dynamic, but there’s nothing stopping you from hard-coding the value.
That takes care of actually downloading the static site and building the image. Now I need to make it run on a regular basis.
Automating the build
I need two things for this: a container quadlet and a timer
The site-updater.container.gotmpl quadlet:
[Unit]
Description=static site updater
[Service]
Type=oneshot
[Container]
Image=site-builder.build
Volume=/srv/www:/srv/www:z
Secret=materia-github-ssh,type=mount,target=/root/.ssh/id_rsa,mode=0600
Environment=REPO_URL={{ .repoURL }}
Environment=SYNC_DEST={{ .dest }}The key here is having the Image= setting be the build unit directly instead of the resulting image. This tells the Quadlet systemd-generator that the resulting service file for the container needs the image built first, automatically adding Wants=site-builder and Requires=site-builder as service dependencies.
I’m mounting a Podman secret into the container with the Secret= option3. This will mount whatever data is stored in the materia-github-ssh secret as a file in the container. I’m also once again we’re using Materia’s templating support to dynamically insert the REPO_URL and SYNC_DEST values, but there’s nothing stopping you from hard coding them.
I manually specified it as a one-shot service so we don’t intend for it to be running all the time, only once to run the embedded script.
Finally, I need a timer to automatically run the container once a day.
site-updater.timer:
[Unit]
Description=Static Site daily update
[Timer]
RandomizedDelaySec=1800
OnCalendar=*-*-* 16:00:00
[Install]
WantedBy=timers.targetThis is pretty standard timer. Since .container Quadlets get converted into .service units I don’t need to specify it with Unit= here.
Tying it all together
Since I’m deploying this as a Materia component we naturally have a MANIFEST.toml file to describe how its deployed:
Secrets = ["github-ssh"]
[[Services]]
Service = "site-updater.timer"
Static = trueThis manifest does two things:
- It declares that the
github-sshattribute should be stored as a Podman secret on the host (and thus made available in the Container with the aboveSecret=line) - The
site-updater.timershould be enabled and started.
Both of these steps can easily be done manually if desired, but keeping it as a Materia component lets me move it to another host easily by just changing my repositories manifest file.
The Final Result
The final Materia component looks like this:
materia_repo/components/staticsite master ❯ ls -la
total 32K
drwxr-xr-x. 2 stryan stryan 4.0K Jun 2 21:00 .
drwxr-xr-x. 49 stryan stryan 4.0K Jun 2 20:38 ..
-rw-r--r--. 1 stryan stryan 119 Jun 2 20:40 site-builder.build.gotmpl
-rw-r--r--. 1 stryan stryan 320 Jun 2 21:17 site-updater.container.gotmpl
-rw-r--r--. 1 stryan stryan 136 Jun 2 20:43 site-updater.timer
-rw-r--r--. 1 stryan stryan 368 Jun 2 20:45 Containerfile
-rw-r--r--. 1 stryan stryan 97 Jun 2 20:49 MANIFEST.toml
-rw-r--r--. 1 stryan stryan 465 Jun 2 20:43 sync_shOn the server itself, running the actual site-updater.service does this:
- Systemd sees
site-updater.servicedepends onsite-builder.build site-builder.buildis started- The
localhost/staticsite:latestimage is build with the embedded shell script. This will be quick since all the layers are cached. site-updater.serviceruns, starting alocalhost/staticsite:latestcontainer with thegithub-sshsecret mounted inside as an SSH key and/srv/wwwbind-mounted.- The script runs, downloading the static site git repo to
/srv/www/<site-destination>.
Was this worth it?
Maybe! If you’re not running an atomic distro like MicroOS or Fedora CoreOS, it’s probably a little more effort then its worth. The way I see it though, this workflow has two big benefits:
- It keeps all the work done on the host itself; I used to deploy my sites with a CI pipeline, but that meant Yet Another Server to manage deploy keys and workflows on.
- Writing it as Quadlets keeps the whole thing essentially OS agnostic. I already know all my machines have Podman and Systemd on them, so I can move this setup to any other server I want without having to worry about having the
git-lfsdependency.
So I’m pretty happy that I went with this instead of a bespoke cron + shell script setup.
There’s a few other improvements I could make to this workflow as well, like container health checks to make sure the site actually syncs properly and moving to image backed Volumes instead of a bind mount. The site-builder.build, site-updater.container pattern is also an obvious candidate for being moved into an instanced component so I can have multiple copies of it like static-site@quadlets-are-cool, static-site@nerf-seven, etc.
Finally, you may have noticed that, if you squint at this hard enough, this is basically a small build/CICD system powered by systemd. I’m not going to say this is a good usage of systemd job scheduling abilities, but if you ever needed a job system that’s already on your host, you could do worse then systemd 4.
Footnotes
That’s right,
/srv/wwwnot/var/www. In this house we respect the FHS to the letter.↩︎I named it
sync_shinstead ofsync.shto prevent Materia from installing it as a script to/usr/local/bin. This just keeps things a littler cleaner.↩︎That
Secret=line would use thesecretMountmacro in reality, but I left it manually inserted for illustrative purposes. And because at the time of writing the macro doesn’t support the mode argument. Whoops.↩︎Looking at you Jenkins and GHA.↩︎