build(nix): modular flake.nix + ./nix static-analysis harness#144
build(nix): modular flake.nix + ./nix static-analysis harness#144daveseddon-runpod wants to merge 18 commits into
Conversation
Small root flake wiring modular ./nix modules (flake-utils.eachSystem):
- nix/lib{,/*}: mk-check, mk-lint, mk-all-app, maybe-tool builders
- nix/checks.nix: sandbox-safe gated checks for `nix flake check`
(nixfmt/statix/deadnix/nil, shellcheck, yamllint, actionlint,
hadolint, markdown, taplo, editorconfig, gitleaks)
- nix/lints/*: per-tool apps + domain composites (shell/yaml/json/py/
nix/docker/md/secrets/sast/supply/spelling) + lint-extreme/everything
- nix/devshell: `nix develop` toolchain
- pragmatic tool defaults; configs: .yamllint.yaml, .markdownlint.yaml,
pyproject.toml; docs in nix/README.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .github/workflows/nix.yml: install Nix and run the gated, sandbox-safe checks (nix flake check --keep-going) on PR / push-to-main / dispatch. - .github/actionlint.yaml: register the repo's self-hosted Blacksmith runner labels so actionlint stops reporting them as unknown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aks FP Two issues the first CI run surfaced: - actionlint gated check failed with "no project was found" because the mkCheck sandbox stages src without .git. `git init` a throwaway repo so actionlint can locate workflows + read .github/actionlint.yaml. - gitleaks flagged .github/actions/smoke-test/action.yml:205 — a "-----BEGIN RSA PRIVATE KEY-----" string literal in a case statement that validates a user-supplied SSH key, not a committed secret. Add .gitleaks.toml allowlisting that file + PEM-header pattern (matchCondition = AND) so real keys elsewhere still trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add .hadolint.yaml ignoring DL3006/DL3008/DL3013/DL3022/DL3042 — the same codes the repo's hadolint-pr/hadolint-push workflows already ignore per-Dockerfile — so the Nix gate agrees with established policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First CI runs — resultsThe Nix static analysis workflow runs ✅ 10 gated checks passing: nixfmt, statix, deadnix, nil, shellcheck, taplo, editorconfig, actionlint, gitleaks, hadolint ❌ 2 remaining — pre-existing style findings (team decision to fix vs. relax): What got fixed along the way
Remaining findingsyamllint — 19 issues, all trivial hygiene:
markdownlint — 130 issues, style: top rules Recommendation: the yamllint set is a cheap cleanup pass (whitespace only). The markdown set is larger — either a |
Make the Nix gated checks pass on the existing tree: - yamllint (19): strip trailing whitespace, add final newlines, CRLF→LF in .github workflow/action/dependabot YAML. - markdownlint (130): markdownlint-cli2 --fix for the auto-fixable rules; prettier --write to normalize GFM tables (MD060/MD056); add `text` language to bare code fences (MD040); fix an invalid heading-anchor link fragment (MD051) in tests/README.md. No behavioral changes — docs/CI YAML formatting only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prototype building runpod/base (CPU) with pkgs.dockerTools.streamLayeredImage
instead of a Dockerfile, to quantify the win. Build-only — nothing is pushed.
- nix/images/base-cpu.nix: Python 3.14 (jupyterlab/notebook/ipywidgets/
hf_transfer) + uv/filebrowser/nginx/openssh + CLI tooling + start.sh.
- nix/images/{default.nix}: exposed as packages.<system>.base-cpu (Linux
only); nix flake check does not build it, so gated lint stays fast.
- nix/images/compare-size.sh: builds, measures compressed/uncompressed size,
asserts stream determinism (sha256 x2), and diffs vs the published image
via skopeo.
- .github/workflows/nix.yml: add build-only `image-size` job.
- nix/images/README.md: approach, measured numbers, caveats, GPU parity path.
Measured: nix base-cpu 295 MB vs runpod/base:1.0.7-ubuntu2404 714 MB
compressed (~59% smaller); byte-identical across two builds. Caveat: PoC ships
one Python (3.14) vs the base's 3.9-3.13.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Experimental: Nix-built container image (build-only)Added a proof-of-concept for building the images with Nix (
≈ 59% smaller compressed download, and the stream is byte-identical across two builds (deterministic — CI asserts it via What's in it
Fair-comparison caveatThe published base bundles Python 3.9–3.13 + a full apt userland; this PoC ships a single Python (3.14). Some of the delta is "fewer Pythons" — but the determinism, per-store-path layer dedup, and no-apt/no-compilers-in-the-image properties are structural and hold regardless. Adding multi-Python parity would grow it (est. +150–250 MB) and still beat the Dockerfile build. Bumps
GPU parity (not done here)Documented in Full write-up, tradeoffs, and local repro in |
- nix/images/userland.nix: shared userland (python3.14 + jupyter + tooling) factored out of base-cpu; used by both CPU and CUDA images. - nix/images/base-cuda.nix: HYBRID image — pin nvidia/cuda:12.8.1-cudnn-devel base (digest + nix-prefetch-docker hash) as fromImage, layer the Nix userland on top. Comparable to runpod/base:*-cuda1281 (torch excluded; it pip-installs identically on both). - nix/images/compare-cuda-size.sh + manual-dispatch `cuda-image-size` CI job (heavy: pulls ~5.6 GB base, so not run on every push; frees disk first). - README: CUDA results + a section on maximizing shared layers across an image family (store-path dedup, the per-image customisation-layer subtlety, tiered fromImage bases, maxLayers, nix2container, a family "footprint" metric). Measured: hybrid base-cuda 5846 MB vs runpod/base:cuda1281 6359 MB compressed. The Nix userland layer is ~67% smaller than the apt layer (253 vs 766 MB); whole-image win is modest since the shared CUDA base + torch dominate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hybrid CUDA image (build-only) — resultPinned
The Nix userland layer is ~67% smaller than the apt one (253 vs 765 MB). But the shared CUDA base + torch wheels dominate, so the whole-image win is only ~8%. Honest takeaway: for GPU images the big levers are a runtime (non-devel) base and a Nix-built torch, not the userland. The Cross-image layer sharing (fleet caching)Since these co-deploy on the same hosts, cross-image Docker layer reuse is the real win. |
Prototype the cross-image layer-sharing win (fleet caching): - userland.nix: add mkContents so family members extend the common userland while reusing the exact same shared store paths (=> shared docker layers). - family.nix: family-base / family-data / family-serve, all sharing the userland, each adding only its own Python stack. - footprint.sh + CI job `family-footprint`: measure unique cached NAR bytes (shared layers deduped) vs the naïve per-image sum. Measured: naïve 3388 MB vs unique 1575 MB across the 3-image family — a host running all three caches ~1.6 GB instead of ~3.4 GB (53% saved by sharing). README documents this plus how to push overlap further (tiered fromImage bases, maxLayers, nix2container). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nix path-info reports narSize only for realized store paths; on a fresh CI runner the image outputs don't exist yet, so the footprint printed 0 MB. Build the family (--no-link) first so path-info sees real sizes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prototype: shared-userland image family + layer-sharing footprintBuilt the tiered/shared-base idea and a metric for the fleet-caching win. A family of images all reuse the exact same Nix userland store paths and only add their own Python stack, so those layers dedupe on any host running more than one:
New CI job
A box running all three flavors caches ~1.6 GB instead of ~3.4 GB. This is automatic in Nix: layers are keyed by store path, so the shared userland + tools are the same layer digests across every image — Dockerfile images only share their exact common prefix. Metric is uncompressed NAR bytes (what the Nix store + Docker dedup key on); compressed is proportional. Ways to push it further (in CI status: |
fleet-footprint.sh + manual-dispatch CI job `fleet-footprint`: measure how much the published runpod fleet (base cpu/cuda/rocm, full pytorch matrix, autoresearch, nvidia-pytorch = 38 release images) already shares Docker layers today — unique blob digests vs the naïve per-image sum, via skopeo manifests (no pulls; authenticates with DOCKERHUB_* if set). Add a workflow_dispatch `heavy` input (none/cuda/fleet/all) so the two heavy jobs can be triggered independently instead of always together. Measured: 38 images, naïve 448.7 GB vs unique 258.6 GB — the Dockerfile fleet already dedupes ~42% via its shared FROM chain. Reframes the Nix win honestly: the CUDA base is shared either way; Nix's lever is the userland tier + finer store-path sharing + determinism. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The family footprint previously summed Nix closure NAR sizes, which overstates sharing: streamLayeredImage only gives the top maxLayers (default 100) store paths a dedicated layer and bundles the rest into a per-image customisation layer with a distinct digest. Store-path sharing != OCI-layer sharing. footprint.sh now streams each image's docker-archive, reads real layer digests + sizes via skopeo, and dedupes by digest. Real result: 34% shared at the default (not 53%). README documents the maxLayers sweep (100→34%, 200→38%, 400→53% but 275 layers/img) and points at nix2container to reach the ceiling without the layer-count blow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correction: real OCI layer sharing (not store-path closure)Good catch on my earlier family-footprint number — it measured Nix closure (store paths), which overstates Docker layer sharing.
And the
So the 53% store-path figure is only a ceiling, reached when every store path is its own layer (~275 layers/image — impractical). Default dockerTools gives 34% real OCI sharing; raising Note: the fleet number (42%, published images) was already real OCI — it used |
…parison Add nix2container as a flake input and mirror the family as separate *-n2c targets (family-base-n2c/family-data-n2c/family-serve-n2c), so the two layering strategies can be compared without disturbing the dockerTools work. Expose the patched skopeo-n2c for reading nix2container's `nix:` transport. footprint.sh now measures BOTH families' real OCI layer sharing side by side. Findings (measured): - The sharing ceiling is closure-bound (~53% here), reached by BOTH builders only at ~275+ layers/image; neither exceeds it. - nix2container does NOT raise the ceiling; at its default (100 layers) it shares LESS (16%) than dockerTools (34%). Its edge is that many layers are cheap to build (lazy store-path refs) — so maxLayers=600 is practical. - The real lever is deliberate tiering (one shared userland layer via buildLayer / fromImage), not cranking maxLayers to ~275 (overlay2/registry discourage that many layers). README updated with the full sweep + guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nix2container as separate targets — head-to-headAdded nix2container as a flake input and mirrored the family as separate
Two findings:
The catch either way: 53% needs ~275+ layers/image — overlay2 and some registries discourage that many. So per-path auto-layering isn't the answer. The actual lever is deliberate tiering: put the whole shared userland into one explicit layer every image reuses (nix2container CI green: |
family-tiered.nix (family-*-tiered): put the whole shared userland in ONE explicit buildLayer every image reuses byte-for-byte, with only a small per-flavor delta on top. Kept as separate targets alongside the dockerTools and auto-layered n2c families. footprint.sh now compares all three approaches on real OCI layers. Result: tiering reaches ~the store-path sharing ceiling (51%, vs 53% auto) at ~15-21 layers/image instead of ~288 — near-maximal fleet sharing at a practical layer count. Extends naturally to the GPU tiers (userland -> +CUDA -> +torch). README updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Explicit-tiering prototype — the practical answerAdded
Tiering reaches ~the sharing ceiling (51% vs 53%) at ~19 layers/image instead of ~288. The shared userland is one cached blob; It extends naturally to the GPU tiers: Where the image experiment stands (all build-only, nothing published)
CI: |
New nix/prod-images/ tree (separate from the experimental nix/images/ PoC), rebuilding base + pytorch entirely from nixpkgs — every apt/pip/uv install as a Nix package (source-built, hash-pinned), CUDA from nixpkgs cudaPackages (no vendor nvidia/cuda base): - pkgs/jupyter-archive.nix: the one requirements.txt entry missing from nixpkgs, built from the PyPI sdist (hatchling, hash-pinned). - userland.nix: python 3.14 env (jupyterlab/notebook/ipywidgets/hf-transfer + jupyter-archive) + uv/filebrowser/nginx/openssh + apt-parity dev toolchain. - runtime.nix: Nix-adapted /start.sh (replaces Debian `service` with direct nginx/sshd/jupyter), stages the nginx proxy config + banner. - base.nix: prod-base-cpu, prod-base-cuda (+ nixpkgs CUDA 12.9/cuDNN). - pytorch.nix: prod-pytorch-cpu (torch 2.12.0 CPU) + prod-pytorch (cudaSupport). - flake.nix: expose prod-* under packages (Linux only). Verified: prod-base-cpu 850 MB gzip, prod-pytorch-cpu 1250 MB gzip; userland python imports (jupyterlab 4.5.8, hf_transfer, source-built jupyter_archive) OK; flake check green. README documents review, approach, numbers, version-drift + CUDA-build-cost caveats. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n vendor) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
prod-pytorch (CUDA) builds: torch 2.12.0 compiled against nixpkgs cudaPackages (verified torch.version.cuda=12.9, cudnn 9.22) — no download.pytorch.org wheel, every input hash-pinned. Image 7791 MB gzip vs vendor runpod/pytorch 11211 MB (~30% smaller). README updated with the full prod-vs-vendor comparison. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native-parity rebuild: base + pytorch entirely from NixNew
What's Nix-native now: the Honest caveats:
Verified: userland imports (jupyterlab/hf_transfer/jupyter_archive), torch CUDA build, flake-check green, all committed on |
- prod-base-cpu-lean: runtime-only CPU base (drops the gcc/gfortran/cmake/ ffmpeg dev toolchain). 299 MB gzip — ~58% smaller than the apt base (714 MB), vs the full toolchain base at 850 MB (+19%). Shows the CPU size is a choice, not a Nix limit. userland.nix split into coreTools/devTools with a `lean` flag. - flake.lock: bump nixpkgs 2026-07-08 -> 2026-07-11 (latest nixos-unstable) so images get current versions (python 3.14.6, jupyterlab 4.5.8, notebook 7.5.6, torch 2.12.0, cuda 12.9). flake check stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Adds a small root
flake.nixthat wires up modular./nix/modules, giving a reproducible, pinned static-analysis surface for this repo — no global tool installs needed.Opened as a draft so we can watch the new Nix static analysis workflow run and see what the harness surfaces.
Layout
flake.nix— thin entry point (flake-utils.eachSystem),srcfilter,formatter = nixfmt-rfc-style.nix/lib{,/*}— shared builders:mk-check(gated derivations),mk-lint(per-tool apps),mk-all-app(composites),maybe-tool(graceful skip when a tool is absent from the pin).nix/checks.nix— sandbox-safe gates fornix flake check.nix/lints/*— per-tool apps + domain composites (lint-shell/-yaml/-json/-py/-nix-files/-docker/-md/-secrets/-sast/-supply/-spelling) +lint-extreme+everything.nix/devshell/—nix developtoolchain..yamllint.yaml,.markdownlint.yaml,pyproject.toml; docs innix/README.md..github/actionlint.yaml— declares the repo's Blacksmith self-hosted runner labels.CI
.github/workflows/nix.ymlinstalls Nix and runsnix flake check --keep-going— the gated, sandbox-safe subset:nixfmt / statix / deadnix / nil, shellcheck, yamllint, actionlint, hadolint, markdown, taplo, editorconfig-checker, gitleaks.
Network / dependency-heavy tools (trivy, osv-scanner, semgrep, lychee, trufflehog, pyright, pylint) are exposed as
nix run .#lint-*apps rather than CI gates.Usage
Heads-up
This runs the linters against the existing tree with pragmatic defaults, so the first CI run is expected to surface pre-existing findings (e.g. hadolint
DL3008, editorconfig/whitespace, shellcheck style). That output is the point — it shows coverage. We can then decide what to tune (per-tool configs) vs. fix before making any of it a required check.Known coverage gaps
HCL (
docker-bake.hcl), INI (grafana.ini), and CSV have no dedicated linter wired in (documented innix/README.md); large generated JSON is validated for well-formedness only.