From cb4f4085c296f2b3ea152c44b33f23e9b945f67b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 08:37:12 -0700 Subject: [PATCH] C1: bake shower wheel into image; wire borgmatic; refine NFS docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ansible/roles/borgmatic/defaults/main.yml | 8 ++ argocd/manifests/shower/pv-nfs.yaml | 18 ++-- containers/shower/default.nix | 104 ++++++++++--------- docs/changelog.d/shower-app-deploy.bugfix.md | 13 +++ docs/how-to/operations/shower-app.md | 76 +++++++++++--- service-versions.yaml | 9 ++ 6 files changed, 155 insertions(+), 73 deletions(-) create mode 100644 docs/changelog.d/shower-app-deploy.bugfix.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 25d0149..123cb0f 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -27,6 +27,9 @@ borgmatic_source_directories: - /Users/erichblume/.config/borgmatic - /Users/erichblume/Documents - /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 borgmatic_repositories: @@ -54,6 +57,11 @@ borgmatic_k8s_sqlite_dumps: label_selector: app=mealie db_path: /app/data/mealie.db context: minikube + - name: shower + namespace: shower + label_selector: app=shower + db_path: /app/data/db.sqlite3 + context: k3s-ringtail # Exclude patterns borgmatic_exclude_patterns: [] diff --git a/argocd/manifests/shower/pv-nfs.yaml b/argocd/manifests/shower/pv-nfs.yaml index d07cecc..7354fb5 100644 --- a/argocd/manifests/shower/pv-nfs.yaml +++ b/argocd/manifests/shower/pv-nfs.yaml @@ -1,15 +1,13 @@ # 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: -# 1. Control Panel > Shared Folder > Create -# 2. Name: shower, Location: Volume 1 -# 3. Control Panel > File Services > NFS > NFS Rules -# 4. Add rule for "shower" share: Hostname=ringtail, Privilege=Read/Write, -# Squash=No mapping -# 5. chown -R 1000:1000 /volume1/shower (or pick another UID and align the -# container's runAsUser to match) +# Requires the `shower` share on sifaka with NFS exports matching the +# blumeops standard (192.168.1.0/24 + 100.64.0.0/10, all_squash → admin). +# See docs/how-to/operations/shower-app.md for the Synology web-UI walk +# and docs/reference/storage/sifaka.md for the exports table. +# +# Because all_squash rewrites every NFS write to admin:users (1024:100), +# the in-pod runAsUser does NOT have to match an on-disk uid. Mode 0777 +# on /volume1/shower lets the pod read back what it wrote. apiVersion: v1 kind: PersistentVolume metadata: diff --git a/containers/shower/default.nix b/containers/shower/default.nix index 5b92e85..e8d7383 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -1,76 +1,79 @@ # Nix-built shower app container — Adelaide / Heidi / Addie baby shower. # # 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 -# fetch the wheel + transitive deps at nix build time (which requires -# every wheel hash to be tracked here), this image ships a Python from -# nixpkgs and pip-installs the wheel into a venv on /app/data at first -# boot. Subsequent boots reuse the venv. This trades reproducibility for -# a much simpler nix file. +# https://forge.eblu.me/api/packages/eblume/pypi/. The wheel + its +# Python deps are baked in at build time via buildPythonPackage so the +# container boots cleanly with no pip-at-runtime. Build runs on the +# nix-container-builder runner (ringtail, amd64) so the image is native. # -# Built on the nix-container-builder runner (ringtail, amd64) so the -# image runs natively on ringtail's k3s without QEMU emulation. +# To bump the version: +# 1. Update `version` below. +# 2. Update `wheelHash` — `nix-prefetch-url ` against the new wheel, +# or set it to `pkgs.lib.fakeHash` and let the build print the right one. { pkgs ? import { } }: let version = "1.0.0"; + wheelHash = "sha256-9Xk3TCzl474As8n0RhLoy/QYw+K1DABBWEwLC8v1X0A="; python = pkgs.python314; - appVersion = version; - entrypoint = pkgs.writeShellScript "shower-entrypoint" '' - set -eu + showerWheel = pkgs.fetchurl { + 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 - DATA_DIR=/app/data - VENV_DIR=$DATA_DIR/.venv - INSTALLED_MARKER=$VENV_DIR/.installed-${appVersion} + shower = python.pkgs.buildPythonPackage { + pname = "adelaide-baby-shower-app"; + inherit version; + format = "wheel"; + src = showerWheel; + doCheck = false; + propagatedBuildInputs = with python.pkgs; [ + django + django-axes + pillow + scipy + segno + ]; + }; - export HOME=$DATA_DIR - export PIP_DISABLE_PIP_VERSION_CHECK=1 - export PIP_NO_CACHE_DIR=1 + pyEnv = python.withPackages (ps: [ + shower + ps.gunicorn + ]); - mkdir -p "$DATA_DIR" "$APP_DIR/media" - - # First boot (or version change): create venv and install the app + deps. - # The wheel comes from the internal devpi mirror (default index), with - # 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' + # Settings shim — config/settings.py's `BASE_DIR = parent.parent` would + # otherwise resolve to site-packages, scattering db.sqlite3 / media / + # staticfiles into the venv. Pin them to /app/{data,media,data/static}. + localSettings = pkgs.writeText "local_settings.py" '' from config.settings import * # noqa: F401,F403 DATABASES["default"]["NAME"] = "/app/data/db.sqlite3" MEDIA_ROOT = "/app/media" 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 - cd "$APP_DIR" + cd /app + + mkdir -p /app/data /app/media echo "shower: running migrations" - "$VENV_DIR/bin/python" -m django migrate --noinput + ${pyEnv}/bin/python -m django migrate --noinput 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" - exec "$VENV_DIR/bin/gunicorn" \ + exec ${pyEnv}/bin/gunicorn \ --bind 0.0.0.0:8000 \ --workers 2 \ --forwarded-allow-ips='*' \ @@ -81,19 +84,20 @@ in pkgs.dockerTools.buildLayeredImage { name = "blumeops/shower"; contents = [ - python + pyEnv pkgs.cacert pkgs.tzdata pkgs.bashInteractive pkgs.coreutils - pkgs.gnused - pkgs.gnugrep ]; - # /app is writable by uid 1000 (matches deployment.yaml runAsUser). - fakeRootCommands = '' + extraCommands = '' mkdir -p app/data app/media tmp chmod 1777 tmp + cp ${localSettings} app/local_settings.py + ''; + + fakeRootCommands = '' chown -R 1000:1000 app ''; enableFakechroot = true; diff --git a/docs/changelog.d/shower-app-deploy.bugfix.md b/docs/changelog.d/shower-app-deploy.bugfix.md new file mode 100644 index 0000000..91d2b3b --- /dev/null +++ b/docs/changelog.d/shower-app-deploy.bugfix.md @@ -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`). diff --git a/docs/how-to/operations/shower-app.md b/docs/how-to/operations/shower-app.md index 401eeb6..9a7af1b 100644 --- a/docs/how-to/operations/shower-app.md +++ b/docs/how-to/operations/shower-app.md @@ -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/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 -first boot, runs migrations, runs `collectstatic`, and `exec`s gunicorn. -A `local_settings.py` shim overrides `DATABASES.NAME`, `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. +The container has the app + its Python deps baked in at nix build time +(`buildPythonPackage` against the wheel fetched from forge PyPI). The +entrypoint runs migrations, runs `collectstatic`, and `exec`s gunicorn — +no pip-at-boot. A `local_settings.py` shim overrides `DATABASES.NAME`, +`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 These steps are required the first time the service is deployed and are 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. -2. Control Panel → File Services → NFS → NFS Rules. Add rule for - `shower`: Hostname=`ringtail`, Privilege=Read/Write, Squash=No mapping. -3. `chown -R 1000:1000 /volume1/shower` over SSH so the pod's uid 1000 - can write. +1. **Control Panel → Shared Folder → Create**. Name: `shower`, + Location: Volume 1. Leave the rest at default. +2. **Control Panel → File Services → NFS → NFS Rules** (on the + `shower` row's *Permissions* tab). Add a rule mirroring the other + 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 @@ -106,7 +150,13 @@ freshly generated. ### 3. Container image 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 mise run container-build-and-release shower diff --git a/service-versions.yaml b/service-versions.yaml index b6163ad..8caa2be 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -106,6 +106,15 @@ services: current-version: "v1.94.2" 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 type: argocd last-reviewed: 2026-04-02