Architecture¶
NullForge is a thin, opinionated layer over pyinfra. Every layer has one job, and imports only flow downward.
Data flow¶
flowchart TB
subgraph plan [Control node]
CLI[cli - nullforge cast] --> FY["foundry - full_cast.py / cast.py"]
INV[inventories] --> FY
FY --> RN["runes/*.py"]
RN --> SM[smithy]
RN --> TP["templates (Jinja2)"]
MD["molds (Pydantic)"] --> INV
MD --> RN
ML["models (domain types)"] --> MD
end
RN -->|pyinfra operations over SSH| HOSTS[(Target hosts)]
- Inventories define hosts and attach
systemandfeaturesdata, built by merging layers over the defaults. - The foundry is the deploy entry point.
full_cast.pycoerces inventory data through the molds, always includespreparethenbase, and then includes the rune of every active feature.cast.pyis the selective variant used bynullforge cast -r .... - Runes are self-contained pyinfra operation sets - one file per concern.
They read validated configuration from
host.dataand emit idempotent operations. - Molds are Pydantic schemas for all configuration;
FeaturesMoldcomposes the per-feature sub-molds. - Models hold pure domain types and constants consumed by molds.
- The smithy provides cross-distro abstractions: package-name mapping (apt/dnf), release-binary installs with checksum verification, version pinning, networking facts, swap, service users.
- Templates are Jinja2 files for systemd units, service configs, and shell profiles.
Layer contracts¶
The layering is enforced by import contracts:
| Contract | Meaning |
|---|---|
| Deploy spine | cli -> foundry -> runes -> smithy -> molds/templates -> models; a layer may import only layers below it |
| Models are pure | models imports no other NullForge package |
| Templates are a leaf | templates imports no other NullForge package, models included - its spine position only says which layers may import it |
| Molds describe, never provision | molds cannot import runes, smithy, or templates |
| Runes are independent | no rune imports another rune |
Why rune independence matters - and how runes coordinate without it - is covered in Runes.
Execution model¶
pyinfra runs in two phases:
- Plan - fact gathering and operation collection on the control node.
Python-level branching (
if host.get_fact(...)) happens here. - Execute - the collected operations run against each host, in a deterministic order shared by all hosts.
The conventions around host.loop and change detection exist to keep that shared ordering stable.
The CLI wrapper¶
nullforge cast is a thin planner around pyinfra: it resolves the cast stages and hands them to pyinfra.
Everything else - connections, facts, operations, parallelism - is stock pyinfra, which is why unknown CLI options are proxied through verbatim.