blumeops/containers/shower/default.nix
Erich Blume ba4c1e8953 C1: switch shower container to pip-install FOD
The buildPythonPackage approach with `propagatedBuildInputs = [ python.pkgs.django ... ]` doesn't work:

  1. nixpkgs python314Packages.django still aliases to Django 4.2 LTS,
     which doesn't support Python 3.14.
  2. django-axes from nixpkgs pulls selenium + browser fonts into its
     check phase, and the nix sandbox can't provide those (fontconfig
     errors, then build dep tree collapses).

Switching to authentik's FOD pattern instead: a single fixed-output
derivation that pip-installs the adelaide-baby-shower-app wheel + every
transitive dep from forge PyPI into a target dir. FODs get network
access in exchange for a pinned output hash, so the closure stays
reproducible.

outputHash is set to fakeHash for the first build — the runner will
print the real hash on failure; a follow-up commit will pin it.

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

181 lines
5.7 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.0";
python = pkgs.python314;
# 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.
pyDeps = pkgs.stdenv.mkDerivation {
pname = "shower-python-deps";
inherit version;
dontUnpack = true;
nativeBuildInputs = [ python pkgs.cacert ];
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
# Install into a venv first so pip's bytecode-compile + entry-point
# generation pick up the right interpreter, then copy site-packages
# + bin into $out at a stable layout.
${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.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
# Copy console scripts (gunicorn, django-admin, etc.) but drop the
# venv-specific shebang prefix that points at $TMPDIR/venv/bin/python.
# Rewrite shebangs to the eventual on-image python path.
for script in "$TMPDIR/venv/bin/"*; do
[ -f "$script" ] || continue
name=$(basename "$script")
case "$name" in
python*|pip*|activate*) continue ;;
esac
# Replace the venv python shebang with a path that resolves inside
# the docker image (where ${python} ends up in /nix/store).
sed -e "1 s|^#!.*python.*|#!${python}/bin/python3.14|" "$script" > "$out/bin/$name"
chmod +x "$out/bin/$name"
done
runHook postInstall
'';
# Bytecode files embed absolute paths; deletion forces re-compile inside
# the image at first run, with paths matching the image filesystem.
postInstall = ''
find $out -type f -name '*.pyc' -delete
find $out -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
'';
outputHashMode = "recursive";
outputHashAlgo = "sha256";
# Computed by setting to pkgs.lib.fakeHash and reading the failure.
# Pin the dep closure — rebuilds are reproducible until the version bumps.
outputHash = pkgs.lib.fakeHash;
dontFixup = true;
};
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 config.settings import * # noqa: F401,F403
DATABASES["default"]["NAME"] = "/app/data/db.sqlite3"
MEDIA_ROOT = "/app/media"
STATIC_ROOT = "/app/data/staticfiles"
'';
entrypoint = pkgs.writeShellScript "shower-entrypoint" ''
set -eu
export HOME=/app/data
export PATH=${pyDeps}/bin:${python}/bin:/bin
export PYTHONPATH=/app:${sitePackages}
export DJANGO_SETTINGS_MODULE=local_settings
cd /app
mkdir -p /app/data /app/media
echo "shower: running migrations"
${python}/bin/python -m django migrate --noinput
echo "shower: collecting static files"
${python}/bin/python -m django collectstatic --noinput --clear
echo "shower: starting gunicorn"
exec ${pyDeps}/bin/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
'';
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"
];
ExposedPorts = {
"8000/tcp" = { };
};
User = "1000";
WorkingDir = "/app";
};
}