C1: deploy adelaide-baby-shower-app to ringtail k3s #349

Merged
eblume merged 20 commits from shower-app-deploy into main 2026-05-11 13:47:20 -07:00
6 changed files with 159 additions and 77 deletions
Showing only changes of commit cb4f4085c2 - Show all commits

C1: bake shower wheel into image; wire borgmatic; refine NFS docs

Three follow-ups on the shower deployment branch:

1. containers/shower/default.nix now uses buildPythonPackage to install
   the adelaide-baby-shower-app wheel + its deps at nix build time. The
   wheel comes from the forge PyPI index with a pinned SRI hash. The
   entrypoint no longer does pip-at-boot — it just runs migrations,
   collectstatic, and execs gunicorn.

2. ansible/roles/borgmatic/defaults/main.yml:
   - Adds shower to borgmatic_k8s_sqlite_dumps (context k3s-ringtail)
     so /app/data/db.sqlite3 is dumped via kubectl exec on every run.
   - Adds /Volumes/shower (sifaka SMB mount on indri) to
     borgmatic_source_directories so prize-photo media gets archived.

3. NFS share docs corrected to match the real on-sifaka pattern:
   exports allowlist 192.168.1.0/24 + 100.64.0.0/10 with all_squash to
   admin (matching frigate/paperless/etc.), not "Squash=No mapping".
   The pod's runAsUser doesn't need to match an on-disk uid because
   all_squash rewrites every write to admin:users.

Also adds a missing service-versions entry for the tailscale container
introduced in PR #347 — pre-existing gap surfaced by the
container-version-check hook on this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Erich Blume 2026-05-11 08:37:12 -07:00

View file

@ -27,6 +27,9 @@ borgmatic_source_directories:
- /Users/erichblume/.config/borgmatic - /Users/erichblume/.config/borgmatic
- /Users/erichblume/Documents - /Users/erichblume/Documents
- /Users/erichblume/.local/share/borgmatic/k8s-dumps - /Users/erichblume/.local/share/borgmatic/k8s-dumps
# Shower app prize-photo uploads (sifaka SMB mount). Mounted manually
# on indri via Finder — see docs/how-to/operations/shower-app.md.
- /Volumes/shower
# Backup repositories # Backup repositories
borgmatic_repositories: borgmatic_repositories:
@ -54,6 +57,11 @@ borgmatic_k8s_sqlite_dumps:
label_selector: app=mealie label_selector: app=mealie
db_path: /app/data/mealie.db db_path: /app/data/mealie.db
context: minikube context: minikube
- name: shower
namespace: shower
label_selector: app=shower
db_path: /app/data/db.sqlite3
context: k3s-ringtail
# Exclude patterns # Exclude patterns
borgmatic_exclude_patterns: [] borgmatic_exclude_patterns: []

View file

@ -1,15 +1,13 @@
# NFS PersistentVolume for shower app media uploads (prize photos). # NFS PersistentVolume for shower app media uploads (prize photos).
# Requires: NFS share on sifaka at /volume1/shower with NFS permissions
# for ringtail.
# #
# To create on Synology: # Requires the `shower` share on sifaka with NFS exports matching the
# 1. Control Panel > Shared Folder > Create # blumeops standard (192.168.1.0/24 + 100.64.0.0/10, all_squash → admin).
# 2. Name: shower, Location: Volume 1 # See docs/how-to/operations/shower-app.md for the Synology web-UI walk
# 3. Control Panel > File Services > NFS > NFS Rules # and docs/reference/storage/sifaka.md for the exports table.
# 4. Add rule for "shower" share: Hostname=ringtail, Privilege=Read/Write, #
# Squash=No mapping # Because all_squash rewrites every NFS write to admin:users (1024:100),
# 5. chown -R 1000:1000 /volume1/shower (or pick another UID and align the # the in-pod runAsUser does NOT have to match an on-disk uid. Mode 0777
# container's runAsUser to match) # on /volume1/shower lets the pod read back what it wrote.
apiVersion: v1 apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
metadata: metadata:

View file

@ -1,76 +1,79 @@
# Nix-built shower app container — Adelaide / Heidi / Addie baby shower. # Nix-built shower app container — Adelaide / Heidi / Addie baby shower.
# #
# The app is published as a wheel to the Forgejo PyPI index at # The app is published as a wheel to the Forgejo PyPI index at
# https://forge.eblu.me/api/packages/eblume/pypi/. Rather than pin and # https://forge.eblu.me/api/packages/eblume/pypi/. The wheel + its
# fetch the wheel + transitive deps at nix build time (which requires # Python deps are baked in at build time via buildPythonPackage so the
# every wheel hash to be tracked here), this image ships a Python from # container boots cleanly with no pip-at-runtime. Build runs on the
# nixpkgs and pip-installs the wheel into a venv on /app/data at first # nix-container-builder runner (ringtail, amd64) so the image is native.
# boot. Subsequent boots reuse the venv. This trades reproducibility for
# a much simpler nix file.
# #
# Built on the nix-container-builder runner (ringtail, amd64) so the # To bump the version:
# image runs natively on ringtail's k3s without QEMU emulation. # 1. Update `version` below.
# 2. Update `wheelHash` — `nix-prefetch-url <url>` against the new wheel,
# or set it to `pkgs.lib.fakeHash` and let the build print the right one.
{ pkgs ? import <nixpkgs> { } }: { pkgs ? import <nixpkgs> { } }:
let let
version = "1.0.0"; version = "1.0.0";
wheelHash = "sha256-9Xk3TCzl474As8n0RhLoy/QYw+K1DABBWEwLC8v1X0A=";
python = pkgs.python314; python = pkgs.python314;
appVersion = version;
entrypoint = pkgs.writeShellScript "shower-entrypoint" '' showerWheel = pkgs.fetchurl {
set -eu name = "adelaide_baby_shower_app-${version}-py3-none-any.whl";
url = "https://forge.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}-py3-none-any.whl";
hash = wheelHash;
};
APP_DIR=/app shower = python.pkgs.buildPythonPackage {
DATA_DIR=/app/data pname = "adelaide-baby-shower-app";
VENV_DIR=$DATA_DIR/.venv inherit version;
INSTALLED_MARKER=$VENV_DIR/.installed-${appVersion} format = "wheel";
src = showerWheel;
doCheck = false;
propagatedBuildInputs = with python.pkgs; [
django
django-axes
pillow
scipy
segno
];
};
export HOME=$DATA_DIR pyEnv = python.withPackages (ps: [
export PIP_DISABLE_PIP_VERSION_CHECK=1 shower
export PIP_NO_CACHE_DIR=1 ps.gunicorn
]);
mkdir -p "$DATA_DIR" "$APP_DIR/media" # Settings shim — config/settings.py's `BASE_DIR = parent.parent` would
# otherwise resolve to site-packages, scattering db.sqlite3 / media /
# First boot (or version change): create venv and install the app + deps. # staticfiles into the venv. Pin them to /app/{data,media,data/static}.
# The wheel comes from the internal devpi mirror (default index), with localSettings = pkgs.writeText "local_settings.py" ''
# forge.eblu.me as the extra index for the adelaide-baby-shower-app wheel.
if [ ! -f "$INSTALLED_MARKER" ]; then
echo "shower: installing adelaide-baby-shower-app==${appVersion} into $VENV_DIR"
rm -rf "$VENV_DIR"
${python}/bin/python -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install \
--index-url=https://pypi.ops.eblu.me/root/pypi/+simple/ \
--extra-index-url=https://forge.eblu.me/api/packages/eblume/pypi/simple/ \
"adelaide-baby-shower-app==${appVersion}" gunicorn
touch "$INSTALLED_MARKER"
fi
# The wheel's config/settings.py uses BASE_DIR = parent.parent of its
# own __file__, so MEDIA_ROOT and DATABASES.NAME resolve relative to
# site-packages. Override with a thin shim placed in $APP_DIR.
cat > "$APP_DIR/local_settings.py" <<'PY'
from config.settings import * # noqa: F401,F403 from config.settings import * # noqa: F401,F403
DATABASES["default"]["NAME"] = "/app/data/db.sqlite3" DATABASES["default"]["NAME"] = "/app/data/db.sqlite3"
MEDIA_ROOT = "/app/media" MEDIA_ROOT = "/app/media"
STATIC_ROOT = "/app/data/staticfiles" STATIC_ROOT = "/app/data/staticfiles"
PY '';
export PYTHONPATH=$APP_DIR entrypoint = pkgs.writeShellScript "shower-entrypoint" ''
set -eu
export HOME=/app/data
export PYTHONPATH=/app
export DJANGO_SETTINGS_MODULE=local_settings export DJANGO_SETTINGS_MODULE=local_settings
cd "$APP_DIR" cd /app
mkdir -p /app/data /app/media
echo "shower: running migrations" echo "shower: running migrations"
"$VENV_DIR/bin/python" -m django migrate --noinput ${pyEnv}/bin/python -m django migrate --noinput
echo "shower: collecting static files" echo "shower: collecting static files"
"$VENV_DIR/bin/python" -m django collectstatic --noinput --clear ${pyEnv}/bin/python -m django collectstatic --noinput --clear
echo "shower: starting gunicorn" echo "shower: starting gunicorn"
exec "$VENV_DIR/bin/gunicorn" \ exec ${pyEnv}/bin/gunicorn \
--bind 0.0.0.0:8000 \ --bind 0.0.0.0:8000 \
--workers 2 \ --workers 2 \
--forwarded-allow-ips='*' \ --forwarded-allow-ips='*' \
@ -81,19 +84,20 @@ in
pkgs.dockerTools.buildLayeredImage { pkgs.dockerTools.buildLayeredImage {
name = "blumeops/shower"; name = "blumeops/shower";
contents = [ contents = [
python pyEnv
pkgs.cacert pkgs.cacert
pkgs.tzdata pkgs.tzdata
pkgs.bashInteractive pkgs.bashInteractive
pkgs.coreutils pkgs.coreutils
pkgs.gnused
pkgs.gnugrep
]; ];
# /app is writable by uid 1000 (matches deployment.yaml runAsUser). extraCommands = ''
fakeRootCommands = ''
mkdir -p app/data app/media tmp mkdir -p app/data app/media tmp
chmod 1777 tmp chmod 1777 tmp
cp ${localSettings} app/local_settings.py
'';
fakeRootCommands = ''
chown -R 1000:1000 app chown -R 1000:1000 app
''; '';
enableFakechroot = true; enableFakechroot = true;

View file

@ -0,0 +1,13 @@
Shower app container now bakes the wheel + Python deps into the image
at build time via `buildPythonPackage` instead of pip-installing on
first boot. Boots are deterministic and don't depend on forge PyPI
being reachable from the pod. The `wheelHash` in
`containers/shower/default.nix` is the sha256 sourced from the
[forge PyPI simple index](https://forge.eblu.me/api/packages/eblume/pypi/simple/adelaide-baby-shower-app/);
bumping the version means bumping that hash too.
Borgmatic now covers the shower app: SQLite is dumped from the live
pod via `kubectl exec` (mirroring the existing mealie entry, with
`context: k3s-ringtail`), and the prize-photo media share is picked up
through `/Volumes/shower` (sifaka SMB mount on indri, same pattern as
`/Volumes/photos`).

View file

@ -64,26 +64,70 @@ django-axes has already locked them out.
| `/app/media` | `shower-media` | NFS RWX on sifaka (`/volume1/shower`) | Prize photos survive pod rescheduling | | `/app/media` | `shower-media` | NFS RWX on sifaka (`/volume1/shower`) | Prize photos survive pod rescheduling |
| `/app/data` | `shower-data` | k3s `local-path` RWO | SQLite DB; NFS file locking can't be trusted for WAL/journal | | `/app/data` | `shower-data` | k3s `local-path` RWO | SQLite DB; NFS file locking can't be trusted for WAL/journal |
The container's entrypoint installs the wheel into `/app/data/.venv` on The container has the app + its Python deps baked in at nix build time
first boot, runs migrations, runs `collectstatic`, and `exec`s gunicorn. (`buildPythonPackage` against the wheel fetched from forge PyPI). The
A `local_settings.py` shim overrides `DATABASES.NAME`, `MEDIA_ROOT`, and entrypoint runs migrations, runs `collectstatic`, and `exec`s gunicorn —
`STATIC_ROOT` to absolute paths under `/app/`, sidestepping the wheel's no pip-at-boot. A `local_settings.py` shim overrides `DATABASES.NAME`,
`BASE_DIR = parent.parent` of an in-site-packages settings module. `MEDIA_ROOT`, and `STATIC_ROOT` to absolute paths under `/app/`,
sidestepping the wheel's `BASE_DIR = parent.parent` of an
in-site-packages settings module.
## Backups
[[borgmatic]] (running on indri) captures both halves of the persistent
state on its daily 2 a.m. run:
- **`/app/data/db.sqlite3`** — dumped via `kubectl exec`'s
`sqlite3.backup()` against the live pod (entry in
`borgmatic_k8s_sqlite_dumps`, context `k3s-ringtail`). The dumped
file lands in `borgmatic_k8s_dump_dir` on indri and is picked up by
the main source-directory sweep.
- **`/app/media`** — picked up via `/Volumes/shower`, the SMB mount of
`sifaka:/volume1/shower` on indri. The same Synology share is exposed
via SMB *and* NFS simultaneously; ringtail's pod uses the NFS export,
while indri reads the SMB side for the borgmatic source.
Both archive to [[sifaka]] (`borg-backups`) and BorgBase offsite, with
retention `keep_daily=7 / keep_monthly=12 / keep_yearly=1000`.
The SMB mount on indri is set up manually once via Finder (Cmd-K →
`smb://sifaka/shower`, save credentials, "Always log in" so it
reconnects after reboot). If `/Volumes/shower` is missing at backup
time borgmatic will fail loudly — `source_directories_must_exist: true`
applies to all entries.
## One-time setup steps ## One-time setup steps
These steps are required the first time the service is deployed and are These steps are required the first time the service is deployed and are
not encoded in the manifests. not encoded in the manifests.
### 1. NFS share on sifaka ### 1. NFS + SMB share on sifaka
On the Synology: On the Synology DSM web UI:
1. Control Panel → Shared Folder → Create. Name: `shower`, Volume 1. 1. **Control Panel → Shared Folder → Create**. Name: `shower`,
2. Control Panel → File Services → NFS → NFS Rules. Add rule for Location: Volume 1. Leave the rest at default.
`shower`: Hostname=`ringtail`, Privilege=Read/Write, Squash=No mapping. 2. **Control Panel → File Services → NFS → NFS Rules** (on the
3. `chown -R 1000:1000 /volume1/shower` over SSH so the pod's uid 1000 `shower` row's *Permissions* tab). Add a rule mirroring the other
can write. shares' pattern: Hostname/IP=`192.168.1.0/24` and again for
`100.64.0.0/10`, Privilege=Read/Write, Squash=`Map all users to
admin` (= `all_squash`), and tick *Allow connections from
non-privileged ports*. (See [[sifaka#NFS Exports]] — the existing
`frigate`, `paperless`, etc. shares use this exact pattern.)
3. **Control Panel → File Services → SMB**: leave SMB enabled
globally. No per-share rule required — the share inherits the
default `eblume` access.
4. The directory ownership at `/volume1/shower` will end up
`root:root`, mode `0777` (DSM default) — which is fine because
`all_squash` rewrites every NFS write to `admin:users`, and the
`0777` lets pods read what other pods wrote. No `chown` needed.
After the share exists, mount it on indri for borgmatic:
- In Finder, **Cmd-K → `smb://sifaka/shower`**, sign in as `eblume`,
and tick **Remember in Keychain** + **Always log in** so it
reconnects on reboot. This produces `/Volumes/shower`, which the
borgmatic source-directory list points at.
### 2. 1Password item ### 2. 1Password item
@ -106,7 +150,13 @@ freshly generated.
### 3. Container image ### 3. Container image
Built by the `build-container` Forgejo Actions workflow on the Built by the `build-container` Forgejo Actions workflow on the
`nix-container-builder` runner (ringtail, amd64). Trigger with: `nix-container-builder` runner (ringtail, amd64). The wheel is fetched
from forge PyPI at nix build time and baked into the image — no
pip-at-runtime. To bump the version, change `version` in
`containers/shower/default.nix` and update `wheelHash` (or set it to
`pkgs.lib.fakeHash` and let the next build print the correct one).
Trigger with:
```fish ```fish
mise run container-build-and-release shower mise run container-build-and-release shower

View file

@ -106,6 +106,15 @@ services:
current-version: "v1.94.2" current-version: "v1.94.2"
upstream-source: https://github.com/tailscale/tailscale/releases upstream-source: https://github.com/tailscale/tailscale/releases
- name: tailscale
type: container
last-reviewed: 2026-05-10
current-version: "1.94.2"
upstream-source: https://github.com/tailscale/tailscale/releases
notes: |
Locally mirrored tailscale image used by ringtail's tailscale-operator
ProxyClass. Built via containers/tailscale/default.nix.
- name: grafana - name: grafana
type: argocd type: argocd
last-reviewed: 2026-04-02 last-reviewed: 2026-04-02