ferroclass 0.11.0

Hierarchical inventory management tool (reclass compatible)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# Ferroclass

![CI](https://github.com/jansenm/ferroclass/actions/workflows/ci.yml/badge.svg)

Ferroclass is a lightweight configuration management database (CMDB) implementation. It is a
reimplementation of [reclass](https://reclass.pantsfullofunix.net/) 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.

## Installation

### From Source

```shell
cargo build --release
make install                 # Installs to /usr/local by default
make install DESTDIR=/tmp/pkg  # For packaging
```

Binaries: `ferroclass`, `ferroclass-ansible`, `ferroclass-salt`
Man pages: `man ferroclass`, `man ferroclass-ansible`, `man ferroclass-salt`

### RPM Packages

```shell
make dist                    # Create source + vendor tarballs
make packaging               # Build RPM packages
```

### Open Build Service (OBS)

OBS 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).

```shell
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 10
```

The `OBS_PROJECT` variable is auto-detected from your `~/.config/osc/oscrc`.
Override it or other variables as needed:

```shell
make osc-build OBS_PROJECT=home:mjansen1972:ferroclass
```

See `make -C packaging/obs help` for all OBS targets and variables.

## Releases

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.

### Release Artifacts

| 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     |

### Release Process

```shell
# 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-build
```

The `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.

### GPG Signing

To add GPG signatures to release tarballs:

```shell
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
```

### Individual Make Targets

| 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                                       |

## Quick Start

Create a minimal inventory:

```shell
mkdir -p inventory/classes inventory/nodes
```

```yaml
# inventory/classes/base.yml
parameters:
    timezone: UTC
    ntp:
        server: pool.ntp.org
```

```yaml
# inventory/classes/web.yml
classes:
    - base
parameters:
    web:
        port: 8080
```

```yaml
# inventory/nodes/web.yml
classes:
    - web
parameters:
    hostname: web-prod-01
```

Run it:

```shell
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 ./inventory
```

For ready-to-use minimal examples, see the [`inventories/example/`](inventories/example)
and [`inventories/example_file/`](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.

## Concepts

### Node

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.

### Class

A class is an abstract concept that you apply to nodes by inheritance. Similar concepts include
Role, Category, Marker, or Trait.

### Repository

A repository is one unit of configuration containing classes and nodes. It is a directory with two
subdirectories:

```shell
$ ls inventory/
classes/
nodes/
```

Optionally, a `reclass-config.yml` file in the repository root (or in the current
working directory) provides default settings.

### Inheritance

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.

### Inheritance Chain

Ferroclass supports multiple inheritances. The inheritance chain is the resulting order
in which objects are merged, left to right.

### Interpolation

After the inheritance chain is determined and configurations are merged, interpolation
resolves cross-references to avoid duplication.

```yaml
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 Name Interpolation

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.

```yaml
# class env_setup
parameters:
    environment: staging
```

```yaml
# class staging.prod
parameters:
    role: production
```

```yaml
# node test_node
classes:
    - env_setup
    - "${environment}.prod"
```

When processing `test_node`:

1. `env_setup` is processed first, setting `environment: staging`.
2. `${environment}.prod` resolves to `staging.prod`, which is looked up and merged.
3. `staging.prod` contributes `role: 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}` where `num: 42`
  resolves to `"42"`.
- If a reference cannot be resolved, an error is raised.

## Rules

### Naming

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.

### Inheritance Chain

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:

```yaml
# class baseA
classes:
```

```yaml
# class baseB
classes:
    - baseA
```

```yaml
# node nodeA
classes:
    - baseB
    - baseA
```

Even 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.

### Merging Values

#### Lists are appended

```yaml
# class baseA
parameters:
    list:
        - A
```

```yaml
# class baseB
classes:
    - baseA
parameters:
    list:
        - B
```

```yaml
# node nodeA
classes:
    - baseB
    - baseA
parameters:
    list:
        - C
```

Result:

```yaml
parameters:
    list:
        - A
        - B
        - C
```

#### Maps are merged

```yaml
# class baseA
parameters:
    map:
        a: 1
```

```yaml
# class baseB
classes:
    - baseA
parameters:
    map:
        b: 2
```

```yaml
# node nodeA
classes:
    - baseB
    - baseA
parameters:
    map:
        c: 3
```

Result:

```yaml
parameters:
    map:
        b: 2
        a: 1
        c: 3
```

Ferroclass 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.

#### Values with different data types overwrite

```yaml
# class baseA
parameters:
    map:
        a: 1
```

```yaml
# node nodeA
classes:
    - baseA
parameters:
    map: "A map"
```

Result:

```yaml
parameters:
    map: "A map"
```

#### Lists and maps can be overwritten

```yaml
# class baseA
parameters:
    list:
        - A
```

```yaml
# node nodeA
classes:
    - baseA
parameters:
    ~list:
        - C
```

Result:

```yaml
parameters:
    list:
        - C
```

A 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.

#### Values can be marked constant

```yaml
# class baseA
parameters:
    port: 80
```

```yaml
# class baseB
classes:
    - baseA
parameters:
    =port: 443
```

```yaml
# class baseC
classes:
    - baseB
parameters:
    port: 9090
```

An 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 Elements

Merging two classes produces a class; merging a class and a node produces a node.

#### Classes

The classes in the result are the classes of the parent plus the name of the parent.

#### Environment

The rule is: the first one wins:

- child's environment
- parent's environment
- none

#### Parameters

The parameters are merged following the rules described in [Merging Values](#merging-values).

#### Exports

Exports allow nodes to publish values that other nodes can query using inventory queries
(`$[...]` syntax). See [Process](#process) for how exports and inventory queries work.

#### Applications

The applications are configured as a list. The child's applications are appended to
the parent's.

## Process

Ferroclass processes a node request in six steps:

1. **Configuration** — CLI arguments and an optional `reclass-config.yml` file are merged.
   Config file search order: current directory, `$HOME/.config/reclass`, `/etc/reclass`.
   CLI arguments take precedence.

2. **Discovery** — The configured directories are walked recursively to find all YAML
   class and node files (`.yml` or `.yaml` extensions).

3. **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.

4. **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.

5. **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.

6. **Output** — The merged and interpolated results are serialized to YAML or JSON.

For a detailed description of each step, see [docs/process.md](docs/process.md).

## Configuration

Ferroclass reads configuration from an optional `reclass-config.yml` file. The file is
searched in this order:

1. Current working directory
2. `$HOME/.config/reclass`
3. `/etc/reclass`

CLI arguments take precedence over the config file.

### Key Options

| 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:

```shell
man ferroclass
man ferroclass-ansible
man ferroclass-salt
```

## Reclass Compatibility

Compatibility with the [salt-formulas/reclass](https://github.com/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](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.

## Roadmap

See [docs/TODO.md](docs/todo.md) for planned features and known incompatibilities.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions, code quality requirements,
and architecture guidelines.

## License

This project is licensed under the [Mozilla Public License 2.0](LICENSES/MPL-2.0.txt).

SPDX-FileCopyrightText: 2026 Michael Jansen <mike@michael-jansen.biz>
SPDX-License-Identifier: MPL-2.0

[reclass]: https://reclass.pantsfullofunix.net/