A variant is one buildable image configuration. Variants live in
config/variants/*.json and are discovered automatically by the CI matrix.
Instead of copy-pasting a 200-line base config into every new variant, a child variant extends a parent and adds only what differs.
base.json canbus-plattform.json
───────── ─────────────────────
packages: [vim, jq] packages: [can-utils]
users: [admin:[sudo]] users: [admin:[spi, i2c]]
boot_config.dtoverlays: [] boot_config.dtoverlays: [mcp2515-can0,
mcp2515-can1]
can: (absent) can: { interfaces: [can0, can1] }
After merge:
packages: [vim, jq, can-utils] ← scalar array: concat + dedupe
users: [admin:[sudo, spi, i2c]] ← named record: by-name merge,
groups concat
boot_config.dtoverlays: [mcp2515-can0, ← object array with `name` key:
mcp2515-can1] by-name merge (no duplicates)
can: { interfaces: [can0, can1] } ← parent had no `can`, inherited
Merge rules are implemented in scripts/generate.py
(deep_merge):
| Value kind | Rule |
|---|---|
Two objects (dict) |
Recursive merge, child keys win on conflict. |
| Two lists of primitives | parent + child, stable-order dedupe. |
Two lists of {name: …} records |
Merge by name; entries with matching names deep-merge. |
Two lists (mixed / no name) |
Plain concatenation. |
| Anything else | Child value replaces parent value. |
Starting from base.json, we want to add:
- a
ublox-gpsAPT package - a
dialoutgroup membership (for/dev/ttyS0) - UART enabled on the Pi
- a different hostname
Create config/variants/gps-tracker.json:
{
"$schema": "../schema.json",
"extends": "./base.json",
"variant": {
"name": "gps-tracker",
"description": "BAUER GROUP GPS telemetry image (u-blox via UART)",
"version": "0.1.0"
},
"hostname": "bg-gps",
"users": [
{ "name": "admin",
"password": "${ADMIN_PASSWORD:-12345678}",
"groups": ["dialout"],
"shell": "/bin/bash",
"sudo_nopasswd": true }
],
"packages": ["gpsd", "gpsd-clients"],
"boot_config": {
"enable_uart": true,
"disable_bluetooth": true
}
}That's it. 25 lines. Validate, render, build:
./tools/run.sh validate gps-tracker
./tools/run.sh render gps-tracker
./tools/run.sh build gps-trackerThe CI matrix picks it up automatically on the next push.
| What | Convention | Example |
|---|---|---|
| Variant file | kebab-case.json |
canbus-plattform.json |
| Variant name | same slug | "name": "canbus-plattform" |
| Hostname | DNS-safe ([a-z0-9-]{1,63}) |
bg-canbus |
| Version | SemVer | "0.1.0" |
The hostname schema regex rejects uppercase and underscores — this is
deliberate, matches Linux hostname conventions, and keeps DNS happy.
Chains are allowed — a child can extend a parent that itself extends another:
common.json ◀─── fleet-eu.json ◀─── gps-tracker-eu.json
The generator follows links recursively and detects cycles (raises
ValueError("circular extends chain")).
Practical guidance:
- Keep the chain shallow (≤ 2 levels). Debuggability degrades fast with depth.
- If you find yourself writing a 3-level chain, that's a signal the base needs refactoring — pull the shared layer up.
The generator can print the fully resolved, schema-validated JSON to stdout:
./tools/run.sh shell
# inside container:
python scripts/generate.py config/variants/canbus-plattform.json --json | jq '.'Useful for answering "where does this user's group list actually come from" without grep'ing multiple files.