Compare commits

...

16 Commits

Author SHA1 Message Date
4fff9f8d80 fix(virtualization): create vmware target folder before vm deploy 2026-06-04 18:17:50 +02:00
7f12a0f3d8 feat: accept proxmox API-token auth alongside password 2026-05-31 12:40:31 +02:00
ceb2237bbb fix(encryption): add tpm2-tss dracut module explicitly for TPM2 LUKS 2026-05-31 12:39:24 +02:00
477c8379c4 fix(configuration): enable per-family time-sync and skip sudo-rs lecture 2026-05-31 12:30:26 +02:00
579c499c02 feat(configuration): multi-backend networking, bind by match not MAC 2026-05-31 12:25:53 +02:00
89e366d0f0 fix: EL10 PAM and crypto readiness via authselect profile and DEFAULT policy 2026-05-28 17:30:57 +02:00
6fe843355e fix(bootstrap): keep package cache off the 2 GiB CIS /var during install 2026-05-28 17:26:25 +02:00
441876fab9 refactor(global_defaults): single source of truth for family-default resolution 2026-05-28 17:25:23 +02:00
00acd4d200 refactor(configuration): consolidate firewall into one phase-aware path 2026-05-27 05:28:00 +02:00
d922efd2e4 feat: uniform system.content source schema across installers and repositories 2026-05-27 05:15:32 +02:00
939c5c741f feat: golden-image build support (cloud-init on EL, selinux relabel, SSH wait) 2026-05-27 05:05:55 +02:00
2c35409519 feat(cis): add selectable profile and per-rule hardening toggles 2026-05-25 04:37:33 +02:00
d2a19cfd5c feat(hardware): auto-detect audio, bluetooth, camera with declarative override 2026-05-25 04:36:21 +02:00
44f5adc682 feat(bootstrap): per-os desktop apps, KDE plasma-login-manager and DM resolution 2026-05-25 04:30:53 +02:00
0185797af9 fix(environment): co-upgrade soname closure when installing installer tools 2026-05-25 03:54:12 +02:00
e0ecf628cd fix(bootstrap): deploy all non-EOL core distros (keyrings, repos, versions) 2026-05-25 03:52:44 +02:00
91 changed files with 2541 additions and 759 deletions

171
README.md
View File

@@ -13,7 +13,7 @@ Non-Arch targets require the appropriate package manager available from the ISO
- 4.1 [Core Variables](#41-core-variables)
- 4.2 [`system` Dictionary](#42-system-dictionary)
- 4.3 [`hypervisor` Dictionary](#43-hypervisor-dictionary)
- 4.4 [`cis` Dictionary](#44-cis-dictionary)
- 4.4 [CIS Hardening](#44-cis-hardening)
- 4.5 [VMware Guest Operations](#45-vmware-guest-operations)
- 4.6 [Multi-Disk Schema](#46-multi-disk-schema)
- 4.7 [Advanced Partitioning Overrides](#47-advanced-partitioning-overrides)
@@ -29,14 +29,14 @@ Non-Arch targets require the appropriate package manager available from the ISO
| `system.os` | Distribution | `system.version` |
| ------------ | ------------------------ | ------------------------------------- |
| `almalinux` | AlmaLinux | `8`, `9`, `10` |
| `almalinux` | AlmaLinux | `9`, `10` |
| `archlinux` | Arch Linux | latest (rolling) |
| `debian` | Debian | `10`-`13`, `unstable` |
| `fedora` | Fedora | `38`-`45` |
| `rhel` | Red Hat Enterprise Linux | `8`, `9`, `10` |
| `rocky` | Rocky Linux | `8`, `9`, `10` |
| `ubuntu` | Ubuntu (latest non-LTS) | optional (e.g. `24.04`) |
| `ubuntu-lts` | Ubuntu LTS | optional (e.g. `24.04`) |
| `debian` | Debian | `12`, `13`, `unstable` |
| `fedora` | Fedora | `43`, `44` |
| `rhel` | Red Hat Enterprise Linux | `9`, `10` |
| `rocky` | Rocky Linux | `9`, `10` |
| `ubuntu` | Ubuntu (latest non-LTS) | optional (tracks 25.10 `questing`) |
| `ubuntu-lts` | Ubuntu LTS | optional (tracks 26.04 `resolute`) |
### Hypervisors
@@ -59,12 +59,10 @@ Non-Arch targets require the appropriate package manager available from the ISO
Two dict-based variables drive the entire configuration:
- **`system`** -- host, network, users, disk layout, encryption, and feature toggles
- **`system`** -- host, network, users, disk layout, encryption, and feature toggles (including CIS hardening under `system.features.cis`)
- **`hypervisor`** -- virtualization backend credentials and targeting
An optional third dict **`cis`** overrides CIS hardening parameters when `system.features.cis.enabled: true`.
All three are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host.
Both are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host.
### Variable Placement
@@ -148,7 +146,7 @@ all:
### 4.1 Core Variables
Top-level variables outside `system`/`hypervisor`/`cis`.
Top-level variables outside `system`/`hypervisor`.
| Variable | Type | Default | Description |
| ---------------- | ------ | -------------------------- | ---------------------------------------------------- |
@@ -174,7 +172,7 @@ Top-level variables outside `system`/`hypervisor`/`cis`.
| `memory` | int | `0` | Memory in MiB (required for virtual) |
| `balloon` | int | `0` | Balloon memory in MiB (Proxmox) |
| `path` | string | -- | Hypervisor folder/path (falls back to `hypervisor.folder`) |
| `mirror` | string | per-distro default | Override package mirror (Debian/Ubuntu) |
| `content` | dict | see below | Package content source (mirror/DVD/Satellite, family-resolved) |
| `packages` | list | `[]` | Additional packages installed post-reboot |
| `network` | dict | see below | Network configuration |
| `disks` | list | `[]` | Disk layout (see [Multi-Disk Schema](#46-multi-disk-schema)) |
@@ -183,6 +181,25 @@ Top-level variables outside `system`/`hypervisor`/`cis`.
| `luks` | dict | see below | Encryption settings |
| `features` | dict | see below | Feature toggles |
#### `system.content`
Uniform package content source, family-resolved. `source: ''` defaults to `dvd` on EL and `mirror` on Debian/Ubuntu/Arch. Satellite values come from inventory/vault only, never committed code.
| Key | Type | Default | Description |
| -------------------------- | ------ | -------------- | ----------------------------------------------------------------- |
| `source` | string | family default | `dvd`, `mirror`, `satellite`, or `none` |
| `url` | string | family default | Mirror URL / EL `.repo` baseurl |
| `proxy` | string | -- | `http://host:port` content proxy (dnf/apt/pacman) |
| `gpgcheck` | bool | `true` | Repository GPG checking |
| `satellite.host` | string | -- | EL Katello/Satellite hostname |
| `satellite.ip` | string | -- | Optional `/etc/hosts` entry when DNS does not resolve the host |
| `satellite.org` | string | -- | Organization label |
| `satellite.activation_key` | string | -- | Activation key |
| `satellite.ca_url` | string | derived | Katello CA RPM URL (default `https://<host>/pub/katello-ca-consumer-latest.noarch.rpm`) |
| `satellite.service_level` | string | -- | syspurpose service level |
| `satellite.environment` | string | -- | Lifecycle environment |
| `satellite.install` | bool | `false` | `false`: base from DVD/mirror then register; `true`: install from Satellite |
#### `system.network`
| Key | Type | Default | Description |
@@ -268,7 +285,10 @@ The bootstrap auto-switches to dracut when `method: tpm2` is set. Override via `
| Key | Type | Default | Description |
| ------------------ | ------ | -------------- | ------------------------------------ |
| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-dictionary)) |
| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-hardening)) |
| `cis.profile` | string | `default` | CIS profile: `default`, `l1`, or `l2` (see [4.4](#44-cis-hardening)) |
| `cis.rules` | dict | `{}` | Per-rule CIS overrides |
| `cis.params` | dict | `{}` | CIS parameter overrides |
| `selinux.enabled` | bool | `true` | SELinux management |
| `firewall.enabled` | bool | `true` | Firewall setup |
| `firewall.backend` | string | `firewalld` | `firewalld` or `ufw` |
@@ -280,14 +300,12 @@ The bootstrap auto-switches to dracut when `method: tpm2` is set. Override via `
| `banner.sudo` | bool | `true` | Sudo banner |
| `chroot.tool` | string | `arch-chroot` | `arch-chroot`, `chroot`, or `systemd-nspawn` |
| `initramfs.generator` | string | auto-detected | Override initramfs generator (see below) |
| `rhel_repo.source` | string | `iso` | RHEL post-install repo source: `iso`, `satellite`, or `none` |
| `rhel_repo.url` | string | -- | Satellite/custom repo URL when `source: satellite` |
| `secure_boot.enabled` | bool | `false` | Enable Secure Boot (Arch via sbctl, others via shim) |
| `secure_boot.method` | string | -- | Arch only: `sbctl` (default) or `uki` |
| `desktop.*` | dict | see below | Desktop environment settings (see [4.2.5](#425-systemfeaturesdesktop)) |
| `firmware.*` | dict | see below | Vendor firmware blobs and CPU microcode (see [4.2.6](#426-systemfeaturesfirmware)) |
| `gpu.*` | dict | see below | Mesa/Vulkan and per-vendor GPU userspace (see [4.2.7](#427-systemfeaturesgpu)) |
| `peripherals.*` | dict | see below | Fingerprint readers, webcams, DisplayLink (see [4.2.8](#428-systemfeaturesperipherals)) |
| `peripherals.*` | dict | see below | Fingerprint, camera, audio, bluetooth, DisplayLink (see [4.2.8](#428-systemfeaturesperipherals)) |
| `hardware.*` | dict | see below | Hardware-detection profile override (see [4.2.9](#429-systemfeatureshardware)) |
**Initramfs generator auto-detection:** RedHat -> dracut, Arch -> mkinitcpio, Debian/Ubuntu -> initramfs-tools.
@@ -301,18 +319,27 @@ On distros with older dracut (no `tpm2-tss` module), clevis is used as a fallbac
| ----------------- | ------ | -------------- | ----------------------------------------- |
| `enabled` | bool | `false` | Install desktop environment |
| `environment` | string | `""` | `gnome`, `kde`, `sway`, or `hyprland` |
| `display_manager` | string | auto-detected | Override DM: `gdm`, `sddm`, or `greetd` |
| `display_manager` | string | auto-detected | Override DM: `gdm`, `sddm`, `plasma-login-manager`, `greetd`, or `ly` |
| `autologin` | bool \| string | `false` | `false` to disable, or a username from `system.users` to auto-login that user |
| `session` | string | auto-from-environment | Session to autologin into; overrides the per-environment default (sddm `.desktop` basename / greetd command) |
| `groups` | list | `[]` | Opt-in package groups installed on top of the base set (keys of `desktop_package_groups`, e.g. `dev`) |
All desktop environments are Wayland-only. `sway` and `hyprland` are available on Arch only;
`gnome` and `kde` are available on all three families.
`gnome` and `kde` are available on all three families. On enterprise Linux
(almalinux/rocky/rhel) the base desktop installs browser, PDF and image viewers but no
video player - none is packaged in the EL base repositories, and no third-party repo is
pulled in; add one from rpmfusion/flatpak if you need it.
When `enabled: true`, the bootstrap installs the desktop environment packages, enables the display manager
and bluetooth services, and sets the systemd default target to `graphical.target`.
Display manager auto-detection: gnome to gdm, kde to sddm, sway to greetd, hyprland to greetd.
Display manager auto-detection: gnome to gdm; kde to plasma-login-manager on Arch and
Fedora 44+ (Plasma 6.6), else sddm; sway and hyprland to greetd.
`ly` is an explicit-only override (never auto-selected), available on Arch only,
and is desktop-agnostic - it can front any environment. It runs on `tty2` with
`getty@tty2` masked, and its autologin is written to `/etc/ly/config.ini`; set `session`
to the target session's `.desktop` basename (sway and hyprland resolve automatically).
When `autologin` names a user, the matching display manager is configured to log that user in without a
password prompt. `session` is resolved automatically per environment when left empty (gdm picks its default,
@@ -350,13 +377,20 @@ automatically when needed. Debian uses `nvidia-driver` from the `non-free` compo
managed `sources.list`). Ubuntu uses `restricted`. Arch ships both `nvidia-open-dkms` and `nvidia-dkms` in
the `extra` repository - no third-party setup required.
> **Known limitation - Nvidia on Enterprise Linux (AlmaLinux/Rocky/RHEL):** the EL `akmod-nvidia*`
> packages live in RPMFusion non-free, and the bootstrap only enables RPMFusion automatically on
> **Fedora**, not on EL. So Nvidia on a bare EL desktop is best-effort: enable RPMFusion (or supply the
> driver repo) out of band, or it falls back to `nouveau`. EL desktops are not a primary target.
#### 4.2.8 `system.features.peripherals`
| Key | Type | Default | Description |
| ------------- | --------------- | ------- | ---------------------------------------------------------- |
| `enabled` | bool \| `auto` | `auto` | Master switch. `auto` follows `desktop.enabled` |
| `fingerprint` | bool \| `auto` | `auto` | `fprintd`/`libfprint`. `auto` = install when reader detected |
| `webcam` | bool \| `auto` | `auto` | `v4l-utils` and userspace tooling. `auto` follows `enabled` |
| `camera` | bool \| `auto` | `auto` | `v4l-utils` for UVC webcams. `auto` = install when a UVC/IPU6 camera is detected (IPU6 out-of-tree stack is logged, not auto-installed) |
| `audio` | bool \| `auto` | `auto` | SOF firmware + ALSA UCM. `auto` = install when an audio device is detected |
| `bluetooth` | bool \| `auto` | `auto` | `bluez`. `auto` = install when a Bluetooth controller is detected |
| `displaylink` | bool | `false` | DisplayLink dock support (explicit opt-in; see notes) |
Fingerprint detection scans `lsusb` for known reader vendor IDs (Synaptics, Validity, Goodix, Elan, Egis,
@@ -372,7 +406,8 @@ must still be installed manually from DisplayLink's site after first boot. Arch
| Key | Type | Default | Description |
| --------- | ---- | ------- | -------------------------------------------------------------------- |
| `profile` | dict | `{}` | Hardware-detection override; empty means autodetect from live host |
| `profile` | dict | `{}` | Full override: non-empty SKIPS detection (golden image); empty = autodetect |
| group fields | mixed | -- | `cpu`/`gpus`/`wireless`/`audio`/`camera`/`fingerprint`/`bluetooth`/`packages`/`disable`/`kernel_params` MERGE over autodetect (see below) |
When empty, hardware is detected at the start of the bootstrap. When set, detection is skipped and the
supplied profile drives package selection - this is the **golden-image** flow: bake an image with a fixed
@@ -393,6 +428,38 @@ system:
fingerprint: false # set true to force fprintd install
```
The same keys (minus `profile`) can also be set **directly under `hardware`** as a
declarative **hardware group** that MERGES over auto-detection (auto-detect = base; the
group supplements/overrides it). Unlike `profile`, which skips detection entirely, the
group keeps detection running and layers on top - use it to pin everything a known device
needs so nothing is ever under-set.
| Key | Type | Merge semantics |
| ------------------------- | ---- | -------------------------------------------------------- |
| `cpu` | str | pin the CPU vendor (overrides detection when non-empty) |
| `gpus`/`wireless`/`audio` | list | union with the detected vendor codes |
| `camera` | dict | `{uvc, ipu6}` booleans OR'd with detection |
| `fingerprint`/`bluetooth` | bool | OR'd with detection (force-on) |
| `packages` | dict | per-`os_family` extra packages, added to the install set (deduped; empty entries dropped) |
| `disable` | list | feature/vendor names force-off, applied last |
| `kernel_params` | list | extra kernel cmdline params, appended to the bootloader |
Example - a laptop with an Intel IPU6 camera (out-of-tree stack) and a Cirrus amp, pinned
in a group's `group_vars`:
```yaml
system:
features:
hardware:
bluetooth: true # force-on if detection misses the combo card
camera:
ipu6: true # force the IPU6 path
packages: # out-of-tree/AUR bits detection must not auto-install
Archlinux: [intel-ipu6-dkms, v4l2-relayd, linux-firmware-cirrus]
disable: [displaylink] # never pull DisplayLink on this device
kernel_params: ["i915.enable_psr=0"]
```
### 4.3 `hypervisor` Dictionary
| Key | Type | Default | Description |
@@ -408,44 +475,50 @@ system:
| `certs` | bool | `false` | TLS certificate validation (VMware) |
| `ssh` | bool | `false` | Enable SSH on guest and switch connection (VMware) |
### 4.4 `cis` Dictionary
### 4.4 CIS Hardening
When `system.features.cis.enabled: true`, the CIS role applies hardening. All values have sensible defaults; override specific keys via the `cis` dict.
When `system.features.cis.enabled: true`, the CIS role applies hardening. The behaviour is driven by three keys under `system.features.cis`:
| Key | Type | Default | Description |
| -------------------- | ------ | ------- | ------------------------------------------------ |
| `modules_blacklist` | list | see below | Kernel modules to blacklist via modprobe |
| `sysctl` | dict | see below | Sysctl key/value pairs written to `10-cis.conf` |
| `sshd_options` | list | see below | SSHD options applied via lineinfile |
| `pwquality_minlen` | int | `14` | Minimum password length |
| `tmout` | int | `900` | Shell timeout (seconds) |
| `umask` | string | `077` | Default umask in bashrc |
| `umask_profile` | string | `027` | Default umask in /etc/profile |
| `faillock_deny` | int | `5` | Failed login attempts before lockout |
| `faillock_unlock_time` | int | `900` | Lockout duration (seconds) |
| `password_remember` | int | `5` | Password history depth |
| Key | Type | Default | Description |
| --------- | ------ | ----------- | ----------------------------------------------------------------- |
| `enabled` | bool | `false` | Apply CIS hardening at all |
| `profile` | string | `default` | `default` (house baseline), `l1` (clean CIS Level 1), or `l2` |
| `rules` | dict | `{}` | Per-rule on/off overrides on top of the profile |
| `params` | dict | `{}` | Parameter overrides (deep-merged; list values replace wholesale) |
**Default modules blacklist:** `freevxfs`, `jffs2`, `hfs`, `hfsplus`, `cramfs`, `udf`, `usb-storage`, `dccp`, `sctp`, `rds`, `tipc`, `firewire-core`, `firewire-sbp2`, `thunderbolt`. `squashfs` is added automatically except on Ubuntu (snap dependency).
**Profiles.** `default` is the established house baseline (CIS Level 1 plus the USB lockdown, full module blacklist, and IPv6-disable extras, minus the usability-hostile controls). `l1` is a clean CIS Level 1: it drops the L2 extras and adds password aging, AIDE, and warning banners. `l2` is `l1` plus auditd and the L2 extras.
**Default sysctl settings** include: `kernel.yama.ptrace_scope=2`, `kernel.kptr_restrict=2`, `kernel.perf_event_paranoid=3`, `kernel.unprivileged_bpf_disabled=1`, IPv4/IPv6 hardening, ARP protection, and IPv6 disabled by default. Override individual keys:
**Per-rule overrides.** Toggle an individual rule without changing profile, e.g. keep the default profile but allow USB and IPv6 on a desktop:
```yaml
cis:
sysctl:
net.ipv6.conf.all.disable_ipv6: 0 # re-enable IPv6
net.ipv4.ip_forward: 1 # enable for routers/containers
system:
features:
cis:
enabled: true
rules:
usb_lockdown: false
ipv6_disable: false
```
**Default SSHD options** enforce: `PermitRootLogin no`, `PasswordAuthentication no`, `X11Forwarding no`, `AllowTcpForwarding no`, `MaxAuthTries 4`, and post-quantum KEX (mlkem768x25519-sha256 on OpenSSH 9.9+). Override per-option:
Rule keys: `module_blacklist`, `usb_lockdown`, `sysctl_hardening`, `ipv6_disable`, `umask_default`, `empty_password_login`, `pwquality`, `core_dumps`, `shell_timeout`, `journald_persistent`, `sudo_logfile`, `su_restriction`, `faillock`, `password_history`, `tcp_wrappers`, `crypto_policy`, `mask_services`, `cron_at_access`, `file_permissions`, `sshd_hardening`, `password_expiry`, `aide`, `warning_banners`, `auditd`, and the opt-in `grub_password` (set `rules.grub_password: true` with `params.grub_password_hash`).
**Parameters.** Override baseline values under `params` (full list in `roles/cis/vars/main.yml`):
```yaml
cis:
sshd_options:
- { option: X11Forwarding, value: "yes" }
- { option: AllowTcpForwarding, value: "yes" }
system:
features:
cis:
enabled: true
profile: l1
params:
pwquality_minlen: 16
sysctl: # dict: deep-merged over the profile's set
net.ipv4.ip_forward: 1
sshd_options: # list: REPLACES the entire default list
- {option: X11Forwarding, value: "yes"}
```
Note: providing `sshd_options` replaces the entire list. Copy the defaults from `roles/cis/defaults/main.yml` and modify as needed.
Common params: `modules_blacklist` (list), `sysctl` (dict), `sshd_options` (list), `pwquality_minlen` (14), `tmout` (900), `umask` (077), `umask_profile` (027), `faillock_deny` (5), `faillock_unlock_time` (900), `password_remember` (5), `pass_max_days` (365), `aide_cron_hour`/`aide_cron_minute`, `banner_text`, `grub_password_hash`.
### 4.5 VMware Guest Operations

View File

@@ -1,14 +1,4 @@
---
# Bootstrap pipeline - role execution order:
# 1. global_defaults - normalize + validate system/hypervisor/disk input
# 2. system_check - pre-flight hardware/environment safety checks
# 3. virtualization - create VM on hypervisor (libvirt/proxmox/vmware/xen)
# 4. environment - detect live ISO, configure installer network, install tools, detect hardware
# 5. partitioning - partition disk, create FS, LUKS, LVM, mount everything
# 6. bootstrap - debootstrap/pacstrap/dnf install the target OS into /mnt
# 7. configuration - users, network, encryption, fstab, bootloader, services
# 8. cis - CIS hardening (optional, per system.features.cis.enabled)
# 9. cleanup - unmount, remove cloud-init artifacts, reboot/shutdown
- name: Create and configure VMs
hosts: "{{ bootstrap_target | default('all') }}"
strategy: free # noqa: run-once[play]
@@ -62,6 +52,12 @@
name: configuration
public: true
# Past this point the OS is installed and configured; a CIS hardening or
# cleanup failure must not delete an otherwise-good VM.
- name: Mark base system complete
ansible.builtin.set_fact:
_bootstrap_base_complete: true
- name: Apply CIS hardening
when: system_cfg.features.cis.enabled | bool
ansible.builtin.include_role:
@@ -75,11 +71,16 @@
public: true
rescue:
- name: Decide whether to delete the half-built VM
ansible.builtin.set_fact:
_delete_vm_on_rescue: >-
{{ _vm_absent_before_bootstrap | default(false) | bool
and virtualization_vm_created_in_run | default(false) | bool
and system_cfg.type == "virtual"
and not (_bootstrap_base_complete | default(false) | bool) }}
- name: Delete VM on bootstrap failure
when:
- _vm_absent_before_bootstrap | default(false) | bool
- virtualization_vm_created_in_run | default(false) | bool
- system_cfg.type == "virtual"
when: _delete_vm_on_rescue | bool
ansible.builtin.include_role:
name: virtualization
tasks_from: delete
@@ -93,9 +94,8 @@
ansible.builtin.fail:
msg: >-
Bootstrap failed for {{ hostname }}.
{{ 'VM was deleted to allow clean retry.'
if (virtualization_vm_created_in_run | default(false))
else 'VM was not created in this run (kept).' }}
{{ 'VM was deleted to allow clean retry.' if (_delete_vm_on_rescue | bool)
else 'VM kept (base system installed or not created this run).' }}
post_tasks:
- name: Set post-reboot connection flags
@@ -131,6 +131,15 @@
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
- name: Wait for the rebooted host to accept SSH
when:
- post_reboot_can_connect | bool
ansible.builtin.wait_for_connection:
delay: 5
sleep: 5
# 600s: a selinux-enabled first boot relabels the filesystem and reboots once more.
timeout: 600
- name: Re-gather facts for target OS after reboot
when:
- post_reboot_can_connect | bool
@@ -140,6 +149,22 @@
- min
- pkg_mgr
- name: Register with the Satellite content source
when:
- post_reboot_can_connect | bool
- system_cfg.content.source == 'satellite'
- system_cfg.os | lower in os_family_rhel
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/configuration/tasks/satellite_register.yml"
- name: Activate the firewall on the rebooted host
when:
- post_reboot_can_connect | bool
- system_cfg.features.firewall.enabled | bool
- system_cfg.features.firewall.backend == 'ufw'
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/configuration/tasks/firewall.yml"
vars:
firewall_phase: postreboot
- name: Install post-reboot packages
when:
- post_reboot_can_connect | bool

View File

@@ -9,6 +9,8 @@
_family_pkgs: "{{ bootstrap_desktop_packages[os_family] | default({}) }}"
_de_config: "{{ _family_pkgs[_de] | default({}) }}"
_base: "{{ bootstrap_desktop_base_packages[os_family] | default([]) }}"
_dm: "{{ system_cfg.features.desktop.display_manager | default('') }}"
_dm_override_pkg: "{{ (bootstrap_dm_override_packages[_dm] | default({}))[os_family] | default('') }}"
_requested_groups: "{{ system_cfg.features.desktop.groups | default([]) }}"
_group_pkgs: >-
{{
@@ -20,16 +22,10 @@
| sum(start=[])
}}
ansible.builtin.set_fact:
# GNOME ships under different dnf environment groups: Fedora uses
# workstation-product-environment, enterprise RHEL/Rocky/Alma use
# graphical-server-environment ("Server with GUI").
_desktop_groups: >-
{{ ['graphical-server-environment']
if (_de == 'gnome' and os_family == 'RedHat' and os != 'fedora')
else (_de_config.groups | default([])) }}
_desktop_groups: "{{ _de_config.groups | default([]) }}"
_desktop_packages: >-
{{
((_de_config.packages | default([])) + _base + _group_pkgs)
((_de_config.packages | default([])) + _base + _group_pkgs + [_dm_override_pkg])
| reject('equalto', '')
| unique
| list
@@ -61,7 +57,8 @@
{{ chroot_command }} dnf --releasever={{ os_version_major }}
--setopt=install_weak_deps=False install -y {{ _desktop_packages | join(' ') }}
Debian: >-
{{ chroot_command }} apt install -y --install-recommends {{ _desktop_packages | join(' ') }}
{{ chroot_command }} env DEBIAN_FRONTEND=noninteractive
apt install -y --install-recommends {{ _desktop_packages | join(' ') }}
Archlinux: >-
pacstrap /mnt {{ _desktop_packages | join(' ') }}
ansible.builtin.command: "{{ _install_commands[os_family] }}"

View File

@@ -1,6 +1,4 @@
---
# Installs vendor-matched microcode/firmware/GPU/peripheral packages into /mnt
# based on hardware_profile_active set by environment/_detect_hardware.yml.
- name: Load hardware package definitions
ansible.builtin.include_vars:
file: hardware.yml
@@ -22,7 +20,7 @@
vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}"
_user_driver: "{{ system_cfg.features.gpu.nvidia_driver | default('auto') }}"
_has_nvidia: "{{ 'nvidia' in (hardware_profile_active.gpus | default([])) }}"
_has_nvidia: "{{ 'nvidia' in (hardware_profile_active.gpus | default([]) | difference(_hardware_profile_disable | default([]))) }}"
_supports_open: "{{ hardware_profile_active.nvidia_supports_open | default(true) | bool }}"
_open_pkgs: "{{ _family.gpu_nvidia.open | default([]) }}"
_prop_pkgs: "{{ _family.gpu_nvidia.proprietary | default([]) }}"
@@ -58,101 +56,7 @@
changed_when: _rpmfusion_result.rc == 0
- name: Resolve hardware package set
vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}"
_cpu: "{{ hardware_profile_active.cpu | default('') | string }}"
_gpus: "{{ hardware_profile_active.gpus | default([]) | list }}"
_wifi: "{{ hardware_profile_active.wireless | default([]) | list }}"
_fp_detected: "{{ hardware_profile_active.fingerprint | default(false) | bool }}"
_firmware_on: "{{ system_cfg.features.firmware.enabled | bool }}"
_microcode_on: "{{ _firmware_on and (system_cfg.features.firmware.microcode | bool) }}"
_gpu_on: "{{ system_cfg.features.gpu.enabled | bool }}"
_peripherals_on: "{{ system_cfg.features.peripherals.enabled | bool }}"
_webcam_pref: "{{ system_cfg.features.peripherals.webcam | default('auto') }}"
_fp_pref: "{{ system_cfg.features.peripherals.fingerprint | default('auto') }}"
_dl_on: "{{ system_cfg.features.peripherals.displaylink | bool }}"
_webcam_on: >-
{{
_peripherals_on
and (_webcam_pref == 'true' or (_webcam_pref == 'auto' and _peripherals_on))
}}
_fp_on: >-
{{
_peripherals_on
and (_fp_pref == 'true' or (_fp_pref == 'auto' and _fp_detected))
}}
# Union of GPU/wireless/CPU vendors; CPU vendor is included so Intel-CPU
# systems pull i915/iwlwifi firmware via the same vendor split.
_cpu_vendor_list: "{{ ([_cpu] if (_cpu | length > 0) else []) | list }}"
_firmware_vendors: >-
{{
(_firmware_on | ternary(
(_gpus + _wifi + _cpu_vendor_list)
| reject('equalto', '') | unique | list,
[]
))
}}
_microcode_pkgs: >-
{{
((_microcode_on and _cpu | length > 0) | ternary(
_family.cpu_microcode[_cpu] | default([]),
[]
)) | list
}}
_firmware_pkgs: >-
{{
(_firmware_on | ternary(
(_family.firmware_base | default([]) | list)
+ (_firmware_vendors
| map('extract', _family.firmware | default({}))
| select('truthy')
| list
| sum(start=[])),
[]
)) | list
}}
_gpu_base_pkgs: "{{ (_gpu_on | ternary(_family.gpu_base | default([]), [])) | list }}"
_gpu_vendor_pkgs: >-
{{
(_gpu_on | ternary(
(_gpus | reject('equalto', 'nvidia') | list)
| map('extract', _family.gpu | default({}))
| select('truthy')
| list
| sum(start=[]),
[]
)) | list
}}
_gpu_nvidia_pkgs: >-
{{
((_gpu_on and ('nvidia' in _gpus)) | ternary(
_family.gpu_nvidia[_nvidia_driver_resolved] | default([]),
[]
)) | list
}}
_peripherals_base_pkgs: >-
{{
(_webcam_on | ternary(_family.peripherals_base | default([]), [])) | list
}}
_peripherals_fingerprint_pkgs: >-
{{
(_fp_on | ternary(_family.peripherals_fingerprint | default([]), [])) | list
}}
_peripherals_displaylink_pkgs: >-
{{
(_dl_on | ternary(_family.peripherals_displaylink | default([]), [])) | list
}}
ansible.builtin.set_fact:
_hardware_packages: >-
{{
(_microcode_pkgs + _firmware_pkgs
+ _gpu_base_pkgs + _gpu_vendor_pkgs + _gpu_nvidia_pkgs
+ _peripherals_base_pkgs + _peripherals_fingerprint_pkgs
+ _peripherals_displaylink_pkgs)
| reject('equalto', '')
| unique
| list
}}
ansible.builtin.include_tasks: _resolve_hardware_packages.yml
- name: Report hardware package selection
ansible.builtin.debug:
@@ -165,6 +69,15 @@
fingerprint={{ hardware_profile_active.fingerprint | default(false) }}
-> {{ _hardware_packages | length }} package(s)
- name: Note Intel IPU6 camera (out-of-tree stack)
when: hardware_profile_active.camera.ipu6 | default(false) | bool
ansible.builtin.debug:
msg: >-
Intel IPU6 MIPI camera detected. Its driver stack (intel-ipu6 firmware,
DKMS module, v4l2-relayd, libcamera) is out-of-tree/AUR and is NOT auto-
installed. Pin the packages in a hardware group via
system.features.hardware.packages[{{ os_family }}].
- name: Install hardware packages
when: _hardware_packages | length > 0
vars:

View File

@@ -0,0 +1,125 @@
---
# Split out of _hardware.yml so fixtures can seed the inputs and assert the
# resolved _hardware_packages list with no chroot/install.
- name: Resolve hardware package set
vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}"
_disable: "{{ _hardware_profile_disable | default([]) | list }}"
_profile_packages: "{{ (_hardware_profile_packages | default({}))[os_family] | default([]) | list }}"
_cpu: "{{ hardware_profile_active.cpu | default('') | string }}"
_gpus: "{{ hardware_profile_active.gpus | default([]) | difference(_disable) | list }}"
_wifi: "{{ hardware_profile_active.wireless | default([]) | difference(_disable) | list }}"
_fp_detected: "{{ hardware_profile_active.fingerprint | default(false) | bool }}"
_audio: "{{ hardware_profile_active.audio | default([]) | difference(_disable) | list }}"
_bt_detected: "{{ hardware_profile_active.bluetooth | default(false) | bool }}"
_firmware_on: "{{ system_cfg.features.firmware.enabled | bool }}"
_microcode_on: "{{ _firmware_on and (system_cfg.features.firmware.microcode | bool) }}"
_gpu_on: "{{ system_cfg.features.gpu.enabled | bool }}"
_peripherals_on: "{{ system_cfg.features.peripherals.enabled | bool }}"
_camera_pref: "{{ system_cfg.features.peripherals.camera | default('auto') }}"
_camera_uvc: "{{ hardware_profile_active.camera.uvc | default(false) | bool }}"
_camera_ipu6: "{{ hardware_profile_active.camera.ipu6 | default(false) | bool }}"
_fp_pref: "{{ system_cfg.features.peripherals.fingerprint | default('auto') }}"
_audio_pref: "{{ system_cfg.features.peripherals.audio | default('auto') }}"
_bt_pref: "{{ system_cfg.features.peripherals.bluetooth | default('auto') }}"
_dl_on: "{{ (system_cfg.features.peripherals.displaylink | bool) and ('displaylink' not in _disable) }}"
_camera_on: >-
{{
_peripherals_on
and ('camera' not in _disable)
and (_camera_pref == 'true' or (_camera_pref == 'auto' and (_camera_uvc or _camera_ipu6)))
}}
_fp_on: >-
{{
_peripherals_on
and ('fingerprint' not in _disable)
and (_fp_pref == 'true' or (_fp_pref == 'auto' and _fp_detected))
}}
_audio_on: >-
{{
_peripherals_on
and ('audio' not in _disable)
and (_audio_pref == 'true' or (_audio_pref == 'auto' and (_audio | length > 0)))
}}
_bt_on: >-
{{
_peripherals_on
and ('bluetooth' not in _disable)
and (_bt_pref == 'true' or (_bt_pref == 'auto' and _bt_detected))
}}
# Union of GPU/wireless/CPU vendors; CPU vendor is included so Intel-CPU
# systems pull i915/iwlwifi firmware via the same vendor split.
_cpu_vendor_list: "{{ ([_cpu] if (_cpu | length > 0 and _cpu not in _disable) else []) | list }}"
_firmware_vendors: >-
{{
(_firmware_on | ternary(
(_gpus + _wifi + _cpu_vendor_list)
| reject('equalto', '') | unique | list,
[]
))
}}
_microcode_pkgs: >-
{{
((_microcode_on and _cpu | length > 0 and _cpu not in _disable) | ternary(
_family.cpu_microcode[_cpu] | default([]),
[]
)) | list
}}
_firmware_pkgs: >-
{{
(_firmware_on | ternary(
(_family.firmware_base | default([]) | list)
+ (_firmware_vendors
| map('extract', _family.firmware | default({}))
| select('truthy')
| list
| sum(start=[])),
[]
)) | list
}}
_gpu_base_pkgs: "{{ (_gpu_on | ternary(_family.gpu_base | default([]), [])) | list }}"
_gpu_vendor_pkgs: >-
{{
(_gpu_on | ternary(
(_gpus | reject('equalto', 'nvidia') | list)
| map('extract', _family.gpu | default({}))
| select('truthy')
| list
| sum(start=[]),
[]
)) | list
}}
_gpu_nvidia_pkgs: >-
{{
((_gpu_on and ('nvidia' in _gpus)) | ternary(
_family.gpu_nvidia[_nvidia_driver_resolved] | default([]),
[]
)) | list
}}
_camera_base_pkgs: >-
{{
(_camera_on | ternary(_family.camera_base | default([]), [])) | list
}}
_peripherals_fingerprint_pkgs: >-
{{
(_fp_on | ternary(_family.peripherals_fingerprint | default([]), [])) | list
}}
_peripherals_displaylink_pkgs: >-
{{
(_dl_on | ternary(_family.peripherals_displaylink | default([]), [])) | list
}}
_audio_base_pkgs: "{{ (_audio_on | ternary(_family.audio_base | default([]), [])) | list }}"
_bluetooth_base_pkgs: "{{ (_bt_on | ternary(_family.bluetooth_base | default([]), [])) | list }}"
ansible.builtin.set_fact:
_hardware_packages: >-
{{
(_microcode_pkgs + _firmware_pkgs
+ _gpu_base_pkgs + _gpu_vendor_pkgs + _gpu_nvidia_pkgs
+ _audio_base_pkgs + _bluetooth_base_pkgs
+ _camera_base_pkgs + _peripherals_fingerprint_pkgs
+ _peripherals_displaylink_pkgs
+ _profile_packages)
| reject('equalto', '')
| unique
| list
}}

View File

@@ -8,7 +8,44 @@
| reject('equalto', '')
| list
}}
ansible.builtin.command: >-
pacstrap /mnt {{ bootstrap_archlinux_packages | join(' ') }}
register: bootstrap_result
changed_when: bootstrap_result.rc == 0
block:
- name: Notify that mirror mode falls back to the public mirrorlist
when:
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length == 0
ansible.builtin.debug:
msg: >-
content.source is 'mirror' but content.url is empty: keeping the live
ISO public mirrorlist (refreshed by reflector). Set content.url to pin
a specific mirror.
- name: Point pacman at the content mirror
when: system_cfg.content.url | length > 0
ansible.builtin.copy:
dest: /etc/pacman.d/mirrorlist
content: "Server = {{ system_cfg.content.url }}/$repo/os/$arch\n"
mode: "0644"
- name: Refresh Arch keyring in the live environment
ansible.builtin.command: pacman -Sy --noconfirm archlinux-keyring
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_arch_keyring
changed_when: bootstrap_arch_keyring.rc == 0
- name: Install Arch base system
ansible.builtin.command: >-
pacstrap /mnt {{ bootstrap_archlinux_packages | join(' ') }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_result
changed_when: bootstrap_result.rc == 0
- name: Persist the content mirror in the installed system
when: system_cfg.content.url | length > 0
ansible.builtin.copy:
dest: /mnt/etc/pacman.d/mirrorlist
content: "Server = {{ system_cfg.content.url }}/$repo/os/$arch\n"
mode: "0644"

View File

@@ -3,9 +3,7 @@
vars:
bootstrap_debian_release: >-
{{
'buster' if (os_version | string) == '10'
else 'bullseye' if (os_version | string) == '11'
else 'bookworm' if (os_version | string) == '12'
'bookworm' if (os_version | string) == '12'
else 'trixie' if (os_version | string) == '13'
else 'sid' if (os_version | string) == 'unstable'
else 'trixie'
@@ -28,10 +26,27 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true
- name: Check for a debootstrap script for the target release
ansible.builtin.stat:
path: "/usr/share/debootstrap/scripts/{{ bootstrap_debian_release }}"
register: bootstrap_debian_script
- name: Symlink a missing debootstrap script to the sid base
ansible.builtin.file:
src: sid
dest: "/usr/share/debootstrap/scripts/{{ bootstrap_debian_release }}"
state: link
when: not bootstrap_debian_script.stat.exists
- name: Install Debian base system
ansible.builtin.command: >-
debootstrap --include={{ bootstrap_debian_base_csv }}
{{ bootstrap_debian_release }} /mnt {{ system_cfg.mirror }}
debootstrap --keyring=/usr/share/keyrings/debian-archive-keyring.gpg
--include={{ bootstrap_debian_base_csv }}
{{ bootstrap_debian_release }} /mnt
{{ system_cfg.content.url }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_debian_base_result
changed_when: bootstrap_debian_base_result.rc == 0
@@ -48,6 +63,10 @@
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Update package lists

View File

@@ -29,11 +29,35 @@
loop_control:
label: "{{ item.path }}"
# Installers write their cache inside the installroot; redirect it off the 2 GiB CIS /var LV.
- name: Create bootstrap package-cache directory
ansible.builtin.file:
path: /mnt/.bootstrap-cache
state: directory
mode: "0755"
- name: Redirect package cache off the CIS /var LV
ansible.posix.mount:
src: /mnt/.bootstrap-cache
path: /mnt/var/cache
fstype: none
opts: bind
state: ephemeral
- name: Run OS-specific bootstrap process
vars:
bootstrap_var_key: "{{ 'bootstrap_' + (os | replace('-lts', '') | replace('-', '_')) }}"
ansible.builtin.include_tasks: "{{ bootstrap_os_task_map[os] }}"
# dnf --installroot never runs anaconda, so no authselect profile is selected and
# /etc/pam.d/system-auth is missing, leaving the system unable to authenticate.
# local is the right profile: local-auth only, no pam_sss.so, still CIS-capable.
- name: Select default authselect profile for the PAM stack
when: is_authselect | bool
ansible.builtin.command: "{{ chroot_command }} authselect select local --force"
register: bootstrap_authselect_result
changed_when: bootstrap_authselect_result.rc == 0
- name: Install hardware-matched firmware/microcode/GPU/peripheral packages
when: >-
(system_cfg.features.firmware.enabled | bool)

View File

@@ -24,12 +24,14 @@
- "'grub2-common' not in (bootstrap_result.stderr | default(''))"
- name: Ensure chroot RHEL DVD directory exists
when: system_cfg.content.source != 'mirror'
ansible.builtin.file:
path: /mnt/usr/local/install/redhat/dvd
state: directory
mode: "0755"
- name: Bind mount RHEL DVD into chroot
when: system_cfg.content.source != 'mirror'
ansible.posix.mount:
src: /usr/local/install/redhat/dvd
path: /mnt/usr/local/install/redhat/dvd

View File

@@ -4,8 +4,8 @@
# ubuntu = latest non-LTS, ubuntu-lts = latest LTS
bootstrap_ubuntu_release_map:
ubuntu: questing
ubuntu-lts: noble
bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('noble') }}"
ubuntu-lts: resolute
bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('resolute') }}"
_config: "{{ lookup('vars', bootstrap_var_key) }}"
bootstrap_ubuntu_base_csv: "{{ (['ca-certificates'] + _config.base) | unique | join(',') }}"
bootstrap_ubuntu_extra_args: >-
@@ -24,13 +24,28 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true
- name: Check for a debootstrap script for the target release
ansible.builtin.stat:
path: "/usr/share/debootstrap/scripts/{{ bootstrap_ubuntu_release }}"
register: bootstrap_ubuntu_script
- name: Symlink a missing debootstrap script to the ubuntu base
ansible.builtin.file:
src: gutsy
dest: "/usr/share/debootstrap/scripts/{{ bootstrap_ubuntu_release }}"
state: link
when: not bootstrap_ubuntu_script.stat.exists
- name: Install Ubuntu base system
ansible.builtin.command: >-
debootstrap
--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg
--include={{ bootstrap_ubuntu_base_csv }}
{{ bootstrap_ubuntu_release }} /mnt
{{ system_cfg.mirror }}
{{ system_cfg.content.url }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_ubuntu_base_result
changed_when: bootstrap_ubuntu_base_result.rc == 0
@@ -47,6 +62,10 @@
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Update package lists

View File

@@ -1,7 +1,7 @@
# Managed by Ansible.
{% set release = bootstrap_debian_release %}
{% set mirror = system_cfg.mirror %}
{% set components = 'main contrib non-free' ~ (' non-free-firmware' if (os_version | string) not in ['10', '11'] else '') %}
{% set mirror = system_cfg.content.url | default('http://deb.debian.org/debian', true) %}
{% set components = 'main contrib non-free non-free-firmware' %}
deb {{ mirror }} {{ release }} {{ components }}
deb-src {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,6 +1,6 @@
# Managed by Ansible.
{% set release = bootstrap_ubuntu_release %}
{% set mirror = system_cfg.mirror %}
{% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,16 +1,41 @@
---
# Per-family desktop environment package definitions.
# Keyed by os_family -> environment -> groups (dnf group install) / packages.
# Wayland only: gnome, kde, sway, hyprland. No X11/xorg-server, no X11-only DEs.
# DE sets carry the session bits + the DE-specific xdg portal backend; the
# shared base below (fonts/audio/portal core/power/viewer apps) is layered on
# top for every DE via bootstrap_desktop_base_packages.
# plasma-login-manager on Arch/Fedora44+ (Plasma 6.6), else sddm.
bootstrap_kde_login_manager: >-
{{
'plasma-login-manager'
if (os == 'archlinux' or (os == 'fedora' and (os_version | int) >= 44))
else 'sddm'
}}
# Native DMs ride in each DE's package set; only explicit non-native overrides
# need a package here. ly is Arch-only (validation rejects it elsewhere first).
bootstrap_dm_override_packages:
ly:
Archlinux: ly
# EL = non-fedora RedHat.
bootstrap_os_is_el: "{{ os in ['almalinux', 'rocky', 'rhel'] }}"
bootstrap_os_is_el10: "{{ bootstrap_os_is_el | bool and (os_version | default('0') | int) >= 10 }}"
# EL10 renames (evince->papers, eog->loupe, ppd->tuned-ppd); fira-code + mpv absent on EL.
bootstrap_desktop_browser: "{{ 'firefox-esr' if os == 'debian' else 'firefox' }}"
bootstrap_desktop_pdf: "{{ 'papers' if bootstrap_os_is_el10 | bool else 'evince' }}"
bootstrap_desktop_image: "{{ 'loupe' if bootstrap_os_is_el10 | bool else 'eog' }}"
bootstrap_desktop_power: "{{ 'tuned-ppd' if bootstrap_os_is_el10 | bool else 'power-profiles-daemon' }}"
bootstrap_desktop_redhat_codefont: "{{ '' if bootstrap_os_is_el | bool else 'fira-code-fonts' }}"
bootstrap_desktop_redhat_video: "{{ '' if bootstrap_os_is_el | bool else 'mpv' }}"
bootstrap_desktop_packages:
RedHat:
gnome:
groups:
- workstation-product-environment
packages: []
groups: []
packages:
- gnome-shell
- gnome-control-center
- nautilus
- gnome-session
- gdm
kde:
groups: []
packages:
@@ -18,7 +43,7 @@ bootstrap_desktop_packages:
- plasma-nm
- plasma-pa
- plasma-systemmonitor
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -41,7 +66,7 @@ bootstrap_desktop_packages:
- plasma-desktop
- plasma-nm
- plasma-pa
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -62,7 +87,7 @@ bootstrap_desktop_packages:
- plasma-desktop
- plasma-nm
- plasma-pa
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -102,26 +127,23 @@ bootstrap_desktop_packages:
- qt6-wayland
- bluez
# Shared desktop base, installed for EVERY DE whenever desktop.enabled.
# Fonts (noto + emoji + one nerd font), audio stack (pipewire + wireplumber +
# pipewire-pulse), xdg portal core, power-profiles-daemon, and viewer-only base
# apps (browser, PDF/image/video viewers). DE metas (gnome/plasma) bundle their
# own file manager + settings, so no file manager is added here - the wlroots
# DE sets above carry their own (nautilus).
# Installed for EVERY DE whenever desktop.enabled. No file manager here: DE metas
# bundle their own and the wlroots sets above carry nautilus.
bootstrap_desktop_base_packages:
RedHat:
- google-noto-sans-fonts
- google-noto-emoji-fonts
- fira-code-fonts
- "{{ bootstrap_desktop_redhat_codefont }}"
- pipewire
- wireplumber
- pipewire-pulseaudio
- xdg-desktop-portal
- power-profiles-daemon
- "{{ bootstrap_desktop_power }}"
- bluez
- firefox
- evince
- eog
- mpv
- "{{ bootstrap_desktop_pdf }}"
- "{{ bootstrap_desktop_image }}"
- "{{ bootstrap_desktop_redhat_video }}"
Debian:
- fonts-noto
- fonts-noto-color-emoji
@@ -131,7 +153,8 @@ bootstrap_desktop_base_packages:
- pipewire-pulse
- xdg-desktop-portal
- power-profiles-daemon
- firefox-esr
- bluez
- "{{ bootstrap_desktop_browser }}"
- evince
- eog
- mpv
@@ -144,14 +167,14 @@ bootstrap_desktop_base_packages:
- pipewire-pulse
- xdg-desktop-portal
- power-profiles-daemon
- bluez
- firefox
- evince
- loupe
- mpv
# Opt-in package groups, selected per host via features.desktop.groups (a list
# of group names). _desktop.yml installs the union of the requested groups'
# packages. Empty selection by default.
# Opt-in groups selected per host via features.desktop.groups; the union of the
# requested groups' packages is installed. Empty selection by default.
desktop_package_groups:
dev:
RedHat:

View File

@@ -1,18 +1,7 @@
---
# Hardware-aware package definitions keyed by os_family. Schema:
# cpu_microcode[intel|amd] CPU vendor microcode
# firmware_base unconditional firmware packages
# firmware[<vendor>] vendor-split firmware (intel|amd|nvidia|
# atheros|broadcom|mediatek|marvell|realtek|
# qcom|cirrus|other)
# gpu_base mesa + vulkan loader
# gpu[intel|amd] per-GPU userspace
# gpu_nvidia[open|proprietary|nouveau] nvidia driver flavor
# peripherals_base webcam/scanner stack
# peripherals_fingerprint fprintd + libfprint
# peripherals_displaylink evdi kernel module + DisplayLink helpers
# Only packages matching detected hardware are installed; families without
# vendor splits collapse to a single firmware meta package.
# Hardware-aware package definitions keyed by os_family, consumed by
# _resolve_hardware_packages.yml. Only packages matching detected hardware are
# installed; families without vendor splits collapse to one firmware meta package.
bootstrap_hardware_packages:
Archlinux:
cpu_microcode:
@@ -40,9 +29,11 @@ bootstrap_hardware_packages:
proprietary: [nvidia-dkms, nvidia-utils]
# Wayland-only: kernel nouveau module + mesa/gbm drive the display; no Xorg DDX.
nouveau: [vulkan-nouveau]
peripherals_base: [v4l-utils]
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libfprint]
peripherals_displaylink: [] # AUR only; user must wire in AUR helper
audio_base: [sof-firmware, alsa-ucm-conf]
bluetooth_base: [bluez, bluez-utils]
Debian:
cpu_microcode:
@@ -72,9 +63,11 @@ bootstrap_hardware_packages:
proprietary: [nvidia-driver, nvidia-vulkan-icd]
# Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: []
peripherals_base: [v4l-utils]
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libpam-fprintd]
peripherals_displaylink: [evdi-dkms] # userspace driver still needs vendor .run
audio_base: [firmware-sof-signed, alsa-ucm-conf]
bluetooth_base: [bluez]
RedHat:
cpu_microcode:
@@ -103,6 +96,8 @@ bootstrap_hardware_packages:
proprietary: [akmod-nvidia, xorg-x11-drv-nvidia, xorg-x11-drv-nvidia-cuda]
# Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: []
peripherals_base: [v4l-utils]
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, fprintd-pam]
peripherals_displaylink: [evdi] # COPR-supplied; repo enablement deferred
audio_base: [alsa-sof-firmware, alsa-ucm]
bluetooth_base: [bluez]

View File

@@ -1,6 +1,6 @@
---
# Feature-gated packages shared across all distros.
# Arch has special nftables handling and composes this differently.
# Feature-gated packages shared across all distros. Arch strips nftables from
# this and composes it differently.
bootstrap_common_conditional: >-
{{
(
@@ -11,18 +11,37 @@ bootstrap_common_conditional: >-
+ (['cryptsetup', 'tpm2-tools'] if system_cfg.luks.enabled | bool else [])
+ (['qemu-guest-agent'] if hypervisor_type in ['libvirt', 'proxmox'] else [])
+ (['open-vm-tools'] if hypervisor_type == 'vmware' else [])
+ (['cloud-init'] if system_cfg.features.cloud_init | bool else [])
)
}}
# ---------------------------------------------------------------------------
# Per-OS package definitions: base (rootfs/group install), extra (post-base),
# conditional (feature/version-gated, appended by task files).
# DNF-based distros also carry repos (dnf --repo) and use base as group names.
# ---------------------------------------------------------------------------
# Native-installer parity backfill: anaconda and the d-i "standard" task leave
# these, but install_weak_deps=False / Recommends-off minimal installs drop them.
bootstrap_el_runtime:
- NetworkManager
- authselect
- authselect-libs
- chrony
- crypto-policies
- crypto-policies-scripts
- dbus
- polkit
bootstrap_deb_runtime:
- apparmor-utils
- chrony
- libpam-pwquality
- needrestart
- network-manager
- sudo
# Per-OS package definitions: base (rootfs/group install), extra (post-base),
# conditional (feature/version-gated, appended by task files). DNF distros also
# carry repos and use base as group names.
bootstrap_rhel:
repos:
- "rhel{{ os_version_major }}-baseos"
- "rhel{{ os_version_major }}-appstream"
base:
- core
- base
@@ -51,6 +70,7 @@ bootstrap_rhel:
+ (['python39'] if os_version_major | default('') == '8' else ['python'])
+ (['kernel'] if os_version_major | default('') == '10' else [])
+ (['zram-generator'] if os_version_major | default('') in ['9', '10'] else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -85,8 +105,8 @@ bootstrap_almalinux:
- zstd
conditional: >-
{{
(['dbus-daemon'] if (os_version_major | default('10') | int) >= 9 else [])
+ (['dhcp-client'] if (os_version_major | default('10') | int) < 10 else [])
(['dhcp-client'] if (os_version_major | default('10') | int) < 10 else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -125,6 +145,7 @@ bootstrap_rocky:
conditional: >-
{{
(['dhcp-client'] if (os_version_major | default('9') | int) < 10 else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -158,7 +179,6 @@ bootstrap_fedora:
- nc
- nfs-utils
- nfsv4-client-utils
- polkit
- ppp
- python3
- ripgrep
@@ -169,7 +189,7 @@ bootstrap_fedora:
- zoxide
- zram-generator
- zstd
conditional: "{{ bootstrap_common_conditional }}"
conditional: "{{ bootstrap_el_runtime + bootstrap_common_conditional }}"
bootstrap_debian:
base:
@@ -187,28 +207,22 @@ bootstrap_debian:
- python3
- xfsprogs
extra:
- apparmor-utils
- bat
- chrony
- curl
- entr
- fish
- fzf
- htop
- jq
- libpam-pwquality
- linux-image-amd64
- lrzsz
- mtr
- ncdu
- needrestart
- net-tools
- network-manager
- python-is-python3
- ripgrep
- rsync
- screen
- sudo
- syslog-ng
- tcpd
- vim
@@ -223,6 +237,7 @@ bootstrap_debian:
+ (['systemd-zram-generator'] if (os_version | string) not in ['10', '11'] else [])
+ (['tldr'] if (os_version | string) not in ['13', 'unstable'] else [])
+ (['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
+ bootstrap_deb_runtime
+ bootstrap_common_conditional
}}
@@ -244,10 +259,8 @@ bootstrap_ubuntu:
- python3
- xfsprogs
extra:
- apparmor-utils
- bash-completion
- bat
- chrony
- curl
- dnsutils
- duf
@@ -259,20 +272,16 @@ bootstrap_ubuntu:
- fzf
- htop
- jq
- libpam-pwquality
- lrzsz
- mtr
- ncdu
- ncurses-term
- needrestart
- net-tools
- network-manager
- python-is-python3
- ripgrep
- rsync
- screen
- software-properties-common
- sudo
- syslog-ng
- systemd-zram-generator
- tcpd
@@ -285,8 +294,8 @@ bootstrap_ubuntu:
- zstd
conditional: >-
{{
(['tldr'] if (os_version | default('') | string | length) > 0 else [])
+ (['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
(['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
+ bootstrap_deb_runtime
+ bootstrap_common_conditional
}}
@@ -313,7 +322,6 @@ bootstrap_archlinux:
- nfs-utils
- ppp
- python
- reflector
- rsync
- sudo
- tldr
@@ -326,5 +334,6 @@ bootstrap_archlinux:
(['openssh'] if system_cfg.features.ssh.enabled | bool else [])
+ (['iptables-nft'] if system_cfg.features.firewall.toolkit == 'nftables' and system_cfg.features.firewall.enabled | bool else [])
+ (['sbctl'] if system_cfg.features.secure_boot.enabled | bool else [])
+ (['reflector'] if system_cfg.content.url | length == 0 else [])
+ (bootstrap_common_conditional | reject('equalto', 'nftables') | list)
}}

View File

@@ -1,100 +1,13 @@
---
# User-facing API: override via top-level `cis` dict in inventory.
# Merged with these defaults in _normalize.yml -> cis_cfg.
cis_defaults:
modules_blacklist:
- freevxfs
- jffs2
- hfs
- hfsplus
- cramfs
- udf
- usb-storage
- dccp
- sctp
- rds
- tipc
- firewire-core
- firewire-sbp2
- thunderbolt
sysctl:
fs.suid_dumpable: 0
kernel.dmesg_restrict: 1
kernel.kptr_restrict: 2
kernel.perf_event_paranoid: 3
kernel.unprivileged_bpf_disabled: 1
kernel.yama.ptrace_scope: 2
kernel.randomize_va_space: 2
net.ipv4.ip_forward: 0
net.ipv4.tcp_syncookies: 1
net.ipv4.icmp_echo_ignore_broadcasts: 1
net.ipv4.icmp_ignore_bogus_error_responses: 1
net.ipv4.conf.all.log_martians: 1
net.ipv4.conf.all.rp_filter: 1
net.ipv4.conf.all.secure_redirects: 0
net.ipv4.conf.all.send_redirects: 0
net.ipv4.conf.all.accept_redirects: 0
net.ipv4.conf.all.accept_source_route: 0
net.ipv4.conf.all.arp_ignore: 1
net.ipv4.conf.all.arp_announce: 2
net.ipv4.conf.default.log_martians: 1
net.ipv4.conf.default.rp_filter: 1
net.ipv4.conf.default.secure_redirects: 0
net.ipv4.conf.default.send_redirects: 0
net.ipv4.conf.default.accept_redirects: 0
net.ipv6.conf.all.accept_redirects: 0
net.ipv6.conf.all.disable_ipv6: 1
net.ipv6.conf.default.accept_redirects: 0
net.ipv6.conf.default.disable_ipv6: 1
net.ipv6.conf.lo.disable_ipv6: 1
sshd_options:
- { option: LogLevel, value: VERBOSE }
- { option: LoginGraceTime, value: "60" }
- { option: PermitRootLogin, value: "no" }
- { option: StrictModes, value: "yes" }
- { option: MaxAuthTries, value: "4" }
- { option: MaxSessions, value: "10" }
- { option: MaxStartups, value: "10:30:60" }
- { option: PubkeyAuthentication, value: "yes" }
- { option: HostbasedAuthentication, value: "no" }
- { option: IgnoreRhosts, value: "yes" }
- { option: PasswordAuthentication, value: "no" }
- { option: PermitEmptyPasswords, value: "no" }
- { option: KerberosAuthentication, value: "no" }
- { option: GSSAPIAuthentication, value: "no" }
- { option: AllowAgentForwarding, value: "no" }
- { option: AllowTcpForwarding, value: "no" }
- { option: KbdInteractiveAuthentication, value: "no" }
- { option: GatewayPorts, value: "no" }
- { option: X11Forwarding, value: "no" }
- { option: PermitUserEnvironment, value: "no" }
- { option: ClientAliveInterval, value: "300" }
- { option: ClientAliveCountMax, value: "1" }
- { option: PermitTunnel, value: "no" }
- { option: Banner, value: /etc/issue.net }
pwquality_minlen: 14
tmout: 900
umask: "077"
umask_profile: "027"
faillock_deny: 5
faillock_unlock_time: 900
password_remember: 5
# Platform-specific binary names for CIS permission targets
cis_fusermount_binary: "{{ 'fusermount3' if is_rhel | default(false) | bool else 'fusermount' }}"
cis_write_binary: "{{ 'write' if is_rhel | default(false) | bool else 'wall' }}"
cis: {}
cis_permission_targets:
- { path: "/mnt/etc/ssh/sshd_config", mode: "0600" }
- { path: "/mnt/etc/cron.hourly", mode: "0700" }
- { path: "/mnt/etc/cron.daily", mode: "0700" }
- { path: "/mnt/etc/cron.weekly", mode: "0700" }
- { path: "/mnt/etc/cron.monthly", mode: "0700" }
- { path: "/mnt/etc/cron.d", mode: "0700" }
- { path: "/mnt/etc/crontab", mode: "0600" }
- { path: "/mnt/etc/logrotate.conf", mode: "0644" }
- { path: "/mnt/usr/sbin/pppd", mode: "0754" }
- { path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755" }
- { path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755" }
- {path: "/mnt/etc/ssh/sshd_config", mode: "0600"}
- {path: "/mnt/etc/cron.hourly", mode: "0700"}
- {path: "/mnt/etc/cron.daily", mode: "0700"}
- {path: "/mnt/etc/cron.weekly", mode: "0700"}
- {path: "/mnt/etc/cron.monthly", mode: "0700"}
- {path: "/mnt/etc/cron.d", mode: "0700"}
- {path: "/mnt/etc/crontab", mode: "0600"}
- {path: "/mnt/etc/logrotate.conf", mode: "0644"}
- {path: "/mnt/usr/sbin/pppd", mode: "0754"}
- {path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755"}
- {path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755"}

View File

@@ -1,10 +1,25 @@
---
- name: Normalize CIS input
- name: Determine CIS profile
ansible.builtin.set_fact:
cis_enabled: "{{ cis is defined and (cis is mapping or cis | bool) }}"
cis_input: "{{ cis if cis is mapping else {} }}"
cis_profile: "{{ system_cfg.features.cis.profile | default('default') }}"
- name: Normalize CIS configuration
when: cis_enabled and cis_cfg is not defined
- name: Validate CIS profile selection
ansible.builtin.assert:
that: cis_profile in cis_profiles
fail_msg: >-
system.features.cis.profile '{{ cis_profile }}' is unknown
(valid: {{ cis_profiles.keys() | list | join(', ') }}).
quiet: true
- name: Resolve CIS rules and parameters
vars:
_cis: "{{ system_cfg.features.cis | default({}) }}"
ansible.builtin.set_fact:
cis_cfg: "{{ cis_defaults | combine(cis_input, recursive=True) }}"
cis_effective_rules: "{{ cis_profiles[cis_profile] | combine(_cis.rules | default({})) }}"
cis_cfg: >-
{{ cis_param_defaults
| combine(cis_profile_params[cis_profile] | default({}), recursive=True)
| combine(_cis.params | default({}), recursive=True) }}
# l1/l2 add the stricter CIS-server controls on top of the legacy `default`
# baseline; gate those tasks on this so `default` stays byte-for-byte unchanged.
cis_strict: "{{ cis_profile in ['l1', 'l2'] }}"

42
roles/cis/tasks/aide.yml Normal file
View File

@@ -0,0 +1,42 @@
---
- name: Install AIDE
when: cis_effective_rules.aide | default(false)
# Debian's aideinit lives in aide-common (only Recommended, so absent under
# the installer's --no-install-recommends); pull it explicitly.
ansible.builtin.command: "{{ cis_pkg_install }} {{ 'aide aide-common' if is_debian | bool else 'aide' }}"
register: cis_aide_install
changed_when: cis_aide_install.rc == 0
- name: Initialize the AIDE database
when: cis_effective_rules.aide | default(false)
# Absolute path: arch-chroot's PATH omits /usr/sbin, so bare aide/aideinit is rc127.
# Debian's aideinit assembles its split config; RHEL/Arch run --init on /etc/aide.conf.
ansible.builtin.command: "{{ chroot_command }} {{ '/usr/sbin/aideinit -y -f' if is_debian | bool else '/usr/sbin/aide --init' }}"
register: cis_aide_init
changed_when: cis_aide_init.rc == 0
- name: Locate the freshly built AIDE database
when: cis_effective_rules.aide | default(false)
ansible.builtin.find:
paths: /mnt/var/lib/aide
patterns: "aide.db.new*"
register: cis_aide_newdb
- name: Activate the AIDE database
when:
- cis_effective_rules.aide | default(false)
- cis_aide_newdb.files | length > 0
ansible.builtin.copy:
src: "{{ cis_aide_newdb.files[0].path }}"
dest: "{{ cis_aide_newdb.files[0].path | regex_replace('\\.new', '') }}"
remote_src: true
mode: "0600"
- name: Schedule the daily AIDE integrity check
when: cis_effective_rules.aide | default(false)
ansible.builtin.copy:
dest: /mnt/etc/cron.d/cis-aide
mode: "0644"
content: |
PATH=/usr/sbin:/usr/bin:/sbin:/bin
{{ cis_cfg.aide_cron_minute }} {{ cis_cfg.aide_cron_hour }} * * * root aide --check

View File

@@ -0,0 +1,42 @@
---
- name: Install the audit daemon
when: cis_effective_rules.auditd | default(false)
ansible.builtin.command: "{{ cis_pkg_install }} {{ 'auditd' if is_debian | bool else 'audit' }}"
register: cis_auditd_install
changed_when: cis_auditd_install.rc == 0
- name: Deploy the CIS audit rule set
when: cis_effective_rules.auditd | default(false)
ansible.builtin.copy:
dest: /mnt/etc/audit/rules.d/cis.rules
mode: "0640"
content: |
## CIS baseline audit rules
-D
-b 8192
-f 1
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
-w /etc/localtime -p wa -k time-change
-w /etc/group -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/security/opasswd -p wa -k identity
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
-w /etc/hosts -p wa -k system-locale
-w /var/log/lastlog -p wa -k logins
-w /var/run/faillock -p wa -k logins
-w /var/run/utmp -p wa -k session
-w /var/log/wtmp -p wa -k session
-w /var/log/btmp -p wa -k session
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat,chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d -p wa -k scope
-a always,exit -F arch=b64 -S init_module,delete_module -k modules
-e 2
- name: Enable the audit daemon
when: cis_effective_rules.auditd | default(false)
ansible.builtin.command: "{{ chroot_command }} systemctl enable auditd"
register: cis_auditd_enable
changed_when: "'Created symlink' in cis_auditd_enable.stderr"

View File

@@ -1,12 +1,35 @@
---
- name: Ensure the Default UMASK is Set Correctly
when: cis_effective_rules.umask_default | default(false)
ansible.builtin.lineinfile:
path: "/mnt/etc/profile"
regexp: "^(\\s*)umask\\s+\\d+"
line: "umask {{ cis_cfg.umask_profile }}"
- name: Set the login.defs UMASK (CIS L1+)
when:
- cis_effective_rules.umask_default | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/login.defs
regexp: '^\s*#?\s*UMASK\b'
line: "UMASK\t\t{{ cis_cfg.umask_profile }}"
# authselect regenerates system-auth from the profile, so a direct edit is lost
# on the next apply; without-nullok is the supported way to drop nullok there.
- name: Prevent Login to Accounts With Empty Password (authselect)
when:
- cis_effective_rules.empty_password_login | default(false)
- is_authselect | bool
ansible.builtin.command: "{{ chroot_command }} authselect enable-feature without-nullok"
register: cis_nullok_result
changed_when: cis_nullok_result.rc == 0
# Non-RHEL/non-Debian distros: loop evaluates to [] (intentional skip)
- name: Prevent Login to Accounts With Empty Password
when:
- cis_effective_rules.empty_password_login | default(false)
- not is_authselect | bool
ansible.builtin.replace:
dest: "{{ item }}"
regexp: "\\s*nullok"

View File

@@ -1,13 +1,20 @@
---
# Fedora ships its own crypto-policies preset and update-crypto-policies
# behaves differently; applying DEFAULT:NO-SHA1 can break package signing.
# EL10 dropped the NO-SHA1 subpolicy module (DEFAULT already disables SHA-1
# signatures), so the modifier is set only on EL9 and below.
- name: Configure System Cryptography Policy
when: os in (os_family_rhel | difference(['fedora']))
ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set DEFAULT:NO-SHA1"
vars:
_cis_crypto_policy: "{{ 'DEFAULT' if (os_version_major | int >= 10) else 'DEFAULT:NO-SHA1' }}"
when:
- cis_effective_rules.crypto_policy | default(false)
- os in (os_family_rhel | difference(['fedora']))
ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set {{ _cis_crypto_policy }}"
register: cis_crypto_policy_result
changed_when: "'Setting system-wide crypto-policies to' in cis_crypto_policy_result.stdout"
- name: Mask Systemd Services
when: cis_effective_rules.mask_services | default(false)
ansible.builtin.command: >
{{ chroot_command }} systemctl mask {{ 'nftables' if system_cfg.features.firewall.toolkit == 'iptables' else 'iptables' }} bluetooth rpcbind
register: cis_mask_services_result

View File

@@ -1,5 +1,6 @@
---
- name: Ensure files exist
- name: Ensure cron and at access files exist
when: cis_effective_rules.cron_at_access | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: touch
@@ -7,10 +8,19 @@
loop:
- /mnt/etc/at.allow
- /mnt/etc/cron.allow
- name: Ensure TCP wrapper files exist
when: cis_effective_rules.tcp_wrappers | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: touch
mode: "0600"
loop:
- /mnt/etc/hosts.allow
- /mnt/etc/hosts.deny
- name: Ensure files do not exist
- name: Ensure cron and at deny files do not exist
when: cis_effective_rules.cron_at_access | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: absent

View File

@@ -0,0 +1,31 @@
---
# Opt-in only: a GRUB superuser password blocks unattended menu edits; the default entry still boots.
- name: Assert a GRUB password hash is supplied
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.assert:
that: cis_cfg.grub_password_hash | length > 0
fail_msg: >-
system.features.cis.rules.grub_password is enabled but
system.features.cis.params.grub_password_hash is empty. Generate one with
grub2-mkpasswd-pbkdf2 and set it there.
quiet: true
- name: Deploy the GRUB superuser password
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.copy:
dest: /mnt/etc/grub.d/01_cis_password
mode: "0755"
content: |
#!/bin/sh
cat <<'EOF'
set superusers="root"
password_pbkdf2 root {{ cis_cfg.grub_password_hash }}
EOF
- name: Regenerate the GRUB configuration
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'grub2-mkconfig -o /boot/grub2/grub.cfg' if is_rhel | bool else 'grub-mkconfig -o /boot/grub/grub.cfg' }}
register: cis_grub_regen
changed_when: cis_grub_regen.rc == 0

View File

@@ -3,7 +3,6 @@
ansible.builtin.import_tasks: _normalize.yml
- name: Apply CIS hardening
when: cis_enabled
block:
- name: Include CIS hardening tasks
ansible.builtin.include_tasks: "{{ cis_task }}"
@@ -16,5 +15,11 @@
- security_lines.yml
- permissions.yml
- sshd.yml
- warning_banners.yml
- password_expiry.yml
- aide.yml
- auditd.yml
- packages.yml
- grub_password.yml
loop_control:
loop_var: cis_task

View File

@@ -1,5 +1,6 @@
---
- name: Disable Kernel Modules
when: cis_effective_rules.module_blacklist | default(false)
vars:
# Ubuntu uses squashfs for snap packages - blacklisting it breaks snap entirely
cis_modules_squashfs: "{{ [] if os in ['ubuntu', 'ubuntu-lts'] else ['squashfs'] }}"
@@ -14,11 +15,13 @@
{% endfor %}
- name: Remove old USB rules file
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.file:
path: /mnt/etc/udev/rules.d/10-cis_usb_devices.sh
state: absent
- name: Create USB rules
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.copy:
dest: /mnt/etc/udev/rules.d/10-cis_usb_devices.rules
mode: "0644"

View File

@@ -0,0 +1,29 @@
---
# CIS L1 names legacy cleartext clients (telnet) for removal. They are absent on
# a fresh minimal install; query first and remove only when present so the run
# stays idempotent (a chroot package-manager remove cannot use the package module).
- name: Check for insecure cleartext clients
when: cis_strict | default(false)
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'dpkg -s' if is_debian | bool else 'pacman -Q' if os == 'archlinux' else 'rpm -q' }}
{{ item }}
loop: "{{ cis_cfg.insecure_packages }}"
register: cis_insecure_present
changed_when: false
failed_when: false
loop_control:
label: "{{ item }}"
- name: Remove insecure cleartext clients (CIS L1+)
when:
- cis_strict | default(false)
- item.rc == 0
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'apt-get remove -y' if is_debian | bool else 'pacman -R --noconfirm' if os == 'archlinux' else 'dnf remove -y' }}
{{ item.item }}
loop: "{{ cis_insecure_present.results | default([]) }}"
changed_when: true
loop_control:
label: "{{ item.item }}"

View File

@@ -0,0 +1,22 @@
---
# login.defs sets policy for future accounts; existing service accounts are intentionally not chage-aged.
- name: Configure password aging defaults
when: cis_effective_rules.password_expiry | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/login.defs
regexp: '^#?\s*{{ item.key }}\b'
line: "{{ item.key }}\t{{ item.value }}"
loop:
- {key: PASS_MAX_DAYS, value: "{{ cis_cfg.pass_max_days }}"}
- {key: PASS_MIN_DAYS, value: "{{ cis_cfg.pass_min_days }}"}
- {key: PASS_WARN_AGE, value: "{{ cis_cfg.pass_warn_age }}"}
loop_control:
label: "{{ item.key }}"
# account_disable_post_pw_expiration: lock accounts INACTIVE days after expiry.
- name: Set the default account inactivity lock period
when: cis_effective_rules.password_expiry | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/default/useradd
regexp: '^\s*#?\s*INACTIVE\s*='
line: "INACTIVE={{ cis_cfg.pass_inactive }}"

View File

@@ -1,5 +1,6 @@
---
- name: Check CIS permission targets
when: cis_effective_rules.file_permissions | default(false)
ansible.builtin.stat:
path: "{{ item.path }}"
loop: "{{ cis_permission_targets }}"
@@ -9,12 +10,14 @@
changed_when: false
- name: Set permissions for existing targets
when:
- cis_effective_rules.file_permissions | default(false)
- item.stat.exists
ansible.builtin.file:
path: "{{ item.item.path }}"
owner: "{{ item.item.owner | default(omit) }}"
group: "{{ item.item.group | default(omit) }}"
mode: "{{ item.item.mode }}"
loop: "{{ cis_permission_stats.results }}"
loop: "{{ cis_permission_stats.results | default([]) }}"
loop_control:
label: "{{ item.item.path }}"
when: item.stat.exists

View File

@@ -1,62 +1,218 @@
---
- name: Add Security related lines into config files
- name: Restrict core dumps
when: cis_effective_rules.core_dumps | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/limits.conf
regexp: '^\*\s+hard\s+core\s+'
line: "* hard core 0"
- name: Ensure the systemd coredump drop-in directory exists (CIS L1+)
when:
- cis_effective_rules.core_dumps | default(false)
- cis_strict | default(false)
ansible.builtin.file:
path: /mnt/etc/systemd/coredump.conf.d
state: directory
mode: "0755"
- name: Disable systemd core dump storage and backtraces (CIS L1+)
when:
- cis_effective_rules.core_dumps | default(false)
- cis_strict | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/coredump.conf.d/10-cis.conf
mode: "0644"
content: |
[Coredump]
Storage=none
ProcessSizeMax=0
- name: Set password quality requirements
when: cis_effective_rules.pwquality | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {regexp: '^\s*#?\s*minlen\s*=', line: "minlen = {{ cis_cfg.pwquality_minlen }}"}
- {regexp: '^\s*#?\s*dcredit\s*=', line: "dcredit = -1"}
- {regexp: '^\s*#?\s*ucredit\s*=', line: "ucredit = -1"}
- {regexp: '^\s*#?\s*ocredit\s*=', line: "ocredit = -1"}
- {regexp: '^\s*#?\s*lcredit\s*=', line: "lcredit = -1"}
loop_control:
label: "{{ item.line }}"
# Stricter complexity SSG cis_server_l1 checks; affects only new-password changes.
- name: Set strict password quality requirements (CIS L1+)
when:
- cis_effective_rules.pwquality | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {regexp: '^\s*#?\s*difok\s*=', line: "difok = {{ cis_cfg.pwquality_difok }}"}
- {regexp: '^\s*#?\s*maxrepeat\s*=', line: "maxrepeat = {{ cis_cfg.pwquality_maxrepeat }}"}
- {regexp: '^\s*#?\s*maxsequence\s*=', line: "maxsequence = {{ cis_cfg.pwquality_maxsequence }}"}
- {regexp: '^\s*#?\s*minclass\s*=', line: "minclass = {{ cis_cfg.pwquality_minclass }}"}
- {regexp: '^\s*#?\s*dictcheck\s*=', line: "dictcheck = {{ cis_cfg.pwquality_dictcheck }}"}
- {regexp: '^\s*#?\s*enforce_for_root\b', line: "enforce_for_root"}
loop_control:
label: "{{ item.line }}"
- name: Set the default shell umask
when: cis_effective_rules.umask_default | default(false)
ansible.builtin.lineinfile:
path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*umask\s+\d+'
line: "umask {{ cis_cfg.umask }}"
- name: Set the shell idle timeout
when: cis_effective_rules.shell_timeout | default(false)
ansible.builtin.lineinfile:
path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*(export\s+)?TMOUT='
line: "export TMOUT={{ cis_cfg.tmout }}"
# A drop-in survives systemd upgrades; the RHEL vendor journald.conf does not.
- name: Ensure the journald drop-in directory exists
when: cis_effective_rules.journald_persistent | default(false)
ansible.builtin.file:
path: /mnt/etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Enable persistent journald storage
when: cis_effective_rules.journald_persistent | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/journald.conf.d/10-cis.conf
mode: "0644"
content: |
[Journal]
Storage=persistent
- name: Compress large journald log files (CIS L1+)
when:
- cis_effective_rules.journald_persistent | default(false)
- cis_strict | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/journald.conf.d/20-cis-compress.conf
mode: "0644"
content: |
[Journal]
Compress=yes
- name: Log sudo commands
when: cis_effective_rules.sudo_logfile | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+logfile='
line: 'Defaults logfile="/var/log/sudo.log"'
- name: Require a pty for sudo (CIS L1+)
when:
- cis_effective_rules.sudo_logfile | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+use_pty\b'
line: "Defaults use_pty"
- name: Restrict su to the wheel group
when: cis_effective_rules.su_restriction | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/pam.d/su
regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so'
line: auth required pam_wheel.so
# authselect wires the pam_faillock stack via the feature; deny/unlock_time live
# in faillock.conf, the supported place (pam_faillock(8) deprecates module args).
- name: Configure account lockout (authselect)
when:
- cis_effective_rules.faillock | default(false)
- is_authselect | bool
block:
- name: Enable the authselect faillock feature
ansible.builtin.command: "{{ chroot_command }} authselect enable-feature with-faillock"
register: cis_faillock_result
changed_when: cis_faillock_result.rc == 0
- name: Set faillock thresholds
ansible.builtin.lineinfile:
path: /mnt/etc/security/faillock.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
create: true
mode: "0644"
loop:
- {regexp: '^\s*#?\s*deny\s*=', line: "deny = {{ cis_cfg.faillock_deny }}"}
- {regexp: '^\s*#?\s*unlock_time\s*=', line: "unlock_time = {{ cis_cfg.faillock_unlock_time }}"}
loop_control:
label: "{{ item.line }}"
- name: Configure account lockout
when:
- cis_effective_rules.faillock | default(false)
- not is_authselect | bool
ansible.builtin.lineinfile:
path: "{{ item.path }}"
regexp: "{{ item.regexp }}"
line: "{{ item.content }}"
line: "{{ item.line }}"
loop:
- { path: /mnt/etc/security/limits.conf, regexp: '^\*\s+hard\s+core\s+', content: "* hard core 0" }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*minlen\s*=', content: "minlen = {{ cis_cfg.pwquality_minlen }}" }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*dcredit\s*=', content: dcredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ucredit\s*=', content: ucredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ocredit\s*=', content: ocredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*lcredit\s*=', content: lcredit = -1 }
- path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*umask\s+\d+'
content: "umask {{ cis_cfg.umask }}"
- path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*(export\s+)?TMOUT='
content: "export TMOUT={{ cis_cfg.tmout }}"
- path: '/mnt/{{ "usr/lib/systemd/journald.conf" if is_rhel | bool else "etc/systemd/journald.conf" }}'
regexp: '^\s*#?\s*Storage='
content: Storage=persistent
- path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+logfile='
content: Defaults logfile="/var/log/sudo.log"
- path: /mnt/etc/pam.d/su
regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so'
content: auth required pam_wheel.so
- path: >-
/mnt/etc/{{
"pam.d/common-auth"
if is_debian | bool
else "authselect/system-auth"
if os == "fedora"
else "pam.d/system-auth"
}}
- path: '/mnt/etc/{{ "pam.d/common-auth" if is_debian | bool else "pam.d/system-auth" }}'
regexp: '^\s*auth\s+required\s+pam_faillock\.so'
content: >-
line: >-
auth required pam_faillock.so onerr=fail audit silent deny={{ cis_cfg.faillock_deny }} unlock_time={{ cis_cfg.faillock_unlock_time }}
- path: >-
/mnt/etc/{{
"pam.d/common-account"
if is_debian | bool
else "authselect/system-auth"
if os == "fedora"
else "pam.d/system-auth"
}}
- path: '/mnt/etc/{{ "pam.d/common-account" if is_debian | bool else "pam.d/system-auth" }}'
regexp: '^\s*account\s+required\s+pam_faillock\.so'
content: account required pam_faillock.so
- path: >-
/mnt/etc/pam.d/{{
"common-password"
if is_debian | bool
else "passwd"
}}
regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so'
content: >-
password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }}
- { path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', content: "ALL: ALL" }
- { path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', content: "sshd: ALL" }
line: account required pam_faillock.so
loop_control:
label: "{{ item.content }}"
label: "{{ item.regexp }}"
- name: Enforce password history
when: cis_effective_rules.password_history | default(false)
ansible.builtin.lineinfile:
path: >-
/mnt/etc/pam.d/{{
"common-password"
if is_debian | bool
else "passwd"
}}
regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so'
line: >-
password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }}
# SSG cis_server_l1 checks pam_pwhistory (not pam_unix remember) in the auth-stack
# files; affects only password changes, so no login-lockout risk. EL9 has no
# authselect path here (same direct-edit the faillock rule above uses).
- name: Enforce password reuse limit via pam_pwhistory (CIS L1+)
when:
- cis_effective_rules.password_history | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: "{{ item }}"
regexp: '^\s*password\s+(requisite|required)\s+pam_pwhistory\.so'
line: "password requisite pam_pwhistory.so use_authtok remember={{ cis_cfg.pwhistory_remember }} enforce_for_root"
insertbefore: '^\s*password\s+.*pam_unix\.so'
loop: >-
{{
['/mnt/etc/pam.d/system-auth', '/mnt/etc/pam.d/password-auth']
if is_rhel | bool
else (['/mnt/etc/pam.d/common-password'] if is_debian | bool else [])
}}
loop_control:
label: "{{ item }}"
- name: Configure TCP wrappers
when: cis_effective_rules.tcp_wrappers | default(false)
ansible.builtin.lineinfile:
path: "{{ item.path }}"
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', line: "ALL: ALL"}
- {path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', line: "sshd: ALL"}
loop_control:
label: "{{ item.path }}"

View File

@@ -1,5 +1,6 @@
---
- name: Adjust SSHD config
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/ssh/sshd_config
regexp: ^\s*#?{{ item.option }}\s+.*$
@@ -9,6 +10,7 @@
label: "{{ item.option }}"
- name: Detect target OpenSSH version
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.shell: >-
set -o pipefail && {{ chroot_command }} ssh -V 2>&1 | grep -oP 'OpenSSH_\K[0-9]+\.[0-9]+'
args:
@@ -18,6 +20,7 @@
failed_when: false
- name: Append CIS specific configurations to sshd_config
when: cis_effective_rules.sshd_hardening | default(false)
vars:
cis_sshd_has_mlkem: "{{ (cis_sshd_openssh_version.stdout | default('0.0') is version('9.9', '>=')) }}"
cis_sshd_kex: >-

View File

@@ -1,10 +1,19 @@
---
- name: Create a consolidated sysctl configuration file
when: cis_effective_rules.sysctl_hardening | default(false)
vars:
# ipv6_disable is a separate rule: when off, drop the disable_ipv6 keys but keep the rest.
_cis_sysctl: >-
{{ cis_cfg.sysctl
if (cis_effective_rules.ipv6_disable | default(false))
else (cis_cfg.sysctl | dict2items | rejectattr('key', 'search', 'disable_ipv6') | items2dict) }}
ansible.builtin.copy:
dest: /mnt/etc/sysctl.d/10-cis.conf
# 99- so CIS wins: a 10- name loses to vendor /usr/lib/sysctl.d/10-default-yama-scope.conf
# (later basename applies last), which reset kernel.yama.ptrace_scope back to 0.
dest: /mnt/etc/sysctl.d/99-cis.conf
mode: "0644"
content: |
## CIS Sysctl configurations
{% for key, value in cis_cfg.sysctl | dictsort %}
{% for key, value in _cis_sysctl | dictsort %}
{{ key }}={{ value }}
{% endfor %}

View File

@@ -0,0 +1,11 @@
---
- name: Set login warning banners
when: cis_effective_rules.warning_banners | default(false)
ansible.builtin.copy:
dest: "/mnt/etc/{{ item }}"
content: "{{ cis_cfg.banner_text }}\n"
mode: "0644"
loop:
- issue
- issue.net
- motd

View File

@@ -1,6 +1,5 @@
---
# OS-specific binary names for CIS permission targets.
# fusermount3 is the modern name; older distros still use fusermount.
# fusermount3 is the modern name; older distros still ship fusermount.
cis_fusermount_binary: >-
{{
'fusermount3'
@@ -19,3 +18,235 @@ cis_write_binary: >-
if (os == 'debian' and (os_version | string) == '11')
else 'write'
}}
cis_pkg_install: >-
{{ chroot_command }} {{
'apt-get install -y'
if is_debian | bool
else 'pacman -S --noconfirm'
if os == 'archlinux'
else 'dnf install -y'
}}
# Rule catalog: control -> CIS level + whether a task implements it.
# `default` enables only implemented rules; `l1`/`l2` add the level-tagged ones.
cis_rule_catalog:
module_blacklist: {level: l1, implemented: true} # fs/net modprobe blacklist (list per profile)
usb_lockdown: {level: l2, implemented: true} # udev authorized_default=0 (aggressive)
sysctl_hardening: {level: l1, implemented: true}
ipv6_disable: {level: l2, implemented: true} # disable_ipv6 subset of the sysctl set
umask_default: {level: l1, implemented: true}
empty_password_login: {level: l1, implemented: true}
pwquality: {level: l1, implemented: true}
core_dumps: {level: l1, implemented: true}
shell_timeout: {level: l1, implemented: true}
journald_persistent: {level: l1, implemented: true}
sudo_logfile: {level: l1, implemented: true}
su_restriction: {level: l1, implemented: true}
faillock: {level: l1, implemented: true}
password_history: {level: l1, implemented: true}
tcp_wrappers: {level: l1, implemented: true}
crypto_policy: {level: l1, implemented: true} # RedHat non-Fedora only
mask_services: {level: l1, implemented: true}
cron_at_access: {level: l1, implemented: true}
file_permissions: {level: l1, implemented: true}
sshd_hardening: {level: l1, implemented: true}
password_expiry: {level: l1, implemented: true} # login.defs aging policy
aide: {level: l1, implemented: true} # file-integrity db + daily check
warning_banners: {level: l1, implemented: true} # /etc/issue, issue.net, motd
auditd: {level: l2, implemented: true} # audit daemon + CIS rule set
grub_password: {level: l1, implemented: true} # opt-in only; needs params.grub_password_hash
# Rules not listed are off. A per-host system.features.cis.rules map overlays this.
cis_profiles:
# default = established house behaviour, kept byte-for-byte unchanged.
default:
module_blacklist: true
usb_lockdown: true
sysctl_hardening: true
ipv6_disable: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
# l1 = clean CIS Level 1: drops the L2 extras (usb_lockdown, ipv6_disable).
l1:
module_blacklist: true
sysctl_hardening: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
password_expiry: true
aide: true
warning_banners: true
# l2 = l1 plus the defence-in-depth Level 2 controls.
l2:
module_blacklist: true
usb_lockdown: true
sysctl_hardening: true
ipv6_disable: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
password_expiry: true
aide: true
warning_banners: true
auditd: true
# Override per host via system.features.cis.params: dicts deep-merge,
# list-valued keys (e.g. sshd_options) replace wholesale.
cis_param_defaults:
modules_blacklist:
- freevxfs
- jffs2
- hfs
- hfsplus
- cramfs
- udf
- usb-storage
- dccp
- sctp
- rds
- tipc
- firewire-core
- firewire-sbp2
- thunderbolt
sysctl:
fs.suid_dumpable: 0
kernel.dmesg_restrict: 1
kernel.kptr_restrict: 2
kernel.perf_event_paranoid: 3
kernel.unprivileged_bpf_disabled: 1
kernel.yama.ptrace_scope: 2
kernel.randomize_va_space: 2
net.ipv4.ip_forward: 0
net.ipv4.tcp_syncookies: 1
net.ipv4.icmp_echo_ignore_broadcasts: 1
net.ipv4.icmp_ignore_bogus_error_responses: 1
net.ipv4.conf.all.log_martians: 1
net.ipv4.conf.all.rp_filter: 1
net.ipv4.conf.all.secure_redirects: 0
net.ipv4.conf.all.send_redirects: 0
net.ipv4.conf.all.accept_redirects: 0
net.ipv4.conf.all.accept_source_route: 0
net.ipv4.conf.all.arp_ignore: 1
net.ipv4.conf.all.arp_announce: 2
net.ipv4.conf.default.log_martians: 1
net.ipv4.conf.default.rp_filter: 1
net.ipv4.conf.default.secure_redirects: 0
net.ipv4.conf.default.send_redirects: 0
net.ipv4.conf.default.accept_redirects: 0
net.ipv6.conf.all.accept_redirects: 0
net.ipv6.conf.all.disable_ipv6: 1
net.ipv6.conf.default.accept_redirects: 0
net.ipv6.conf.default.disable_ipv6: 1
net.ipv6.conf.lo.disable_ipv6: 1
sshd_options:
- {option: LogLevel, value: VERBOSE}
- {option: LoginGraceTime, value: "60"}
- {option: PermitRootLogin, value: "no"}
- {option: StrictModes, value: "yes"}
- {option: MaxAuthTries, value: "4"}
- {option: MaxSessions, value: "10"}
- {option: MaxStartups, value: "10:30:60"}
- {option: PubkeyAuthentication, value: "yes"}
- {option: HostbasedAuthentication, value: "no"}
- {option: IgnoreRhosts, value: "yes"}
- {option: PasswordAuthentication, value: "no"}
- {option: PermitEmptyPasswords, value: "no"}
- {option: KerberosAuthentication, value: "no"}
- {option: GSSAPIAuthentication, value: "no"}
- {option: AllowAgentForwarding, value: "no"}
- {option: AllowTcpForwarding, value: "no"}
- {option: KbdInteractiveAuthentication, value: "no"}
- {option: GatewayPorts, value: "no"}
- {option: X11Forwarding, value: "no"}
- {option: PermitUserEnvironment, value: "no"}
- {option: ClientAliveInterval, value: "300"}
- {option: ClientAliveCountMax, value: "1"}
- {option: PermitTunnel, value: "no"}
- {option: Banner, value: /etc/issue.net}
pwquality_minlen: 14
# pwquality strict set (l1/l2 only, cis_strict): SSG cis_server_l1 values.
pwquality_difok: 2
pwquality_maxrepeat: 3
pwquality_maxsequence: 3
pwquality_minclass: 4
pwquality_dictcheck: 1
tmout: 900
umask: "077"
umask_profile: "027"
faillock_deny: 5
faillock_unlock_time: 900
password_remember: 5
# pwhistory remember (l1/l2 only, cis_strict): SSG wants 24 via pam_pwhistory.
pwhistory_remember: 24
# password_expiry (l1/l2): /etc/login.defs aging.
pass_max_days: 365
pass_min_days: 1
pass_warn_age: 7
# account_disable_post_pw_expiration (l1/l2): days after expiry to lock (SSG=45).
pass_inactive: 45
# aide (l1/l2): daily integrity-check schedule.
aide_cron_hour: "5"
aide_cron_minute: "0"
# warning_banners (l1/l2): login/MOTD text.
banner_text: "Authorized access only. All activity may be monitored and reported."
# grub_password (opt-in only): a grub2 pbkdf2 hash; empty unless opted in.
grub_password_hash: ""
# insecure_packages (l1/l2 only, cis_strict): legacy cleartext clients to remove.
insecure_packages:
- telnet
# Only the module blacklist differs by profile: l1 trims to the L1 filesystem
# modules; default/l2 keep the full list.
cis_profile_params:
default: {}
l1:
modules_blacklist:
- cramfs
- freevxfs
- jffs2
- hfs
- hfsplus
- udf
- usb-storage
l2: {}

View File

@@ -16,7 +16,7 @@
loop: >-
{{
['ide0', 'ide2']
+ (['ide1'] if not (os == 'rhel' and system_cfg.features.rhel_repo.source == 'iso') else [])
+ (['ide1'] if not (os == 'rhel' and system_cfg.content.source == 'dvd') else [])
}}
failed_when: false
no_log: true

View File

@@ -2,6 +2,16 @@
- name: Unmount Disks
become: true
block:
- name: Unmount the bootstrap package cache
ansible.posix.mount:
path: /mnt/var/cache
state: unmounted
- name: Remove the bootstrap package cache so it is not sealed into the image
ansible.builtin.file:
path: /mnt/.bootstrap-cache
state: absent
- name: Disable Swap
ansible.builtin.command: swapoff -a
register: cleanup_swapoff_result

View File

@@ -35,7 +35,7 @@
}
]
if (rhel_iso is defined and rhel_iso | length > 0
and not (os == 'rhel' and system_cfg.features.rhel_repo.source == 'iso'))
and not (os == 'rhel' and system_cfg.content.source == 'dvd'))
else []
)
}}

View File

@@ -1,5 +1,3 @@
---
# Network configuration dispatch - maps OS name to the task file
# that writes network config. Default (NetworkManager) applies to
# all OSes not explicitly listed.
configuration_network_task_map: {}
# Network backend is detected per host from the target rootfs in network.yml;
# no static map needed.

View File

@@ -42,25 +42,37 @@
- name: Configure sudo banner
when: system_cfg.features.banner.sudo | bool
block:
- name: Create sudo lecture file
ansible.builtin.copy:
content: |
I am Groot, and I know what I'm doing.
dest: /mnt/etc/sudo_lecture
mode: "0644"
owner: root
group: root
- name: Detect the target sudo implementation
ansible.builtin.command: "{{ chroot_command }} /usr/bin/sudo --version"
register: configuration_sudo_version
changed_when: false
failed_when: false
- name: Enable sudo lecture in sudoers
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
line: "{{ item }}"
state: present
create: true
mode: "0440"
owner: root
group: root
validate: "/usr/sbin/visudo --check --file=%s"
loop:
- "Defaults lecture=always"
- "Defaults lecture_file=/etc/sudo_lecture"
# sudo-rs (Ubuntu 25.10+) implements neither `lecture` nor `lecture_file`
# and warns on every sudo call when they are set. It prints its version banner
# to stderr, not stdout, so match against both streams.
- name: Configure the sudo lecture
when: "'sudo-rs' not in (configuration_sudo_version.stdout ~ configuration_sudo_version.stderr)"
block:
- name: Create sudo lecture file
ansible.builtin.copy:
content: |
I am Groot, and I know what I'm doing.
dest: /mnt/etc/sudo_lecture
mode: "0644"
owner: root
group: root
- name: Enable sudo lecture in sudoers
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
line: "{{ item }}"
state: present
create: true
mode: "0440"
owner: root
group: root
validate: "/usr/sbin/visudo --check --file=%s"
loop:
- "Defaults lecture=always"
- "Defaults lecture_file=/etc/sudo_lecture"

View File

@@ -14,11 +14,11 @@
install_items+=" {{ configuration_luks_keyfile_path }} "
{% endif %}
{% if configuration_luks_auto_method == 'tpm2' %}
add_dracutmodules+=" tpm2-tss "
install_items+=" {{ configuration_luks_tpm2_token_lib | default('') }} "
{% endif %}
mode: "0644"
# --- Kernel cmdline: write rd.luks.* args for dracut ---
- name: Ensure kernel cmdline directory exists
ansible.builtin.file:
path: /mnt/etc/kernel
@@ -58,7 +58,6 @@
mode: "0644"
content: "{{ _dracut_kernel_cmdline }}\n"
# --- BLS entries: RedHat-specific ---
- name: Update BLS entries with LUKS kernel cmdline
when: os_family == 'RedHat'
vars:

View File

@@ -0,0 +1,34 @@
---
- name: Enable the firewall daemon in the install chroot
when:
- firewall_phase == 'install'
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ system_cfg.features.firewall.backend }}"
register: _firewall_enable
changed_when: _firewall_enable.rc == 0
failed_when: >-
_firewall_enable.rc != 0
and 'No such file or directory' not in (_firewall_enable.stderr | default(''))
and 'does not exist' not in (_firewall_enable.stderr | default(''))
# ufw's CLI needs a running kernel and is a no-op in the chroot (leaves ENABLED=no),
# so its activation and SSH rule are applied here, after reboot.
- name: Allow SSH through ufw before enabling
when:
- firewall_phase == 'postreboot'
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
- system_cfg.features.ssh.enabled | bool
ansible.builtin.command: ufw allow 22/tcp
register: _ufw_allow
changed_when: "'added' in _ufw_allow.stdout or 'updated' in _ufw_allow.stdout"
- name: Activate ufw on the booted target
when:
- firewall_phase == 'postreboot'
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: ufw --force enable
register: _ufw_enable
changed_when: "'active' in _ufw_enable.stdout"

View File

@@ -26,7 +26,7 @@
- name: Remove RHEL ISO fstab entry when not using local repo
when:
- os == "rhel"
- system_cfg.features.rhel_repo.source != "iso"
- system_cfg.content.source != "dvd"
ansible.builtin.lineinfile:
path: /mnt/etc/fstab
regexp: "^.*\\/dvd.*$"
@@ -35,7 +35,7 @@
- name: Replace ISO UUID entry with /dev/sr0 in fstab
when:
- os == "rhel"
- system_cfg.features.rhel_repo.source == "iso"
- system_cfg.content.source == "dvd"
vars:
configuration_fstab_dvd_line: >-
{{
@@ -53,7 +53,7 @@
when:
- os == "rhel"
- hypervisor_type == "vmware"
- system_cfg.features.rhel_repo.source == "iso"
- system_cfg.content.source == "dvd"
ansible.builtin.command:
argv:
- dd

View File

@@ -7,7 +7,7 @@
line: "{{ item.line }}"
loop:
- regexp: ^GRUB_CMDLINE_LINUX_DEFAULT=
line: GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3"
line: 'GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3{{ (" " ~ (_hardware_profile_kernel_params | join(" "))) if (_hardware_profile_kernel_params | default([]) | length > 0) else "" }}"'
- regexp: ^GRUB_TIMEOUT=
line: GRUB_TIMEOUT=1
loop_control:
@@ -43,19 +43,21 @@
}}
grub_root_flags: >-
{{ ['rootflags=subvol=@'] if system_cfg.filesystem == 'btrfs' else [] }}
# String-concat (not list-concat like grub_kernel_cmdline_base below): ansible-lint's
# jinja render trips on list+list when grub_lvm_args leads the expression here.
grub_cmdline_linux_base: >-
{{
(['crashkernel=auto'] + grub_lvm_args)
| join(' ')
((grub_lvm_args | join(' ')) ~ ' ' ~ (_hardware_profile_kernel_params | default([]) | join(' '))) | trim
}}
grub_kernel_cmdline_base: >-
{{
(
(['root=UUID=' + grub_root_uuid]
if grub_root_uuid | length > 0 else [])
+ ['ro', 'crashkernel=auto']
+ ['ro']
+ grub_lvm_args
+ grub_root_flags
+ (_hardware_profile_kernel_params | default([]))
)
| join(' ')
}}

View File

@@ -5,14 +5,16 @@
- name: Include configuration tasks
when: configuration_task.when | default(true)
ansible.builtin.include_tasks: "{{ configuration_task.file }}"
vars:
firewall_phase: install
loop:
- file: repositories.yml
when: "{{ os_family == 'Debian' }}"
- file: banner.yml
- file: fstab.yml
- file: locales.yml
- file: ssh.yml
- file: services.yml
- file: firewall.yml
- file: grub.yml
- file: encryption.yml
when: "{{ system_cfg.luks.enabled | bool }}"

View File

@@ -1,38 +1,51 @@
---
- name: Read network interfaces
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_ip_link
changed_when: false
failed_when: false
- name: Detect available network interface names
vars:
configuration_detected_interfaces: >-
{{
configuration_ip_link.stdout
| default('')
| regex_findall('^[0-9]+: ([^:]+):', multiline=True)
| reject('equalto', 'lo')
| list
}}
ansible.builtin.set_fact:
configuration_detected_interfaces: "{{ configuration_detected_interfaces }}"
- name: Validate at least one network interface detected
ansible.builtin.assert:
that:
- configuration_detected_interfaces | length > 0
fail_msg: Failed to detect any network interfaces.
- name: Set DNS configuration facts
ansible.builtin.set_fact:
configuration_dns_list: "{{ system_cfg.network.dns.servers }}"
configuration_dns_search: "{{ system_cfg.network.dns.search }}"
- name: Configure networking
ansible.builtin.include_tasks: "{{ configuration_network_task_map[os] | default('network_nm.yml') }}"
# 2+ unnamed interfaces would all match the first-ethernet glob, leaving the rest unconfigured.
- name: Require an explicit name on every interface for multi-NIC
vars:
_unnamed: "{{ system_cfg.network.interfaces | map(attribute='name', default='') | map('string') | select('equalto', '') | list | length }}"
ansible.builtin.assert:
that:
- system_cfg.network.interfaces | length <= 1 or _unnamed == 0
fail_msg: >-
Multi-NIC (system.network.interfaces with 2+ entries) requires a name on
every interface; the first-adapter glob only binds a single NIC.
# Probe /mnt to detect the stack the installed rootfs will run (nothing runs in
# the chroot). NM is checked first and wins, since bootstrap installs it on every
# family; the rest are the fallback for a non-NM base image.
- name: Probe the installed network stack on the target rootfs
ansible.builtin.stat:
path: "{{ item }}"
register: configuration_net_probe
loop:
- /mnt/usr/bin/nmcli
- /mnt/usr/lib/systemd/system/NetworkManager.service
- /mnt/usr/sbin/netplan
- /mnt/etc/netplan
- /mnt/sbin/ifup
- /mnt/usr/sbin/ifup
- /mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service
- /mnt/etc/systemd/system/dbus-org.freedesktop.network1.service
loop_control:
label: "{{ item }}"
- name: Resolve the network backend from the probe
vars:
_found: "{{ configuration_net_probe.results | selectattr('stat.exists') | map(attribute='item') | list }}"
ansible.builtin.set_fact:
configuration_network_backend: >-
{{
'nm' if (['/mnt/usr/bin/nmcli', '/mnt/usr/lib/systemd/system/NetworkManager.service'] | intersect(_found))
else 'netplan' if (['/mnt/usr/sbin/netplan', '/mnt/etc/netplan'] | intersect(_found))
else 'eni' if (['/mnt/sbin/ifup', '/mnt/usr/sbin/ifup'] | intersect(_found))
else 'networkd' if (['/mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service', '/mnt/etc/systemd/system/dbus-org.freedesktop.network1.service'] | intersect(_found))
else 'nm'
}}
- name: Configure networking for the detected backend {{ configuration_network_backend }}
ansible.builtin.include_tasks: "network_{{ configuration_network_backend }}.yml"

View File

@@ -0,0 +1,35 @@
---
# ifupdown can't glob iface stanzas (no mapping on ifupdown2/Proxmox), so ENI binds
# a literal name detected here. The chroot only sees live-ISO names: on a real
# ifupdown base, set system.network.interfaces[].name to the installed name. Bootstrap
# installs NetworkManager, so this fires only on a non-NM base image.
- name: Detect ethernet interface names
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_eni_link
changed_when: false
failed_when: false
- name: Resolve detected ethernet interface names
ansible.builtin.set_fact:
configuration_eni_detected: >-
{{
configuration_eni_link.stdout | default('')
| regex_findall('^[0-9]+: ([^:@]+)[@:].*?link/ether', multiline=True)
}}
- name: Ensure the network configuration directory exists
ansible.builtin.file:
path: /mnt/etc/network
state: directory
mode: "0755"
- name: Write the ifupdown interfaces file
ansible.builtin.template:
src: network_eni.j2
dest: /mnt/etc/network/interfaces
mode: "0644"

View File

@@ -0,0 +1,12 @@
---
- name: Ensure the netplan directory exists
ansible.builtin.file:
path: /mnt/etc/netplan
state: directory
mode: "0755"
- name: Write the netplan configuration
ansible.builtin.template:
src: network_netplan.j2
dest: /mnt/etc/netplan/10-sg.yaml
mode: "0600"

View File

@@ -0,0 +1,18 @@
---
- name: Ensure the systemd-networkd directory exists
ansible.builtin.file:
path: /mnt/etc/systemd/network
state: directory
mode: "0755"
- name: Write systemd-networkd configuration per interface
vars:
configuration_iface: "{{ item }}"
ansible.builtin.template:
src: network_networkd.j2
dest: "/mnt/etc/systemd/network/10-static-{{ idx }}.network"
mode: "0644"
loop: "{{ system_cfg.network.interfaces }}"
loop_control:
index_var: idx
label: "10-static-{{ idx }}"

View File

@@ -2,7 +2,6 @@
- name: Copy NetworkManager keyfile per interface
vars:
configuration_iface: "{{ item }}"
configuration_iface_name: "{{ item.name | default(configuration_detected_interfaces[idx] | default('')) }}"
configuration_net_uuid: "{{ ('LAN-' ~ idx ~ '-' ~ hostname) | ansible.builtin.to_uuid }}"
ansible.builtin.template:
src: network.j2

View File

@@ -1,25 +1,84 @@
---
- name: Write final sources.list
# Config runs against the chroot, so these write /mnt directly via templates
# rather than apt_repository/yum_repository, which would touch the live host.
- name: Write the apt sources.list
when: os_family == 'Debian'
vars:
_debian_release_map:
"10": buster
"11": bullseye
"12": bookworm
"13": trixie
unstable: sid
_ubuntu_release_map:
ubuntu: questing
ubuntu-lts: noble
ubuntu-lts: resolute
ansible.builtin.template:
src: "{{ os | replace('-lts', '') }}.sources.list.j2"
dest: /mnt/etc/apt/sources.list
mode: "0644"
- name: Ensure apt performance configuration persists
- name: Ensure apt performance and content-proxy configuration
when: os_family == 'Debian'
ansible.builtin.copy:
dest: /mnt/etc/apt/apt.conf.d/99performance
content: |
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Drop the install-time DVD repo from the target on non-dvd sources
when:
- os_family == 'RedHat'
- system_cfg.content.source != 'dvd'
ansible.builtin.file:
path: /mnt/etc/yum.repos.d/redhat.repo
state: absent
- name: Write the EL mirror repo on the target
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.template:
src: el_mirror.repo.j2
dest: "/mnt/etc/yum.repos.d/{{ os }}.repo"
mode: "0644"
- name: Find the stock vendor repos shipped by the release package
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.find:
paths: /mnt/etc/yum.repos.d
patterns: "*.repo"
excludes: "{{ os }}.repo"
register: el_stock_repos
- name: Remove the stock vendor repos so only the custom mirror is reachable
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ el_stock_repos.files | default([]) }}"
loop_control:
label: "{{ item.path }}"
- name: Configure the dnf content proxy on the target
when:
- os_family == 'RedHat'
- system_cfg.content.proxy | length > 0
ansible.builtin.lineinfile:
path: /mnt/etc/dnf/dnf.conf
line: "proxy={{ system_cfg.content.proxy }}"
regexp: "^proxy="
create: true
mode: "0644"
state: present

View File

@@ -0,0 +1,46 @@
---
# Invoked post-reboot on the booted host, not in the chroot: subscription-manager
# needs a running systemd and the live network.
- name: Add the Satellite host to /etc/hosts
when: system_cfg.content.satellite.ip | length > 0
ansible.builtin.lineinfile:
path: /etc/hosts
line: "{{ system_cfg.content.satellite.ip }} {{ system_cfg.content.satellite.host }}"
regexp: "[[:space:]]{{ system_cfg.content.satellite.host | regex_escape }}([[:space:]]|$)"
state: present
- name: Fetch the Katello CA consumer RPM
ansible.builtin.get_url:
url: >-
{{ system_cfg.content.satellite.ca_url
if (system_cfg.content.satellite.ca_url | length > 0)
else 'https://' ~ system_cfg.content.satellite.host ~ '/pub/katello-ca-consumer-latest.noarch.rpm' }}
dest: /tmp/katello-ca-consumer-latest.noarch.rpm
validate_certs: false
mode: "0644"
- name: Install the Katello CA consumer RPM
ansible.builtin.dnf:
name: /tmp/katello-ca-consumer-latest.noarch.rpm
state: present
disable_gpg_check: true
- name: Clean any stale subscription identity
ansible.builtin.command: subscription-manager clean
changed_when: true
- name: Register with Satellite via activation key
no_log: true
community.general.redhat_subscription:
state: present
server_hostname: "{{ system_cfg.content.satellite.host }}"
org_id: "{{ system_cfg.content.satellite.org }}"
activationkey: "{{ system_cfg.content.satellite.activation_key }}"
environment: "{{ system_cfg.content.satellite.environment | default(omit, true) }}"
auto_attach: true
force_register: true
server_proxy_hostname: "{{ (system_cfg.content.proxy | urlsplit('hostname')) | default(omit, true) }}"
server_proxy_port: "{{ (system_cfg.content.proxy | urlsplit('port')) | default(omit, true) }}"
syspurpose:
service_level_agreement: "{{ system_cfg.content.satellite.service_level | default(omit, true) }}"
sync: true

View File

@@ -11,6 +11,16 @@
register: configuration_setfiles_result
changed_when: configuration_setfiles_result.rc == 0
# setfiles in the chroot misses paths created at first boot (e.g. /var/lib/sss),
# leaving unlabeled_t files that block services under enforcing SELinux. Force a
# complete relabel on first boot; fixfiles consumes and removes the flag.
- name: Force a complete SELinux relabel on first boot
when: os in ['almalinux', 'rocky', 'rhel'] and system_cfg.features.selinux.enabled | bool
ansible.builtin.file:
path: /mnt/.autorelabel
state: touch
mode: "0644"
# Fedora: setfiles segfaults during bootstrap chroot relabeling, so SELinux
# is left permissive and expected to relabel on first boot.
- name: Disable SELinux

View File

@@ -4,11 +4,20 @@
vars:
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.set_fact:
# KDE resolves to the plasmalogin unit on Arch/Fedora44+ (Plasma 6.6), else sddm.
_desktop_dm: >-
{{
system_cfg.features.desktop.display_manager
('plasmalogin'
if system_cfg.features.desktop.display_manager == 'plasma-login-manager'
else system_cfg.features.desktop.display_manager)
if (system_cfg.features.desktop.display_manager | length > 0)
else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default(''))
else (
('plasmalogin'
if (os == 'archlinux' or (os == 'fedora' and (os_version | int) >= 44))
else 'sddm')
if system_cfg.features.desktop.environment == 'kde'
else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default(''))
)
}}
_desktop_session: "{{ system_cfg.features.desktop.session | default('') }}"
# Explicit session wins, else the per-environment command. Single source of
@@ -31,11 +40,9 @@
vars:
configuration_systemd_services: >-
{{
['NetworkManager']
+ (['firewalld'] if system_cfg.features.firewall.backend == 'firewalld' and system_cfg.features.firewall.enabled | bool else [])
+ (['ufw'] if system_cfg.features.firewall.backend == 'ufw' and system_cfg.features.firewall.enabled | bool else [])
['NetworkManager', _configuration_platform.time_sync_service]
+ ([_configuration_platform.ssh_service] if system_cfg.features.ssh.enabled | bool else [])
+ (['logrotate', 'systemd-timesyncd'] if os == 'archlinux' else [])
+ (['logrotate'] if os == 'archlinux' else [])
+ (['bluetooth'] if system_cfg.features.desktop.enabled | bool else [])
}}
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ item }}"
@@ -52,6 +59,7 @@
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm | length > 0
- _desktop_dm != 'ly'
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ _desktop_dm }}"
register: configuration_enable_dm_result
changed_when: configuration_enable_dm_result.rc == 0
@@ -62,22 +70,40 @@
or 'No such file or directory' in (configuration_enable_dm_result.stderr | default(''))
or 'does not exist' in (configuration_enable_dm_result.stderr | default(''))
- name: Activate UFW firewall
when:
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: "{{ chroot_command }} ufw --force enable"
register: _ufw_enable_result
changed_when: _ufw_enable_result.rc == 0
failed_when: false
- name: Set default systemd target to graphical
- name: Enable ly on its tty
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl set-default graphical.target"
register: _desktop_target_result
changed_when: _desktop_target_result.rc == 0
- _desktop_dm == 'ly'
vars:
_ly_tty: tty2
block:
- name: Enable ly display manager
ansible.builtin.command: "{{ chroot_command }} systemctl enable ly@{{ _ly_tty }}.service"
register: configuration_enable_ly_result
changed_when: configuration_enable_ly_result.rc == 0
failed_when: >-
configuration_enable_ly_result.rc != 0
or 'No such file or directory' in (configuration_enable_ly_result.stderr | default(''))
or 'does not exist' in (configuration_enable_ly_result.stderr | default(''))
# ly drives the VT itself; mask getty so logind never spawns a login on that tty.
- name: Mask getty on ly's tty
ansible.builtin.command: "{{ chroot_command }} systemctl mask getty@{{ _ly_tty }}.service"
register: configuration_mask_getty_result
changed_when: configuration_mask_getty_result.rc == 0
failed_when: >-
configuration_mask_getty_result.rc != 0
and 'No such file or directory' not in (configuration_mask_getty_result.stderr | default(''))
and 'does not exist' not in (configuration_mask_getty_result.stderr | default(''))
- name: Set default systemd target
when: _configuration_platform.init_system == 'systemd'
vars:
_default_target: "{{ 'graphical.target' if system_cfg.features.desktop.enabled | bool else 'multi-user.target' }}"
ansible.builtin.command: "{{ chroot_command }} systemctl set-default {{ _default_target }}"
register: _set_default_target_result
changed_when: _set_default_target_result.rc == 0
- name: Enable PipeWire user services globally
when:
@@ -134,8 +160,7 @@
- _desktop_dm == 'gdm'
- _desktop_autologin_user | length > 0
vars:
# Debian's gdm3 reads /etc/gdm3/daemon.conf; RedHat/Arch GDM read
# /etc/gdm/custom.conf. The keys are identical, only the path differs.
# Debian gdm3 reads daemon.conf; RedHat/Arch gdm read custom.conf.
_gdm_dir: "/mnt/etc/{{ 'gdm3' if os_family == 'Debian' else 'gdm' }}"
_gdm_conf: "{{ 'daemon.conf' if os_family == 'Debian' else 'custom.conf' }}"
block:
@@ -151,16 +176,20 @@
dest: "{{ _gdm_dir }}/{{ _gdm_conf }}"
mode: "0644"
- name: Configure SDDM autologin
# SDDM and plasma-login-manager share the [Autologin] format and the KDE Wayland
# session; only the config dir differs (sddm.conf.d vs plasmalogin.conf.d).
- name: Configure SDDM / plasma-login-manager autologin
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'sddm'
- _desktop_dm in ['sddm', 'plasmalogin']
- _desktop_autologin_user | length > 0
vars:
_autologin_conf_dir: "/mnt/etc/{{ 'plasmalogin.conf.d' if _desktop_dm == 'plasmalogin' else 'sddm.conf.d' }}"
block:
- name: Ensure SDDM config directory exists
- name: Ensure KDE login-manager config directory exists
ansible.builtin.file:
path: /mnt/etc/sddm.conf.d
path: "{{ _autologin_conf_dir }}"
state: directory
mode: "0755"
@@ -179,8 +208,29 @@
{%- set names = _kde_wayland_sessions.files | map(attribute='path') | map('basename') | list -%}
{{ 'plasma.desktop' if 'plasma.desktop' in names else (names | first | default('')) }}
- name: Write SDDM autologin drop-in
- name: Write KDE login-manager autologin drop-in
ansible.builtin.template:
src: sddm-autologin.conf.j2
dest: /mnt/etc/sddm.conf.d/10-autologin.conf
dest: "{{ _autologin_conf_dir }}/10-autologin.conf"
mode: "0644"
# ly ships a flat (sectionless) config.ini; edit it in place to keep upstream
# defaults. Both keys are required: an unresolved session writes 'null', which
# disables autologin rather than leaving it half-configured.
- name: Configure ly autologin
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'ly'
- _desktop_autologin_user | length > 0
community.general.ini_file:
path: /mnt/etc/ly/config.ini
option: "{{ item.key }}"
value: "{{ item.value }}"
create: false
mode: "0644"
loop:
- key: auto_login_user
value: "{{ _desktop_autologin_user }}"
- key: auto_login_session
value: "{{ _greetd_session if (_greetd_session | length > 0) else 'null' }}"

View File

@@ -1,7 +1,7 @@
# Managed by Ansible.
{% set release = _debian_release_map[os_version | string] | default('trixie') %}
{% set mirror = system_cfg.mirror %}
{% set components = 'main contrib non-free' ~ (' non-free-firmware' if (os_version | string) not in ['10', '11'] else '') %}
{% set mirror = system_cfg.content.url | default('http://deb.debian.org/debian', true) %}
{% set components = 'main contrib non-free non-free-firmware' %}
deb {{ mirror }} {{ release }} {{ components }}
deb-src {{ mirror }} {{ release }} {{ components }}

View File

@@ -0,0 +1,17 @@
[{{ os }}{{ os_version_major }}-baseos]
name={{ os }} {{ os_version_major }} BaseOS
baseurl={{ system_cfg.content.url }}/BaseOS
enabled=1
gpgcheck={{ 1 if system_cfg.content.gpgcheck | bool else 0 }}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}
[{{ os }}{{ os_version_major }}-appstream]
name={{ os }} {{ os_version_major }} AppStream
baseurl={{ system_cfg.content.url }}/AppStream
enabled=1
gpgcheck={{ 1 if system_cfg.content.gpgcheck | bool else 0 }}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}

View File

@@ -3,12 +3,18 @@ id=LAN-{{ idx }}
uuid={{ configuration_net_uuid }}
type=ethernet
autoconnect-priority=10
{% if configuration_iface_name | length > 0 %}
interface-name={{ configuration_iface_name }}
{% endif %}
[ipv4]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
interface-name={{ iface.name }}
{% else %}
{# Bind the first available ethernet by name glob, never a MAC: a clone with a new adapter/MAC stays networked (#12). #}
[match]
interface-name=en*;eth*;
{% endif %}
[ipv4]
{% set dns_list = configuration_dns_list %}
{% set search_list = configuration_dns_search %}
{% if iface.ip | default('') | string | length %}

View File

@@ -0,0 +1,23 @@
auto lo
iface lo inet loopback
{% for iface in system_cfg.network.interfaces %}
{% set ifname = iface.name if (iface.name | default('') | string | length) else (configuration_eni_detected[loop.index0] | default('eth' ~ loop.index0)) %}
auto {{ ifname }}
{% if iface.ip | default('') | string | length %}
iface {{ ifname }} inet static
address {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
gateway {{ iface.gateway }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_list %}
dns-nameservers {{ configuration_dns_list | join(' ') }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_search %}
dns-search {{ configuration_dns_search | join(' ') }}
{% endif %}
{% else %}
iface {{ ifname }} inet dhcp
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,29 @@
network:
version: 2
ethernets:
{% for iface in system_cfg.network.interfaces %}
lan{{ loop.index0 }}:
{# Unnamed binds the first ethernet by name glob (e* = en*/eth*, netplan match.name takes one glob), never a MAC (#12). #}
match:
name: "{{ iface.name if (iface.name | default('') | string | length) else 'e*' }}"
{% if iface.ip | default('') | string | length %}
addresses:
- {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
routes:
- to: default
via: {{ iface.gateway }}
{% endif %}
{% else %}
dhcp4: true
{% endif %}
{% if loop.index0 == 0 and (configuration_dns_list or configuration_dns_search) %}
nameservers:
{% if configuration_dns_list %}
addresses: [{{ configuration_dns_list | join(', ') }}]
{% endif %}
{% if configuration_dns_search %}
search: [{{ configuration_dns_search | join(', ') }}]
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,27 @@
[Match]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
Name={{ iface.name }}
{% else %}
{# First available ethernet by name glob + device type, never a MAC (#12). #}
Name=en* eth*
Type=ether
{% endif %}
[Network]
{% if iface.ip | default('') | string | length %}
Address={{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
Gateway={{ iface.gateway }}
{% endif %}
{% else %}
DHCP=yes
{% endif %}
{% if idx | int == 0 and configuration_dns_list %}
{% for dns in configuration_dns_list %}
DNS={{ dns }}
{% endfor %}
{% if configuration_dns_search %}
Domains={{ configuration_dns_search | join(' ') }}
{% endif %}
{% endif %}

View File

@@ -1,6 +1,6 @@
# Managed by Ansible.
{% set release = _ubuntu_release_map[os] | default('noble') %}
{% set mirror = system_cfg.mirror %}
{% set release = _ubuntu_release_map[os] | default('resolute') %}
{% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,12 +1,11 @@
---
# Platform-specific configuration values keyed by os_family.
# Consumed as _configuration_platform in tasks via:
# configuration_platform_config[os_family]
# Keyed by os_family; tasks read configuration_platform_config[os_family] as _configuration_platform.
configuration_platform_config:
RedHat:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
time_sync_service: chronyd
efi_loader: shimx64.efi
grub_install: false
initramfs_cmd: "/usr/bin/dracut --regenerate-all --force"
@@ -17,6 +16,7 @@ configuration_platform_config:
user_group: sudo
sudo_group: "%sudo"
ssh_service: ssh
time_sync_service: chrony
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: >-
@@ -29,6 +29,7 @@ configuration_platform_config:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
time_sync_service: systemd-timesyncd
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: "/usr/sbin/mkinitcpio -P"
@@ -36,21 +37,17 @@ configuration_platform_config:
locale_gen: true
init_system: systemd
# Display manager auto-detection from desktop environment name.
configuration_desktop_dm_map:
gnome: gdm
kde: sddm
sway: greetd
hyprland: greetd
# Per-environment session command for greetd-launched compositors (sway/hyprland):
# the executable greetd starts. kde/gnome use a display manager (sddm/gdm) whose
# Wayland session is resolved separately, so they are not in this map.
# greetd session commands for sway/hyprland (gnome/kde use a DM instead).
configuration_desktop_session_cmd_map:
sway: sway
hyprland: Hyprland
# PipeWire user units enabled globally when a desktop is installed.
# pipewire/pipewire-pulse are socket-activated; wireplumber ships no socket.
configuration_desktop_audio_units:
- pipewire.socket

View File

@@ -1,16 +1,21 @@
---
# Connection and timing
environment_wait_timeout: 180
environment_wait_delay: 5
# Pacman installer settings
environment_parallel_downloads: 20
environment_pacman_lock_timeout: 120
environment_pacman_retries: 4
environment_pacman_retry_delay: 15
# PCI vendor IDs -> vendor codes used by hardware detection.
# Only vendors that drive distinct firmware/driver packages are mapped.
# Installer-tool libraries whose soname may have bumped past the ISO. Each one's
# installed reverse-deps are co-upgraded so the install stays a consistent
# transaction. Extend if a future transition breaks the install.
environment_partial_upgrade_libs:
- nettle
- leancrypto
# PCI vendor ID -> vendor code. Only vendors that drive distinct
# firmware/driver packages are mapped.
environment_pci_vendor_map:
"8086": intel
"1002": amd
@@ -29,8 +34,8 @@ environment_pci_vendor_map:
"1cf3": cirrus
"13d7": cirrus
# USB vendor IDs of fingerprint readers supported by libfprint / fprintd.
# Lowercase, four-digit hex; matched against `lsusb` output.
# USB vendor IDs of fingerprint readers supported by libfprint / fprintd,
# matched against `lsusb` output.
environment_fingerprint_vendor_ids:
- "06cb" # Synaptics (modern ThinkPad/Dell)
- "138a" # Validity Sensors (older ThinkPad)
@@ -41,3 +46,15 @@ environment_fingerprint_vendor_ids:
- "08ff" # AuthenTec (legacy)
- "147e" # Upek (legacy)
- "1491" # Futronic
# USB vendor IDs of common Bluetooth controllers. A fallback: detection also
# matches the literal "Bluetooth" string in `lsusb` for adapters that omit it.
environment_bluetooth_vendor_ids:
- "8087" # Intel (AX2xx combo cards)
- "0a12" # Cambridge Silicon Radio (CSR)
- "0bda" # Realtek
- "0cf3" # Qualcomm Atheros
- "13d3" # IMC / AzureWave
- "0489" # Foxconn / Lite-On
- "04ca" # Lite-On
- "0b05" # ASUS

View File

@@ -1,22 +1,6 @@
---
# Hardware detection on the live installer host.
#
# Resolves system_cfg.features.hardware.profile when not explicitly set, so
# downstream bootstrap can install vendor-matched microcode/firmware/GPU/
# peripheral packages. When the user supplies an override profile, detection
# is skipped (golden-image flow: bake an image with a fixed profile).
#
# Output fact: hardware_profile_active = {
# cpu: 'intel'|'amd'|'',
# gpus: list of 'intel'|'amd'|'nvidia',
# nvidia_supports_open: bool, # true when all detected Nvidia GPUs are
# # Turing or newer (device id >= 0x1e00)
# wireless: list of vendor codes ('intel'|'realtek'|'atheros'|...),
# fingerprint: bool, # USB fingerprint reader detected
# }
#
# Skipped entirely when neither firmware/gpu/peripherals features are enabled.
# A user-supplied override profile skips detection (golden-image flow: bake an
# image with a fixed profile).
- name: Resolve hardware detection requirement
ansible.builtin.set_fact:
_hardware_detection_needed: >-
@@ -37,7 +21,12 @@
gpus: "{{ _hardware_profile_override.gpus | default([]) | map('lower') | list }}"
nvidia_supports_open: "{{ _hardware_profile_override.nvidia_supports_open | default(true) | bool }}"
wireless: "{{ _hardware_profile_override.wireless | default([]) | map('lower') | list }}"
audio: "{{ _hardware_profile_override.audio | default([]) | map('lower') | list }}"
fingerprint: "{{ _hardware_profile_override.fingerprint | default(false) | bool }}"
bluetooth: "{{ _hardware_profile_override.bluetooth | default(false) | bool }}"
camera:
uvc: "{{ _hardware_profile_override.camera.uvc | default(false) | bool }}"
ipu6: "{{ _hardware_profile_override.camera.ipu6 | default(false) | bool }}"
- name: Detect hardware from live host
when:
@@ -61,71 +50,7 @@
failed_when: false
- name: Resolve detected hardware profile
vars:
_vendor_keys: "{{ environment_pci_vendor_map.keys() | list }}"
_cpu_vendor_raw: >-
{{
_hardware_lscpu.stdout
| regex_search('(?im)^Vendor ID:\\s*(\\S+)', '\\1')
| default([''], true)
| first
}}
_cpu_vendor: >-
{{
'intel' if _cpu_vendor_raw == 'GenuineIntel'
else ('amd' if _cpu_vendor_raw == 'AuthenticAMD' else '')
}}
# PCI classes: 0300 = VGA, 0302 = 3D, 0280 = wireless network controller.
_gpu_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[(0300|0302)\\]:') | list }}"
_gpu_pairs: >-
{{
_gpu_lines
| map('regex_search', '\\[([0-9a-f]{4}):([0-9a-f]{4})\\]', '\\1', '\\2')
| select('truthy')
| list
}}
_gpu_vendor_ids: "{{ _gpu_pairs | map('first') | select('in', _vendor_keys) | list }}"
_gpu_vendors: "{{ _gpu_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_nvidia_device_ids: >-
{{
_gpu_pairs
| selectattr('0', 'equalto', '10de')
| map(attribute=1)
| list
}}
_nvidia_min_id: >-
{{
(_nvidia_device_ids | map('int', base=16) | list | min)
if _nvidia_device_ids | length > 0 else 0
}}
# 0x1e00 = 7680 = first Turing device id; Turing+ supports nvidia-open.
_nvidia_supports_open: "{{ _nvidia_device_ids | length > 0 and (_nvidia_min_id | int) >= 7680 }}"
_wifi_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[0280\\]:') | list }}"
_wifi_vendor_ids: >-
{{
_wifi_lines
| map('regex_search', '\\[([0-9a-f]{4}):[0-9a-f]{4}\\]', '\\1')
| select('truthy')
| map('first')
| select('in', _vendor_keys)
| list
}}
_wifi_vendors: "{{ _wifi_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_fingerprint_present: >-
{{
(_hardware_lsusb.stdout | default(''))
| regex_search(
'(?i)ID (' ~ (environment_fingerprint_vendor_ids | join('|')) ~ '):'
)
is not none
}}
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ _cpu_vendor }}"
gpus: "{{ _gpu_vendors }}"
nvidia_supports_open: "{{ _nvidia_supports_open | bool }}"
wireless: "{{ _wifi_vendors }}"
fingerprint: "{{ _fingerprint_present | bool }}"
ansible.builtin.include_tasks: _resolve_hardware_profile.yml
- name: Initialize empty hardware profile when detection skipped
when: not (_hardware_detection_needed | bool)
@@ -135,7 +60,14 @@
gpus: []
nvidia_supports_open: true
wireless: []
audio: []
fingerprint: false
bluetooth: false
camera: { uvc: false, ipu6: false }
- name: Merge declarative hardware group over detection
when: _hardware_detection_needed | bool
ansible.builtin.include_tasks: _merge_hardware_profile.yml
- name: Report active hardware profile
when: _hardware_detection_needed | bool
@@ -146,4 +78,7 @@
gpus={{ hardware_profile_active.gpus | default([]) | join(',') | default('-', true) }}
{{ '(open-supported)' if hardware_profile_active.nvidia_supports_open | bool else '(legacy)' }},
wireless={{ hardware_profile_active.wireless | default([]) | join(',') | default('-', true) }},
fingerprint={{ hardware_profile_active.fingerprint | default(false) }}
audio={{ hardware_profile_active.audio | default([]) | join(',') | default('-', true) }},
fingerprint={{ hardware_profile_active.fingerprint | default(false) }},
bluetooth={{ hardware_profile_active.bluetooth | default(false) }},
camera={{ 'uvc' if hardware_profile_active.camera.uvc | default(false) else '' }}{{ '+ipu6' if hardware_profile_active.camera.ipu6 | default(false) else '' }}

View File

@@ -0,0 +1,22 @@
---
# Supplements whatever profile is active (detected or full-override) rather than
# replacing it: vendor lists union, booleans OR, cpu overrides when set.
- name: Merge declarative hardware group over detection
vars:
_hw: "{{ system_cfg.features.hardware }}"
_det: "{{ hardware_profile_active }}"
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ (_hw.cpu | default('') | string | lower) if (_hw.cpu | default('') | length > 0) else _det.cpu }}"
gpus: "{{ ((_det.gpus | default([])) + (_hw.gpus | default([]) | map('lower') | list)) | unique | list }}"
nvidia_supports_open: "{{ _det.nvidia_supports_open | default(true) | bool }}"
wireless: "{{ ((_det.wireless | default([])) + (_hw.wireless | default([]) | map('lower') | list)) | unique | list }}"
audio: "{{ ((_det.audio | default([])) + (_hw.audio | default([]) | map('lower') | list)) | unique | list }}"
fingerprint: "{{ (_det.fingerprint | default(false) | bool) or (_hw.fingerprint | default(false) | bool) }}"
bluetooth: "{{ (_det.bluetooth | default(false) | bool) or (_hw.bluetooth | default(false) | bool) }}"
camera:
uvc: "{{ (_det.camera.uvc | default(false) | bool) or (_hw.camera.uvc | default(false) | bool) }}"
ipu6: "{{ (_det.camera.ipu6 | default(false) | bool) or (_hw.camera.ipu6 | default(false) | bool) }}"
_hardware_profile_packages: "{{ _hw.packages | default({}) }}"
_hardware_profile_disable: "{{ _hw.disable | default([]) | list }}"
_hardware_profile_kernel_params: "{{ _hw.kernel_params | default([]) | list }}"

View File

@@ -14,24 +14,52 @@
timeout: "{{ environment_pacman_lock_timeout }}"
changed_when: false
- name: Setup Pacman
- name: Resolve installer tools for the target OS
when: not (custom_iso | bool)
ansible.builtin.set_fact:
environment_installer_tools: >-
{{
['glibc']
+ (['lua', 'dnf'] if os in ['almalinux', 'fedora', 'rhel', 'rocky'] else [])
+ (['debootstrap'] if os in ['debian', 'ubuntu', 'ubuntu-lts'] else [])
+ (['debian-archive-keyring'] if os == 'debian' else [])
+ (['ubuntu-keyring'] if os in ['ubuntu', 'ubuntu-lts'] else [])
}}
- name: Query reverse-dependencies of transition-sensitive libraries
when:
- not (custom_iso | bool)
- item.os is not defined or os in item.os
- environment_partial_upgrade_libs | length > 0
ansible.builtin.command: "pacman -Qi {{ item }}"
loop: "{{ environment_partial_upgrade_libs }}"
register: environment_revdep_query
changed_when: false
failed_when: false
# Co-upgrade each transition library with its installed reverse-deps so a soname
# bump moves the whole closure in one transaction, not a partial upgrade.
- name: Setup Pacman
when: not (custom_iso | bool)
vars:
environment_pacman_closure: >-
{{
(
environment_installer_tools
+ (environment_revdep_query.results | default([])
| selectattr('rc', 'equalto', 0) | map(attribute='item') | list)
+ (environment_revdep_query.results | default([])
| selectattr('rc', 'equalto', 0) | map(attribute='stdout')
| map('regex_search', 'Required By\s*:\s*(.+)', '\1')
| map('first') | map('split') | flatten)
)
| reject('equalto', 'None') | unique
}}
community.general.pacman:
update_cache: true
force: true
name: "{{ item.name }}"
name: "{{ environment_pacman_closure }}"
state: latest
loop:
- { name: glibc }
- { name: lua, os: [almalinux, fedora, rhel, rocky] }
- { name: dnf, os: [almalinux, fedora, rhel, rocky] }
- { name: debootstrap, os: [debian, ubuntu, ubuntu-lts] }
- { name: debian-archive-keyring, os: [debian] }
- { name: ubuntu-keyring, os: [ubuntu, ubuntu-lts] }
loop_control:
label: "{{ item.name }}"
register: environment_tool_install
until: environment_tool_install is succeeded
retries: "{{ environment_pacman_retries }}"
delay: "{{ environment_pacman_retry_delay }}"
@@ -76,10 +104,8 @@
opts: "ro,loop"
state: mounted
# Security note: RPM Sequoia signature policy is relaxed to allow
# bootstrapping RHEL-family distros from the Arch ISO, where the
# host rpm/dnf does not trust target distro GPG keys. Package
# integrity is verified by the target system's own rpm after reboot.
# RPM Sequoia signature policy is relaxed because the Arch ISO host does not
# trust target-distro GPG keys; the target's own rpm re-verifies after reboot.
- name: Create RPM macros directory
when: is_rhel | bool
ansible.builtin.file:

View File

@@ -0,0 +1,57 @@
---
# Split out of _detect_hardware.yml so fixtures can seed the lscpu/lspci/lsusb
# registers and assert the result with no real hardware. Keep regex exprs
# double-quoted single-line: ansible-core 2.21 set_fact mangles backslash escapes
# inside folded (>-) scalars.
- name: Resolve detected hardware profile
vars:
_vendor_keys: "{{ environment_pci_vendor_map.keys() | list }}"
_cpu_vendor_raw: "{{ _hardware_lscpu.stdout | regex_findall('(?im)^Vendor ID:\\s*(\\S+)') | first | default('') }}"
_cpu_vendor: >-
{{
'intel' if _cpu_vendor_raw == 'GenuineIntel'
else ('amd' if _cpu_vendor_raw == 'AuthenticAMD' else '')
}}
# PCI classes: 0300 = VGA, 0302 = 3D, 0280 = wireless network controller.
_gpu_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[(0300|0302)\\]:') | list }}"
_gpu_pairs: "{{ (_gpu_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):([0-9a-f]{4})\\]') | list }}"
_gpu_vendor_ids: "{{ _gpu_pairs | map('first') | select('in', _vendor_keys) | list }}"
_gpu_vendors: "{{ _gpu_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_nvidia_device_ids: "{{ _gpu_pairs | selectattr('0', 'equalto', '10de') | map(attribute=1) | list }}"
_nvidia_min_id: >-
{{
(_nvidia_device_ids | map('int', base=16) | list | min)
if _nvidia_device_ids | length > 0 else 0
}}
# 0x1e00 = 7680 = first Turing device id; Turing+ supports nvidia-open.
_nvidia_supports_open: "{{ _nvidia_device_ids | length > 0 and (_nvidia_min_id | int) >= 7680 }}"
_wifi_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[0280\\]:') | list }}"
_wifi_vendor_ids: "{{ (_wifi_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):[0-9a-f]{4}\\]') | select('in', _vendor_keys) | list }}"
_wifi_vendors: "{{ _wifi_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
# PCI class 0403 = audio device (HD-audio controller). Vendor drives SOF/firmware.
_audio_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[0403\\]:') | list }}"
_audio_vendor_ids: "{{ (_audio_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):[0-9a-f]{4}\\]') | select('in', _vendor_keys) | list }}"
_audio_vendors: "{{ _audio_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_fingerprint_present: "{{ (_hardware_lsusb.stdout | default('')) | regex_search('(?i)ID (' ~ (environment_fingerprint_vendor_ids | join('|')) ~ '):') is not none }}"
_camera_uvc_present: "{{ (_hardware_lsusb.stdout | default('')) is search('(?i)camera|webcam') }}"
# Intel IPU6 MIPI camera: PCI class 0480 (multimedia) under Intel 8086, or an ISP description. Out-of-tree userspace.
_camera_ipu6_desc: "{{ (_hardware_lspci.stdout | default('')) is search('(?i)image signal processor|IPU6') }}"
_camera_ipu6_pci: "{{ (_hardware_lspci.stdout_lines | select('search', '\\[0480\\]:') | select('search', '\\[8086:') | list) | length > 0 }}"
# No backslash escapes here, so a folded scalar is safe (unlike the \[..\] regexes above).
_bluetooth_present: >-
{{
((_hardware_lsusb.stdout | default('')) | regex_search('(?i)ID (' ~ (environment_bluetooth_vendor_ids | join('|')) ~ '):') is not none)
or ((_hardware_lsusb.stdout | default('')) is search('(?i)bluetooth'))
}}
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ _cpu_vendor }}"
gpus: "{{ _gpu_vendors }}"
nvidia_supports_open: "{{ _nvidia_supports_open | bool }}"
wireless: "{{ _wifi_vendors }}"
audio: "{{ _audio_vendors }}"
fingerprint: "{{ _fingerprint_present | bool }}"
bluetooth: "{{ _bluetooth_present | bool }}"
camera:
uvc: "{{ _camera_uvc_present | bool }}"
ipu6: "{{ (_camera_ipu6_desc | bool) or (_camera_ipu6_pci | bool) }}"

View File

@@ -1,9 +1,10 @@
# gpgcheck off: bootstrap-time only; the Arch live env has no AlmaLinux key.
[appstream]
name=AlmaLinux $releasever - AppStream
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/appstream
# baseurl=https://repo.almalinux.org/almalinux/$releasever/AppStream/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -14,7 +15,7 @@ name=AlmaLinux $releasever - BaseOS
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/baseos
# baseurl=https://repo.almalinux.org/almalinux/$releasever/BaseOS/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -25,7 +26,7 @@ name=AlmaLinux $releasever - Extras
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/extras
# baseurl=https://repo.almalinux.org/almalinux/$releasever/extras/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -36,7 +37,7 @@ name=AlmaLinux $releasever - HighAvailability
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/highavailability
# baseurl=https://repo.almalinux.org/almalinux/$releasever/HighAvailability/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400

View File

@@ -1,13 +1,24 @@
{% set _baseurl = system_cfg.content.url if system_cfg.content.source == 'mirror' else 'file:///usr/local/install/redhat/dvd' %}
[rhel{{ os_version_major }}-baseos]
name=RHEL {{ os_version_major }} BaseOS
baseurl=file:///usr/local/install/redhat/dvd/BaseOS
baseurl={{ _baseurl }}/BaseOS
enabled=1
gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release
{% endif %}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}
[rhel{{ os_version_major }}-appstream]
name=RHEL {{ os_version_major }} AppStream
baseurl=file:///usr/local/install/redhat/dvd/AppStream
baseurl={{ _baseurl }}/AppStream
enabled=1
gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release
{% endif %}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}

View File

@@ -1,8 +1,9 @@
# gpgcheck off: bootstrap-time only; the Arch live env has no Rocky key.
[baseos]
name=Rocky Linux $releasever - BaseOS
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=BaseOS-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/BaseOS/$basearch/os/
gpgcheck=1
gpgcheck=0
enabled=1
countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever
@@ -13,7 +14,7 @@ enabled_metadata=1
name=Rocky Linux $releasever - AppStream
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=AppStream-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/AppStream/$basearch/os/
gpgcheck=1
gpgcheck=0
enabled=1
countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever

View File

@@ -1,5 +1,4 @@
---
# OS family lists - single source of truth for platform detection and validation
os_family_rhel:
- almalinux
- fedora
@@ -10,8 +9,7 @@ os_family_debian:
- ubuntu
- ubuntu-lts
# OS -> family mapping - aligns with the main project's ansible_os_family pattern.
# Enables platform_config dict lookups per role instead of inline when: is_rhel chains.
# OS -> family, so roles do platform_config lookups instead of is_rhel when-chains.
os_family_map:
almalinux: RedHat
archlinux: Archlinux
@@ -40,6 +38,8 @@ hypervisor_defaults:
url: ""
username: ""
password: ""
token_id: ""
token_secret: ""
node: ""
storage: ""
datacenter: ""
@@ -58,6 +58,8 @@ system_defaults:
version: ""
filesystem: "ext4"
name: ""
# consumed by the golden produce/deploy wrappers, not the bootstrap itself
source: ""
id: ""
cpus: 0
memory: 0 # MiB
@@ -76,7 +78,22 @@ system_defaults:
timezone: "Europe/Vienna"
locale: "en_US.UTF-8"
keymap: "us"
mirror: ""
# source: dvd|mirror|satellite|none ('' -> family default: EL=dvd, else mirror).
# satellite values come from inventory/vault only, never committed code.
content:
source: ""
url: ""
proxy: ""
gpgcheck: true
satellite:
host: ""
ip: "" # optional /etc/hosts entry when DNS does not resolve host
org: ""
activation_key: ""
ca_url: ""
service_level: ""
environment: ""
install: false
packages: []
disks: []
users: {}
@@ -101,13 +118,18 @@ system_defaults:
bits: 512
pbkdf: "argon2id"
features:
# On only for the clone-deploy golden path; off keeps ansible-direct + smaller image.
cloud_init: false
cis:
enabled: false
profile: default # default|l1|l2 (default = current house behaviour)
rules: {} # per-rule overrides, e.g. {usb_lockdown: false}
params: {} # parameter overrides, e.g. {pwquality_minlen: 16}
selinux:
enabled: true
firewall:
enabled: true
backend: "firewalld" # firewalld|ufw
backend: "" # '' -> family default (EL/arch=firewalld, debian/ubuntu=ufw); override: firewalld|ufw
toolkit: "nftables" # nftables|iptables
ssh:
enabled: true
@@ -118,9 +140,6 @@ system_defaults:
banner:
motd: false
sudo: true
rhel_repo:
source: "iso" # iso|satellite|none - how RHEL systems get packages post-install
url: "" # Satellite/custom repo URL when source=satellite
chroot:
tool: "arch-chroot" # arch-chroot|chroot|systemd-nspawn
initramfs:
@@ -128,7 +147,7 @@ system_defaults:
desktop:
enabled: false
environment: "" # gnome|kde|sway|hyprland
display_manager: "" # auto from environment when empty; override: gdm|sddm|greetd
display_manager: "" # auto from environment when empty; override: gdm|sddm|greetd|plasma-login-manager|ly
autologin: false # false | username from system.users
session: "" # session name/command for the autologin user
groups: [] # opt-in package groups (keys of desktop_package_groups)
@@ -143,17 +162,30 @@ system_defaults:
nvidia_driver: "auto" # auto | open | proprietary | nouveau
peripherals:
enabled: "auto" # auto = follows desktop.enabled
fingerprint: "auto"
webcam: "auto"
fingerprint: "auto" # auto|true|false (auto = install when detected)
camera: "auto" # v4l-utils when a UVC/IPU6 camera is detected
audio: "auto" # SOF firmware + ALSA UCM when an audio device is present
bluetooth: "auto" # bluez when a Bluetooth controller is present
displaylink: false
hardware:
profile: {} # empty = autodetect; set to override (golden image)
profile: {} # full override: non-empty SKIPS detection (golden image)
# The keys below MERGE over detection: lists union, booleans OR, packages
# and kernel_params append, disable[] force-off applied last.
cpu: "" # pin a CPU vendor (intel|amd); empty = use detection
gpus: [] # extra GPU vendor codes to force
wireless: [] # extra wireless vendor codes to force
audio: [] # extra audio vendor codes to force
camera: {} # {uvc: true, ipu6: true} to force a camera kind
fingerprint: false # force-on a fingerprint reader detection missed
bluetooth: false # force-on a Bluetooth controller detection missed
packages: {} # per-os_family extra packages, e.g. {Archlinux: [intel-ipu6-dkms]}
disable: [] # feature/vendor names to force-off (audio|bluetooth|camera|fingerprint|displaylink|<vendor>)
kernel_params: [] # extra kernel cmdline params (quirks), e.g. ["i915.enable_psr=0"]
# Per-hypervisor required fields - drives data-driven validation.
# All virtual types additionally require network bridge or interfaces.
# Drives data-driven validation. Virtual types also require a network bridge or interfaces.
hypervisor_required_fields:
proxmox:
hypervisor: [url, username, password, node, storage]
hypervisor: [url, username, node, storage]
system: [id]
vmware:
hypervisor: [url, username, password, datacenter, storage]
@@ -165,8 +197,13 @@ hypervisor_required_fields:
hypervisor: []
system: []
# Hypervisor-to-disk device prefix mapping for virtual machines.
# Physical installs must set system.disks[].device explicitly.
# Used when content.url is empty.
content_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
# Virtual-only; physical installs must set system.disks[].device explicitly.
hypervisor_disk_device_map:
libvirt: "/dev/vd"
xen: "/dev/xvd"

View File

@@ -0,0 +1,25 @@
---
# Shared by both the fresh-run path (_normalize_system.yml) and the pre-computed
# enrichment path (system.yml) so the family-default rules live in one place.
- name: Apply family defaults to system_cfg
vars:
_os: "{{ system_cfg.os | default('') | string | lower }}"
ansible.builtin.set_fact:
system_cfg: >-
{{
system_cfg | combine({
'content': {
'source': system_cfg.content.source
if (system_cfg.content.source | default('') | string | trim | length > 0)
else ('dvd' if _os == 'rhel' else 'mirror'),
'url': system_cfg.content.url
if (system_cfg.content.url | default('') | string | trim | length > 0)
else (content_mirror_defaults[_os] | default('')),
},
'features': {'firewall': {'backend':
system_cfg.features.firewall.backend
if (system_cfg.features.firewall.backend | default('') | string | trim | length > 0)
else ('ufw' if _os in os_family_debian else 'firewalld')
}},
}, recursive=True)
}}

View File

@@ -10,29 +10,19 @@
if (system_raw.name | default('') | string | trim | length) > 0
else inventory_hostname
}}
_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
ansible.builtin.set_fact:
system_cfg:
# --- Identity & platform ---
type: "{{ system_type }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}"
version: "{{ system_raw.version | default('') | string }}"
filesystem: "{{ system_raw.filesystem | default('') | string | lower }}"
name: "{{ system_name }}"
id: "{{ system_raw.id | default('') | string }}"
# --- VM sizing (ignored for physical) ---
cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}"
memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}"
balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}"
# --- Network ---
# Flat fields (bridge, ip, etc.) and interfaces[] express the same primary NIC.
# When only flat fields are set, a synthetic interfaces[] entry is built below.
# When interfaces[] is set, the flat ip/prefix/gateway are backfilled from
# interfaces[0] so consumers reading the flat fields (e.g. the post-reboot
# reconnect block) still work.
# Flat fields and interfaces[] describe the same primary NIC: each is
# backfilled from the other so consumers reading either form still work.
network:
bridge: >-
{{
@@ -87,16 +77,24 @@
else []
)
}}
# --- Locale & environment ---
timezone: "{{ system_raw.timezone | string }}"
locale: "{{ system_raw.locale | string }}"
keymap: "{{ system_raw.keymap | string }}"
mirror: >-
{{
system_raw.mirror | string | trim
if (system_raw.mirror | default('') | string | trim | length) > 0
else _mirror_defaults[system_raw.os | default('') | string | lower] | default('')
}}
content:
# Family defaults for empty source/url are applied by _apply_family_defaults.yml.
source: "{{ system_raw.content.source | default('') | string | lower | trim }}"
url: "{{ system_raw.content.url | default('') | string | trim }}"
proxy: "{{ system_raw.content.proxy | default('') | string | trim }}"
gpgcheck: "{{ system_raw.content.gpgcheck | default(true) | bool }}"
satellite:
host: "{{ system_raw.content.satellite.host | default('') | string | trim }}"
ip: "{{ system_raw.content.satellite.ip | default('') | string | trim }}"
org: "{{ system_raw.content.satellite.org | default('') | string }}"
activation_key: "{{ system_raw.content.satellite.activation_key | default('') | string }}"
ca_url: "{{ system_raw.content.satellite.ca_url | default('') | string | trim }}"
service_level: "{{ system_raw.content.satellite.service_level | default('') | string }}"
environment: "{{ system_raw.content.satellite.environment | default('') | string }}"
install: "{{ system_raw.content.satellite.install | default(false) | bool }}"
path: >-
{{
(system_raw.path | default('') | string)
@@ -114,13 +112,11 @@
| reject('equalto', '')
| list
}}
# --- Storage & accounts ---
disks: "{{ system_raw.disks | default([]) }}"
users: "{{ system_raw.users | default({}) }}"
root:
password: "{{ system_raw.root.password | string }}"
shell: "{{ system_raw.root.shell | default('/bin/bash') | string }}"
# --- LUKS disk encryption ---
luks:
enabled: "{{ system_raw.luks.enabled | bool }}"
passphrase: "{{ system_raw.luks.passphrase | string }}"
@@ -138,15 +134,19 @@
iter: "{{ system_raw.luks.iter | int }}"
bits: "{{ system_raw.luks.bits | int }}"
pbkdf: "{{ system_raw.luks.pbkdf | string }}"
# --- Feature flags ---
features:
cloud_init: "{{ system_raw.features.cloud_init | default(false) | bool }}"
cis:
enabled: "{{ system_raw.features.cis.enabled | bool }}"
profile: "{{ system_raw.features.cis.profile | default('default') | string }}"
rules: "{{ system_raw.features.cis.rules | default({}) }}"
params: "{{ system_raw.features.cis.params | default({}) }}"
selinux:
enabled: "{{ system_raw.features.selinux.enabled | bool }}"
firewall:
enabled: "{{ system_raw.features.firewall.enabled | bool }}"
backend: "{{ system_raw.features.firewall.backend | string | lower }}"
# Empty backend is family-resolved by _apply_family_defaults.yml.
backend: "{{ system_raw.features.firewall.backend | default('') | string | lower | trim }}"
toolkit: "{{ system_raw.features.firewall.toolkit | string | lower }}"
ssh:
enabled: "{{ system_raw.features.ssh.enabled | bool }}"
@@ -157,9 +157,6 @@
banner:
motd: "{{ system_raw.features.banner.motd | bool }}"
sudo: "{{ system_raw.features.banner.sudo | bool }}"
rhel_repo:
source: "{{ system_raw.features.rhel_repo.source | default('iso') | string | lower }}"
url: "{{ system_raw.features.rhel_repo.url | default('') | string }}"
chroot:
tool: "{{ system_raw.features.chroot.tool | string }}"
initramfs:
@@ -206,23 +203,44 @@
if (system_raw.features.peripherals.enabled | string | lower) == 'auto'
else (system_raw.features.peripherals.enabled | bool)
}}
# fingerprint/webcam stay tri-state ('auto'|'true'|'false') because the
# 'auto' branch is resolved at install time using detection results.
# Kept tri-state ('auto'|'true'|'false'): 'auto' resolves at install time from detection.
fingerprint: >-
{{
'auto'
if (system_raw.features.peripherals.fingerprint | string | lower) == 'auto'
else (system_raw.features.peripherals.fingerprint | bool | string | lower)
}}
webcam: >-
camera: >-
{{
'auto'
if (system_raw.features.peripherals.webcam | string | lower) == 'auto'
else (system_raw.features.peripherals.webcam | bool | string | lower)
if (system_raw.features.peripherals.camera | string | lower) == 'auto'
else (system_raw.features.peripherals.camera | bool | string | lower)
}}
audio: >-
{{
'auto'
if (system_raw.features.peripherals.audio | string | lower) == 'auto'
else (system_raw.features.peripherals.audio | bool | string | lower)
}}
bluetooth: >-
{{
'auto'
if (system_raw.features.peripherals.bluetooth | string | lower) == 'auto'
else (system_raw.features.peripherals.bluetooth | bool | string | lower)
}}
displaylink: "{{ system_raw.features.peripherals.displaylink | bool }}"
hardware:
profile: "{{ system_raw.features.hardware.profile | default({}) }}"
cpu: "{{ system_raw.features.hardware.cpu | default('') | string }}"
gpus: "{{ system_raw.features.hardware.gpus | default([]) | list }}"
wireless: "{{ system_raw.features.hardware.wireless | default([]) | list }}"
audio: "{{ system_raw.features.hardware.audio | default([]) | list }}"
camera: "{{ system_raw.features.hardware.camera | default({}) }}"
fingerprint: "{{ system_raw.features.hardware.fingerprint | default(false) | bool }}"
bluetooth: "{{ system_raw.features.hardware.bluetooth | default(false) | bool }}"
packages: "{{ system_raw.features.hardware.packages | default({}) }}"
disable: "{{ system_raw.features.hardware.disable | default([]) | list }}"
kernel_params: "{{ system_raw.features.hardware.kernel_params | default([]) | list }}"
hostname: "{{ system_name }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}"
os_version: "{{ system_raw.version | default('') | string }}"

View File

@@ -44,7 +44,7 @@
label: "system.features.{{ item }}"
ansible.builtin.assert:
that:
- (system.features[item] | default({})) is mapping
- (system_defaults.features[item] is not mapping) or ((system.features[item] | default({})) is mapping)
fail_msg: "system.features.{{ item }} must be a dictionary."
quiet: true

View File

@@ -1,8 +1,6 @@
---
# Centralized normalization - all input dicts (system, hypervisor, disks)
# are normalized here into system_cfg, hypervisor_cfg, etc.
# Downstream roles consume these computed facts directly and do NOT need
# per-role _normalize.yml (except CIS, which has its own input dict).
# Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
# roles consume the computed facts directly with no per-role _normalize (except CIS).
- name: Global defaults loaded
ansible.builtin.debug:
msg: Global defaults loaded.
@@ -27,11 +25,15 @@
_proxmox_auth:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
_proxmox_auth_node:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
node: "{{ hypervisor_cfg.node }}"
no_log: true
@@ -61,6 +63,12 @@
ansible.builtin.set_fact:
os_version_major: "{{ (os_version | string).split('.')[0] }}"
# EL>=10 and Fedora dropped the static /etc/pam.d/system-auth shipped by pam;
# the PAM stack is generated by authselect and absent until a profile is selected.
- name: Flag authselect-managed PAM stacks
ansible.builtin.set_fact:
is_authselect: "{{ is_rhel | bool and (os_version_major | default('0') | int) >= 10 }}"
- name: Set chroot command wrapper
ansible.builtin.set_fact:
chroot_command: >-

View File

@@ -1,10 +1,7 @@
---
# Two code paths:
# 1. Fresh run (system_cfg undefined): normalize from raw `system` input.
# 2. Pre-computed (system_cfg already set, e.g. from main project's deploy_iac):
# merge with bootstrap system_defaults to fill missing fields (luks, features,
# etc.) that bootstrap expects but the main project doesn't set, then derive
# convenience facts (hostname, os, os_version).
# Fresh run normalizes raw `system` input. A pre-computed system_cfg (from the main
# project's deploy_iac) is instead merged with system_defaults to fill the fields
# bootstrap expects, then convenience facts are derived.
- name: Normalize system and disk configuration
when: system_cfg is not defined
block:
@@ -50,25 +47,6 @@
ansible.builtin.set_fact:
system_cfg: "{{ system_defaults | combine(system | default({}), recursive=True) | combine(system_cfg, recursive=True) }}"
- name: Apply mirror default for pre-computed system_cfg
when:
- system_cfg is defined
- _bootstrap_needs_enrichment | default(false) | bool
- system_cfg.mirror | default('') | string | trim | length == 0
vars:
# Same as _normalize_system.yml - kept in sync manually.
_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
ansible.builtin.set_fact:
system_cfg: >-
{{
system_cfg | combine({
'mirror': _mirror_defaults[system_cfg.os | default('') | string | lower] | default('')
}, recursive=True)
}}
- name: Populate primary network fields from first interface (pre-computed)
when:
- system_cfg is defined
@@ -105,3 +83,8 @@
- system_cfg is defined
- install_drive is not defined
ansible.builtin.include_tasks: _normalize_disks.yml
# Runs on every path before validation, so an empty firewall.backend / content.source
# resolves to the family default even when system_cfg arrived pre-computed.
- name: Apply family defaults (content source, firewall backend)
ansible.builtin.include_tasks: _apply_family_defaults.yml

View File

@@ -96,7 +96,7 @@
quiet: true
- name: Validate system.features leaf schemas
loop: "{{ system_defaults.features | dict2items }}"
loop: "{{ system_defaults.features | dict2items | selectattr('value', 'mapping') }}"
loop_control:
label: "system.features.{{ item.key }}"
vars:
@@ -121,18 +121,18 @@
- >-
os_version is not defined or (os_version | string | length) == 0
or (
os == "debian" and (os_version | string) in ["10", "11", "12", "13", "unstable"]
os == "debian" and (os_version | string) in ["12", "13", "unstable"]
) or (
os == "fedora" and (os_version | int) >= 38 and (os_version | int) <= 45
os == "fedora" and (os_version | int) >= 43 and (os_version | int) <= 44
) or (
os in ["rocky", "almalinux"]
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$")
and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or (
os == "rhel"
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$")
and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or (
os == "ubuntu"
and (os_version | string) is match("^(2[0-9])\\.04$")
and (os_version | string) is match("^(2[0-9])\\.(04|10)$")
) or (
os == "ubuntu-lts"
and (os_version | string) is match("^(2[0-9])\\.04$")
@@ -148,8 +148,8 @@
- name: Validate RHEL ISO requirement
ansible.builtin.assert:
that:
- os != "rhel" or (rhel_iso is defined and (rhel_iso | string | length) > 0)
fail_msg: "rhel_iso is required when os=rhel."
- os != "rhel" or system_cfg.content.source == "mirror" or (rhel_iso is defined and (rhel_iso | string | length) > 0)
fail_msg: "rhel_iso is required when os=rhel unless content.source is mirror."
quiet: true
- name: Validate hypervisor-specific required fields
@@ -166,6 +166,21 @@
label: "hypervisor.{{ item }}"
no_log: true
- name: Validate Proxmox authentication (password or API token)
when:
- system_cfg.type == "virtual"
- hypervisor_type == "proxmox"
ansible.builtin.assert:
that:
- >-
(hypervisor_cfg.password | default('') | string | length > 0)
or (hypervisor_cfg.token_id | default('') | string | length > 0
and hypervisor_cfg.token_secret | default('') | string | length > 0)
fail_msg: >-
Proxmox requires either hypervisor.password or
hypervisor.token_id + hypervisor.token_secret (API token).
quiet: true
- name: Validate VMware placement (cluster or node required, mutually exclusive)
when:
- system_cfg.type == "virtual"
@@ -241,15 +256,21 @@
- system_cfg.features.gpu.nvidia_driver in ["auto", "open", "proprietary", "nouveau"]
- system_cfg.features.peripherals.enabled is defined
- system_cfg.features.peripherals.fingerprint in ["auto", "true", "false"]
- system_cfg.features.peripherals.webcam in ["auto", "true", "false"]
- system_cfg.features.peripherals.camera in ["auto", "true", "false"]
- system_cfg.features.peripherals.audio in ["auto", "true", "false"]
- system_cfg.features.peripherals.bluetooth in ["auto", "true", "false"]
- system_cfg.features.peripherals.displaylink is defined
- system_cfg.features.hardware.profile is mapping
- system_cfg.features.hardware.packages is mapping
- system_cfg.features.hardware.camera is mapping
- system_cfg.features.hardware.disable is sequence
- system_cfg.features.hardware.kernel_params is sequence
fail_msg: >-
Invalid hardware feature flags. firmware.enabled/microcode,
peripherals.enabled and peripherals.displaylink must be bool (or 'auto'
sentinel for firmware); gpu.nvidia_driver in
[auto|open|proprietary|nouveau]; peripherals.fingerprint and
peripherals.webcam in [auto|true|false]; hardware.profile must be a dict.
[auto|open|proprietary|nouveau]; peripherals.fingerprint/camera/audio/
bluetooth in [auto|true|false]; hardware.profile must be a dict.
quiet: true
- name: Validate desktop environment
@@ -262,7 +283,7 @@
or os_family_map[os] | default('') == "Archlinux"
- >-
system_cfg.features.desktop.display_manager | default('') | length == 0
or system_cfg.features.desktop.display_manager in ["gdm", "sddm", "greetd"]
or system_cfg.features.desktop.display_manager in ["gdm", "sddm", "greetd", "plasma-login-manager", "ly"]
- >-
system_cfg.features.desktop.display_manager | default('') != "greetd"
or system_cfg.features.desktop.environment in ["sway", "hyprland"]
@@ -271,15 +292,23 @@
or system_cfg.features.desktop.display_manager | default('') in ["", "gdm"]
- >-
system_cfg.features.desktop.environment != "kde"
or system_cfg.features.desktop.display_manager | default('') in ["", "sddm"]
or system_cfg.features.desktop.display_manager | default('') in ["", "sddm", "plasma-login-manager"]
- >-
system_cfg.features.desktop.display_manager | default('') != "plasma-login-manager"
or os == "archlinux" or (os == "fedora" and (os_version | int) >= 44)
- >-
system_cfg.features.desktop.display_manager | default('') != "ly"
or os == "archlinux"
fail_msg: >-
Invalid desktop config: environment '{{ system_cfg.features.desktop.environment }}'
for os_family '{{ os_family_map[os] | default('Unknown') }}',
display_manager '{{ system_cfg.features.desktop.display_manager | default('') }}'.
gnome and kde are available on all families; sway and hyprland are Archlinux only.
display_manager must be empty (auto) or match the environment's native DM:
gnome->gdm, kde->sddm, sway/hyprland->greetd. Only that DM's package is
installed, so a mismatched override fails at enable time.
gnome->gdm, kde->plasma-login-manager on Arch/Fedora44+ else sddm,
sway/hyprland->greetd. ly is an explicit override on Arch only and may
front any environment. Only that DM's package is installed, so a mismatched
override fails at enable time.
quiet: true
- name: Validate desktop autologin

View File

@@ -41,7 +41,9 @@
community.proxmox.proxmox_vm_info:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
block:
- name: Query Proxmox for existing VM
community.proxmox.proxmox_vm_info:

View File

@@ -20,7 +20,9 @@
community.proxmox.proxmox_kvm:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
node: "{{ hypervisor_cfg.node }}"
vmid: "{{ system_cfg.id | default(omit, true) }}"
name: "{{ hostname }}"

View File

@@ -17,8 +17,15 @@
community.vmware.vmware_guest: "{{ _vmware_auth }}"
community.vmware.vmware_guest_tpm: "{{ _vmware_auth }}"
vmware.vmware.vm_powerstate: "{{ _vmware_auth }}"
vmware.vmware.folder: "{{ _vmware_auth }}"
block:
# community.vmware: full-featured guest management
# vmware_guest places into folder but will not create it; ensure it exists first.
- name: Ensure target folder exists
when: system_cfg.path | string | length > 0
vmware.vmware.folder:
absolute_path: "/{{ hypervisor_cfg.datacenter }}/vm/{{ system_cfg.path | regex_replace('^/+', '') }}"
state: present
- name: Create VM in vCenter
vars:
virtualization_vmware_networks: >-
@@ -98,7 +105,6 @@
fail_msg: "Failed to add vTPM2 to VM '{{ hostname }}'. LUKS with TPM2 requires a virtual TPM device."
quiet: true
# vmware.vmware: modern collection for power operations
- name: Start VM in vCenter
when: virtualization_tpm2_enabled | bool
vmware.vmware.vm_powerstate:

View File

@@ -0,0 +1,40 @@
---
- name: "Seed raw system input (content {{ cf.name }})"
ansible.builtin.set_fact:
system: "{{ cf.system }}"
- name: "Normalize system (content {{ cf.name }})"
ansible.builtin.include_tasks: ../../roles/global_defaults/tasks/_normalize_system.yml
- name: "Apply family defaults (content {{ cf.name }})"
ansible.builtin.include_tasks: ../../roles/global_defaults/tasks/_apply_family_defaults.yml
- name: "Assert computed content source and url (content {{ cf.name }})"
ansible.builtin.assert:
that:
- system_cfg.content.source == cf.expect.source
- system_cfg.content.url == cf.expect.url
fail_msg: "[content {{ cf.name }}] FAIL got {{ system_cfg.content }}"
success_msg: "[content {{ cf.name }}] OK {{ system_cfg.content.source }} {{ system_cfg.content.url }}"
- name: "Assert satellite register inputs (content {{ cf.name }})"
when: cf.expect.satellite is defined
ansible.builtin.assert:
that:
- system_cfg.content.satellite.host == cf.expect.satellite.host
- system_cfg.content.satellite.org == cf.expect.satellite.org
- system_cfg.content.satellite.activation_key == cf.expect.satellite.activation_key
fail_msg: "[content {{ cf.name }}] satellite FAIL got {{ system_cfg.content.satellite }}"
- name: "Assert Arch reflector lock-out (content {{ cf.name }})"
when: cf.expect_reflector is defined
block:
- name: "Load Arch package set (content {{ cf.name }})"
ansible.builtin.include_vars:
file: ../../roles/bootstrap/vars/main.yml
- name: "Assert reflector membership (content {{ cf.name }})"
ansible.builtin.assert:
that:
- ('reflector' in (bootstrap_archlinux.base + bootstrap_archlinux.conditional)) == (cf.expect_reflector == 'present')
fail_msg: "[content {{ cf.name }}] reflector expected {{ cf.expect_reflector }}, url='{{ system_cfg.content.url }}'"

View File

@@ -0,0 +1,78 @@
---
# Raw system.content input -> expected normalized system_cfg.content. Covers family
# source defaults (EL=dvd, else mirror), url pass-through, satellite inputs, and the
# Arch reflector lock-out (dropped only when a custom mirror url is set).
content_fixtures:
- name: arch-mirror-url-drops-reflector
system:
os: archlinux
content: { url: "https://mirror.internal/archlinux" }
expect:
source: mirror
url: "https://mirror.internal/archlinux"
expect_reflector: absent
- name: arch-no-url-keeps-public-and-reflector
system:
os: archlinux
content: {}
expect:
source: mirror
url: ""
expect_reflector: present
- name: el-mirror-url
system:
os: almalinux
version: "10"
content: { source: mirror, url: "https://mirror.internal/almalinux" }
expect:
source: mirror
url: "https://mirror.internal/almalinux"
- name: el-default-dvd
system:
os: rhel
version: "10"
content: {}
expect:
source: dvd
url: ""
- name: el-satellite-register
system:
os: almalinux
version: "10"
content:
source: satellite
satellite: { host: "sat.internal", org: "SG", activation_key: "ak-el10" }
expect:
source: satellite
url: ""
satellite: { host: "sat.internal", org: "SG", activation_key: "ak-el10" }
- name: debian-default-mirror
system:
os: debian
version: "13"
content: {}
expect:
source: mirror
url: "https://deb.debian.org/debian/"
- name: debian-custom-mirror-lands
system:
os: debian
version: "13"
content: { source: mirror, url: "https://mirror.internal/debian" }
expect:
source: mirror
url: "https://mirror.internal/debian"
- name: ubuntu-default-mirror
system:
os: ubuntu
content: {}
expect:
source: mirror
url: "http://archive.ubuntu.com/ubuntu/"

View File

@@ -0,0 +1,22 @@
---
# Run: ansible-playbook tests/content/test_content.yml
- name: Content-source fixture tests
hosts: localhost
gather_facts: false
connection: local
vars:
hypervisor_type: libvirt
hypervisor_cfg: { folder: "" }
vars_files:
- content_fixtures.yml
tasks:
- name: Load global defaults (system_defaults, content_mirror_defaults, os maps)
ansible.builtin.include_vars:
file: ../../roles/global_defaults/defaults/main.yml
- name: Run each content fixture
ansible.builtin.include_tasks: _assert_content.yml
loop: "{{ content_fixtures }}"
loop_control:
loop_var: cf
label: "{{ cf.name }}"

View File

@@ -0,0 +1,24 @@
---
- name: "Seed lscpu/lspci/lsusb registers ({{ fixture.name }})"
ansible.builtin.set_fact:
_hardware_lscpu: { stdout: "{{ fixture.lscpu }}" }
_hardware_lspci: { stdout: "{{ fixture.lspci | join('\n') }}", stdout_lines: "{{ fixture.lspci }}" }
_hardware_lsusb: { stdout: "{{ fixture.lsusb }}" }
- name: "Resolve hardware profile ({{ fixture.name }})"
ansible.builtin.include_tasks: ../../roles/environment/tasks/_resolve_hardware_profile.yml
- name: "Assert resolved profile ({{ fixture.name }})"
ansible.builtin.assert:
that:
- hardware_profile_active.cpu == fixture.expect.cpu
- hardware_profile_active.gpus == fixture.expect.gpus
- hardware_profile_active.wireless == fixture.expect.wireless
- hardware_profile_active.audio == fixture.expect.audio
- (hardware_profile_active.fingerprint | bool) == (fixture.expect.fingerprint | bool)
- (hardware_profile_active.bluetooth | bool) == (fixture.expect.bluetooth | bool)
- (hardware_profile_active.camera.uvc | bool) == (fixture.expect.camera.uvc | bool)
- (hardware_profile_active.camera.ipu6 | bool) == (fixture.expect.camera.ipu6 | bool)
- (hardware_profile_active.nvidia_supports_open | bool) == (fixture.expect.nvidia_supports_open | bool)
fail_msg: "[{{ fixture.name }}] FAIL got {{ hardware_profile_active }}"
success_msg: "[{{ fixture.name }}] OK {{ hardware_profile_active }}"

View File

@@ -0,0 +1,44 @@
---
- name: "Seed detection + declarative group (merge {{ mf.name }})"
ansible.builtin.set_fact:
hardware_profile_active: "{{ mf.detected }}"
os_family: "{{ mf.os_family }}"
_nvidia_driver_resolved: "{{ mf.nvidia_driver_resolved | default('nouveau') }}"
system_cfg:
features:
firmware: { enabled: true, microcode: true }
gpu: { enabled: true }
peripherals:
enabled: true
camera: "auto"
fingerprint: "auto"
audio: "auto"
bluetooth: "auto"
displaylink: false
hardware: "{{ mf.hardware }}"
- name: "Merge group over detection (merge {{ mf.name }})"
ansible.builtin.include_tasks: ../../roles/environment/tasks/_merge_hardware_profile.yml
- name: "Assert merged profile (merge {{ mf.name }})"
ansible.builtin.assert:
that:
- (hardware_profile_active.fingerprint | bool) == (mf.expect_profile.fingerprint | bool)
- (hardware_profile_active.bluetooth | bool) == (mf.expect_profile.bluetooth | bool)
- _hardware_profile_kernel_params == (mf.hardware.kernel_params | default([]))
fail_msg: "[merge {{ mf.name }}] profile FAIL {{ hardware_profile_active }}"
- name: "Load package map (merge {{ mf.name }})"
ansible.builtin.include_vars:
file: ../../roles/bootstrap/vars/hardware.yml
- name: "Resolve packages (merge {{ mf.name }})"
ansible.builtin.include_tasks: ../../roles/bootstrap/tasks/_resolve_hardware_packages.yml
- name: "Assert resolved package list (merge {{ mf.name }})"
ansible.builtin.assert:
that:
- (mf.expect_contains | default([])) | difference(_hardware_packages) | length == 0
- (mf.expect_excludes | default([])) | intersect(_hardware_packages) | length == 0
fail_msg: "[merge {{ mf.name }}] FAIL got {{ _hardware_packages }}"
success_msg: "[merge {{ mf.name }}] OK {{ _hardware_packages }}"

View File

@@ -0,0 +1,32 @@
---
- name: "Seed resolve inputs (pkg {{ pf.name }})"
ansible.builtin.set_fact:
os_family: "{{ pf.os_family }}"
hardware_profile_active: "{{ pf.profile }}"
_nvidia_driver_resolved: "{{ pf.nvidia_driver_resolved | default('nouveau') }}"
system_cfg:
features:
firmware: { enabled: true, microcode: true }
gpu: { enabled: true }
peripherals:
enabled: true
camera: "auto"
fingerprint: "auto"
audio: "auto"
bluetooth: "auto"
displaylink: false
- name: "Load package map (pkg {{ pf.name }})"
ansible.builtin.include_vars:
file: ../../roles/bootstrap/vars/hardware.yml
- name: "Resolve packages (pkg {{ pf.name }})"
ansible.builtin.include_tasks: ../../roles/bootstrap/tasks/_resolve_hardware_packages.yml
- name: "Assert resolved package list (pkg {{ pf.name }})"
ansible.builtin.assert:
that:
- (pf.expect_contains | default([])) | difference(_hardware_packages) | length == 0
- (pf.expect_excludes | default([])) | intersect(_hardware_packages) | length == 0
fail_msg: "[pkg {{ pf.name }}] FAIL got {{ _hardware_packages }}"
success_msg: "[pkg {{ pf.name }}] OK {{ _hardware_packages }}"

View File

@@ -0,0 +1,41 @@
---
# Canned lscpu/lspci/lsusb -> expected profile. lspci lines are `lspci -nn` shaped,
# lsusb is plain `lsusb`.
hardware_fixtures:
- name: intel-laptop-nvidia-turing
lscpu: "Architecture: x86_64\nVendor ID: GenuineIntel\nModel name: 12th Gen Core i7"
lspci:
- "00:02.0 VGA compatible controller [0300]: Intel Corporation Alder Lake-P GT2 [8086:46a6] (rev 0c)"
- "01:00.0 3D controller [0302]: NVIDIA Corporation TU117M [GeForce GTX 1650 Mobile] [10de:1f99] (rev a1)"
- "00:14.3 Network controller [0280]: Intel Corporation Wi-Fi 6 AX201 [8086:a0f0] (rev 11)"
- "00:1f.3 Audio device [0403]: Intel Corporation Alder Lake PCH-P High Definition Audio [8086:51c8] (rev 01)"
lsusb: |-
Bus 001 Device 003: ID 06cb:00bd Synaptics, Inc. Prometheus MIS Touch Fingerprint Reader
Bus 001 Device 004: ID 8087:0026 Intel Corp. AX201 Bluetooth
Bus 001 Device 005: ID 5986:118d Acer, Inc. Integrated Camera
expect: { cpu: intel, gpus: [intel, nvidia], nvidia_supports_open: true, wireless: [intel], audio: [intel], fingerprint: true, bluetooth: true, camera: { uvc: true, ipu6: false } }
- name: amd-desktop-realtek
lscpu: "Architecture: x86_64\nVendor ID: AuthenticAMD\nModel name: AMD Ryzen 7 5800X"
lspci:
- "0a:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 22 [1002:73df] (rev c7)"
- "05:00.0 Network controller [0280]: Realtek Semiconductor Co., Ltd. RTL8822CE 802.11ac [10ec:c822]"
- "0b:00.4 Audio device [0403]: Advanced Micro Devices, Inc. [AMD] Starship/Matisse HD Audio [1022:1487]"
lsusb: "Bus 002 Device 002: ID 046d:c52b Logitech, Inc. Unifying Receiver"
expect: { cpu: amd, gpus: [amd], nvidia_supports_open: false, wireless: [realtek], audio: [amd], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: false } }
- name: nvidia-legacy-maxwell
lscpu: "Vendor ID: GenuineIntel"
lspci:
- "01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GM107 [GeForce GTX 750 Ti] [10de:1380] (rev a2)"
lsusb: ""
expect: { cpu: intel, gpus: [nvidia], nvidia_supports_open: false, wireless: [], audio: [], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: false } }
- name: intel-laptop-ipu6-camera
lscpu: "Vendor ID: GenuineIntel"
lspci:
- "00:02.0 VGA compatible controller [0300]: Intel Corporation Raptor Lake-P [8086:a7a0] (rev 04)"
- "00:05.0 Multimedia controller [0480]: Intel Corporation Raptor Lake IPU6 [8086:a75d] (rev 04)"
- "00:14.3 Network controller [0280]: Intel Corporation Wi-Fi 6E AX211 [8086:51f0]"
lsusb: "Bus 003 Device 002: ID 8087:0033 Intel Corp. AX211 Bluetooth"
expect: { cpu: intel, gpus: [intel], nvidia_supports_open: false, wireless: [intel], audio: [], fingerprint: false, bluetooth: true, camera: { uvc: false, ipu6: true } }

View File

@@ -0,0 +1,37 @@
---
# Detected profile + declarative hardware.* -> merged profile + resolved packages.
# Exercises union/OR/force-on, disable[] force-off, and packages[os_family] append.
merge_fixtures:
- name: force-fingerprint-disable-audio-pin-ipu6
os_family: Archlinux
nvidia_driver_resolved: nouveau
detected: { cpu: intel, gpus: [intel], nvidia_supports_open: true, wireless: [intel], audio: [intel], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: true } }
hardware:
fingerprint: true # force-on (detection missed it)
packages: { Archlinux: [intel-ipu6-dkms, v4l2-relayd] }
disable: [audio] # force-off audio even though detected
kernel_params: ["i915.enable_psr=0"]
expect_profile: { fingerprint: true, bluetooth: false }
expect_contains: [intel-ipu6-dkms, v4l2-relayd, fprintd, libfprint, v4l-utils, intel-ucode]
expect_excludes: [sof-firmware, alsa-ucm-conf, bluez]
- name: union-vendors-force-bluetooth
os_family: Debian
nvidia_driver_resolved: nouveau
detected: { cpu: amd, gpus: [amd], nvidia_supports_open: false, wireless: [], audio: [amd], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: false } }
hardware:
wireless: [realtek] # union: add a wireless vendor detection missed
bluetooth: true # force-on bluetooth
expect_profile: { fingerprint: false, bluetooth: true }
expect_contains: [amd64-microcode, firmware-realtek, firmware-sof-signed, bluez]
expect_excludes: [fprintd, v4l-utils]
- name: disable-vendor-drops-microcode-firmware-gpu
os_family: Archlinux
nvidia_driver_resolved: open
detected: { cpu: intel, gpus: [intel, nvidia], nvidia_supports_open: true, wireless: [intel], audio: [intel], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: false } }
hardware:
disable: [nvidia, intel] # drop the nvidia GPU and every intel-vendor contribution
expect_profile: { fingerprint: false, bluetooth: false }
expect_contains: [mesa, vulkan-icd-loader]
expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-other]

View File

@@ -0,0 +1,22 @@
---
# profile + os_family -> resolved package list. expect_contains: all must be present;
# expect_excludes: none may be present. Features default all-on with auto peripherals.
package_fixtures:
- name: arch-intel-audio-bt-fp
os_family: Archlinux
profile: { cpu: intel, gpus: [intel], wireless: [intel], audio: [intel], fingerprint: true, bluetooth: true, camera: { uvc: true, ipu6: false } }
expect_contains: [intel-ucode, linux-firmware-other, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils]
expect_excludes: [nvidia-open-dkms, evdi]
- name: debian-amd-audio-no-bt-no-fp
os_family: Debian
profile: { cpu: amd, gpus: [amd], wireless: [realtek], audio: [amd], fingerprint: false, bluetooth: false, camera: { uvc: false, ipu6: false } }
expect_contains: [amd64-microcode, firmware-linux-free, firmware-amd-graphics, firmware-realtek, mesa-vulkan-drivers, firmware-sof-signed, alsa-ucm-conf]
expect_excludes: [bluez, fprintd, libpam-fprintd, v4l-utils]
- name: redhat-intel-nvidia-bt
os_family: RedHat
profile: { cpu: intel, gpus: [intel, nvidia], wireless: [intel], audio: [intel], fingerprint: false, bluetooth: true, camera: { uvc: false, ipu6: true } }
nvidia_driver_resolved: open
expect_contains: [microcode_ctl, linux-firmware, mesa-dri-drivers, vulkan-loader, akmod-nvidia-open, alsa-sof-firmware, alsa-ucm, bluez, v4l-utils]
expect_excludes: [fprintd, evdi]

View File

@@ -0,0 +1,16 @@
---
# Run: ansible-playbook tests/hardware/test_detection.yml
- name: Hardware detection fixture tests
hosts: localhost
gather_facts: false
connection: local
vars_files:
- ../../roles/environment/defaults/main.yml
- fixtures.yml
tasks:
- name: Run each detection fixture
ansible.builtin.include_tasks: _assert_fixture.yml
loop: "{{ hardware_fixtures }}"
loop_control:
loop_var: fixture
label: "{{ fixture.name }}"

View File

@@ -0,0 +1,15 @@
---
# Run: ansible-playbook tests/hardware/test_merge.yml
- name: Hardware merge-layer fixture tests
hosts: localhost
gather_facts: false
connection: local
vars_files:
- merge_fixtures.yml
tasks:
- name: Run each merge fixture
ansible.builtin.include_tasks: _assert_merge.yml
loop: "{{ merge_fixtures }}"
loop_control:
loop_var: mf
label: "{{ mf.name }}"

View File

@@ -0,0 +1,15 @@
---
# Run: ansible-playbook tests/hardware/test_packages.yml
- name: Hardware package-resolution fixture tests
hosts: localhost
gather_facts: false
connection: local
vars_files:
- pkg_fixtures.yml
tasks:
- name: Run each package fixture
ansible.builtin.include_tasks: _assert_packages.yml
loop: "{{ package_fixtures }}"
loop_control:
loop_var: pf
label: "{{ pf.name }}"

View File

@@ -1,5 +1,4 @@
---
# Example variables for virtual provisioning.
custom_iso: false
hypervisor:
@@ -85,6 +84,9 @@ system:
features:
cis:
enabled: false
profile: default # default|l1|l2
rules: {} # per-rule overrides, e.g. {usb_lockdown: false}
params: {} # parameter overrides, e.g. {pwquality_minlen: 16}
selinux:
enabled: true
firewall: