From ba4c1e89531d2f28434d0addbfe944aca00c29ed Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 09:00:26 -0700 Subject: [PATCH] C1: switch shower container to pip-install FOD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- containers/shower/default.nix | 126 +++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 33 deletions(-) diff --git a/containers/shower/default.nix b/containers/shower/default.nix index e8d7383..cb64ca8 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -2,51 +2,108 @@ # # The app is published as a wheel to the Forgejo PyPI index at # 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 +# 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. Update `wheelHash` — `nix-prefetch-url ` against the new wheel, -# or set it to `pkgs.lib.fakeHash` and let the build print the right one. +# 2. Set `outputHash` to `pkgs.lib.fakeHash`, run the build, copy the +# real hash out of the error, and commit it. { pkgs ? import { } }: let version = "1.0.0"; - wheelHash = "sha256-9Xk3TCzl474As8n0RhLoy/QYw+K1DABBWEwLC8v1X0A="; python = pkgs.python314; - 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; - }; - - shower = python.pkgs.buildPythonPackage { - pname = "adelaide-baby-shower-app"; + # 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; - format = "wheel"; - src = showerWheel; - doCheck = false; - propagatedBuildInputs = with python.pkgs; [ - django - django-axes - pillow - scipy - segno - ]; + + 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; }; - pyEnv = python.withPackages (ps: [ - shower - ps.gunicorn - ]); + 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/static}. + # 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 @@ -59,7 +116,8 @@ let set -eu export HOME=/app/data - export PYTHONPATH=/app + export PATH=${pyDeps}/bin:${python}/bin:/bin + export PYTHONPATH=/app:${sitePackages} export DJANGO_SETTINGS_MODULE=local_settings cd /app @@ -67,13 +125,13 @@ let mkdir -p /app/data /app/media echo "shower: running migrations" - ${pyEnv}/bin/python -m django migrate --noinput + ${python}/bin/python -m django migrate --noinput echo "shower: collecting static files" - ${pyEnv}/bin/python -m django collectstatic --noinput --clear + ${python}/bin/python -m django collectstatic --noinput --clear echo "shower: starting gunicorn" - exec ${pyEnv}/bin/gunicorn \ + exec ${pyDeps}/bin/gunicorn \ --bind 0.0.0.0:8000 \ --workers 2 \ --forwarded-allow-ips='*' \ @@ -84,7 +142,8 @@ in pkgs.dockerTools.buildLayeredImage { name = "blumeops/shower"; contents = [ - pyEnv + python + pyDeps pkgs.cacert pkgs.tzdata pkgs.bashInteractive @@ -111,6 +170,7 @@ pkgs.dockerTools.buildLayeredImage { "TMPDIR=/tmp" "LANG=C.UTF-8" "LC_ALL=C.UTF-8" + "PYTHONDONTWRITEBYTECODE=1" ]; ExposedPorts = { "8000/tcp" = { };