An Ansible build repository for composing workstation images — unattended installer
ISOs, bootc containers, application containers, and Vagrant boxes — for Fedora,
AlmaLinux, and Rocky Linux, on Red Hat image-builder and osbuild. Layer 0 of
the three-repository Syncopated stack: where workstation converges machines
into correctness after they exist, this one composes correctness into the
machine before it exists.
One Ansible role and five playbooks (four in playbooks/ plus the thin
root osbuild.yml import wrapper) that turn a validated intent into a
bootable, unattended installer ISO. Component files are the only package data,
audited against the repositories they cite; intents validate against
schema/intent.schema.json before any task may act; hardening comes from
OpenSCAP profiles with auditors attached — CIS L2 Workstation, PCI-DSS, HIPAA —
tailored for standalone workstations with no central log collector.
The restore point is the credential. An intent becomes an ISO that installs the machine unattended, so a workstation is re-made in minutes, not hours. Dotfiles manage the user layer well — drift with a changelog — but they do not capture the machine. The images are built on members of the very fleet they are meant to restore, which the author chooses to find tidy rather than circular. The final acceptance test cannot run in CI: install the hardened image on the physical test laptop and live on it for 48 hours. The machine has to survive its author; that is the bar.
The documentation is a manual in three parts — what you type, what it means, and how the rules are written — and the manual's layers deliberately mirror the repository's own: a keystroke layer that acts, a story layer that decides, and a reference layer that explains how the decisions are written down.
| Part | Document | Job |
|---|---|---|
| 0 — the hands | docs/just-for-dummies.md |
What to type and what you will see. Zero theory. |
| 1 — the map | this README | What the system is, why it is shaped this way, and which decisions to make. |
| 2 — the machinery | roles/osbuild/README.md |
The complete reference: variables, kickstart, firstboot, OpenSCAP tailoring, bootc, blueprint composition. |
Reading progression: type first (Part 0), understand second (this page), look things up forever after (Part 2). Where to start:
- Read nothing yet →
docs/just-for-dummies.md. - Here to orient (human or agent) → this page, in order; agents start at For Coding Agents.
- Here for a specific variable or mechanism →
roles/osbuild/README.md.
Six words carry the whole manual; the other two documents link here for their definitions.
| Term | Meaning |
|---|---|
| intent | A YAML file in profiles/ describing one workstation: distro, components, hardening, disk, users, locale, remote logging. Validated against schema/intent.schema.json before any task may act |
| component | A YAML file in roles/osbuild/files/<distro>/<major>/<arch>/components/<name>.yml — the only package data: packages, services, kernel args, repositories, fragments. One component per concern |
| frame | The static, non-composable TOML (a target's frames/workstation.toml) that the selected components are merged with |
| target | One roles/osbuild/files/<distro>/<major>/<arch>/ directory; five exist (see Package Tracker) |
| blueprint | The TOML image-builder consumes — composed from components plus frame, or copied from a static file (osbuild_static_blueprint_path) |
| profile | An OpenSCAP hardening profile — cis-l2-workstation, pci-dss, hipaa — each carried by its own component |
Prerequisites: just, Python 3, a Ruby matching .ruby-version, passwordless
sudo on a builder host (gir or tinybot), x86_64 — the targets cover Fedora
43/44, AlmaLinux 10 and Rocky 9/10. The same steps with zero theory are in
docs/just-for-dummies.md.
# Prerequisites: just, Python 3, Ruby matching .ruby-version (yadm bootstrap),
# passwordless sudo on a builder host (gir or tinybot), x86_64,
# Fedora 43/44 · AlmaLinux 10 · Rocky 9/10
just setup # .venv, pip, galaxy collections, bundle (Ruby must match .ruby-version)
just doctor # bats, shellcheck, ansible-lint, ... found/missing
just check-host # ssh, sudo -n, image-builder on the builder
# 1. Copy the closest example intent and edit it
cp profiles/examples/rocky-10-2-workstation-openscap-cis-l2-workstation.yml profiles/studio.yml
$EDITOR profiles/studio.yml # name, hostname, disk.size_gib, users, locale
# 2. Validate the intent and build the ISO in the play on the builder
just iso studio # → Output File: /var/tmp/osbuild-images/studio-<commit>.iso
just fetch studio # rsync the ISO to ./dist/
just iso accepts a path, a name in profiles/, or a name in
profiles/examples/ (just intents lists them). Recipes run on tinybot;
just host=gir iso studio or OSBUILD_HOST=gir just iso studio picks another
builder. Trailing arguments go to ansible-playbook (just iso studio --check).
Without just, ansible-playbook playbooks/build.yml -e @profiles/studio.yml
generates the blueprint and build-studio.sh without building.
Other images:
# Host-native bootc container image (or a VM disk: just qcow2)
just bootc # ansible-playbook playbooks/osbuild.yml
# Watch builds live on the builder (run in another terminal while a build runs)
just watch
Full usage: sections below. Package management: Package Tracker.
If you are an AI coding agent starting work in this repository, read this section first. It is the onboarding overview — everything you need to orient before touching code.
What this repository is: An Ansible build repository for composing
workstation images across Fedora, AlmaLinux, and Rocky Linux. Roles live in
roles/, playbooks in playbooks/, and the root osbuild.yml is a thin
import_playbook wrapper. The root Justfile is the command interface:
just lists every build, test, package and setup recipe.
Which document answers which question: keystrokes —
docs/just-for-dummies.md; concepts and
decisions — this page; variables and mechanisms —
roles/osbuild/README.md.
Where things live:
| Path | Contents |
|---|---|
AGENTS.md |
Project conventions, commands, testing, gotchas — read this before editing |
roles/osbuild/ |
Primary role: image-builder CLI workflow, intent engine, kickstart, hardening, firstboot, progress — full reference in roles/osbuild/README.md |
playbooks/ |
build.yml (intent → ISO), osbuild.yml (bootc / VM image), osbuild-fedora44-vagrant.yml, osbuild-rocky-cuda-container.yml — variable patterns in roles/osbuild/README.md §Usage Examples |
profiles/ |
Your intents (gitignored); profiles/examples/ — 13 committed examples, one per ISO variant |
schema/intent.schema.json |
What an intent may say (validated before any task) |
roles/osbuild/files/<distro>/<major>/<arch>/ |
Component files and frames per target — 5 targets, see Package Tracker |
scripts/package_tracker.py |
CLI and TUI for the component files' package lists |
lib/osbuild_progress_viewer/ + bin/osbuild-progress |
Ruby TUI: live multi-host build progress viewer |
gems/osbuild_progress/ |
Ruby gem (v0.1.0): parses image-builder --progress verbose output into JSON Lines; installs osbuild-progress-parse |
tests/ |
pytest: intents, disk sizing, tailoring, parity, remote logging e2e, manifest, VM install |
roles/osbuild/tests/ |
BATS suites and validate_*.yml playbooks |
features/, spec/ |
Cucumber BDD features; RSpec for the build module, the gem, and the viewer |
goals/ |
Goal packages (specifications and acceptance criteria), including standalone-workstation-hardening/ |
inventory/hosts.yml |
Builder group: gir, tinybot |
Hard rules (full details in AGENTS.md):
- Component files are immutable sources. Only
scripts/package_tracker.pyedits apackages:list — comments, key order, and package order are preserved byte-for-byte. One component per concern. - Repositories cascade.
repo_urlsfrom selected components become--extra-repoflags; dropping a component drops its repositories. A component withoutrepo_urlsuses the target base repos. - Intents declare user intent; playbooks declare build environment. The
build host computes variables an intent never sets — image type, extra
repositories,
/usrsizing for CUDA. - Remote logging is armed at playbook level, not intent level. The
kickstart pull injection happens only when both
osbuild_ansible_pull_repoandosbuild_ansible_pull_argsare non-empty; the intent schema has no such field. The injection mechanics live inroles/osbuild/README.md§First-Login Splash. - bootc inverts the Ansible pattern. Ansible runs inside the Containerfile
at build time and targets
/usr/etc(vendor, immutable), not/etc; runtime merges the two. The diagram and the full pattern are inroles/osbuild/README.md§Ansible Role Inversion Pattern. - The tracker refuses rather than guesses.
addrefuses names with no component file for the target (and names another component already owns, unless--force);removerefuses to empty a component — one package minimum. - Unpinned packages get
*at build time.osbuild_package_pinsrestricts versions for component blueprint composition only.
Commands:
ansible-playbook playbooks/build.yml -e @profiles/<name>.yml # intent → blueprint + build script
ansible-playbook osbuild.yml # bootc / VM image
python3 scripts/package_tracker.py tui # package management
python3 -m pytest tests/ # pytest suite
bats -r roles/osbuild/tests/ # role BATS tests
ansible-lint # lint
Verify an intent against the schema:
python3 -c "import jsonschema, yaml, sys; jsonschema.validate(yaml.safe_load(open('profiles/my-intent.yml')), yaml.safe_load(open('schema/intent.schema.json')))"
Three repositories provision one machine, in order, and this one is the floor:
| Layer | Repository | Transforms |
|---|---|---|
| 0 — base image | osbuild (this repository) |
an intent into a blueprint, a build script, and a bootable ISO |
| 1 — host | workstation |
an installed system into a converged one, through ansible-pull of local.yml |
| 2 — user | dotfiles (yadm) | a converged host into a user environment, through yadm clone and yadm bootstrap |
The image-building roles and blueprints lived in workstation until they moved
here; workstation/AGENTS.md records the move. The two repositories share
the builder hosts (gir, tinybot — this repository's builder group).
Converging a running machine belongs to Layer 1. The handoff runs through one injected script:
roles/osbuild/tasks/blueprint.ymlinjects the first-login splash —/usr/local/bin/syncopated-firstboot, its launcher, its XDG autostart entry, and its tty1 profile hook (/etc/profile.d/syncopated-firstboot.sh) — into every prepared blueprint, gated byosbuild_firstboot_enabled(on by default; container builds switch it off).- The installed system boots to
multi-user.target— the kickstart%postsets it — so the splash runs at the first interactive tty1 console login, where the profile hook triggers it; a graphical login runs it through the XDG autostart entry instead. The splash updates the system, sets up the language toolchains, runssudo ansible-pullonly when the image carries/etc/syncopated-ansible-pull.conf, then offers a reboot, setsgraphical.targetas the default boot target, and asks whether to switch to the graphical desktop now. - The playbook writes that file only when both
osbuild_ansible_pull_repoandosbuild_ansible_pull_argsare set (role defaults:""and[]). An intent cannot arm the pull; a playbook can. The skip and run paths are covered byroles/osbuild/tests/firstboot/firstboot.bats. - The splash then offers the user layer: it downloads yadm to
~/.local/binwhen it is missing and prints the clone and bootstrap commands without running them.
One path is deliberately shared. When Layer 1 runs on an image built here —
the armed first-login pull — workstation's common role deploys its own
syncopated-firstboot to the same path, overwriting the injected splash:
single ownership, next writer wins. The handoff is recorded in
workstation/docs/end-to-end-validation.md.
The same handoff at mechanic depth — every installed file, mode and step of
the splash — is roles/osbuild/README.md §First-Login Splash.
- It does not converge a running host. Convergence is Layer 1's job; the user layer is Layer 2's.
- An intent-built ISO does not converge at first login by default. Without
a playbook arming the pull, the splash updates the system and installs
yadm's binary — but
local.ymlnever runs, so no host convergence happens. - No single run has yet been validated end to end across both repositories.
Each repository proves its own stages; the composition is validated at the
seams — the ISO, the injected splash, the pull entry point. The full
boundary, including the compositions that remain unvalidated, is stated in
workstation/docs/end-to-end-validation.md.
An intent describes one workstation: distro, components (hardware and software
stack), hardening profile, disk, users, locale and remote logging.
schema/intent.schema.json defines it; profiles/examples/ has one example
per supported ISO variant.
cp profiles/examples/rocky-10-2-workstation-openscap-cis-l2-workstation.yml profiles/studio.yml
$EDITOR profiles/studio.yml # name, hostname, disk.size_gib, users, locale
ansible-playbook playbooks/build.yml -e @profiles/studio.yml
bash /var/tmp/osbuild-images/build-<name>.sh # builds <name>-<commit>.iso
The play validates the intent before anything else and stops naming the
offending field (intent.disk.size_gib: 10 is less than the minimum of 32).
It writes, owned by you, mode 0644 (0600 with remote logging, whose client
key is in the blueprint and image; the credentials file is always 0600):
| File | Content |
|---|---|
<name>.toml |
Blueprint with the kickstart: users, locked root, the disk layout |
build-<name>.sh |
Runs image-builder build with the components' extra repositories |
<name>.credentials.txt |
The initial password of each user, syncopated by default (also printed once at the end of the play) |
Install. The install asks nothing except the LUKS passphrase (shown only
when disk.encrypt is true). Boot the ISO and walk away: the installer picks
the target disk (install media, removable, read-only and USB disks excluded;
NVMe first, then the largest), shows a 30-second countdown on a console naming
the disk's model, serial and size (any key aborts), and installs. The installed
system boots to multi-user.target, so each user signs in on the tty1 console
with the initial password syncopated and chooses a new one there; at the end
of firstboot the splash sets graphical.target as default and offers to switch
to the graphical desktop immediately. Root is locked, wheel uses sudo. The
play hashes the initial password with a fresh random salt on every run, and
only the SHA-512 hash reaches the ISO.
Disk. Volume sizes are computed at generation time from disk.size_gib
(declare the disk in GiB: a 512 GB disk is 476 GiB):
| Condition | Result |
|---|---|
| A disk smaller than the declared size | the install aborts with both sizes on screen |
| A disk larger than declared | the surplus goes to /home |
disk.serial set |
the install is pinned to one disk (lsblk -dno SERIAL) |
hardening: none |
simple layout: /, /usr, /var, /home |
| Any hardening profile | compliant layout with separate /var/tmp, /var/log and a nodev,nosuid,noexec /var/log/audit — 4 GiB for CIS and PCI-DSS, 10 GiB for HIPAA |
nvidia or cuda-container in the build |
/usr raised to 80 GiB |
Hardening (cis-l2-workstation, pci-dss, hipaa; Rocky 10 and
AlmaLinux 10 — the schema rejects a hardening profile on any other distro)
applies the stock OpenSCAP profile at build time, tailoring it for a
standalone workstation with no central log collector:
| Profile | /var/log/audit |
auditd rotation cap (max_log_file × num_logs) |
Rules unselected in the tailoring | Default minimum password length |
|---|---|---|---|---|
cis-l2-workstation |
4 GiB | 32 MB × 16 | dac_modification_*, file_deletion_events_*, unsuccessful_file_modification_* |
14 characters |
pci-dss |
4 GiB | 32 MB × 16 | dac_modification_*, file_deletion_events_* |
12 characters |
hipaa |
10 GiB | 64 MB × 32 | none — HIPAA keeps every audit rule | the rule is not selected; the role refuses password_min_length with hipaa |
The unselected families are the desktop-session noise: the
dac_modification_* and file_deletion_events_* rules fire on every file
save and rename of a desktop session, and CIS's
unsuccessful_file_modification_* rules log every failed read of
/proc/<pid>/* by htop, top and system monitors. When the audit volume
fills, auditd rotates within the cap and never halts, suspends or drops to
single-user mode: admin_space_left_action and disk_full_action rotate,
disk_error_action logs. password_min_length (CIS and PCI-DSS) sets the
minimum password length the profile enforces, overriding the defaults in the
table.
Below 25 % free on /var/log/audit, syncopated-audit-warn logs a warning
to the journal, writes a motd and Cockpit banner, and shows wheel users a
GNOME critical notification that returns at each login while the condition
holds.
The deviations are unselected in the tailoring, not overridden afterwards.
sudo syncopated-scan evaluates the host against the stock and the
tailored profile and writes HTML and ARF reports to
/var/lib/syncopated/scans/<timestamp>/; the tailored report lists the
deviations as not selected. The tailoring pipeline, variable by variable, is
roles/osbuild/README.md §OpenSCAP Profiles.
Remote logging is off by default. remote_logging: {enabled: true, url: https://logs:19532, ca_cert, client_cert, client_key} (PEM text or controller
paths) is the only change needed to ship the journal, audit events included,
with systemd-journal-upload over mTLS. An unreachable log host does not
affect the workstation's operation; the uploader logs the failure and retries
every 30 s.
Build-host details an intent never sets are derived by the role: the image
type, the extra repositories (Rocky CRB, Plus and RT, CUDA, ... come from the
components' repo_urls; the build host's own repositories never reach another
distribution's compose), the openscap component, /usr sizing for CUDA, and no
blueprint user next to the kickstart. Your intents live in profiles/
(gitignored); only profiles/examples/ is committed. passlib must be
importable by the controller's Python (dnf install python3-passlib); the
play checks before anything else.
# Rocky Linux 10.2 CUDA Application Container → /var/tmp/osbuild-images/rocky-10-2-cuda-container-<commit>.tar,
# loaded into the builder's podman store as localhost/rocky-10-2-cuda-container:<commit>
just cuda-container
# Host-native bootc container image
just bootc
# VM disk image (playbooks/osbuild.yml with osbuild_build_bootc=false, osbuild_image_type=qcow2)
just qcow2
# Fedora 44 Minimal Headless Vagrant Box (Libvirt)
just vagrant
just vagrant-add # fetch the box to ./dist/, then vagrant box add
Each recipe runs the playbook with -e osbuild_only_generate=false, so the
image builds inside the play; artifacts stay in /var/tmp/osbuild-images/ on
the builder until just fetch <name> copies them to ./dist/. <name> resolves
to a path, a profiles/ intent, or a profiles/examples/ intent; when it
resolves to none of these (e.g. fedora-44-vagrant-minimal, which has no
intent), fetch treats it as a literal artifact prefix.
| Variable | Default | Description |
|---|---|---|
osbuild_distro |
Auto-detected | Target OS (e.g. fedora-44, fedora-43, rocky-10.2, almalinux-10.2) |
osbuild_image_type |
Auto-detected | image-installer, minimal-installer, qcow2, container, generic-vagrant-libvirt, generic-vagrant-virtualbox |
osbuild_only_generate |
true |
Generates blueprint and build script without running image-builder |
osbuild_build_bootc |
false |
Set true to build a bootc container image |
osbuild_blueprint_components |
per distro in osbuild.yml |
Component files (files/<distro>/<major>/<arch>/components/<name>.yml) the blueprint is composed from. The only variable that selects packages; an intent sets it from components + hardening |
osbuild_extra_packages |
[] |
Additional packages for component blueprints; static blueprints ignore it |
The intent maps to the remaining role variables (osbuild_hardening,
osbuild_disk_*, osbuild_users, osbuild_remote_logging, ...); see
roles/osbuild/tasks/intent.yml and
roles/osbuild/README.md §Role Variables.
Checks to run by hand against any generated build:
ansible-playbook playbooks/build.yml -e @profiles/<name>.yml
shellcheck /var/tmp/osbuild-images/build-<name>.sh
ansible-lint
# Depsolve without building; run in bash (zsh does not word-split the repo flags)
bash -c 'image-builder manifest image-installer --distro rocky-10.2 --arch x86_64 \
--blueprint <name>.toml --extra-repo <URL> > m.json'
Note:
image-builder buildprints scriptlet errors fromgrub2-probeanddracut(no/dev/log, no block devices in the sandbox). These are expected; a failed osbuild stage or a non-zero exit code is the real signal.
Build host umask: the generated build script and the
osbuild_image_buildmodule both reset the process umask to0022(templates/image-builder-build.sh.j2,library/osbuild_image_build), so a hardened login shell (CIS umask027) cannot leak0640modes into the installer rootfs —/etc/machine-idmust stay world-readable (0644) for dbus-broker to start on the installed system.
Keystroke-level triage when something breaks — including the
system-ansible-lint version quirk — is
docs/just-for-dummies.md §5.
Every build writes one JSON object per line to
{{ osbuild_log_dir }}/{{ osbuild_blueprint_name }}-<epoch>.progress.jsonl;
{{ osbuild_log_dir }}/current.progress.jsonl is a symlink to the newest
file, and earlier files are kept. Files are root-owned, mode 0644. The
osbuild_image_build module and both Ruby tools below read or write this
format.
| Field | Type | Meaning |
|---|---|---|
ts |
String | ISO 8601 timestamp with a UTC offset |
event |
String | "phase", "stage", "log" or "result" |
phase |
String or null | "depsolve", "manifest", "build" or "export" |
pipeline |
String or null | osbuild pipeline of the current stage (build, os, image, qcow2, ...) |
stage |
String or null | current osbuild stage, e.g. org.osbuild.rpm |
step |
Integer or null | running ordinal of the current stage |
total |
Integer or null | total stage count; null because image-builder does not print it |
elapsed_s |
Integer | whole seconds since the build started |
message |
String or null | the output line, credentials redacted, at most 4096 characters |
The last line of every file is a "result" record, adding three fields:
| Field | Type | Meaning |
|---|---|---|
status |
String | "FINISHED" or "FAILED" |
image_path |
String or null | absolute image path inside the build directory; null on failure |
exit_code |
Integer | image-builder's exit code |
Two Ruby tools consume the format:
| Tool | What it does |
|---|---|
just watch → bundle exec bin/osbuild-progress [HOST] [--file PATH] [--exit-on-done] |
Live multi-host TUI viewer — one command, run in another terminal while just iso builds; polls current.progress.jsonl locally or follows it over SSH on each builder (gir, tinybot); per-host progress bars and FINISHED/FAILED banners. HOST selects one member of the builder inventory group (default: all) |
osbuild-progress-parse |
From gems/osbuild_progress (stdlib-only Ruby ≥ 3.3): pipes image-builder build ... | osbuild-progress-parse --file F to write the JSON Lines records, records the final exit code (--result CODE), or emits one-line poll output (--poll-line F) |
The role builds the gem on the controller, installs it on each builder, and
symlinks osbuild-progress-parse into /usr/bin (sudo secure_path); the
play waits for the asynchronous build with ansible.builtin.async_status
(roles/osbuild/tasks/build.yml) and directs operators to just watch for
live progress. Manual build-<name>.sh runs write the same format.
osbuild-progress-parse --watch PATH [--interval SECONDS] [--timeout SECONDS]
The play no longer runs this mode during builds; it waits for the async job
with ansible.builtin.async_status and prints no live progress. For live
feedback while just iso runs, use just watch — in a second terminal,
just host=gir watch for the other builder — the one-command way to watch
the same build with the TUI viewer above. --watch remains a manual,
on-the-builder tool.
Polls the progress JSONL file — {{ osbuild_log_dir }}/current.progress.jsonl
when wired through the role — until the result record appears. The other
modes (--file, --result, --poll-line) are the pipe/record tools in the
table above.
- On a TTY: a single line, redrawn in place with
\rand cleared with\e[K— a braille spinner, and when the record carriesstep/totala progress bar[██████████░░░░░░░░░░] 50%(width 20), plus pipeline, stage and elapsed. - Not on a TTY (CI, redirected output): one clean
[build] ...line per record change, no control characters. - Exit status: the job's
exit_codefrom the result record;124when--timeoutelapses (prints[build] timed out);2on usage errors, including--interval/--timeoutvalues that are not positive. - Defaults:
--interval 1(must be > 0);--timeoutomitted means poll forever.
Watch a build by hand on a builder:
osbuild-progress-parse --watch /var/tmp/osbuild-logs/current.progress.jsonl
The play waits for the asynchronous build with ansible.builtin.async_status
(roles/osbuild/tasks/build.yml): it retries every _osbuild_poll_delay
seconds — osbuild_progress_interval clamped to at least 1 second and at
most osbuild_build_timeout — for osbuild_build_timeout // _osbuild_poll_delay + 10 retries, and the async wrapper enforces the job
timeout and kills the job. Two role variables tune the wait, set in
roles/osbuild/defaults/main.yml:
| Variable | Default | Description |
|---|---|---|
osbuild_progress_interval |
30 |
Seconds between async_status retries (clamped to at least 1 and at most osbuild_build_timeout) |
osbuild_build_timeout |
14400 |
Async job window in seconds; also fixes the retry count of the wait task |
Both must resolve to positive integers: the wait clamps a value of 0 up to
1 second, and the async wrapper abandons the job when the window elapses.
The canonical variable reference, including these two and the clamping
rules, is roles/osbuild/README.md §Build mode.
The component files are the only package data. A target is one
roles/osbuild/files/<distro>/<major>/<arch>/ directory; the tracker lists,
searches, adds and removes packages in <target>/components/*.yml — and
touches only the packages: list of one file: comments, key order and the
order of existing packages stay, and it appends new packages.
| Target | Components | Frames |
|---|---|---|
rocky/10/x86_64 |
27 | 2 |
rocky/9/x86_64 |
15 | 1 |
almalinux/10/x86_64 |
24 | 1 |
fedora/44/x86_64 |
21 | 2 |
fedora/43/x86_64 |
17 | 1 |
Selected components merge with the target's frame; packages, services and
kernel_args are de-duplicated. Multiple OpenSCAP profiles are supported via
separate components. How the merge renders into a blueprint is
roles/osbuild/README.md §Composing a Blueprint from Components.
# Components of a target, with label and package count
python3 scripts/package_tracker.py list --target rocky/10/x86_64
# Every target/component that contains a matching package
python3 scripts/package_tracker.py search nvidia
# Add packages (--verify checks the target's repos first; @groups skip the check)
python3 scripts/package_tracker.py add --target rocky/10/x86_64 base htop fastfetch --verify
# Remove packages (the last package of a component cannot be removed)
python3 scripts/package_tracker.py remove --target rocky/10/x86_64 base htop
# Audit a component, or a whole target, against the repos
python3 scripts/package_tracker.py verify --target rocky/10/x86_64 base
The just recipes run the same commands through .venv/bin/python:
just pkg <args> passes any subcommand through, just pkg-tui,
just pkg-search nvidia, just pkg-verify rocky/10 base, and
just pkg-verify-all (every target; exits 1 if any fails).
--target is required for every command except search; without it the
command exits with status 2 and lists the available targets. A unique prefix
such as rocky/10 works too. If another component of the same target already
has a package, add prints a warning naming that component and refuses the
package unless you pass --force.
playbook edits the components list of an intent (profiles/*.yml) or the
osbuild_blueprint_components list of a playbook without touching anything
else in the file. The target comes from the file's distro /
osbuild_distro.
python3 scripts/package_tracker.py playbook profiles/my-workstation.yml list
python3 scripts/package_tracker.py playbook profiles/my-workstation.yml add gui-toolkit --after fonts
python3 scripts/package_tracker.py playbook profiles/my-workstation.yml remove cockpit multimedia
list shows every component of the target with [x] for the selected ones.
add refuses names that have no component file for that target, and remove
refuses to empty the list. In the TUI, choose Components in a playbook for
a checkbox list of playbooks and intents.
python3 scripts/package_tracker.py tui
The TUI asks for a target only when more than one exists, then lists the components (label and package count). Choosing one opens a submenu:
- Edit packages: a checkbox list with every package checked. Unchecked packages are removed after a confirmation that lists them.
- Add packages: type names (space or comma separated), optionally verify them, and confirm when another component already has a name.
- Show details: label,
repo_urls,services,kernel_argsandfragment. - Back: returns to the component list, whose own Back returns to the target list.
Component files carry all package data; static blueprints
(osbuild_static_blueprint_path) are hand-written and outside the tracker. To
add a package to every workstation build, add it to the matching component in
each target that provides the name (search shows where a name already
lives). Before adding a name, confirm the target repository provides it, or
use add --verify:
dnf repoquery -q --repofrompath=r,<repo URL> --repo r --qf '%{name} %{repoid}\n' <package>
just test runs every suite below; just lint runs ansible-lint, rubocop,
shellcheck and just validate-intents. CI calls just ci-lint and
just ci-test. just test-manifest, just test-vm <iso> and just mutant
are separate.
| Suite | Command | Covers |
|---|---|---|
| pytest | just test-py (.venv/bin/python -m pytest tests/; pytest starts ksvalidator and ansible-playbook from the venv) |
intents, disk sizing, OpenSCAP tailoring, component parity, package tracker, remote logging e2e, manifest, VM install |
| BATS | just test-bats (bats -r roles/osbuild/tests/) |
firstboot injection, kickstart injection, hardening (audit-warn, audit-notify, scan), preflight |
| Validate playbooks | just test-role (ansible-playbook -i roles/osbuild/tests/inventory roles/osbuild/tests/validate_*.yml) |
build modes, firstboot injection, hardening, kickstart injection, repo preflight |
| Cucumber | just test-features (bundle exec cucumber, features/) |
misuse boundaries, module contract, parser CLI, progress file/parser, provisioning, quality gates, README schema |
| RSpec | just test-ruby (bundle exec rspec) |
the osbuild_image_build module, the progress gem, the viewer |
Targeted validation:
# Disk sizing math and intent schema
python3 -m pytest tests/test_disk_sizing.py tests/test_intent.py --tb=short
# Rendered kickstarts (needs ksvalidator)
ansible-playbook roles/osbuild/tests/validate_kickstart_injection.yml -e ksvalidator=$(which ksvalidator)
# Firstboot injection
ansible-playbook roles/osbuild/tests/validate_firstboot_injection.yml
# Unattended VM install test (KVM)
SYNCOPATED_VM_ISO=<iso> python3 -m pytest tests/test_vm_install.py -s
Keystroke-level triage when something breaks — including the
system-ansible-lint version quirk — is
docs/just-for-dummies.md §5.
Installer ISOs are generated from a validated intent and install unattended,
with OpenSCAP profiles tailored for standalone artist and developer
workstations that have no external log server. Phases 1–4 and the remote
logging switch of phase 5 are implemented; the remaining acceptance step is
the 48-hour burn-in on the physical test laptop. The complete specification
and acceptance criteria live in
goals/standalone-workstation-hardening/goal.md.
┌────────────────────────────────────────┐
│ User / Wizard Intent Profile │
│ (profiles/<machine>.yml) │
└──────────────────────────┬─────────────┘
│ Validates against schema/intent.schema.json
▼
┌────────────────────────────────────────┐
│ Universal Build Playbook │
│ (playbooks/build.yml) │
└────────────────┬───────────────┬───────┘
│ │
Generation-Time │ │ OpenSCAP Tailoring &
Disk Layout │ │ Credentials Engine
▼ ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ Deterministic Kickstart │ │ Hardened Blueprint Recipe │
│ - Fixed logvol computed sizes │ │ - Audit log rotation (capped) │
│ - /home takes a larger disk's │ │ - Non-halting disk full actions │
│ surplus; reserve stays free │ │ - Desktop noise rules unselected │
│ - 30s abort countdown on a tty │ │ - Initial password, SHA-512 hash │
│ - Zero interactive spokes │ │ only + forced change on login │
└──────────────────┬─────────────────┘ └──────────────────┬─────────────────┘
│ │
└──────────────────┬─────────────────────┘
│ image-builder compose
▼
┌────────────────────────────────────────┐
│ Personalized Unattended ISO │
│ Plug in -> auto install -> first boot │
└────────────────────────────────────────┘
| Phase | Status | Delivers |
|---|---|---|
| 1 — OpenSCAP tailoring & standalone audit policy | implemented | Capped log rotation (512 MB CIS/PCI, 2 GiB HIPAA); non-halting disk-full actions; syncopated-audit-warn persistent notifications; desktop noise rules unselected; syncopated-scan dual-profile audits (HTML + ARF) |
| 2 — Generation-time deterministic disk sizing | implemented | Exact partition and LV sizes computed at build time from disk.size_gib; fixed slices for /, /usr, /var, /var/tmp, /var/log, /var/log/audit; /home takes the surplus; 30 s abort countdown on target selection |
| 3 — Personalized unattended install & credentials | implemented | Zero interactive spokes; initial password syncopated (SHA-512 hash only on the ISO, forced change via chage -d 0); component-derived group membership; artifacts 0644 (0600 with remote logging) |
| 4 — Intent specification & single build engine | implemented | schema/intent.schema.json; universal playbooks/build.yml; example catalog in profiles/examples/ |
| 5 — Pluggable remote logging & advanced protections | logging implemented | mTLS journal shipping via systemd-journal-upload behind remote_logging.enabled; credential segmentation and self-service CLI/web UI remain future goals |
The last acceptance step cannot run in CI: install the CIS example (or your own hardened intent) on the physical test laptop, use it normally for 48 hours, then check that the audit volume stayed at or under its rotation cap and the machine never halted or went single-user:
sudo du -sh /var/log/audit # at or under 512 MB (CIS, PCI-DSS) / 2 GiB (HIPAA)
df -h /var/log/audit
sudo ls -l /var/log/audit # audit.log + at most num_logs - 1 rotated files
journalctl -b -0 --since -48h -t syncopated-audit
last -x shutdown reboot runlevel | head # no unexpected halts or runlevel 1
uptime
sudo syncopated-scan # tailored report: deviations "not selected"
The manual's three parts, plus the annexes:
| Document | What it covers |
|---|---|
docs/just-for-dummies.md |
The quick start — every just recipe with no theory (manual Part 0) |
roles/osbuild/README.md |
Role reference: variables, tasks, module, build modes (manual Part 2) |
AGENTS.md |
Project conventions, commands, testing, gotchas — the agent onboarding file |
goals/standalone-workstation-hardening/goal.md |
The hardening goal package: specifications, facts, plan, acceptance criteria |
CHANGELOG.md |
Release history |
| Repository | Purpose |
|---|---|
workstation |
Layer 1 — converges the installed machine into correctness via ansible-pull |
rpmbuild |
RPM packaging: rpm_dev role, mock build chroots |
llmops |
LLM operations: Dify, Langfuse, remediation pipeline |
| dotfiles (yadm) | Layer 2 — the user environment, via yadm clone and yadm bootstrap |
The repository is licensed under the MIT License — see the root
LICENSE file. Per-file SPDX headers remain in force and are at
least as permissive: Ansible content and tooling carry
SPDX-License-Identifier: MIT-0 (MIT without the attribution requirement),
and the Ruby gem gems/osbuild_progress is MIT (spec.license = "MIT" in
gems/osbuild_progress/osbuild_progress.gemspec).