Skip to content
b08xPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

112 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OSBuild Image Builder & Package Tracker

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.

License: MIT ansible-core python ruby

Fedora AlmaLinux Rocky Linux


What This Is

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.


This Manual

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:

Vocabulary

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

Quick Start

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.


For Coding Agents

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

  1. Component files are immutable sources. Only scripts/package_tracker.py edits a packages: list — comments, key order, and package order are preserved byte-for-byte. One component per concern.
  2. Repositories cascade. repo_urls from selected components become --extra-repo flags; dropping a component drops its repositories. A component without repo_urls uses the target base repos.
  3. Intents declare user intent; playbooks declare build environment. The build host computes variables an intent never sets — image type, extra repositories, /usr sizing for CUDA.
  4. Remote logging is armed at playbook level, not intent level. The kickstart pull injection happens only when both osbuild_ansible_pull_repo and osbuild_ansible_pull_args are non-empty; the intent schema has no such field. The injection mechanics live in roles/osbuild/README.md §First-Login Splash.
  5. 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 in roles/osbuild/README.md §Ansible Role Inversion Pattern.
  6. The tracker refuses rather than guesses. add refuses names with no component file for the target (and names another component already owns, unless --force); remove refuses to empty a component — one package minimum.
  7. Unpinned packages get * at build time. osbuild_package_pins restricts 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')))"

Where This Sits

The three repositories

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

How the image hands off

Converging a running machine belongs to Layer 1. The handoff runs through one injected script:

  1. roles/osbuild/tasks/blueprint.yml injects 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 by osbuild_firstboot_enabled (on by default; container builds switch it off).
  2. The installed system boots to multi-user.target — the kickstart %post sets 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, runs sudo ansible-pull only when the image carries /etc/syncopated-ansible-pull.conf, then offers a reboot, sets graphical.target as the default boot target, and asks whether to switch to the graphical desktop now.
  3. The playbook writes that file only when both osbuild_ansible_pull_repo and osbuild_ansible_pull_args are set (role defaults: "" and []). An intent cannot arm the pull; a playbook can. The skip and run paths are covered by roles/osbuild/tests/firstboot/firstboot.bats.
  4. The splash then offers the user layer: it downloads yadm to ~/.local/bin when 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.

What this repository does not do

  • 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.yml never 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.

Building Installer ISOs from an Intent

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.

Other Images

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

Key Role Options

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.

Verifying a Generated Build

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 build prints scriptlet errors from grub2-probe and dracut (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_build module both reset the process umask to 0022 (templates/image-builder-build.sh.j2, library/osbuild_image_build), so a hardened login shell (CIS umask 027) cannot leak 0640 modes into the installer rootfs — /etc/machine-id must 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.


Build Progress

Progress record schema

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

Watching a Build

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.

Inline watch mode

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 \r and cleared with \e[K — a braille spinner, and when the record carries step/total a 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_code from the result record; 124 when --timeout elapses (prints [build] timed out); 2 on usage errors, including --interval/--timeout values that are not positive.
  • Defaults: --interval 1 (must be > 0); --timeout omitted 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.


Package Tracker

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.

Component Targets

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.

CLI

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

Choosing an Intent's (or a Playbook's) Components

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.

Interactive Mode (TUI)

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_args and fragment.
  • Back: returns to the component list, whose own Back returns to the target list.

Where Package Lists Are Defined

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>

Testing

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.


Architecture Roadmap

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

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

Acceptance: 48-hour burn-in on the test laptop

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"

Documentation

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

Companion Repositories

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

License

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages