|
| 1 | +# Stackinator |
| 2 | + |
| 3 | +Stackinator is a Python CLI tool that generates build configurations for scientific software stacks on HPE Cray EX (Alps) systems. It acts like `cmake`/`configure`: given a **recipe** and a **cluster configuration**, it produces a build directory with Makefiles and Spack YAML files. The actual build is then performed by `make`. |
| 4 | + |
| 5 | +## Two-Phase Workflow |
| 6 | + |
| 7 | +``` |
| 8 | +stack-config -b BUILD -r RECIPE -s SYSTEM [-c CACHE] [-m MOUNT] |
| 9 | + → generates BUILD/ directory with Makefiles + spack.yaml files |
| 10 | +
|
| 11 | +cd BUILD |
| 12 | +env --ignore-environment PATH=/usr/bin:/bin:`pwd -P`/spack/bin make store.squashfs -j64 |
| 13 | + → clones Spack, concretises, builds, creates store.squashfs |
| 14 | +``` |
| 15 | + |
| 16 | +The `store.squashfs` SquashFS image is the final artifact, intended to be mounted at the recipe's `store` path (default `/user-environment`) as a uenv image. |
| 17 | + |
| 18 | +## Repository Layout |
| 19 | + |
| 20 | +``` |
| 21 | +stackinator/ # Python package |
| 22 | + main.py # CLI entry point (stack-config) |
| 23 | + recipe.py # Recipe class: parses and validates all recipe YAML |
| 24 | + builder.py # Builder class: writes all files to the build path |
| 25 | + cache.py # Build cache configuration helpers |
| 26 | + spack_util.py # Tiny helper: checks if a path is a spack package repo |
| 27 | + schema.py # JSON schema validators with default-injection |
| 28 | + schema/ # JSON schemas for each YAML file type |
| 29 | + config.json |
| 30 | + compilers.json |
| 31 | + environments.json |
| 32 | + cache.json |
| 33 | + modules.json |
| 34 | + templates/ # Jinja2 templates for all generated files |
| 35 | + Makefile # Top-level build orchestration |
| 36 | + Makefile.compilers # Compiler build steps |
| 37 | + Makefile.environments # Environment build + view generation steps |
| 38 | + Makefile.generate-config # Generates upstream spack config for the uenv |
| 39 | + Make.user # Build path / store / sandbox variable definitions |
| 40 | + repos.yaml # Generated spack repos.yaml |
| 41 | + stack-debug.sh # Debug helper script |
| 42 | + compilers.*.spack.yaml # Per-compiler spack.yaml configs |
| 43 | + environments.spack.yaml # Environment spack.yaml config |
| 44 | + etc/ |
| 45 | + Make.inc # Shared make rules (concretize, depfile, compiler_bin_dirs) |
| 46 | + bwrap-mutable-root.sh # Bubblewrap sandbox wrapper |
| 47 | + envvars.py # CLI tool: generates env.json for views and uenv metadata |
| 48 | +docs/ # MkDocs documentation source |
| 49 | +unittests/ # pytest test suite |
| 50 | + test_schema.py # Schema validation tests (primary test coverage) |
| 51 | + recipes/ # Example recipes used by tests |
| 52 | + yaml/ # Example YAML snippets for testing |
| 53 | +``` |
| 54 | + |
| 55 | +## Recipe Format (input) |
| 56 | + |
| 57 | +A recipe is a directory containing YAML files: |
| 58 | + |
| 59 | +### `config.yaml` (required) |
| 60 | +```yaml |
| 61 | +name: prgenv-gnu |
| 62 | +store: /user-environment # mount point; default /user-environment |
| 63 | +version: 2 # must be 2 for Spack 1.0 (Stackinator 6+) |
| 64 | +spack: |
| 65 | + repo: https://github.com/spack/spack.git |
| 66 | + commit: releases/v1.0 # branch, tag, or SHA; null = default branch |
| 67 | + packages: |
| 68 | + repo: https://github.com/spack/spack-packages.git |
| 69 | + commit: develop |
| 70 | +description: "optional text" |
| 71 | +default-view: develop # optional: view loaded when no view is specified |
| 72 | +``` |
| 73 | +
|
| 74 | +- `version: 1` (the default) targets Spack v0.23 and is only supported by Stackinator v5 (`releases/v5` branch). **Current `main` requires `version: 2`.** |
| 75 | +- `store` can be overridden at configure time with `-m/--mount`. |
| 76 | + |
| 77 | +### `compilers.yaml` (required) |
| 78 | +```yaml |
| 79 | +gcc: |
| 80 | + version: "13" # required; must be quoted string |
| 81 | +nvhpc: # optional |
| 82 | + version: "25.1" |
| 83 | +llvm: # optional |
| 84 | + version: "16" |
| 85 | +llvm-amdgpu: # optional |
| 86 | + version: "6.0" |
| 87 | +intel-oneapi-compilers: # optional |
| 88 | + version: "2024.1" |
| 89 | +``` |
| 90 | + |
| 91 | +Build order: `gcc` is built first (using system compiler), then `nvhpc`/`llvm`/`llvm-amdgpu`/`intel-oneapi-compilers` are built using the gcc toolchain. Stackinator appends opinionated variants (e.g. `gcc@13 +bootstrap`, `nvhpc@25.1 ~mpi~blas~lapack`, `llvm@16 +clang ~gold`). |
| 92 | + |
| 93 | +### `environments.yaml` (required) |
| 94 | +```yaml |
| 95 | +my-env: |
| 96 | + compiler: [gcc] # required; list from compilers.yaml keys; first = default |
| 97 | + specs: # required; list of spack specs |
| 98 | + - cmake |
| 99 | + - hdf5+mpi |
| 100 | + network: # optional; null = no MPI |
| 101 | + mpi: cray-mpich # full spack spec for MPI (cray-mpich or openmpi) |
| 102 | + specs: ['libfabric@1.22'] # optional; overrides network.yaml defaults |
| 103 | + unify: true # concretizer: true | false | when_possible (default true) |
| 104 | + duplicates: |
| 105 | + strategy: minimal # minimal | full | none (default minimal) |
| 106 | + deprecated: false # allow deprecated spack versions (default false) |
| 107 | + variants: # applied to all packages (packages:all:variants) |
| 108 | + - +cuda |
| 109 | + - cuda_arch=80 |
| 110 | + prefer: null # packages:all:prefer; auto-set if null |
| 111 | + packages: # external packages to discover via `spack external find` |
| 112 | + - perl |
| 113 | + - git |
| 114 | + views: # optional filesystem views |
| 115 | + default: null # view name → view config (null = defaults) |
| 116 | + no-python: |
| 117 | + exclude: [python] |
| 118 | + uenv: |
| 119 | + add_compilers: true # default true; adds compiler symlinks to view/bin |
| 120 | + prefix_paths: |
| 121 | + LD_LIBRARY_PATH: [lib, lib64] |
| 122 | + env_vars: |
| 123 | + set: |
| 124 | + - MYVAR: "value" |
| 125 | + - MYVAR2: null # unsets the variable |
| 126 | + prepend_path: |
| 127 | + - PATH: "/some/path" |
| 128 | + append_path: |
| 129 | + - PKG_CONFIG_PATH: "/usr/lib/pkgconfig" |
| 130 | +``` |
| 131 | +
|
| 132 | +**Key constraints:** |
| 133 | +- Do not include MPI or compilers in `specs`; they are handled by `network.mpi` and `compiler`. |
| 134 | +- Spec matrices are not supported. |
| 135 | +- Only one MPI per environment; create separate environments for multiple MPIs. |
| 136 | +- The `prefer` field is auto-generated if `null`: it nudges Spack to use the first compiler for all packages. |
| 137 | + |
| 138 | +#### Environment variable special syntax |
| 139 | +- `${@VAR@}` — deferred expansion: expands `VAR` at uenv load time (e.g. `${@HOME@}`) |
| 140 | +- `$@key@` — substitution at configure time: `mount`, `view_name`, `view_path` |
| 141 | + |
| 142 | +#### Supported prefix-path variables (hardcoded in `etc/envvars.py`) |
| 143 | +`ACLOCAL_PATH`, `CMAKE_PREFIX_PATH`, `CPATH`, `LD_LIBRARY_PATH`, `LIBRARY_PATH`, `MANPATH`, `MODULEPATH`, `PATH`, `PKG_CONFIG_PATH`, `PYTHONPATH` |
| 144 | + |
| 145 | +### `modules.yaml` (optional) |
| 146 | +Presence of this file enables module generation. Follows Spack's module config format with two differences: |
| 147 | +- `modules:default:arch_folder` must be `false` (Stackinator doesn't support `true`) |
| 148 | +- `modules:default:roots:tcl` is ignored and overwritten by Stackinator |
| 149 | + |
| 150 | +### `packages.yaml` (optional) |
| 151 | +Standard Spack `packages.yaml` with recipe-specific external package overrides. |
| 152 | + |
| 153 | +### `repo/` (optional) |
| 154 | +Custom Spack package definitions. Must contain a `packages/` subdirectory. |
| 155 | +Merged into a single `alps` namespace repo alongside system and site packages. |
| 156 | +Precedence: recipe repo > site repos (from cluster config `repos.yaml`) > Spack builtin. |
| 157 | + |
| 158 | +### `post-install` / `pre-install` (optional) |
| 159 | +Shell scripts (any language) run inside the bwrap sandbox: |
| 160 | +- `pre-install`: after Spack is set up, before first compiler build |
| 161 | +- `post-install`: after all packages are built, before squashfs generation |
| 162 | + |
| 163 | +Both are Jinja-templated with variables: `env.mount`, `env.config`, `env.build`, `env.spack`. |
| 164 | + |
| 165 | +### `extra/` (optional) |
| 166 | +Arbitrary files copied to `meta/extra/` in the final image (used for CI metadata). |
| 167 | + |
| 168 | +## Cluster Configuration (input) |
| 169 | + |
| 170 | +A directory (passed via `-s/--system`) containing: |
| 171 | + |
| 172 | +``` |
| 173 | +cluster-config/ |
| 174 | + packages.yaml # Spack external packages; must include gcc |
| 175 | + network.yaml # MPI defaults and network library package configs |
| 176 | + repos.yaml # optional; list of relative paths to site-wide spack repos |
| 177 | +``` |
| 178 | + |
| 179 | +`network.yaml` structure: |
| 180 | +```yaml |
| 181 | +mpi: |
| 182 | + cray-mpich: |
| 183 | + specs: [libfabric@1.22] # default specs injected when cray-mpich is chosen |
| 184 | + openmpi: |
| 185 | + specs: [libfabric@2.2.0] |
| 186 | +packages: # standard spack packages.yaml content |
| 187 | + libfabric: ... |
| 188 | + cray-mpich: ... |
| 189 | +``` |
| 190 | + |
| 191 | +Package precedence (recipe.py merges these): recipe `packages.yaml` > `network.yaml` packages > `packages.yaml` (minus gcc). The `gcc` entry from `packages.yaml` is isolated and used only for the gcc compiler build step. |
| 192 | + |
| 193 | +## Build Directory Structure (output) |
| 194 | + |
| 195 | +``` |
| 196 | +BUILD/ |
| 197 | + Makefile # top-level orchestration |
| 198 | + Make.user # variables: BUILD_ROOT, STORE, SANDBOX, etc. |
| 199 | + Make.inc # shared make rules (copied from etc/) |
| 200 | + bwrap-mutable-root.sh # sandbox wrapper (copied from etc/) |
| 201 | + envvars.py # view/meta generator (copied from etc/) |
| 202 | + spack/ # cloned Spack repository |
| 203 | + spack-packages/ # cloned spack-packages repository |
| 204 | + config/ # global spack configuration scope |
| 205 | + packages.yaml |
| 206 | + mirrors.yaml # only if --cache provided |
| 207 | + repos.yaml |
| 208 | + compilers/ |
| 209 | + Makefile |
| 210 | + gcc/ |
| 211 | + spack.yaml |
| 212 | + packages.yaml # generated by spack external find |
| 213 | + nvhpc/ # if nvhpc in recipe |
| 214 | + spack.yaml |
| 215 | + environments/ |
| 216 | + Makefile |
| 217 | + my-env/ |
| 218 | + spack.yaml |
| 219 | + generate-config/ # generates the upstream spack config for the final image |
| 220 | + Makefile |
| 221 | + modules/ # only if modules.yaml in recipe |
| 222 | + modules.yaml |
| 223 | + store/ # installation root (bind-mounted to recipe.store during build) |
| 224 | + meta/ |
| 225 | + configure.json # build metadata |
| 226 | + env.json.in # view metadata template |
| 227 | + recipe/ # copy of the recipe |
| 228 | + repos/spack_repo/alps/ # consolidated custom package repo |
| 229 | + repos/spack_repo/builtin/ # copy of spack builtin repo |
| 230 | + env/ # filesystem views (created during build) |
| 231 | + view-name/ |
| 232 | + activate.sh |
| 233 | + env.json |
| 234 | + bin/ lib/ ... |
| 235 | + store.squashfs # final compressed image |
| 236 | + stack-debug.sh # debug helper: opens shell in build environment |
| 237 | +``` |
| 238 | +
|
| 239 | +## Python Architecture |
| 240 | +
|
| 241 | +### `Recipe` class (`recipe.py`) |
| 242 | +Parses and validates all recipe inputs in `__init__`. Key responsibilities: |
| 243 | +- Validates each YAML file against its JSON schema (with default injection) |
| 244 | +- Merges packages from cluster config, network.yaml, and recipe |
| 245 | +- Generates full compiler specs (e.g. `gcc@13 +bootstrap`) from `compilers.yaml` |
| 246 | +- Processes environments: resolves MPI specs from `network.yaml` templates, sets default `prefer` constraints, builds view metadata |
| 247 | +- Provides `compiler_files` and `environment_files` properties (Jinja-rendered Makefiles and spack.yaml files) |
| 248 | +
|
| 249 | +### `Builder` class (`builder.py`) |
| 250 | +Writes all files to the build path. Key responsibilities: |
| 251 | +- Creates directory structure |
| 252 | +- Clones Spack and spack-packages repositories |
| 253 | +- Merges and writes the consolidated `alps` spack package repo |
| 254 | +- Renders all Jinja templates into build path files |
| 255 | +- Writes metadata JSON files |
| 256 | +
|
| 257 | +### `schema.py` |
| 258 | +JSON schema validation using `jsonschema`. The `validator()` function extends the validator to auto-inject `default` values from schemas into parsed instances, so downstream code can rely on optional fields always being present. |
| 259 | +
|
| 260 | +### `etc/envvars.py` |
| 261 | +A standalone CLI tool (copied into the build directory) with two subcommands: |
| 262 | +- `envvars.py view <root> <build_path> [--compilers] [--prefix_paths]`: reads a Spack-generated `activate.sh`, parses env vars, adds compiler symlinks and prefix paths, writes `env.json` for the view |
| 263 | +- `envvars.py uenv <mount> [--modules] [--spack]`: merges view `env.json` files with recipe `env_vars` config, writes the final `meta/env.json` |
| 264 | +
|
| 265 | +The `EnvVarSet` class in `envvars.py` is also imported by `recipe.py` for processing `env_vars` at configure time. |
| 266 | +
|
| 267 | +## Build Pipeline (Make targets) |
| 268 | +
|
| 269 | +The top-level `Makefile` orchestrates in order: |
| 270 | +1. `spack-setup` — sanity check, bootstrap concretizer |
| 271 | +2. `pre-install` — run `pre-install-hook` if provided |
| 272 | +3. `mirror-setup` — configure build cache keys |
| 273 | +4. `compilers` — build gcc, then nvhpc/llvm/etc. (parallel within each stage) |
| 274 | +5. `environments` — build all user environments (parallel) |
| 275 | +6. `generate-config` — generate the upstream spack config files for the installed image |
| 276 | +7. `modules-done` — generate TCL module files (if `modules.yaml` present) |
| 277 | +8. `env-meta` — run `envvars.py uenv` to produce final `meta/env.json` |
| 278 | +9. `post-install` — run `post-install-hook` if provided |
| 279 | +10. `store.squashfs` — create the final squashfs image |
| 280 | +
|
| 281 | +Key Make.inc rules: |
| 282 | +- `%/spack.lock`: concretize a spack environment |
| 283 | +- `%/Makefile`: generate a depfile from a lock file (enables parallel package builds) |
| 284 | +- `compiler_bin_dirs`: helper to find compiler binaries given install prefixes |
| 285 | +
|
| 286 | +The build runs inside a bwrap sandbox (`bwrap-mutable-root.sh`) that: |
| 287 | +- Bind-mounts `BUILD/store` → `STORE` (the recipe mount point) |
| 288 | +- Bind-mounts `BUILD/tmp` → `/tmp` |
| 289 | +- Puts a tmpfs over `$HOME` (isolates user config) |
| 290 | +
|
| 291 | +## Build Cache |
| 292 | +
|
| 293 | +Optional binary cache configured via YAML file passed to `-c/--cache`: |
| 294 | +```yaml |
| 295 | +root: /path/to/cache # directory; env vars expanded |
| 296 | +key: /path/to/pgp.key # optional; omit for read-only cache |
| 297 | +``` |
| 298 | + |
| 299 | +Cache is stored in a subdirectory named after the mount point (e.g. `cache/user-environment/`) to avoid relocation issues. Packages are pushed per-environment after a successful build. Large binary packages (`cuda`, `nvhpc`, `perl`) are excluded from cache pushes. |
| 300 | + |
| 301 | +## Testing |
| 302 | + |
| 303 | +```bash |
| 304 | +uv run pytest # run tests |
| 305 | +./lint # ruff format + ruff check --fix |
| 306 | +``` |
| 307 | + |
| 308 | +Tests live in `unittests/test_schema.py` and cover schema validation and default injection. Test recipes are in `unittests/recipes/`, example YAML in `unittests/yaml/`. |
| 309 | + |
| 310 | +The test coverage is limited — the schema validators and their default-injection are well tested, but `Recipe`, `Builder`, and `envvars.py` have minimal test coverage. |
| 311 | + |
| 312 | +## Code Style |
| 313 | + |
| 314 | +- Python 3.12+ |
| 315 | +- Linting: `ruff` (line length 120, E + F rules, E203 ignored) |
| 316 | +- Format: `ruff format` |
| 317 | +- Run both via `./lint` |
| 318 | + |
| 319 | +## Key Invariants and Pitfalls |
| 320 | + |
| 321 | +- **Build path restrictions**: cannot be in `/tmp`, `$HOME`, or root `/`. The bwrap sandbox rebinds these. |
| 322 | +- **Version 2 is required**: `config.yaml` must have `version: 2` for current `main`. Version 1 recipes require the `releases/v5` branch. |
| 323 | +- **gcc is required**: `packages.yaml` in cluster config must define an external `gcc`. It is handled separately from other system packages for the bootstrap build step. |
| 324 | +- **MPI validation**: the MPI name in `network.mpi` must match a key in `network.yaml:mpi` templates from the cluster config. Unknown MPI implementations raise an error. |
| 325 | +- **View names are globally unique**: view names must be unique across all environments in a recipe. |
| 326 | +- **`mirrors.yaml` in recipes is unsupported**: use `--cache` CLI flag instead. |
| 327 | +- **`default-view` must exist**: if set in `config.yaml`, the named view must be defined in `environments.yaml` (or be `modules`/`spack`). |
| 328 | +- **`prefer` is auto-set**: if `null` in the recipe, Stackinator generates a `prefer` constraint using Spack's `%[when=...]` syntax to pin the default compiler. |
| 329 | +- **Spack `uenv_tools` environment**: an internal environment named `uenv_tools` is injected into every build to install `squashfs`. Recipe authors must not use this name. |
0 commit comments