Ferroclass is a lightweight configuration management database (CMDB) implementation. It is a
reimplementation of reclass in Rust, with full CLI
compatibility for the reclass, reclass-ansible, and reclass-salt commands.
The installed binaries are ferroclass, ferroclass-ansible, and ferroclass-salt,
allowing coexistence with the Python reclass package on the same system.
Common use cases include replacing the built-in inventory of Ansible, acting as an external node classifier for Puppet, or managing configuration for any system that needs hierarchical data with inheritance and interpolation.
cargo build --release
make install # Installs to /usr/local by default
make install DESTDIR=/tmp/pkg # For packagingBinaries: ferroclass, ferroclass-ansible, ferroclass-salt
Man pages: man ferroclass, man ferroclass-ansible, man ferroclass-salt
make dist # Create source + vendor tarballs
make packaging # Build RPM packagesOBS builds binary RPM packages for multiple distributions. The project is configured for openSUSE Tumbleweed, Rocky Linux 9, and Rocky Linux 10 (x86_64 and aarch64).
make osc-sync # Sync spec/changes/_service to OBS checkout
make osc-build # Build for openSUSE Tumbleweed (default)
make osc-build-rocky9 # Build for Rocky Linux 9
make osc-build-rocky10 # Build for Rocky Linux 10The OBS_PROJECT variable is auto-detected from your ~/.config/osc/oscrc.
Override it or other variables as needed:
make osc-build OBS_PROJECT=home:mjansen1972:ferroclassSee make -C packaging/obs help for all OBS targets and variables.
Ferroclass uses a hybrid release strategy: source tarballs and checksums are published on GitHub Releases, while binary RPM packages are built and distributed through the Open Build Service.
| Artifact | Location | Purpose |
|---|---|---|
ferroclass-X.Y.Z.tar.gz |
GitHub Releases | Source tarball |
ferroclass-X.Y.Z-vendor.tar.gz |
GitHub Releases | Vendored Rust dependencies |
ferroclass-X.Y.Z.tar.gz.sha256 |
GitHub Releases | SHA256 checksum |
ferroclass-X.Y.Z-vendor.tar.gz.sha256 |
GitHub Releases | SHA256 checksum |
ferroclass-X.Y.Z.tar.gz.asc |
GitHub Releases | GPG signature (when available) |
ferroclass-X.Y.Z-vendor.tar.gz.asc |
GitHub Releases | GPG signature (when available) |
| Binary RPMs for Tumbleweed, Rocky 9, Rocky 10 | OBS repositories | Distro package installation |
# 1. Bump version
make bump-version VERSION_NEW=X.Y.Z
# 2. Update CHANGELOG.md manually
# 3. Run quality gates and create release
make release
# 4. Sync to OBS and build
make osc-sync
cd ~/obs/home:mjansen1972:ferroclass/ferroclass && osc commit
make osc-build-rocky9
make osc-buildThe release target runs: commit → dist → checksums → tag → release-gh
→ osc-sync. It creates a GitHub Release with source tarballs and SHA256
checksums, and syncs packaging files to the OBS checkout.
To add GPG signatures to release tarballs:
make sign GPG_KEY=<key-id>
gh release upload vX.Y.Z packaging/rpm/ferroclass-X.Y.Z.tar.gz.asc \
packaging/rpm/ferroclass-X.Y.Z-vendor.tar.gz.asc| Target | Purpose |
|---|---|
bump-version |
Update version in spec file and Cargo.toml (VERSION_NEW=) |
dist |
Create source and vendor tarballs |
checksums |
Generate SHA256 checksums for tarballs |
sign |
Sign tarballs with GPG (GPG_KEY=) |
tag |
Create and push git tag |
release-gh |
Create GitHub Release with artifacts and changelog |
release |
Full release pipeline |
Create a minimal inventory:
mkdir -p inventory/classes inventory/nodes# inventory/classes/base.yml
parameters:
timezone: UTC
ntp:
server: pool.ntp.org# inventory/classes/web.yml
classes:
- base
parameters:
web:
port: 8080# inventory/nodes/web.yml
classes:
- web
parameters:
hostname: web-prod-01Run it:
ferroclass --nodeinfo web --inventory-base-uri ./inventory
ferroclass --inventory --output json --inventory-base-uri ./inventory
ferroclass-ansible --list --inventory-base-uri ./inventory
ferroclass-salt --top --inventory-base-uri ./inventoryFor ready-to-use minimal examples, see the inventories/example/
and inventories/example_file/ directories in the source
tree. The former uses the directory-based storage format; the latter uses the single-file
format. Both contain the same logical data. A full-featured showcase inventory with
advanced features (interpolation, exports, inventory queries, etc.) is planned for a
future release.
A node is a concrete item. It represents all the concrete items you need to act upon. For example, a host that should be deployed, an account on a host, or a piece of software you want to build.
A class is an abstract concept that you apply to nodes by inheritance. Similar concepts include Role, Category, Marker, or Trait.
A repository is one unit of configuration containing classes and nodes. It is a directory with two subdirectories:
$ ls inventory/
classes/
nodes/Optionally, a reclass-config.yml file in the repository root (or in the current
working directory) provides default settings.
Nodes and classes can inherit from classes. The configuration of the child is merged into the configuration of the base class following a clear set of rules leading to reproducible and predictable results.
Ferroclass supports multiple inheritances. The inheritance chain is the resulting order in which objects are merged, left to right.
After the inheritance chain is determined and configurations are merged, interpolation resolves cross-references to avoid duplication.
parameters:
host:
name: myserver
ip-address: 127.0.0.1
motd: |-
Welcome to ${host:name} ${host:ip-address}After interpolation, the value of motd is Welcome to myserver 127.0.0.1.
Class names in the classes: list can contain ${...} references that are resolved
during the merge step, using the parameters accumulated from previously processed
ancestor classes as the resolution context.
# class env_setup
parameters:
environment: staging# class staging.prod
parameters:
role: production# node test_node
classes:
- env_setup
- "${environment}.prod"When processing test_node:
env_setupis processed first, settingenvironment: staging.${environment}.prodresolves tostaging.prod, which is looked up and merged.staging.prodcontributesrole: production.
Key behaviors:
- Class name interpolation happens inline during the inheritance chain walk, before parameter interpolation. The resolved class feeds back into the accumulator.
- Only parameters from previously-processed classes are available. A class cannot reference parameters from itself or later classes in the list.
- Relative class names (
.foo,..bar) are resolved before interpolation. - Non-string parameter values are coerced to strings:
${num}wherenum: 42resolves to"42". - If a reference cannot be resolved, an error is raised.
The name of an object is derived from its filesystem path.
For classes, the path under the classes directory becomes the name with all slashes substituted with a dot.
| Path | Name |
|---|---|
| $REPO/classes/distribution/opensuse.yml | distribution.opensuse |
| $REPO/classes/domain/michael-jansen.biz.yml | domain.michael-jansen.biz |
The rule stems from reclass. I personally don't like it because, as the second example shows, you can't infer the path from the resulting name.
For nodes, the filename becomes the name. Subdirectories under nodes are discarded.
| Path | Name |
|---|---|
| $REPO/nodes/host/michael-jansen.biz.yml | michael-jansen.biz |
The namespaces of nodes and classes are distinct. It is possible to have a node and class with the same name.
The inheritance chain is determined according to the following rules:
- The classes are merged depth-first in the order they appear in the file.
- A class is ignored if it is encountered a second time.
- The inheritance chain of a class is inserted in front of the class itself.
- A recursive inheritance chain is a non-recoverable error.
Example:
# class baseA
classes:# class baseB
classes:
- baseA# node nodeA
classes:
- baseB
- baseAEven if nodeA inherits baseA after baseB, the effective inheritance chain is baseA, baseB, and then nodeA because baseB inherits baseA, effectively moving baseA in front of itself.
# class baseA
parameters:
list:
- A# class baseB
classes:
- baseA
parameters:
list:
- B# node nodeA
classes:
- baseB
- baseA
parameters:
list:
- CResult:
parameters:
list:
- A
- B
- C# class baseA
parameters:
map:
a: 1# class baseB
classes:
- baseA
parameters:
map:
b: 2# node nodeA
classes:
- baseB
- baseA
parameters:
map:
c: 3Result:
parameters:
map:
b: 2
a: 1
c: 3Ferroclass preserves insertion order for maps. While YAML itself makes no guarantees about map key order, this implementation uses ordered collections internally, so the output order matches the merge order.
# class baseA
parameters:
map:
a: 1# node nodeA
classes:
- baseA
parameters:
map: "A map"Result:
parameters:
map: "A map"# class baseA
parameters:
list:
- A# node nodeA
classes:
- baseA
parameters:
~list:
- CResult:
parameters:
list:
- CA tilde (~) in front of a key tells Ferroclass to replace the existing value entirely
instead of merging.
The override prefix can be used with any value type:
| Syntax | Effect |
|---|---|
~key: {new: true} |
Replace dict entirely (no deep merge) |
~key: [] |
Replace list entirely (no append) |
~key: 443 |
Replace scalar value |
~key: null |
Set to null (requires allow_none_override) |
~key: {} |
Reset dict to empty |
The tilde override is independent of the allow_none_override setting. ~key always
triggers override semantics. allow_none_override only controls whether key: null
(without a tilde) overwrites a dict or list instead of raising an error.
# class baseA
parameters:
port: 80# class baseB
classes:
- baseA
parameters:
=port: 443# class baseC
classes:
- baseB
parameters:
port: 9090An equal sign (=) in front of a key marks the value as constant. Any later class
attempting to change the parameter will either raise an error (strict mode, default)
or be silently ignored (non-strict mode).
In the example above, baseC tries to set port: 9090 but baseB already locked it
to 443. The final value is 443.
Use constant parameters sparingly. They can be a sign that your configuration is structured in a way that fights the inheritance model.
Merging two classes produces a class; merging a class and a node produces a node.
The classes in the result are the classes of the parent plus the name of the parent.
The rule is: the first one wins:
- child's environment
- parent's environment
- none
The parameters are merged following the rules described in Merging Values.
Exports allow nodes to publish values that other nodes can query using inventory queries
($[...] syntax). See Process for how exports and inventory queries work.
The applications are configured as a list. The child's applications are appended to the parent's.
Ferroclass processes a node request in six steps:
-
Configuration — CLI arguments and an optional
reclass-config.ymlfile are merged. Config file search order: current directory,$HOME/.config/reclass,/etc/reclass. CLI arguments take precedence. -
Discovery — The configured directories are walked recursively to find all YAML class and node files (
.ymlor.yamlextensions). -
Parsing — Each file is parsed into its constituent parts: classes, environment, parameters, applications, and exports. Reference patterns (
${...}) and inventory query expressions ($[...]) are detected and preserved for later resolution. -
Inheritance chain resolution & merging — For a given node, the inheritance chain is built and merged in a single pass using a depth-first traversal. Class mappings (glob/regex patterns) are applied to auto-include classes, relative class names (
.foo,..bar) and class name interpolation (${var}) are resolved. Each class is merged into an accumulator as it is encountered, with the node merged last. When a reference value collides with another value and the type cannot be determined yet, the merge is deferred. -
Interpolation — References are resolved by looking up parameter paths. Deferred merges are collapsed. For inventory queries, a two-pass rendering is used: all nodes are first merged and interpolated to build an inventory map, then nodes with queries are re-interpolated using that map. Circular references are detected and reported.
-
Output — The merged and interpolated results are serialized to YAML or JSON.
For a detailed description of each step, see docs/process.md.
Ferroclass reads configuration from an optional reclass-config.yml file. The file is
searched in this order:
- Current working directory
$HOME/.config/reclass/etc/reclass
CLI arguments take precedence over the config file.
| Option | CLI Flag | Config Key | Default |
|---|---|---|---|
| Inventory base URI | --inventory-base-uri |
inventory_base_uri |
(required) |
| Nodes URI | --nodes-uri |
nodes_uri |
nodes |
| Classes URI | --classes-uri |
classes_uri |
classes |
| Output format | --output (yaml/json) |
output |
yaml |
| Pretty-print | --pretty-print |
(always enabled) | on |
| Node info | --nodeinfo |
— | — |
| Inventory | --inventory |
— | — |
| Environment | --environment |
default_environment |
base |
| Compose node name | --compose-node-name |
compose_node_name |
off |
| Ignore class not found | --ignore-class-notfound |
ignore_class_notfound |
off |
| Group errors | --group-errors |
group_errors |
off |
See the man pages for full reference:
man ferroclass
man ferroclass-ansible
man ferroclass-saltCompatibility with the salt-formulas/reclass Python implementation is a core goal. Known deviations:
YAML 1.1 vs YAML 1.2: Python reclass uses PyYAML which follows YAML 1.1,
where yes/no/on/off are parsed as booleans. This implementation uses
yaml-rust2, which follows YAML 1.2, where these are plain strings. Inventory
files that rely on YAML 1.1 boolean coercion will produce different results.
Wildcard/regexp class mappings are not yet implemented. See docs/TODO.md for planned features.
YAML anchors/aliases never emitted: Python reclass emits YAML anchors and
aliases (&id001, *id001) by default, and the --no-refs / -r flag disables
them. This implementation never emits anchors/aliases because the merge pipeline
produces owned values with no shared references. The -r / --no-refs flag is
accepted for CLI compatibility but has no effect (anchors are always suppressed).
Class name interpolation does not have access to the node's own parameters: The
salt-formulas/reclass README-extensions include an example implying that node-level
parameters are available for class name interpolation. This does not work in the Python
implementation either (it raises ClassNameResolveError). Class name interpolation
can only reference parameters from previously processed ancestor classes, not the
node's own parameters. The example in the reclass documentation is incorrect.
inventory_ignore_failed_node skips all merge errors, not just YAML parse errors:
Python reclass only skips nodes that raise yaml.scanner.ScannerError (malformed
YAML). This implementation skips nodes that fail for any reason during merge (class
not found, interpolation errors, type merge conflicts). Since Rust loads all YAML
upfront, parse errors are not per-node during iteration; the meaningful per-node
failures are merge errors, which this flag covers.
scalar_reclass_parameters not implemented: Python reclass supports a
scalar_parameters config option that promotes a designated parameter key to a higher
merge priority. This feature has zero test coverage, zero documentation, and no known
users. It will not be implemented.
--ignore-class-notfound is a boolean flag, not a string parameter: Python reclass
defines this as a string-valued option, meaning it requires a value like
--ignore-class-notfound True. However, downstream the value is only ever checked for
Python truthiness. This makes the string form a design bug: passing
--ignore-class-notfound False would set the value to the string "False", which
Python evaluates as truthy — the opposite of the intended behavior. This implementation
treats it as a proper boolean flag (--ignore-class-notfound), which matches the actual
semantics and avoids the string-truthiness footgun.
inventory_ignore_failed_render / +IgnoreErrors granularity: Python reclass
deletes individual export keys that fail to resolve when +IgnoreErrors or
inventory_ignore_failed_render is set. This implementation instead skips the entire
node when the merge with inventory fails. Per-key deletion within a single node's
exports requires architectural changes to the merge pipeline (partial success from
interpolation). The +IgnoreErrors per-query flag is parsed, stored, and checked — keys
whose inv query values fail are removed from the hash. However, the most common failure
path (unresolvable ${...} references within export values) causes the entire node merge
to fail rather than a per-key deletion, because the merge pipeline does not support
per-key error recovery.
See docs/TODO.md for planned features and known incompatibilities.
See CONTRIBUTING.md for build instructions, code quality requirements, and architecture guidelines.
This project is licensed under the Mozilla Public License 2.0.
SPDX-FileCopyrightText: 2026 Michael Jansen mike@michael-jansen.biz SPDX-License-Identifier: MPL-2.0