Inventories¶
An inventory is a standard pyinfra inventory: a Python file with a hosts list.
Each host is either a bare address or an (address, data) tuple.
NullForge reads two data keys - system and features - plus an optional versions map for pin overrides.
from nullforge.molds.defaults import BASE_FEATURES, BASE_SYSTEM
from nullforge.molds.utils import merge_features, merge_system
hosts = [
(
"203.0.113.10",
{
"system": merge_system(BASE_SYSTEM, {"hostname": "node1.example.com"}),
"features": merge_features(BASE_FEATURES, ...),
},
),
]
Defaults and layering¶
BASE_SYSTEM and BASE_FEATURES (from nullforge.molds.defaults) are plain default-constructed molds.
merge_system / merge_features (from nullforge.molds.utils) start from a base and deep-merge each layer in order - later layers win.
A features layer can be any of:
| Layer type | Example |
|---|---|
Full FeaturesMold |
another host's merged result |
| Any feature sub-mold | WarpMold(install=True) - merged under its own key |
dict fragment |
{"warp": {"install": True}} (sub-molds allowed as values) |
None |
skipped; useful for conditional layers |
from nullforge.molds import DnsMold, UserMold, WarpMold
common = (
UserMold(manage=True, name="core"),
WarpMold(install=True),
)
features = merge_features(BASE_FEATURES, *common, {"dns": {"ecs": True}})
The set of allowed sub-mold types is derived from FeaturesMold.model_fields, so a new feature is mergeable without touching the merge logic.
Deep-merge is per-key
Dictionaries merge recursively, but any non-dict value - including lists - replaces the previous value outright.
A layer that sets netsec.firewall_rules replaces the whole rule list; extend BASE_FEATURES.netsec.firewall_rules in Python if you want "default rules plus mine".
Because inventories are Python, you can factor shared preset tuples, per-environment modules, or host loops however you like - the only contract is the final hosts list.
Validation and coercion¶
The foundry coerces whatever the inventory provided - None, a dict, or a mold instance - into validated SystemMold / FeaturesMold objects before any rune runs (ensure_system / ensure_features).
Missing keys fall back to mold defaults; unknown keys are rejected because every mold forbids extra fields.
A typo like {"warp": {"instal": True}} fails the cast at validation time, before anything touches a host.
Scaling to a fleet¶
Inventories are Python, so fleet structure is ordinary code. A pattern that works well in practice: per-zone feature presets, a YAML host registry, and a small builder that turns them into pyinfra groups.
Shared mold instances compose into one FeaturesMold per zone:
from nullforge.models.netsec import SshHostKeyType
from nullforge.molds import ContainersMold, FeaturesMold, FirewallRule, HaproxyMold, NetSecMold, SshMold, UserMold
from nullforge.molds.defaults import BASE_FEATURES
from nullforge.molds.utils import merge_features
netsec = NetSecMold(
firewall_rules=[
FirewallRule(port=443, comment="HTTPS"),
FirewallRule(port=22, proto="tcp", from_ip="203.0.113.7", comment="Admin SSH"),
],
ssh=SshMold(host_keys=[SshHostKeyType.ED25519]),
)
users = UserMold(name="ops", fetch_key_from_github="your-github-username")
ZONE_FEATURES: dict[str, FeaturesMold] = {
"web": merge_features(BASE_FEATURES, netsec, users, HaproxyMold(install=True)),
"workers": merge_features(BASE_FEATURES, netsec, users, ContainersMold(install=True)),
}
Host membership lives in data, not code - one YAML entry per host, with an optional overrides fragment deep-merged onto the zone preset:
web:
- address: 203.0.113.10
hostname: web1.example.com
- address: 203.0.113.11
hostname: web2.example.com
overrides:
netsec:
firewall: false
workers:
- address: 203.0.113.20
hostname: worker1.example.com
The builder exposes one module-level list per zone - pyinfra treats each as a named group, so --limit web targets a whole zone:
from pathlib import Path
import yaml
from nullforge.molds.defaults import BASE_SYSTEM
from nullforge.molds.utils import merge_features, merge_system
from inventory.zones import ZONE_FEATURES
DATA: dict = yaml.safe_load((Path(__file__).parent / "hosts.yaml").read_text()) or {}
def _build(zone: str) -> list:
rows = []
for entry in DATA.get(zone, []):
rows.append(
(
entry["address"],
{
"zone": zone,
"system": merge_system(BASE_SYSTEM, {"hostname": entry["hostname"]}),
"features": merge_features(ZONE_FEATURES[zone], entry.get("overrides")),
},
)
)
return rows
for zone in ZONE_FEATURES:
globals()[zone] = _build(zone)
Details worth stealing:
entry.get("overrides")is either a dict fragment orNone- both are validmerge_featureslayers, so per-host overrides cost one line and still go through mold validation.- Extra data keys (like
zoneabove) ride along onhost.datauntouched; custom runes can branch on them. - Adding a host is a YAML edit, reviewable in a PR and scriptable from CI.
This is the pattern behind the deploy repo of the Conglomerate proxy fleet: zone presets over NullForge molds, a YAML host registry edited from CI workflows, and per-zone --limit casts.
Secrets in inventories¶
Inventories are code; secrets in them (tunnel tokens, proxy user secrets) end up on disk. Keep real inventories out of public repos, or load secrets from the environment.
Mold fields marked sensitive (user password, Zero Trust token, Nezha secrets, Telemt users) are redacted as *** in pyinfra's --debug-inventory output, so inspecting a plan does not leak them.