diff --git a/README.md b/README.md index 5199aac..cd91f0f 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,32 @@ evidence. Use `-vvv` to include recommended remediation and labeled reference URLs. Use `--command raw` to print the saved collector log. Use `--no-interactive` to print the review and exit. +## Develop with Nix + +The repository ships a [Nix](https://nixos.org) flake for a reproducible +development environment, a hermetic build of `cmax`, OCI container images, and +report-only static analysers. It is optional: it does not change the pip install +above. If you are new to Nix, [`nix/README.md`](nix/README.md) has a short +introduction, install steps, and video walkthroughs. + +Quickstart, from the repo root (flakes must be enabled — see +[`nix/README.md`](nix/README.md)): + +``` +nix develop # dev shell (Python, ruff, mypy, bandit, shellcheck); type 'cmax-help' +nix build .#cmax # build the CLI -> ./result/bin/cmax +nix run .#test # run the pytest suite +nix build .#analysis # run all static analysers; cat result/summary.txt +nix build .#oci-cmax # OCI image for the host arch; docker load < result +nix flake check # package build + CLI smoke check + nix formatting +``` + +The full target list is in the header comment of `flake.nix` and in +[`nix/README.md`](nix/README.md). + +> Flakes only see git-tracked files. After adding or editing files under `nix/`, +> `git add` them before `nix build` / `nix develop`. + ## Repository contents | Path | Contents | @@ -323,6 +349,7 @@ URLs. Use `--command raw` to print the saved collector log. Use | `cmax/` | This directory contains the command code and `cmax.yaml` configuration. | | `cmax/scripts/1-audit/` | This directory contains the scripts that run an audit. | | `tests/audit/` | This directory contains all audit tests, fixtures, and test helpers. | +| `flake.nix`, `nix/` | The Nix flake and its modules. See [`nix/README.md`](nix/README.md). | This release excludes provider results, internal notes, the private dashboard, benchmark implementations, bundled data, and database code. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5998912 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1788039129, + "narHash": "sha256-pa4Q0qErvCvzCaaUph7Sm37RhR4xvPrYI8Lgz6k85+A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d2f67949798825fe853f7c5d0492b8bf016d3f88", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..30e993d --- /dev/null +++ b/flake.nix @@ -0,0 +1,71 @@ +# +# flake.nix — ClusterMAX +# +# Thin orchestrator. Every concern lives under ./nix/ and is wired up here. +# See ./nix/default.nix for the per-system aggregator. +# +# Targets: +# nix develop # dev shell (ruff, mypy, bandit, shellcheck, pytest) +# nix build .#cmax # build the cmax CLI +# nix run .#cmax -- --help # run the CLI +# nix build .#oci-cmax # OCI image for the host arch (amd64 or aarch64) +# nix build .#analysis # run ALL static-analysis reports (report-only) +# nix build .#analysis-ruff # ruff lint report +# nix build .#analysis-ruff-format # ruff format --check report +# nix build .#analysis-mypy # mypy type report +# nix build .#analysis-bandit # bandit security report +# nix build .#analysis-shellcheck # shellcheck report for the audit .sh scripts +# nix run .#test # run the pytest suite (uses the host toolchain) +# nix flake check # package build + CLI smoke + nix formatting (gates) +# nix fmt # format the .nix files +# +# Static analysis is report-only: the analysis-* targets always succeed and write +# their findings to $out/report.txt. +# +# The pytest suite runs via `nix run .#test`, not `nix flake check`: its command +# stubs hard-code /bin/bash and /bin/cat, which do not exist in the hermetic Nix +# build sandbox. The gates that DO run under `nix flake check` are the package +# build (its installCheck smoke-tests the CLI + resources) and nix formatting. +# +# Containers are native per-system: build .#oci-cmax on an x86_64 host for the +# amd64 image, and on an aarch64 host (or through binfmt/qemu) for the arm64 image. +# +{ + description = "ClusterMAX — GPU cluster audit and security CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] ( + system: + let + pkgs = import nixpkgs { inherit system; }; + lib = nixpkgs.lib; + + aggregator = import ./nix { + inherit pkgs lib; + src = ./.; + }; + in + { + inherit (aggregator) + packages + devShells + checks + apps + formatter + ; + } + ) + // { + overlays.default = import ./nix/overlays.nix { inherit self; }; + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 0000000..7b244c9 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,170 @@ +# Nix flake: dev shell, package, containers, and analysis + +This flake gives you a reproducible development environment, a hermetic build +of the `cmax` CLI, OCI container images, and a set of report-only static +analysers — pinned so you get the same tools and results on any machine. The +design is modular: a slim top-level `flake.nix` wires together small +single-purpose modules under `nix/`. + +## New to Nix? + +[Nix](https://nixos.org) is a package manager that builds software in isolation +from pinned inputs. In practice that means `nix develop` drops you into a shell +with the exact Python and tools this project needs — nothing installed +system-wide, nothing to conflict with your distro — and `nix build` produces the +same result on any machine. + +**Install Nix** (multi-user, recommended): + +``` +sh <(curl -L https://nixos.org/nix/install) --daemon +``` + +Single-user (no root, e.g. in a container): use `--no-daemon` instead. Full +instructions: . + +**Enable flakes** (once). Either add this line to `/etc/nix/nix.conf` (or +`~/.config/nix/nix.conf`): + +``` +experimental-features = nix-command flakes +``` + +…or prefix each command with +`--extra-experimental-features 'nix-command flakes'`. + +**Two commands to get started**, from the repo root: + +``` +nix develop # enter the dev shell; type 'cmax-help' +nix build .#cmax # build the CLI -> ./result/bin/cmax +``` + +Video walkthroughs of the install: +[Ubuntu](https://youtu.be/cb7BBZLhuUY) · +[Fedora](https://youtu.be/RvaTxMa4IiY). Handy references: the +[flakes wiki](https://nixos.wiki/wiki/flakes) and +[search.nixos.org](https://search.nixos.org) to find any package. + +> Flakes only see git-tracked files. After adding or editing files under +> `nix/`, `git add` them before `nix build` / `nix develop`. + +## Dev shell + +``` +nix develop # dev shell (Python 3.12, ruff, mypy, bandit, shellcheck) +``` + +Type `cmax-help` for the menu. The shell defines helper functions that call the +same tool binaries the flake uses, so the shell and CI cannot drift: + +| Command | Action | +|---|---| +| `cmax [args]` | Run the CLI from the working tree (your edits apply at once). | +| `cmax-test [paths]` | Run pytest (default: `tests`). | +| `cmax-lint` | `ruff check cmax` | +| `cmax-fmt` | `ruff format cmax` (rewrites files) | +| `cmax-fmt-check` | `ruff format --check cmax` | +| `cmax-types` | `mypy cmax` | +| `cmax-sec` | `bandit -r cmax` | +| `cmax-shellcheck` | shellcheck the audit `.sh` scripts | +| `cmax-analysis` | run every analyser (report-only) | + +`cmax` is also on `PATH` as the built program, so `nix develop -c cmax ...` works +too; the shell function shadows it interactively to run your working tree. + +## Build & run + +``` +nix build .#cmax # -> ./result/bin/cmax +./result/bin/cmax --version +nix run .#cmax -- --help # build and run in one step +``` + +The build runs an install check: it confirms `cmax --version` works and that the +bundled resources (`cmax.yaml`, `scripts/1-audit/run.sh`) shipped in the wheel. + +## Static analysis (report-only) + +Each analyser is a build target. The targets **always succeed** and write their +findings to `$out/report.txt` — they generate reports, they do not gate. + +``` +nix build .#analysis # run all analysers; writes result/summary.txt +cat result/summary.txt + +nix build .#analysis-ruff # ruff lint +nix build .#analysis-ruff-format # ruff format --check +nix build .#analysis-mypy # mypy (non-strict) +nix build .#analysis-bandit # bandit security scan +nix build .#analysis-shellcheck # shellcheck for the audit .sh scripts +``` + +Each per-tool result holds `report.txt` (the findings), `exit-code.txt` (the +tool's real exit status), and `count.txt` (a rough finding-line count). Tool +configuration lives in the repo's `pyproject.toml` (`[tool.ruff]`, +`[tool.mypy]`, `[tool.bandit]`), so the same rules apply inside and outside Nix. + +## Tests + +``` +nix run .#test # run the pytest suite in the host environment +nix run .#test -- tests/audit/test_security.py # a subset +``` + +The suite runs through `nix run .#test`, **not** `nix flake check`. Its command +stubs write helper scripts that hard-code `/bin/bash` and `/bin/cat`; those paths +exist on a real host but not inside Nix's hermetic build sandbox, so the tests +must run in the host. Inside the dev shell, `cmax-test` does the same thing. + +## Container images (OCI) + +``` +nix build .#oci-cmax # OCI image for the host architecture +docker load < result # -> loads cmax:latest +docker run --rm cmax:latest --version +``` + +The image is built with `dockerTools.buildLayeredImage` over the `cmax` package +plus `bash` and `coreutils` (the audit scripts are shell scripts that call +`python3`; cluster tools such as `kubectl`/`srun` are provided by the +environment, not baked in). The entrypoint is `/bin/cmax`, and the image +timestamp is fixed for reproducibility. + +**amd64 and aarch64.** Images are native per-system: build `.#oci-cmax` on an +x86_64 host for the amd64 image and on an aarch64 host for the arm64 image. On a +single host you can build the other architecture through binfmt/QEMU emulation. +The package and image derivations evaluate for both `x86_64-linux` and +`aarch64-linux`. + +## Gates: `nix flake check` + +``` +nix flake check # package build + CLI smoke check + nix formatting +nix fmt # format the .nix files +``` + +Only sandbox-safe gates run here: the package build (whose install check +smoke-tests the CLI and the bundled resources) and a `nixfmt` formatting check. +Static analysis is report-only (above) and the test suite runs in the host +(above), so neither gates this command. + +## Module layout + +| Path | Role | +|---|---| +| `flake.nix` | Thin orchestrator; the target list lives in its header comment. | +| `nix/default.nix` | Per-system aggregator: assembles packages, devShells, checks, apps. | +| `nix/versions.nix` | Single source of truth for the Python interpreter and tool versions. | +| `nix/packages.nix` | Dependency lists fed to the dev shell. | +| `nix/clustermax.nix` | The `cmax` package (`buildPythonApplication`). | +| `nix/devshell.nix` | The `nix develop` environment and its helper functions. | +| `nix/lib/mkOciImage.nix` | OCI image factory. | +| `nix/containers/` | The `oci-cmax` image for the current system. | +| `nix/analysis/` | Report-only analysers (`ruff`, `mypy`, `bandit`, `shellcheck`) + combined report. | +| `nix/checks/` | The `nix flake check` gates (package build + nixfmt). | +| `nix/overlays.nix` | Exposes `cmax` and `cmax-oci` to downstream flakes. | + +Every module is a function that takes an explicit attribute set and returns a +derivation (or a set of them). Tool versions come from `nix/versions.nix` alone, +so there is one place to change them. diff --git a/nix/analysis/bandit.nix b/nix/analysis/bandit.nix new file mode 100644 index 0000000..49a2b9b --- /dev/null +++ b/nix/analysis/bandit.nix @@ -0,0 +1,15 @@ +# +# nix/analysis/bandit.nix — bandit security report (report-only). +# +{ + pkgs, + versions, + mkReport, +}: +mkReport { + name = "bandit"; + nativeBuildInputs = [ versions.bandit ]; + text = '' + bandit -r cmax + ''; +} diff --git a/nix/analysis/default.nix b/nix/analysis/default.nix new file mode 100644 index 0000000..6daafff --- /dev/null +++ b/nix/analysis/default.nix @@ -0,0 +1,47 @@ +# +# nix/analysis/default.nix — report-only static-analysis aggregator. +# +# Report-only model: analyzers are exposed as *packages*, never as +# `nix flake check` gates. Each per-tool package writes $out/report.txt; the +# combined `analysis` package gathers them under one tree with a summary.txt. +# +{ + pkgs, + lib, + versions, + src, +}: +let + mkReport = import ./mk-report.nix { inherit pkgs lib src; }; + + tools = { + "analysis-ruff" = import ./ruff.nix { inherit pkgs versions mkReport; }; + "analysis-ruff-format" = import ./ruff-format.nix { inherit pkgs versions mkReport; }; + "analysis-mypy" = import ./mypy.nix { inherit pkgs versions mkReport; }; + "analysis-bandit" = import ./bandit.nix { inherit pkgs versions mkReport; }; + "analysis-shellcheck" = import ./shellcheck.nix { inherit pkgs versions mkReport; }; + }; + + # Combined report: one subdirectory per tool plus a top-level summary. + combined = pkgs.runCommand "clustermax-analysis" { } ( + '' + mkdir -p "$out" + summary="$out/summary.txt" + echo "ClusterMAX static-analysis summary (report-only)" >"$summary" + echo "" >>"$summary" + '' + + lib.concatStringsSep "\n" ( + lib.mapAttrsToList (name: drv: '' + cp -r ${drv} "$out/${name}" + printf '%-22s exit=%s lines=%s\n' \ + "${name}" "$(cat ${drv}/exit-code.txt)" "$(cat ${drv}/count.txt)" >>"$summary" + '') tools + ) + + '' + + echo "" >>"$summary" + cat "$summary" + '' + ); +in +tools // { analysis = combined; } diff --git a/nix/analysis/mk-report.nix b/nix/analysis/mk-report.nix new file mode 100644 index 0000000..3b741cc --- /dev/null +++ b/nix/analysis/mk-report.nix @@ -0,0 +1,43 @@ +# +# nix/analysis/mk-report.nix — uniform report-only analysis runner. +# +# Copies the source into a writable tree, runs one tool, and tees everything to +# $out/report.txt. It NEVER fails the build: static analysis here produces +# reports, it does not gate. $out/exit-code.txt records the tool's real exit +# status and $out/count.txt a crude finding-line count. +# +{ + pkgs, + lib, + src, +}: +{ + name, + nativeBuildInputs ? [ ], + # Shell snippet that runs the tool. cwd is a writable copy of the repo. + text, +}: +pkgs.runCommand "clustermax-analysis-${name}" + { + inherit nativeBuildInputs; + passthru.reportName = name; + } + '' + cp -r ${src} ./source + chmod -R +w ./source + cd ./source + + mkdir -p "$out" + report="$out/report.txt" + + set +e + { + ${text} + } >"$report" 2>&1 + status=$? + set -e + + echo "$status" >"$out/exit-code.txt" + grep -c . "$report" >"$out/count.txt" 2>/dev/null || echo 0 >"$out/count.txt" + echo "clustermax analysis ${name}: tool exit=$status, $(cat "$out/count.txt") report lines" + '' diff --git a/nix/analysis/mypy.nix b/nix/analysis/mypy.nix new file mode 100644 index 0000000..90da39c --- /dev/null +++ b/nix/analysis/mypy.nix @@ -0,0 +1,18 @@ +# +# nix/analysis/mypy.nix — mypy type report (report-only, non-strict). +# +# The codebase has no type annotations yet; --ignore-missing-imports keeps the +# report focused on real errors rather than un-annotated third-party stubs. +# +{ + pkgs, + versions, + mkReport, +}: +mkReport { + name = "mypy"; + nativeBuildInputs = [ versions.mypy ]; + text = '' + mypy --ignore-missing-imports --no-error-summary cmax + ''; +} diff --git a/nix/analysis/ruff-format.nix b/nix/analysis/ruff-format.nix new file mode 100644 index 0000000..c9441bc --- /dev/null +++ b/nix/analysis/ruff-format.nix @@ -0,0 +1,15 @@ +# +# nix/analysis/ruff-format.nix — ruff format --check report (report-only). +# +{ + pkgs, + versions, + mkReport, +}: +mkReport { + name = "ruff-format"; + nativeBuildInputs = [ versions.ruff ]; + text = '' + ruff format --check --diff cmax + ''; +} diff --git a/nix/analysis/ruff.nix b/nix/analysis/ruff.nix new file mode 100644 index 0000000..233341c --- /dev/null +++ b/nix/analysis/ruff.nix @@ -0,0 +1,15 @@ +# +# nix/analysis/ruff.nix — ruff lint report (report-only). +# +{ + pkgs, + versions, + mkReport, +}: +mkReport { + name = "ruff"; + nativeBuildInputs = [ versions.ruff ]; + text = '' + ruff check --output-format=full cmax + ''; +} diff --git a/nix/analysis/shellcheck.nix b/nix/analysis/shellcheck.nix new file mode 100644 index 0000000..5624c17 --- /dev/null +++ b/nix/analysis/shellcheck.nix @@ -0,0 +1,17 @@ +# +# nix/analysis/shellcheck.nix — shellcheck report for the audit .sh scripts. +# +# -x follows `source`d files (e.g. audit-common.sh). Report-only. +# +{ + pkgs, + versions, + mkReport, +}: +mkReport { + name = "shellcheck"; + nativeBuildInputs = [ versions.shellcheck ]; + text = '' + find cmax/scripts -name '*.sh' -print0 | sort -z | xargs -0 shellcheck -x + ''; +} diff --git a/nix/checks/default.nix b/nix/checks/default.nix new file mode 100644 index 0000000..227b022 --- /dev/null +++ b/nix/checks/default.nix @@ -0,0 +1,25 @@ +# +# nix/checks/default.nix — the `nix flake check` gates. +# +# These must run inside Nix's hermetic build sandbox, so only sandbox-safe gates +# live here: nix formatting, plus the package build (its installCheck smoke-tests +# the CLI and asserts the bundled resources shipped — see ../clustermax.nix). +# +# The pytest suite is NOT a check: it stubs external tools with scripts that +# hard-code /bin/bash and /bin/cat, which do not exist in the sandbox. It runs in +# the host environment instead, via `nix run .#test` or `cmax-test` in the shell. +# +{ + pkgs, + lib, + versions, + cmax, + src, +}: +{ + # Building the package runs its installCheckPhase: `cmax --version` plus the + # cmax.yaml / scripts/1-audit/run.sh resource assertions. + cmax = cmax; + + nixfmt = import ./nixfmt.nix { inherit pkgs versions src; }; +} diff --git a/nix/checks/nixfmt.nix b/nix/checks/nixfmt.nix new file mode 100644 index 0000000..bcab136 --- /dev/null +++ b/nix/checks/nixfmt.nix @@ -0,0 +1,17 @@ +# +# nix/checks/nixfmt.nix — assert every .nix file is nixfmt-formatted (a gate). +# +{ + pkgs, + versions, + src, +}: +pkgs.runCommand "clustermax-nixfmt-check" + { + nativeBuildInputs = [ versions.nixfmt ]; + } + '' + cd ${src} + find . -name '*.nix' -print0 | xargs -0 nixfmt --check + touch "$out" + '' diff --git a/nix/clustermax.nix b/nix/clustermax.nix new file mode 100644 index 0000000..56ac51b --- /dev/null +++ b/nix/clustermax.nix @@ -0,0 +1,62 @@ +# +# nix/clustermax.nix — the cmax CLI package. +# +# buildPythonApplication over the repo's pyproject (setuptools backend). The +# dynamic version resolves through setup.py; with no CLUSTERMAX_BUILD_VERSION set +# it falls back to cmax/_version.py (0.2.1), which setup.py rewrites into the wheel. +# +{ + pkgs, + lib, + versions, + src, +}: +let + inherit (versions) python; +in +python.pkgs.buildPythonApplication { + pname = "clustermax"; + version = "0.2.1"; + pyproject = true; + + inherit src; + + build-system = [ versions.setuptools ]; + + # pyproject pins `requires = ["setuptools==84.0.0"]`; nixpkgs ships a nearby + # setuptools that the backend builds fine with. Skip the frontend's exact-pin + # check (build isolation is already off) rather than chase the pinned version. + pypaBuildFlags = [ "--skip-dependency-check" ]; + + dependencies = versions.runtimeDeps python.pkgs; + + # Tests run as a dedicated flake check (see nix/checks/pytest.nix), not here. + doCheck = false; + + pythonImportsCheck = [ "cmax" ]; + + # Assert the CLI runs and that the bundled resources shipped in the wheel — + # the same invariants the CI wheel smoke-test guards. + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + + echo "checking cmax --version" + "$out/bin/cmax" --version + + siteDir=$(echo "$out/lib/"python*"/site-packages") + test -f "$siteDir/cmax/cmax.yaml" \ + || (echo "missing cmax/cmax.yaml in $siteDir" && exit 1) + test -f "$siteDir/cmax/scripts/1-audit/run.sh" \ + || (echo "missing scripts/1-audit/run.sh in $siteDir" && exit 1) + + runHook postInstallCheck + ''; + + meta = { + description = "ClusterMAX GPU cluster audit and security CLI"; + homepage = "https://clustermax.semianalysis.com/"; + license = lib.licenses.asl20; + mainProgram = "cmax"; + }; +} diff --git a/nix/containers/default.nix b/nix/containers/default.nix new file mode 100644 index 0000000..d7bc624 --- /dev/null +++ b/nix/containers/default.nix @@ -0,0 +1,20 @@ +# +# nix/containers/default.nix — OCI images for the current system. +# +# Native per-system: `nix build .#oci-cmax` produces the amd64 image on an +# x86_64 host and the arm64 image on an aarch64 host. +# +{ + pkgs, + lib, + cmax, +}: +let + mkOciImage = import ../lib/mkOciImage.nix { inherit pkgs lib; }; +in +{ + oci = mkOciImage { + name = "cmax"; + inherit cmax; + }; +} diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..61e2c6e --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,101 @@ +# +# nix/default.nix — per-system aggregator. +# +# Imports every sub-module and assembles the flake outputs for one system: +# packages, devShells, checks, apps, and the formatter. flake.nix re-exports +# these under flake-utils.lib.eachSystem. +# +{ + pkgs, + lib, + src, +}: +let + versions = import ./versions.nix { inherit pkgs; }; + packages = import ./packages.nix { inherit pkgs versions; }; + + cmax = import ./clustermax.nix { + inherit + pkgs + lib + versions + src + ; + }; + containers = import ./containers { inherit pkgs lib cmax; }; + analysis = import ./analysis { + inherit + pkgs + lib + versions + src + ; + }; + + devshell = import ./devshell.nix { + inherit + pkgs + lib + packages + cmax + ; + }; + checks = import ./checks { + inherit + pkgs + lib + versions + cmax + src + ; + }; + + # The test suite runs in the host environment (not the hermetic sandbox), + # because its command stubs hard-code /bin/bash and /bin/cat. + testApp = pkgs.writeShellApplication { + name = "clustermax-test"; + runtimeInputs = [ versions.pythonEnv ]; + text = '' + python -m pytest -q "''${@:-tests}" + ''; + }; +in +{ + packages = { + default = cmax; + inherit cmax; + oci-cmax = containers.oci; + } + // analysis; # analysis-ruff, analysis-mypy, ..., and combined `analysis` + + devShells.default = devshell; + + checks = checks; + + apps = { + cmax = { + type = "app"; + program = "${lib.getExe cmax}"; + meta.description = "Run the cmax CLI"; + }; + analysis = { + type = "app"; + program = "${lib.getExe ( + pkgs.writeShellApplication { + name = "clustermax-analysis"; + text = '' + cat ${analysis.analysis}/summary.txt + ''; + } + )}"; + meta.description = "Print the combined static-analysis summary"; + }; + test = { + type = "app"; + program = "${lib.getExe testApp}"; + meta.description = "Run the pytest suite in the host environment"; + }; + }; + + formatter = versions.nixfmt; +} diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 0000000..952bc86 --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,55 @@ +# +# nix/devshell.nix — the `nix develop` environment. +# +# Plain pkgs.mkShell. The shellHook prints a menu and defines helper functions +# that call the SAME tool binaries the flake checks and analysis reports use, so +# the shell and CI cannot drift. +# +{ + pkgs, + lib, + packages, + cmax, +}: +pkgs.mkShell { + name = "clustermax-dev"; + # The built cmax is on PATH so `cmax` works in any invocation (incl. + # `nix develop -c cmax ...`); the shell function below shadows it + # interactively to run the working tree instead. + packages = packages.allDevPackages ++ [ cmax ]; + + shellHook = '' + # Interactively, run the working-tree source so `cmax` reflects local edits. + cmax() { python3 -m cmax.cli "$@" ; } + cmax-test() { python3 -m pytest -q "''${@:-tests}" ; } + cmax-lint() { ruff check cmax ; } + cmax-fmt() { ruff format cmax ; } + cmax-fmt-check() { ruff format --check cmax ; } + cmax-types() { mypy cmax ; } + cmax-sec() { bandit -r cmax ; } + cmax-shellcheck() { find cmax/scripts -name '*.sh' -print0 | xargs -0 shellcheck ; } + cmax-analysis() { + echo "== ruff ==" ; cmax-lint || true + echo "== ruff format ==" ; cmax-fmt-check || true + echo "== mypy ==" ; cmax-types || true + echo "== bandit ==" ; cmax-sec || true + echo "== shellcheck ==" ; cmax-shellcheck || true + } + cmax-help() { + cat <<'EOF' + ClusterMAX dev shell + cmax [args] run the CLI from the working tree (live edits) + cmax-test [paths] run pytest (default: tests) + cmax-lint ruff check cmax + cmax-fmt ruff format cmax (rewrites files) + cmax-fmt-check ruff format --check cmax + cmax-types mypy cmax + cmax-sec bandit -r cmax + cmax-shellcheck shellcheck the audit .sh scripts + cmax-analysis run every analyzer (report-only) + EOF + } + + cmax-help + ''; +} diff --git a/nix/lib/mkOciImage.nix b/nix/lib/mkOciImage.nix new file mode 100644 index 0000000..6221a37 --- /dev/null +++ b/nix/lib/mkOciImage.nix @@ -0,0 +1,41 @@ +# +# nix/lib/mkOciImage.nix — OCI image factory. +# +# Wraps dockerTools.buildLayeredImage over the cmax package plus bash/coreutils +# (the audit scripts are .sh files that shell out to python3; external cluster +# tools like kubectl/slurm are environment-provided, not baked in). +# +# The image architecture follows pkgs.stdenv.hostPlatform, so building on an +# x86_64 host yields the amd64 image and building on aarch64 yields the arm64 +# image — native per-system, no cross plumbing. +# +{ pkgs, lib }: +{ + name, + cmax, + tag ? "latest", +}: +pkgs.dockerTools.buildLayeredImage { + inherit name tag; + + # Reproducible: a fixed epoch instead of "now". + created = "1970-01-01T00:00:00Z"; + + contents = [ + cmax + pkgs.bashInteractive + pkgs.coreutils + pkgs.dockerTools.caCertificates + ]; + + config = { + Entrypoint = [ "/bin/cmax" ]; + Env = [ "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" ]; + Labels = { + "org.opencontainers.image.title" = "clustermax"; + "org.opencontainers.image.description" = "ClusterMAX GPU cluster audit and security CLI"; + "org.opencontainers.image.licenses" = "Apache-2.0"; + "org.opencontainers.image.source" = "https://github.com/SemiAnalysisAI/ClusterMAX"; + }; + }; +} diff --git a/nix/overlays.nix b/nix/overlays.nix new file mode 100644 index 0000000..9396b11 --- /dev/null +++ b/nix/overlays.nix @@ -0,0 +1,8 @@ +# +# nix/overlays.nix — expose the package and image to downstream flakes. +# +{ self }: +final: _prev: { + cmax = self.packages.${final.system}.cmax or null; + cmax-oci = self.packages.${final.system}.oci-cmax or null; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 0000000..b23c3d2 --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,31 @@ +# +# nix/packages.nix — dependency lists fed to the dev shell. +# +# Splits packages by role so the dev shell can pull them all in one place while +# each concern (checks, containers) imports only what it needs from versions.nix. +# +{ pkgs, versions }: +let + # The Python interpreter plus runtime deps and pytest, importable as `cmax`. + inherit (versions) pythonEnv; + + # Static-analysis tools exposed both as report packages and in the shell. + analysisTools = [ + versions.ruff + versions.mypy + versions.bandit + versions.shellcheck + ]; + + # General development conveniences. + devTools = [ + versions.nixfmt + pkgs.git + ]; +in +{ + inherit analysisTools devTools; + + # Everything the `nix develop` shell should put on PATH. + allDevPackages = [ pythonEnv ] ++ analysisTools ++ devTools; +} diff --git a/nix/versions.nix b/nix/versions.nix new file mode 100644 index 0000000..9a83703 --- /dev/null +++ b/nix/versions.nix @@ -0,0 +1,34 @@ +# +# nix/versions.nix — single source of truth for tool and dependency versions. +# +# Every other module imports this so shell, checks, package, and container all +# agree on the same Python interpreter and tool set — no drift. +# +{ pkgs }: +let + # CI runs on Python 3.12; pin to match (pyproject requires >= 3.10). + python = pkgs.python312; + + # Runtime dependencies of the cmax package (pyproject `dependencies`). + runtimeDeps = ps: [ + ps.prompt-toolkit + ps.pyyaml + ]; + + # A Python environment that can import cmax and run the test suite. + # Used by the pytest check and the dev shell. + pythonEnv = python.withPackages (ps: (runtimeDeps ps) ++ [ ps.pytest ]); +in +{ + inherit python runtimeDeps pythonEnv; + + # Build-time tools for producing the wheel / package. + setuptools = python.pkgs.setuptools; + + # Static-analysis tools (report-only) and the Nix formatter. + ruff = pkgs.ruff; + mypy = pkgs.mypy; + bandit = pkgs.bandit; + shellcheck = pkgs.shellcheck; + nixfmt = pkgs.nixfmt; +} diff --git a/pyproject.toml b/pyproject.toml index 3dee9fd..47a343c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,27 @@ cmax = [ [tool.check-wheel-contents] # The audit runs these Python files as package resources through their paths. ignore = ["W004"] + +# --- Static analysis (report-only; wired into the Nix flake analysis-* targets) --- +# These configs make the analyzers usable both inside `nix build .#analysis` and +# directly (ruff/mypy/bandit) from a plain checkout. + +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = ["tests/audit/fixtures"] + +[tool.ruff.lint] +# A sensible default rule set. Reports only — nothing gates on these. +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true +# Non-strict: the codebase is not annotated yet, so surface only real errors. +warn_unused_ignores = true +warn_redundant_casts = true + +[tool.bandit] +# Test fixtures and the suite are not shipped code. +exclude_dirs = ["tests"]