C1: deploy adelaide-baby-shower-app to ringtail k3s #349
6 changed files with 159 additions and 77 deletions
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>
commit
cb4f4085c2
|
|
@ -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: []
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
13
docs/changelog.d/shower-app-deploy.bugfix.md
Normal file
13
docs/changelog.d/shower-app-deploy.bugfix.md
Normal 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`).
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue