blumeops/containers/shower/default.nix
Erich Blume 039d9b9507 C1: pull shower sdist for vendored static (fixes /host/ 500)
The wheel ships config/ and shower/ only (per pyproject hatchling
config), leaving the repo's top-level static/ dir — Sortable.min.js,
cropper.min.js, cropper.min.css, prize-placeholder.svg — behind. At
runtime, host_dashboard.html's {% static 'css/cropper.min.css' %}
hits the manifest, CompressedManifestStaticFilesStorage raises
ValueError on the missing entry, /host/ returns 500.

Fix on the deploy side: fetch the sdist via fetchurl (pinned SRI hash
from forge PyPI), extract its top-level static/ subtree into a
non-FOD derivation, lay it down at /app/static in the image. The
local_settings shim adds /app/static to STATICFILES_DIRS so
collectstatic at boot picks the vendored assets up alongside the
Django admin's own static files.

Sdist URL is forge.ops.eblu.me/api/packages/... (tailnet) — matches
the just-landed edge block on forge.eblu.me/api/packages/*. The
nix-container-builder runner on ringtail is on the tailnet, so the
FOD fetch works.

App doesn't change. v1.0.3 is no longer needed for the static gap —
the wheel's "packages = [config, shower]" pattern stays as-is, and we
treat the sdist as the canonical bundle for the assets the wheel
intentionally omits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:18:16 -07:00

259 lines
8.8 KiB
Nix

# 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/. The wheel + its
# transitive Python deps are baked in at build time via a fixed-output
# derivation that runs `pip install --target` against forge PyPI (proxied
# through pypi.ops.eblu.me for upstream packages). Build runs on the
# nix-container-builder runner (ringtail, amd64) so the image is native.
#
# Going through pip-install-target rather than nixpkgs Python packages
# sidesteps two issues we hit going through `python.pkgs.buildPythonPackage`:
# 1. python314Packages.django still aliases to Django 4.2 LTS, which
# doesn't support Python 3.14 at all.
# 2. django-axes pulls selenium + browser fonts into its check phase
# and the nix sandbox can't provide those.
#
# To bump the version:
# 1. Update `version` below.
# 2. Set `outputHash` to `pkgs.lib.fakeHash`, run the build, copy the
# real hash out of the error, and commit it.
{ pkgs ? import <nixpkgs> { } }:
let
version = "1.0.2";
python = pkgs.python314;
# The repo's top-level static/ directory (vendored Sortable + cropper
# JS/CSS, prize placeholder SVG) isn't shipped in the wheel — hatchling
# only packages config/ and shower/, leaving the repo-root static/
# behind. Pull the sdist (which contains the full source tree) and
# extract just the static/ subtree into the image as /app/static.
# local_settings adds it to STATICFILES_DIRS so collectstatic at boot
# picks it up alongside the Django admin's static files.
#
# Fetched from forge.ops.eblu.me (tailnet) because /api/packages/* is
# blocked at the fly edge — see fly/nginx.conf forge.eblu.me block.
# Hash is the upstream sha256 from forge PyPI's simple index.
showerSdist = pkgs.fetchurl {
name = "adelaide_baby_shower_app-${version}.tar.gz";
url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz";
hash = "sha256-nlCtlx9zuYaLoJZSckybLV5YPpA8vZamN96O3RXOstM=";
};
staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } ''
${pkgs.gnutar}/bin/tar -xzf ${showerSdist} -C $TMPDIR
cp -r $TMPDIR/adelaide_baby_shower_app-${version}/static $out
'';
# Fixed-output derivation: pip-installs the app wheel + every transitive
# dep into a single target dir. FODs get network access in exchange for
# a pinned output hash, which means the whole dependency closure is
# immutable across rebuilds.
pyDepsFOD = pkgs.stdenv.mkDerivation {
pname = "shower-python-deps-fod";
inherit version;
dontUnpack = true;
nativeBuildInputs = [ python pkgs.cacert pkgs.removeReferencesTo ];
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export PIP_DISABLE_PIP_VERSION_CHECK=1
${python}/bin/python -m venv "$TMPDIR/venv"
"$TMPDIR/venv/bin/pip" install --upgrade pip
"$TMPDIR/venv/bin/pip" install \
--no-cache-dir \
--index-url=https://pypi.ops.eblu.me/root/pypi/+simple/ \
--extra-index-url=https://forge.ops.eblu.me/api/packages/eblume/pypi/simple/ \
"adelaide-baby-shower-app==${version}" \
gunicorn
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/python3.14 $out/bin
cp -r "$TMPDIR/venv/lib/python3.14/site-packages" $out/lib/python3.14/site-packages
for script in "$TMPDIR/venv/bin/"*; do
[ -f "$script" ] || continue
name=$(basename "$script")
case "$name" in
python*|pip*|activate*) continue ;;
esac
cp "$script" "$out/bin/$name"
chmod +x "$out/bin/$name"
done
# --- Strip Nix store references (FOD outputs must be self-contained) ---
# The wrapper derivation below restores them via autoPatchelfHook + a
# python wrapper that points pyc-less imports at the on-image python.
# Strip bytecode entirely pyc files embed compile-time paths.
find $out -type f -name '*.pyc' -delete
find $out -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
# Dynamically discover all nix store references and strip them. We
# don't have a static list because pip pulls in stdenv via Python's
# build env (gcc-lib, libstdc++, etc.) and the closure is opaque.
{ find $out -type f -print0 \
| xargs -0 grep -aohE '/nix/store/[a-z0-9]{32}-[^/"[:space:]]+' 2>/dev/null \
|| true; } | sort -u > $TMPDIR/store-refs.txt
echo "Found $(wc -l < $TMPDIR/store-refs.txt) unique store path references to strip"
refs_args=""
while IFS= read -r ref; do
refs_args="$refs_args -t $ref"
done < $TMPDIR/store-refs.txt
if [ -n "$refs_args" ]; then
find $out -type f -exec remove-references-to $refs_args {} + 2>/dev/null || true
fi
remaining=$({ find $out -type f -print0 | xargs -0 grep -cl '/nix/store/' 2>/dev/null || true; } | wc -l)
echo "Files with remaining store references: $remaining"
runHook postInstall
'';
outputHashMode = "recursive";
outputHashAlgo = "sha256";
# Pinned dep closure — reproducible until version bumps. To recompute,
# set to pkgs.lib.fakeHash and read the failure.
outputHash = "sha256-tSTH/HaDY7M0qxlauBTM+JekZAgF++K2lGP3PLvym/o=";
dontFixup = true;
};
# Non-FOD wrapper: re-applies RPATHs to pre-built .so files (pillow,
# scipy) so they find libstdc++ / libz / etc. at runtime. autoPatchelfHook
# discovers needed libraries from buildInputs.
pyDeps = pkgs.stdenv.mkDerivation {
pname = "shower-python-deps";
inherit version;
dontUnpack = true;
nativeBuildInputs = [ pkgs.autoPatchelfHook ];
buildInputs = with pkgs; [
python
stdenv.cc.cc.lib # libstdc++, libgcc_s
zlib
libjpeg
libwebp
libtiff
openjpeg
lcms2
freetype
];
installPhase = ''
cp -r ${pyDepsFOD} $out
chmod -R u+w $out
'';
};
sitePackages = "${pyDeps}/lib/python3.14/site-packages";
# 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/staticfiles}.
localSettings = pkgs.writeText "local_settings.py" ''
from pathlib import Path
from config.settings import * # noqa: F401,F403
DATABASES["default"]["NAME"] = "/app/data/db.sqlite3"
MEDIA_ROOT = "/app/media"
STATIC_ROOT = "/app/data/staticfiles"
# /app/static comes from the repo-root static/ subtree of the sdist
# (see default.nix staticAssets). Added because the wheel doesn't
# ship vendored Sortable/cropper assets.
STATICFILES_DIRS = [Path("/app/static")]
'';
# PYTHONPATH, DJANGO_SETTINGS_MODULE, PATH, and HOME live in the image's
# `Env` block below — that way `kubectl exec deploy/shower -- python -m
# django <subcommand>` Just Works without an inline `env` ceremony.
# The entrypoint just changes directory and runs the boot sequence.
entrypoint = pkgs.writeShellScript "shower-entrypoint" ''
set -eu
cd /app
mkdir -p /app/data /app/media
echo "shower: running migrations"
python -m django migrate --noinput
echo "shower: collecting static files"
python -m django collectstatic --noinput --clear
echo "shower: starting gunicorn"
exec gunicorn \
--bind 0.0.0.0:8000 \
--workers 2 \
--forwarded-allow-ips='*' \
config.wsgi:application
'';
in
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/shower";
contents = [
python
pyDeps
pkgs.cacert
pkgs.tzdata
pkgs.bashInteractive
pkgs.coreutils
];
extraCommands = ''
mkdir -p app/data app/media tmp
chmod 1777 tmp
cp ${localSettings} app/local_settings.py
cp -r ${staticAssets} app/static
chmod -R u+w app/static
'';
fakeRootCommands = ''
chown -R 1000:1000 app
'';
enableFakechroot = true;
config = {
Entrypoint = [ "${entrypoint}" ];
Env = [
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"TZDIR=${pkgs.tzdata}/share/zoneinfo"
"TZ=America/Los_Angeles"
"TMPDIR=/tmp"
"LANG=C.UTF-8"
"LC_ALL=C.UTF-8"
"PYTHONDONTWRITEBYTECODE=1"
"HOME=/app/data"
"PATH=${pyDeps}/bin:${python}/bin:/bin"
# /app first so local_settings.py is importable; sitePackages second so
# django, gunicorn, etc. resolve. Inherited by entrypoint + any
# `kubectl exec` so manual django subcommands work without ceremony.
"PYTHONPATH=/app:${sitePackages}"
"DJANGO_SETTINGS_MODULE=local_settings"
];
ExposedPorts = {
"8000/tcp" = { };
};
User = "1000";
WorkingDir = "/app";
};
}