Compare commits

...

27 Commits

Author SHA1 Message Date
9c4a86c0ec style(cleanup): drop redundant comments from the power-off tasks 2026-07-15 00:09:44 +02:00
723f0516da fix(cleanup): wait for the guest shutdown before forcing the VM off 2026-07-15 00:05:38 +02:00
975384661e docs(content): correct the family default to rhel=dvd, not EL=dvd 2026-07-14 21:14:41 +02:00
ad25bec9fd fix(cleanup): force-stop the vmware VM before booting the installed OS 2026-07-14 06:48:25 +02:00
6240217318 feat(system_check): preflight boot media and datastore placement 2026-07-14 01:48:26 +02:00
1c00ec9370 fix(configuration): grant the platform sudo group only when sudo is set 2026-07-14 01:48:26 +02:00
371d5a0acd fix(configuration): deploy sudoers rules for bool-or-string sudo values 2026-07-14 01:48:26 +02:00
41ccf2a5b9 fix(configuration): allow guest-exec RPCs for EL golden cloning 2026-05-29 22:43:14 +02:00
8f9cfe3b2f fix(cleanup): force-stop then start the proxmox VM after install 2026-05-29 22:40:02 +02:00
6a75237197 fix(environment): pick the largest cdrom as the RHEL install DVD 2026-05-28 17:52:44 +02:00
b04aad12fb fix(satellite): drop auto-attach (mutually exclusive with activation key) 2026-05-26 01:08:09 +02:00
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
95 changed files with 2865 additions and 781 deletions

163
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.1 [Core Variables](#41-core-variables)
- 4.2 [`system` Dictionary](#42-system-dictionary) - 4.2 [`system` Dictionary](#42-system-dictionary)
- 4.3 [`hypervisor` Dictionary](#43-hypervisor-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.5 [VMware Guest Operations](#45-vmware-guest-operations)
- 4.6 [Multi-Disk Schema](#46-multi-disk-schema) - 4.6 [Multi-Disk Schema](#46-multi-disk-schema)
- 4.7 [Advanced Partitioning Overrides](#47-advanced-partitioning-overrides) - 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` | | `system.os` | Distribution | `system.version` |
| ------------ | ------------------------ | ------------------------------------- | | ------------ | ------------------------ | ------------------------------------- |
| `almalinux` | AlmaLinux | `8`, `9`, `10` | | `almalinux` | AlmaLinux | `9`, `10` |
| `archlinux` | Arch Linux | latest (rolling) | | `archlinux` | Arch Linux | latest (rolling) |
| `debian` | Debian | `10`-`13`, `unstable` | | `debian` | Debian | `12`, `13`, `unstable` |
| `fedora` | Fedora | `38`-`45` | | `fedora` | Fedora | `43`, `44` |
| `rhel` | Red Hat Enterprise Linux | `8`, `9`, `10` | | `rhel` | Red Hat Enterprise Linux | `9`, `10` |
| `rocky` | Rocky Linux | `8`, `9`, `10` | | `rocky` | Rocky Linux | `9`, `10` |
| `ubuntu` | Ubuntu (latest non-LTS) | optional (e.g. `24.04`) | | `ubuntu` | Ubuntu (latest non-LTS) | optional (tracks 25.10 `questing`) |
| `ubuntu-lts` | Ubuntu LTS | optional (e.g. `24.04`) | | `ubuntu-lts` | Ubuntu LTS | optional (tracks 26.04 `resolute`) |
### Hypervisors ### 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: 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 - **`hypervisor`** -- virtualization backend credentials and targeting
An optional third dict **`cis`** overrides CIS hardening parameters when `system.features.cis.enabled: true`. 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.
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.
### Variable Placement ### Variable Placement
@@ -148,7 +146,7 @@ all:
### 4.1 Core Variables ### 4.1 Core Variables
Top-level variables outside `system`/`hypervisor`/`cis`. Top-level variables outside `system`/`hypervisor`.
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
| ---------------- | ------ | -------------------------- | ---------------------------------------------------- | | ---------------- | ------ | -------------------------- | ---------------------------------------------------- |
@@ -174,7 +172,7 @@ Top-level variables outside `system`/`hypervisor`/`cis`.
| `memory` | int | `0` | Memory in MiB (required for virtual) | | `memory` | int | `0` | Memory in MiB (required for virtual) |
| `balloon` | int | `0` | Balloon memory in MiB (Proxmox) | | `balloon` | int | `0` | Balloon memory in MiB (Proxmox) |
| `path` | string | -- | Hypervisor folder/path (falls back to `hypervisor.folder`) | | `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 | | `packages` | list | `[]` | Additional packages installed post-reboot |
| `network` | dict | see below | Network configuration | | `network` | dict | see below | Network configuration |
| `disks` | list | `[]` | Disk layout (see [Multi-Disk Schema](#46-multi-disk-schema)) | | `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 | | `luks` | dict | see below | Encryption settings |
| `features` | dict | see below | Feature toggles | | `features` | dict | see below | Feature toggles |
#### `system.content`
Uniform package content source, family-resolved. `source: ''` defaults to `dvd` on `rhel` and to `mirror` everywhere else, AlmaLinux, Rocky and Fedora included: they have public mirrors, RHEL does not. 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` #### `system.network`
| Key | Type | Default | Description | | 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 | | 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 | | `selinux.enabled` | bool | `true` | SELinux management |
| `firewall.enabled` | bool | `true` | Firewall setup | | `firewall.enabled` | bool | `true` | Firewall setup |
| `firewall.backend` | string | `firewalld` | `firewalld` or `ufw` | | `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 | | `banner.sudo` | bool | `true` | Sudo banner |
| `chroot.tool` | string | `arch-chroot` | `arch-chroot`, `chroot`, or `systemd-nspawn` | | `chroot.tool` | string | `arch-chroot` | `arch-chroot`, `chroot`, or `systemd-nspawn` |
| `initramfs.generator` | string | auto-detected | Override initramfs generator (see below) | | `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.enabled` | bool | `false` | Enable Secure Boot (Arch via sbctl, others via shim) |
| `secure_boot.method` | string | -- | Arch only: `sbctl` (default) or `uki` | | `secure_boot.method` | string | -- | Arch only: `sbctl` (default) or `uki` |
| `desktop.*` | dict | see below | Desktop environment settings (see [4.2.5](#425-systemfeaturesdesktop)) | | `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)) | | `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)) | | `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)) | | `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. **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 | | `enabled` | bool | `false` | Install desktop environment |
| `environment` | string | `""` | `gnome`, `kde`, `sway`, or `hyprland` | | `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 | | `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) | | `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`) | | `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; 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 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`. 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 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, 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 managed `sources.list`). Ubuntu uses `restricted`. Arch ships both `nvidia-open-dkms` and `nvidia-dkms` in
the `extra` repository - no third-party setup required. 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` #### 4.2.8 `system.features.peripherals`
| Key | Type | Default | Description | | Key | Type | Default | Description |
| ------------- | --------------- | ------- | ---------------------------------------------------------- | | ------------- | --------------- | ------- | ---------------------------------------------------------- |
| `enabled` | bool \| `auto` | `auto` | Master switch. `auto` follows `desktop.enabled` | | `enabled` | bool \| `auto` | `auto` | Master switch. `auto` follows `desktop.enabled` |
| `fingerprint` | bool \| `auto` | `auto` | `fprintd`/`libfprint`. `auto` = install when reader detected | | `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) | | `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, 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 | | 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 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 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 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 ### 4.3 `hypervisor` Dictionary
| Key | Type | Default | Description | | Key | Type | Default | Description |
@@ -408,44 +475,50 @@ system:
| `certs` | bool | `false` | TLS certificate validation (VMware) | | `certs` | bool | `false` | TLS certificate validation (VMware) |
| `ssh` | bool | `false` | Enable SSH on guest and switch connection (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 | | Key | Type | Default | Description |
| -------------------- | ------ | ------- | ------------------------------------------------ | | --------- | ------ | ----------- | ----------------------------------------------------------------- |
| `modules_blacklist` | list | see below | Kernel modules to blacklist via modprobe | | `enabled` | bool | `false` | Apply CIS hardening at all |
| `sysctl` | dict | see below | Sysctl key/value pairs written to `10-cis.conf` | | `profile` | string | `default` | `default` (house baseline), `l1` (clean CIS Level 1), or `l2` |
| `sshd_options` | list | see below | SSHD options applied via lineinfile | | `rules` | dict | `{}` | Per-rule on/off overrides on top of the profile |
| `pwquality_minlen` | int | `14` | Minimum password length | | `params` | dict | `{}` | Parameter overrides (deep-merged; list values replace wholesale) |
| `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 |
**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 ```yaml
system:
features:
cis: cis:
sysctl: enabled: true
net.ipv6.conf.all.disable_ipv6: 0 # re-enable IPv6 rules:
net.ipv4.ip_forward: 1 # enable for routers/containers 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 ```yaml
system:
features:
cis: cis:
sshd_options: 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"} - {option: X11Forwarding, value: "yes"}
- { option: AllowTcpForwarding, 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 ### 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 - name: Create and configure VMs
hosts: "{{ bootstrap_target | default('all') }}" hosts: "{{ bootstrap_target | default('all') }}"
strategy: free # noqa: run-once[play] strategy: free # noqa: run-once[play]
@@ -62,6 +52,12 @@
name: configuration name: configuration
public: true 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 - name: Apply CIS hardening
when: system_cfg.features.cis.enabled | bool when: system_cfg.features.cis.enabled | bool
ansible.builtin.include_role: ansible.builtin.include_role:
@@ -75,11 +71,16 @@
public: true public: true
rescue: 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 - name: Delete VM on bootstrap failure
when: when: _delete_vm_on_rescue | bool
- _vm_absent_before_bootstrap | default(false) | bool
- virtualization_vm_created_in_run | default(false) | bool
- system_cfg.type == "virtual"
ansible.builtin.include_role: ansible.builtin.include_role:
name: virtualization name: virtualization
tasks_from: delete tasks_from: delete
@@ -93,9 +94,8 @@
ansible.builtin.fail: ansible.builtin.fail:
msg: >- msg: >-
Bootstrap failed for {{ hostname }}. Bootstrap failed for {{ hostname }}.
{{ 'VM was deleted to allow clean retry.' {{ 'VM was deleted to allow clean retry.' if (_delete_vm_on_rescue | bool)
if (virtualization_vm_created_in_run | default(false)) else 'VM kept (base system installed or not created this run).' }}
else 'VM was not created in this run (kept).' }}
post_tasks: post_tasks:
- name: Set post-reboot connection flags - name: Set post-reboot connection flags
@@ -131,6 +131,15 @@
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3 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 - name: Re-gather facts for target OS after reboot
when: when:
- post_reboot_can_connect | bool - post_reboot_can_connect | bool
@@ -140,6 +149,22 @@
- min - min
- pkg_mgr - 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 - name: Install post-reboot packages
when: when:
- post_reboot_can_connect | bool - post_reboot_can_connect | bool

View File

@@ -9,6 +9,8 @@
_family_pkgs: "{{ bootstrap_desktop_packages[os_family] | default({}) }}" _family_pkgs: "{{ bootstrap_desktop_packages[os_family] | default({}) }}"
_de_config: "{{ _family_pkgs[_de] | default({}) }}" _de_config: "{{ _family_pkgs[_de] | default({}) }}"
_base: "{{ bootstrap_desktop_base_packages[os_family] | 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([]) }}" _requested_groups: "{{ system_cfg.features.desktop.groups | default([]) }}"
_group_pkgs: >- _group_pkgs: >-
{{ {{
@@ -20,16 +22,10 @@
| sum(start=[]) | sum(start=[])
}} }}
ansible.builtin.set_fact: ansible.builtin.set_fact:
# GNOME ships under different dnf environment groups: Fedora uses _desktop_groups: "{{ _de_config.groups | default([]) }}"
# 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_packages: >- _desktop_packages: >-
{{ {{
((_de_config.packages | default([])) + _base + _group_pkgs) ((_de_config.packages | default([])) + _base + _group_pkgs + [_dm_override_pkg])
| reject('equalto', '') | reject('equalto', '')
| unique | unique
| list | list
@@ -61,7 +57,8 @@
{{ chroot_command }} dnf --releasever={{ os_version_major }} {{ chroot_command }} dnf --releasever={{ os_version_major }}
--setopt=install_weak_deps=False install -y {{ _desktop_packages | join(' ') }} --setopt=install_weak_deps=False install -y {{ _desktop_packages | join(' ') }}
Debian: >- 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: >- Archlinux: >-
pacstrap /mnt {{ _desktop_packages | join(' ') }} pacstrap /mnt {{ _desktop_packages | join(' ') }}
ansible.builtin.command: "{{ _install_commands[os_family] }}" 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 - name: Load hardware package definitions
ansible.builtin.include_vars: ansible.builtin.include_vars:
file: hardware.yml file: hardware.yml
@@ -22,7 +20,7 @@
vars: vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}" _family: "{{ bootstrap_hardware_packages[os_family] }}"
_user_driver: "{{ system_cfg.features.gpu.nvidia_driver | default('auto') }}" _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 }}" _supports_open: "{{ hardware_profile_active.nvidia_supports_open | default(true) | bool }}"
_open_pkgs: "{{ _family.gpu_nvidia.open | default([]) }}" _open_pkgs: "{{ _family.gpu_nvidia.open | default([]) }}"
_prop_pkgs: "{{ _family.gpu_nvidia.proprietary | default([]) }}" _prop_pkgs: "{{ _family.gpu_nvidia.proprietary | default([]) }}"
@@ -58,101 +56,7 @@
changed_when: _rpmfusion_result.rc == 0 changed_when: _rpmfusion_result.rc == 0
- name: Resolve hardware package set - name: Resolve hardware package set
vars: ansible.builtin.include_tasks: _resolve_hardware_packages.yml
_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
}}
- name: Report hardware package selection - name: Report hardware package selection
ansible.builtin.debug: ansible.builtin.debug:
@@ -165,6 +69,15 @@
fingerprint={{ hardware_profile_active.fingerprint | default(false) }} fingerprint={{ hardware_profile_active.fingerprint | default(false) }}
-> {{ _hardware_packages | length }} package(s) -> {{ _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 - name: Install hardware packages
when: _hardware_packages | length > 0 when: _hardware_packages | length > 0
vars: 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', '') | reject('equalto', '')
| list | list
}} }}
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: >- ansible.builtin.command: >-
pacstrap /mnt {{ bootstrap_archlinux_packages | join(' ') }} pacstrap /mnt {{ bootstrap_archlinux_packages | join(' ') }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_result register: bootstrap_result
changed_when: bootstrap_result.rc == 0 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: vars:
bootstrap_debian_release: >- bootstrap_debian_release: >-
{{ {{
'buster' if (os_version | string) == '10' 'bookworm' if (os_version | string) == '12'
else 'bullseye' if (os_version | string) == '11'
else 'bookworm' if (os_version | string) == '12'
else 'trixie' if (os_version | string) == '13' else 'trixie' if (os_version | string) == '13'
else 'sid' if (os_version | string) == 'unstable' else 'sid' if (os_version | string) == 'unstable'
else 'trixie' else 'trixie'
@@ -28,10 +26,27 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys." fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true 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 - name: Install Debian base system
ansible.builtin.command: >- ansible.builtin.command: >-
debootstrap --include={{ bootstrap_debian_base_csv }} debootstrap --keyring=/usr/share/keyrings/debian-archive-keyring.gpg
{{ bootstrap_debian_release }} /mnt {{ system_cfg.mirror }} --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 register: bootstrap_debian_base_result
changed_when: bootstrap_debian_base_result.rc == 0 changed_when: bootstrap_debian_base_result.rc == 0
@@ -48,6 +63,10 @@
Acquire::Retries "3"; Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10"; Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false"; 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" mode: "0644"
- name: Update package lists - name: Update package lists

View File

@@ -29,11 +29,35 @@
loop_control: loop_control:
label: "{{ item.path }}" 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 - name: Run OS-specific bootstrap process
vars: vars:
bootstrap_var_key: "{{ 'bootstrap_' + (os | replace('-lts', '') | replace('-', '_')) }}" bootstrap_var_key: "{{ 'bootstrap_' + (os | replace('-lts', '') | replace('-', '_')) }}"
ansible.builtin.include_tasks: "{{ bootstrap_os_task_map[os] }}" 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 - name: Install hardware-matched firmware/microcode/GPU/peripheral packages
when: >- when: >-
(system_cfg.features.firmware.enabled | bool) (system_cfg.features.firmware.enabled | bool)

View File

@@ -24,12 +24,14 @@
- "'grub2-common' not in (bootstrap_result.stderr | default(''))" - "'grub2-common' not in (bootstrap_result.stderr | default(''))"
- name: Ensure chroot RHEL DVD directory exists - name: Ensure chroot RHEL DVD directory exists
when: system_cfg.content.source != 'mirror'
ansible.builtin.file: ansible.builtin.file:
path: /mnt/usr/local/install/redhat/dvd path: /mnt/usr/local/install/redhat/dvd
state: directory state: directory
mode: "0755" mode: "0755"
- name: Bind mount RHEL DVD into chroot - name: Bind mount RHEL DVD into chroot
when: system_cfg.content.source != 'mirror'
ansible.posix.mount: ansible.posix.mount:
src: /usr/local/install/redhat/dvd src: /usr/local/install/redhat/dvd
path: /mnt/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 # ubuntu = latest non-LTS, ubuntu-lts = latest LTS
bootstrap_ubuntu_release_map: bootstrap_ubuntu_release_map:
ubuntu: questing ubuntu: questing
ubuntu-lts: noble ubuntu-lts: resolute
bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('noble') }}" bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('resolute') }}"
_config: "{{ lookup('vars', bootstrap_var_key) }}" _config: "{{ lookup('vars', bootstrap_var_key) }}"
bootstrap_ubuntu_base_csv: "{{ (['ca-certificates'] + _config.base) | unique | join(',') }}" bootstrap_ubuntu_base_csv: "{{ (['ca-certificates'] + _config.base) | unique | join(',') }}"
bootstrap_ubuntu_extra_args: >- bootstrap_ubuntu_extra_args: >-
@@ -24,13 +24,28 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys." fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true 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 - name: Install Ubuntu base system
ansible.builtin.command: >- ansible.builtin.command: >-
debootstrap debootstrap
--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg --keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg
--include={{ bootstrap_ubuntu_base_csv }} --include={{ bootstrap_ubuntu_base_csv }}
{{ bootstrap_ubuntu_release }} /mnt {{ 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 register: bootstrap_ubuntu_base_result
changed_when: bootstrap_ubuntu_base_result.rc == 0 changed_when: bootstrap_ubuntu_base_result.rc == 0
@@ -47,6 +62,10 @@
Acquire::Retries "3"; Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10"; Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false"; 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" mode: "0644"
- name: Update package lists - name: Update package lists

View File

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

View File

@@ -1,6 +1,6 @@
# Managed by Ansible. # Managed by Ansible.
{% set release = bootstrap_ubuntu_release %} {% set release = bootstrap_ubuntu_release %}
{% set mirror = system_cfg.mirror %} {% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %} {% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }} 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. # 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 # plasma-login-manager on Arch/Fedora44+ (Plasma 6.6), else sddm.
# top for every DE via bootstrap_desktop_base_packages. 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: bootstrap_desktop_packages:
RedHat: RedHat:
gnome: gnome:
groups: groups: []
- workstation-product-environment packages:
packages: [] - gnome-shell
- gnome-control-center
- nautilus
- gnome-session
- gdm
kde: kde:
groups: [] groups: []
packages: packages:
@@ -18,7 +43,7 @@ bootstrap_desktop_packages:
- plasma-nm - plasma-nm
- plasma-pa - plasma-pa
- plasma-systemmonitor - plasma-systemmonitor
- sddm - "{{ bootstrap_kde_login_manager }}"
- konsole - konsole
- dolphin - dolphin
- kate - kate
@@ -41,7 +66,7 @@ bootstrap_desktop_packages:
- plasma-desktop - plasma-desktop
- plasma-nm - plasma-nm
- plasma-pa - plasma-pa
- sddm - "{{ bootstrap_kde_login_manager }}"
- konsole - konsole
- dolphin - dolphin
- kate - kate
@@ -62,7 +87,7 @@ bootstrap_desktop_packages:
- plasma-desktop - plasma-desktop
- plasma-nm - plasma-nm
- plasma-pa - plasma-pa
- sddm - "{{ bootstrap_kde_login_manager }}"
- konsole - konsole
- dolphin - dolphin
- kate - kate
@@ -102,26 +127,23 @@ bootstrap_desktop_packages:
- qt6-wayland - qt6-wayland
- bluez - bluez
# Shared desktop base, installed for EVERY DE whenever desktop.enabled. # Installed for EVERY DE whenever desktop.enabled. No file manager here: DE metas
# Fonts (noto + emoji + one nerd font), audio stack (pipewire + wireplumber + # bundle their own and the wlroots sets above carry nautilus.
# 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).
bootstrap_desktop_base_packages: bootstrap_desktop_base_packages:
RedHat: RedHat:
- google-noto-sans-fonts - google-noto-sans-fonts
- google-noto-emoji-fonts - google-noto-emoji-fonts
- fira-code-fonts - "{{ bootstrap_desktop_redhat_codefont }}"
- pipewire - pipewire
- wireplumber - wireplumber
- pipewire-pulseaudio - pipewire-pulseaudio
- xdg-desktop-portal - xdg-desktop-portal
- power-profiles-daemon - "{{ bootstrap_desktop_power }}"
- bluez
- firefox - firefox
- evince - "{{ bootstrap_desktop_pdf }}"
- eog - "{{ bootstrap_desktop_image }}"
- mpv - "{{ bootstrap_desktop_redhat_video }}"
Debian: Debian:
- fonts-noto - fonts-noto
- fonts-noto-color-emoji - fonts-noto-color-emoji
@@ -131,7 +153,8 @@ bootstrap_desktop_base_packages:
- pipewire-pulse - pipewire-pulse
- xdg-desktop-portal - xdg-desktop-portal
- power-profiles-daemon - power-profiles-daemon
- firefox-esr - bluez
- "{{ bootstrap_desktop_browser }}"
- evince - evince
- eog - eog
- mpv - mpv
@@ -144,14 +167,14 @@ bootstrap_desktop_base_packages:
- pipewire-pulse - pipewire-pulse
- xdg-desktop-portal - xdg-desktop-portal
- power-profiles-daemon - power-profiles-daemon
- bluez
- firefox - firefox
- evince - evince
- loupe - loupe
- mpv - mpv
# Opt-in package groups, selected per host via features.desktop.groups (a list # Opt-in groups selected per host via features.desktop.groups; the union of the
# of group names). _desktop.yml installs the union of the requested groups' # requested groups' packages is installed. Empty selection by default.
# packages. Empty selection by default.
desktop_package_groups: desktop_package_groups:
dev: dev:
RedHat: RedHat:

View File

@@ -1,18 +1,7 @@
--- ---
# Hardware-aware package definitions keyed by os_family. Schema: # Hardware-aware package definitions keyed by os_family, consumed by
# cpu_microcode[intel|amd] CPU vendor microcode # _resolve_hardware_packages.yml. Only packages matching detected hardware are
# firmware_base unconditional firmware packages # installed; families without vendor splits collapse to one firmware meta package.
# 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.
bootstrap_hardware_packages: bootstrap_hardware_packages:
Archlinux: Archlinux:
cpu_microcode: cpu_microcode:
@@ -40,9 +29,11 @@ bootstrap_hardware_packages:
proprietary: [nvidia-dkms, nvidia-utils] proprietary: [nvidia-dkms, nvidia-utils]
# Wayland-only: kernel nouveau module + mesa/gbm drive the display; no Xorg DDX. # Wayland-only: kernel nouveau module + mesa/gbm drive the display; no Xorg DDX.
nouveau: [vulkan-nouveau] nouveau: [vulkan-nouveau]
peripherals_base: [v4l-utils] camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libfprint] peripherals_fingerprint: [fprintd, libfprint]
peripherals_displaylink: [] # AUR only; user must wire in AUR helper peripherals_displaylink: [] # AUR only; user must wire in AUR helper
audio_base: [sof-firmware, alsa-ucm-conf]
bluetooth_base: [bluez, bluez-utils]
Debian: Debian:
cpu_microcode: cpu_microcode:
@@ -72,9 +63,11 @@ bootstrap_hardware_packages:
proprietary: [nvidia-driver, nvidia-vulkan-icd] proprietary: [nvidia-driver, nvidia-vulkan-icd]
# Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg. # Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: [] nouveau: []
peripherals_base: [v4l-utils] camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libpam-fprintd] peripherals_fingerprint: [fprintd, libpam-fprintd]
peripherals_displaylink: [evdi-dkms] # userspace driver still needs vendor .run peripherals_displaylink: [evdi-dkms] # userspace driver still needs vendor .run
audio_base: [firmware-sof-signed, alsa-ucm-conf]
bluetooth_base: [bluez]
RedHat: RedHat:
cpu_microcode: cpu_microcode:
@@ -103,6 +96,8 @@ bootstrap_hardware_packages:
proprietary: [akmod-nvidia, xorg-x11-drv-nvidia, xorg-x11-drv-nvidia-cuda] 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. # Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: [] nouveau: []
peripherals_base: [v4l-utils] camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, fprintd-pam] peripherals_fingerprint: [fprintd, fprintd-pam]
peripherals_displaylink: [evdi] # COPR-supplied; repo enablement deferred 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. # Feature-gated packages shared across all distros. Arch strips nftables from
# Arch has special nftables handling and composes this differently. # this and composes it differently.
bootstrap_common_conditional: >- bootstrap_common_conditional: >-
{{ {{
( (
@@ -11,18 +11,37 @@ bootstrap_common_conditional: >-
+ (['cryptsetup', 'tpm2-tools'] if system_cfg.luks.enabled | bool else []) + (['cryptsetup', 'tpm2-tools'] if system_cfg.luks.enabled | bool else [])
+ (['qemu-guest-agent'] if hypervisor_type in ['libvirt', 'proxmox'] else []) + (['qemu-guest-agent'] if hypervisor_type in ['libvirt', 'proxmox'] else [])
+ (['open-vm-tools'] if hypervisor_type == 'vmware' else []) + (['open-vm-tools'] if hypervisor_type == 'vmware' else [])
+ (['cloud-init'] if system_cfg.features.cloud_init | bool else [])
) )
}} }}
# --------------------------------------------------------------------------- # Native-installer parity backfill: anaconda and the d-i "standard" task leave
# Per-OS package definitions: base (rootfs/group install), extra (post-base), # these, but install_weak_deps=False / Recommends-off minimal installs drop them.
# conditional (feature/version-gated, appended by task files). bootstrap_el_runtime:
# DNF-based distros also carry repos (dnf --repo) and use base as group names. - 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: bootstrap_rhel:
repos: repos:
- "rhel{{ os_version_major }}-baseos" - "rhel{{ os_version_major }}-baseos"
- "rhel{{ os_version_major }}-appstream"
base: base:
- core - core
- base - base
@@ -51,6 +70,7 @@ bootstrap_rhel:
+ (['python39'] if os_version_major | default('') == '8' else ['python']) + (['python39'] if os_version_major | default('') == '8' else ['python'])
+ (['kernel'] if os_version_major | default('') == '10' else []) + (['kernel'] if os_version_major | default('') == '10' else [])
+ (['zram-generator'] if os_version_major | default('') in ['9', '10'] else []) + (['zram-generator'] if os_version_major | default('') in ['9', '10'] else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional + bootstrap_common_conditional
}} }}
@@ -85,8 +105,8 @@ bootstrap_almalinux:
- zstd - zstd
conditional: >- 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 + bootstrap_common_conditional
}} }}
@@ -125,6 +145,7 @@ bootstrap_rocky:
conditional: >- conditional: >-
{{ {{
(['dhcp-client'] if (os_version_major | default('9') | int) < 10 else []) (['dhcp-client'] if (os_version_major | default('9') | int) < 10 else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional + bootstrap_common_conditional
}} }}
@@ -158,7 +179,6 @@ bootstrap_fedora:
- nc - nc
- nfs-utils - nfs-utils
- nfsv4-client-utils - nfsv4-client-utils
- polkit
- ppp - ppp
- python3 - python3
- ripgrep - ripgrep
@@ -169,7 +189,7 @@ bootstrap_fedora:
- zoxide - zoxide
- zram-generator - zram-generator
- zstd - zstd
conditional: "{{ bootstrap_common_conditional }}" conditional: "{{ bootstrap_el_runtime + bootstrap_common_conditional }}"
bootstrap_debian: bootstrap_debian:
base: base:
@@ -187,28 +207,22 @@ bootstrap_debian:
- python3 - python3
- xfsprogs - xfsprogs
extra: extra:
- apparmor-utils
- bat - bat
- chrony
- curl - curl
- entr - entr
- fish - fish
- fzf - fzf
- htop - htop
- jq - jq
- libpam-pwquality
- linux-image-amd64 - linux-image-amd64
- lrzsz - lrzsz
- mtr - mtr
- ncdu - ncdu
- needrestart
- net-tools - net-tools
- network-manager
- python-is-python3 - python-is-python3
- ripgrep - ripgrep
- rsync - rsync
- screen - screen
- sudo
- syslog-ng - syslog-ng
- tcpd - tcpd
- vim - vim
@@ -223,6 +237,7 @@ bootstrap_debian:
+ (['systemd-zram-generator'] if (os_version | string) not in ['10', '11'] else []) + (['systemd-zram-generator'] if (os_version | string) not in ['10', '11'] else [])
+ (['tldr'] if (os_version | string) not in ['13', 'unstable'] else []) + (['tldr'] if (os_version | string) not in ['13', 'unstable'] 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 + bootstrap_common_conditional
}} }}
@@ -244,10 +259,8 @@ bootstrap_ubuntu:
- python3 - python3
- xfsprogs - xfsprogs
extra: extra:
- apparmor-utils
- bash-completion - bash-completion
- bat - bat
- chrony
- curl - curl
- dnsutils - dnsutils
- duf - duf
@@ -259,20 +272,16 @@ bootstrap_ubuntu:
- fzf - fzf
- htop - htop
- jq - jq
- libpam-pwquality
- lrzsz - lrzsz
- mtr - mtr
- ncdu - ncdu
- ncurses-term - ncurses-term
- needrestart
- net-tools - net-tools
- network-manager
- python-is-python3 - python-is-python3
- ripgrep - ripgrep
- rsync - rsync
- screen - screen
- software-properties-common - software-properties-common
- sudo
- syslog-ng - syslog-ng
- systemd-zram-generator - systemd-zram-generator
- tcpd - tcpd
@@ -285,8 +294,8 @@ bootstrap_ubuntu:
- zstd - zstd
conditional: >- 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 + bootstrap_common_conditional
}} }}
@@ -313,7 +322,6 @@ bootstrap_archlinux:
- nfs-utils - nfs-utils
- ppp - ppp
- python - python
- reflector
- rsync - rsync
- sudo - sudo
- tldr - tldr
@@ -326,5 +334,6 @@ bootstrap_archlinux:
(['openssh'] if system_cfg.features.ssh.enabled | bool else []) (['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 []) + (['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 []) + (['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) + (bootstrap_common_conditional | reject('equalto', 'nftables') | list)
}} }}

View File

@@ -1,91 +1,4 @@
--- ---
# 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: cis_permission_targets:
- {path: "/mnt/etc/ssh/sshd_config", mode: "0600"} - {path: "/mnt/etc/ssh/sshd_config", mode: "0600"}
- {path: "/mnt/etc/cron.hourly", mode: "0700"} - {path: "/mnt/etc/cron.hourly", mode: "0700"}

View File

@@ -1,10 +1,25 @@
--- ---
- name: Normalize CIS input - name: Determine CIS profile
ansible.builtin.set_fact: ansible.builtin.set_fact:
cis_enabled: "{{ cis is defined and (cis is mapping or cis | bool) }}" cis_profile: "{{ system_cfg.features.cis.profile | default('default') }}"
cis_input: "{{ cis if cis is mapping else {} }}"
- name: Normalize CIS configuration - name: Validate CIS profile selection
when: cis_enabled and cis_cfg is not defined 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: 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 - name: Ensure the Default UMASK is Set Correctly
when: cis_effective_rules.umask_default | default(false)
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: "/mnt/etc/profile" path: "/mnt/etc/profile"
regexp: "^(\\s*)umask\\s+\\d+" regexp: "^(\\s*)umask\\s+\\d+"
line: "umask {{ cis_cfg.umask_profile }}" 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) # Non-RHEL/non-Debian distros: loop evaluates to [] (intentional skip)
- name: Prevent Login to Accounts With Empty Password - name: Prevent Login to Accounts With Empty Password
when:
- cis_effective_rules.empty_password_login | default(false)
- not is_authselect | bool
ansible.builtin.replace: ansible.builtin.replace:
dest: "{{ item }}" dest: "{{ item }}"
regexp: "\\s*nullok" regexp: "\\s*nullok"

View File

@@ -1,13 +1,20 @@
--- ---
# Fedora ships its own crypto-policies preset and update-crypto-policies # Fedora ships its own crypto-policies preset and update-crypto-policies
# behaves differently; applying DEFAULT:NO-SHA1 can break package signing. # 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 - name: Configure System Cryptography Policy
when: os in (os_family_rhel | difference(['fedora'])) vars:
ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set DEFAULT:NO-SHA1" _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 register: cis_crypto_policy_result
changed_when: "'Setting system-wide crypto-policies to' in cis_crypto_policy_result.stdout" changed_when: "'Setting system-wide crypto-policies to' in cis_crypto_policy_result.stdout"
- name: Mask Systemd Services - name: Mask Systemd Services
when: cis_effective_rules.mask_services | default(false)
ansible.builtin.command: > ansible.builtin.command: >
{{ chroot_command }} systemctl mask {{ 'nftables' if system_cfg.features.firewall.toolkit == 'iptables' else 'iptables' }} bluetooth rpcbind {{ chroot_command }} systemctl mask {{ 'nftables' if system_cfg.features.firewall.toolkit == 'iptables' else 'iptables' }} bluetooth rpcbind
register: cis_mask_services_result 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: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
state: touch state: touch
@@ -7,10 +8,19 @@
loop: loop:
- /mnt/etc/at.allow - /mnt/etc/at.allow
- /mnt/etc/cron.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.allow
- /mnt/etc/hosts.deny - /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: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
state: absent 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 ansible.builtin.import_tasks: _normalize.yml
- name: Apply CIS hardening - name: Apply CIS hardening
when: cis_enabled
block: block:
- name: Include CIS hardening tasks - name: Include CIS hardening tasks
ansible.builtin.include_tasks: "{{ cis_task }}" ansible.builtin.include_tasks: "{{ cis_task }}"
@@ -16,5 +15,11 @@
- security_lines.yml - security_lines.yml
- permissions.yml - permissions.yml
- sshd.yml - sshd.yml
- warning_banners.yml
- password_expiry.yml
- aide.yml
- auditd.yml
- packages.yml
- grub_password.yml
loop_control: loop_control:
loop_var: cis_task loop_var: cis_task

View File

@@ -1,5 +1,6 @@
--- ---
- name: Disable Kernel Modules - name: Disable Kernel Modules
when: cis_effective_rules.module_blacklist | default(false)
vars: vars:
# Ubuntu uses squashfs for snap packages - blacklisting it breaks snap entirely # Ubuntu uses squashfs for snap packages - blacklisting it breaks snap entirely
cis_modules_squashfs: "{{ [] if os in ['ubuntu', 'ubuntu-lts'] else ['squashfs'] }}" cis_modules_squashfs: "{{ [] if os in ['ubuntu', 'ubuntu-lts'] else ['squashfs'] }}"
@@ -14,11 +15,13 @@
{% endfor %} {% endfor %}
- name: Remove old USB rules file - name: Remove old USB rules file
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.file: ansible.builtin.file:
path: /mnt/etc/udev/rules.d/10-cis_usb_devices.sh path: /mnt/etc/udev/rules.d/10-cis_usb_devices.sh
state: absent state: absent
- name: Create USB rules - name: Create USB rules
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.copy: ansible.builtin.copy:
dest: /mnt/etc/udev/rules.d/10-cis_usb_devices.rules dest: /mnt/etc/udev/rules.d/10-cis_usb_devices.rules
mode: "0644" 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 - name: Check CIS permission targets
when: cis_effective_rules.file_permissions | default(false)
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ item.path }}" path: "{{ item.path }}"
loop: "{{ cis_permission_targets }}" loop: "{{ cis_permission_targets }}"
@@ -9,12 +10,14 @@
changed_when: false changed_when: false
- name: Set permissions for existing targets - name: Set permissions for existing targets
when:
- cis_effective_rules.file_permissions | default(false)
- item.stat.exists
ansible.builtin.file: ansible.builtin.file:
path: "{{ item.item.path }}" path: "{{ item.item.path }}"
owner: "{{ item.item.owner | default(omit) }}" owner: "{{ item.item.owner | default(omit) }}"
group: "{{ item.item.group | default(omit) }}" group: "{{ item.item.group | default(omit) }}"
mode: "{{ item.item.mode }}" mode: "{{ item.item.mode }}"
loop: "{{ cis_permission_stats.results }}" loop: "{{ cis_permission_stats.results | default([]) }}"
loop_control: loop_control:
label: "{{ item.item.path }}" 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: ansible.builtin.lineinfile:
path: "{{ item.path }}" path: "{{ item.path }}"
regexp: "{{ item.regexp }}" regexp: "{{ item.regexp }}"
line: "{{ item.content }}" line: "{{ item.line }}"
loop: loop:
- { path: /mnt/etc/security/limits.conf, regexp: '^\*\s+hard\s+core\s+', content: "* hard core 0" } - path: '/mnt/etc/{{ "pam.d/common-auth" if is_debian | bool else "pam.d/system-auth" }}'
- { 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"
}}
regexp: '^\s*auth\s+required\s+pam_faillock\.so' 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 }} auth required pam_faillock.so onerr=fail audit silent deny={{ cis_cfg.faillock_deny }} unlock_time={{ cis_cfg.faillock_unlock_time }}
- path: >- - path: '/mnt/etc/{{ "pam.d/common-account" if is_debian | bool else "pam.d/system-auth" }}'
/mnt/etc/{{
"pam.d/common-account"
if is_debian | bool
else "authselect/system-auth"
if os == "fedora"
else "pam.d/system-auth"
}}
regexp: '^\s*account\s+required\s+pam_faillock\.so' regexp: '^\s*account\s+required\s+pam_faillock\.so'
content: account required pam_faillock.so line: account required pam_faillock.so
- path: >- loop_control:
label: "{{ item.regexp }}"
- name: Enforce password history
when: cis_effective_rules.password_history | default(false)
ansible.builtin.lineinfile:
path: >-
/mnt/etc/pam.d/{{ /mnt/etc/pam.d/{{
"common-password" "common-password"
if is_debian | bool if is_debian | bool
else "passwd" else "passwd"
}} }}
regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so' regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so'
content: >- line: >-
password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }} 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" } # 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: loop_control:
label: "{{ item.content }}" 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 - name: Adjust SSHD config
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /mnt/etc/ssh/sshd_config path: /mnt/etc/ssh/sshd_config
regexp: ^\s*#?{{ item.option }}\s+.*$ regexp: ^\s*#?{{ item.option }}\s+.*$
@@ -9,6 +10,7 @@
label: "{{ item.option }}" label: "{{ item.option }}"
- name: Detect target OpenSSH version - name: Detect target OpenSSH version
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.shell: >- ansible.builtin.shell: >-
set -o pipefail && {{ chroot_command }} ssh -V 2>&1 | grep -oP 'OpenSSH_\K[0-9]+\.[0-9]+' set -o pipefail && {{ chroot_command }} ssh -V 2>&1 | grep -oP 'OpenSSH_\K[0-9]+\.[0-9]+'
args: args:
@@ -18,6 +20,7 @@
failed_when: false failed_when: false
- name: Append CIS specific configurations to sshd_config - name: Append CIS specific configurations to sshd_config
when: cis_effective_rules.sshd_hardening | default(false)
vars: vars:
cis_sshd_has_mlkem: "{{ (cis_sshd_openssh_version.stdout | default('0.0') is version('9.9', '>=')) }}" cis_sshd_has_mlkem: "{{ (cis_sshd_openssh_version.stdout | default('0.0') is version('9.9', '>=')) }}"
cis_sshd_kex: >- cis_sshd_kex: >-

View File

@@ -1,10 +1,19 @@
--- ---
- name: Create a consolidated sysctl configuration file - 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: 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" mode: "0644"
content: | content: |
## CIS Sysctl configurations ## CIS Sysctl configurations
{% for key, value in cis_cfg.sysctl | dictsort %} {% for key, value in _cis_sysctl | dictsort %}
{{ key }}={{ value }} {{ key }}={{ value }}
{% endfor %} {% 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 ship fusermount.
# fusermount3 is the modern name; older distros still use fusermount.
cis_fusermount_binary: >- cis_fusermount_binary: >-
{{ {{
'fusermount3' 'fusermount3'
@@ -19,3 +18,235 @@ cis_write_binary: >-
if (os == 'debian' and (os_version | string) == '11') if (os == 'debian' and (os_version | string) == '11')
else 'write' 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

@@ -3,3 +3,5 @@
cleanup_verify_boot: true cleanup_verify_boot: true
cleanup_boot_timeout: 300 cleanup_boot_timeout: 300
cleanup_remove_on_failure: true cleanup_remove_on_failure: true
cleanup_poweroff_retries: 24
cleanup_poweroff_delay: 5

View File

@@ -6,7 +6,30 @@
module_defaults: module_defaults:
community.proxmox.proxmox_disk: "{{ _proxmox_auth }}" community.proxmox.proxmox_disk: "{{ _proxmox_auth }}"
community.proxmox.proxmox_kvm: "{{ _proxmox_auth_node }}" community.proxmox.proxmox_kvm: "{{ _proxmox_auth_node }}"
community.proxmox.proxmox_vm_info: "{{ _proxmox_auth_node }}"
block: block:
- name: Wait for the installer environment to power off
community.proxmox.proxmox_vm_info:
vmid: "{{ system_cfg.id }}"
register: cleanup_proxmox_power
until: >-
((cleanup_proxmox_power.proxmox_vms | default([]) | first | default({})).status
| default('')) == 'stopped'
retries: "{{ cleanup_poweroff_retries }}"
delay: "{{ cleanup_poweroff_delay }}"
failed_when: false
no_log: true
- name: Force the installer environment off when it did not shut down
when: >-
((cleanup_proxmox_power.proxmox_vms | default([]) | first | default({})).status
| default('')) != 'stopped'
community.proxmox.proxmox_kvm:
vmid: "{{ system_cfg.id }}"
state: stopped
force: true
no_log: true
- name: Cleanup Setup Disks - name: Cleanup Setup Disks
community.proxmox.proxmox_disk: community.proxmox.proxmox_disk:
name: "{{ hostname }}" name: "{{ hostname }}"
@@ -16,13 +39,13 @@
loop: >- loop: >-
{{ {{
['ide0', 'ide2'] ['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 failed_when: false
no_log: true no_log: true
- name: Start the VM - name: Boot the installed OS
community.proxmox.proxmox_kvm: community.proxmox.proxmox_kvm:
vmid: "{{ system_cfg.id }}" vmid: "{{ system_cfg.id }}"
state: restarted state: started
no_log: true no_log: true

View File

@@ -2,6 +2,16 @@
- name: Unmount Disks - name: Unmount Disks
become: true become: true
block: 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 - name: Disable Swap
ansible.builtin.command: swapoff -a ansible.builtin.command: swapoff -a
register: cleanup_swapoff_result register: cleanup_swapoff_result

View File

@@ -5,9 +5,26 @@
become: false become: false
module_defaults: module_defaults:
community.vmware.vmware_guest: "{{ _vmware_auth }}" community.vmware.vmware_guest: "{{ _vmware_auth }}"
community.vmware.vmware_guest_info: "{{ _vmware_auth }}"
vmware.vmware.vm_powerstate: "{{ _vmware_auth }}" vmware.vmware.vm_powerstate: "{{ _vmware_auth }}"
no_log: true no_log: true
block: block:
- name: Wait for the installer environment to power off
community.vmware.vmware_guest_info:
name: "{{ hostname }}"
register: cleanup_vmware_power
until: (cleanup_vmware_power.instance.hw_power_status | default('') | lower) == 'poweredoff'
retries: "{{ cleanup_poweroff_retries }}"
delay: "{{ cleanup_poweroff_delay }}"
failed_when: false
- name: Force the installer environment off when it did not shut down
when: (cleanup_vmware_power.instance.hw_power_status | default('') | lower) != 'poweredoff'
vmware.vmware.vm_powerstate:
name: "{{ hostname }}"
state: powered-off
force: true
- name: Remove CD-ROM from VM in vCenter - name: Remove CD-ROM from VM in vCenter
community.vmware.vmware_guest: community.vmware.vmware_guest:
name: "{{ hostname }}" name: "{{ hostname }}"
@@ -35,13 +52,13 @@
} }
] ]
if (rhel_iso is defined and rhel_iso | length > 0 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 [] else []
) )
}} }}
failed_when: false failed_when: false
- name: Start VM in vCenter - name: Boot the installed OS
vmware.vmware.vm_powerstate: vmware.vmware.vm_powerstate:
name: "{{ hostname }}" name: "{{ hostname }}"
state: powered-on state: powered-on

View File

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

View File

@@ -41,6 +41,18 @@
- name: Configure sudo banner - name: Configure sudo banner
when: system_cfg.features.banner.sudo | bool when: system_cfg.features.banner.sudo | bool
block:
- 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
# 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: block:
- name: Create sudo lecture file - name: Create sudo lecture file
ansible.builtin.copy: ansible.builtin.copy:

View File

@@ -14,11 +14,11 @@
install_items+=" {{ configuration_luks_keyfile_path }} " install_items+=" {{ configuration_luks_keyfile_path }} "
{% endif %} {% endif %}
{% if configuration_luks_auto_method == 'tpm2' %} {% if configuration_luks_auto_method == 'tpm2' %}
add_dracutmodules+=" tpm2-tss "
install_items+=" {{ configuration_luks_tpm2_token_lib | default('') }} " install_items+=" {{ configuration_luks_tpm2_token_lib | default('') }} "
{% endif %} {% endif %}
mode: "0644" mode: "0644"
# --- Kernel cmdline: write rd.luks.* args for dracut ---
- name: Ensure kernel cmdline directory exists - name: Ensure kernel cmdline directory exists
ansible.builtin.file: ansible.builtin.file:
path: /mnt/etc/kernel path: /mnt/etc/kernel
@@ -58,7 +58,6 @@
mode: "0644" mode: "0644"
content: "{{ _dracut_kernel_cmdline }}\n" content: "{{ _dracut_kernel_cmdline }}\n"
# --- BLS entries: RedHat-specific ---
- name: Update BLS entries with LUKS kernel cmdline - name: Update BLS entries with LUKS kernel cmdline
when: os_family == 'RedHat' when: os_family == 'RedHat'
vars: 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 - name: Remove RHEL ISO fstab entry when not using local repo
when: when:
- os == "rhel" - os == "rhel"
- system_cfg.features.rhel_repo.source != "iso" - system_cfg.content.source != "dvd"
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /mnt/etc/fstab path: /mnt/etc/fstab
regexp: "^.*\\/dvd.*$" regexp: "^.*\\/dvd.*$"
@@ -35,7 +35,7 @@
- name: Replace ISO UUID entry with /dev/sr0 in fstab - name: Replace ISO UUID entry with /dev/sr0 in fstab
when: when:
- os == "rhel" - os == "rhel"
- system_cfg.features.rhel_repo.source == "iso" - system_cfg.content.source == "dvd"
vars: vars:
configuration_fstab_dvd_line: >- configuration_fstab_dvd_line: >-
{{ {{
@@ -53,7 +53,7 @@
when: when:
- os == "rhel" - os == "rhel"
- hypervisor_type == "vmware" - hypervisor_type == "vmware"
- system_cfg.features.rhel_repo.source == "iso" - system_cfg.content.source == "dvd"
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- dd - dd

View File

@@ -7,7 +7,7 @@
line: "{{ item.line }}" line: "{{ item.line }}"
loop: loop:
- regexp: ^GRUB_CMDLINE_LINUX_DEFAULT= - 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= - regexp: ^GRUB_TIMEOUT=
line: GRUB_TIMEOUT=1 line: GRUB_TIMEOUT=1
loop_control: loop_control:
@@ -43,19 +43,21 @@
}} }}
grub_root_flags: >- grub_root_flags: >-
{{ ['rootflags=subvol=@'] if system_cfg.filesystem == 'btrfs' else [] }} {{ ['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: >- grub_cmdline_linux_base: >-
{{ {{
(['crashkernel=auto'] + grub_lvm_args) ((grub_lvm_args | join(' ')) ~ ' ' ~ (_hardware_profile_kernel_params | default([]) | join(' '))) | trim
| join(' ')
}} }}
grub_kernel_cmdline_base: >- grub_kernel_cmdline_base: >-
{{ {{
( (
(['root=UUID=' + grub_root_uuid] (['root=UUID=' + grub_root_uuid]
if grub_root_uuid | length > 0 else []) if grub_root_uuid | length > 0 else [])
+ ['ro', 'crashkernel=auto'] + ['ro']
+ grub_lvm_args + grub_lvm_args
+ grub_root_flags + grub_root_flags
+ (_hardware_profile_kernel_params | default([]))
) )
| join(' ') | join(' ')
}} }}

View File

@@ -5,14 +5,16 @@
- name: Include configuration tasks - name: Include configuration tasks
when: configuration_task.when | default(true) when: configuration_task.when | default(true)
ansible.builtin.include_tasks: "{{ configuration_task.file }}" ansible.builtin.include_tasks: "{{ configuration_task.file }}"
vars:
firewall_phase: install
loop: loop:
- file: repositories.yml - file: repositories.yml
when: "{{ os_family == 'Debian' }}"
- file: banner.yml - file: banner.yml
- file: fstab.yml - file: fstab.yml
- file: locales.yml - file: locales.yml
- file: ssh.yml - file: ssh.yml
- file: services.yml - file: services.yml
- file: firewall.yml
- file: grub.yml - file: grub.yml
- file: encryption.yml - file: encryption.yml
when: "{{ system_cfg.luks.enabled | bool }}" 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 - name: Set DNS configuration facts
ansible.builtin.set_fact: ansible.builtin.set_fact:
configuration_dns_list: "{{ system_cfg.network.dns.servers }}" configuration_dns_list: "{{ system_cfg.network.dns.servers }}"
configuration_dns_search: "{{ system_cfg.network.dns.search }}" configuration_dns_search: "{{ system_cfg.network.dns.search }}"
- name: Configure networking # 2+ unnamed interfaces would all match the first-ethernet glob, leaving the rest unconfigured.
ansible.builtin.include_tasks: "{{ configuration_network_task_map[os] | default('network_nm.yml') }}" - 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 - name: Copy NetworkManager keyfile per interface
vars: vars:
configuration_iface: "{{ item }}" configuration_iface: "{{ item }}"
configuration_iface_name: "{{ item.name | default(configuration_detected_interfaces[idx] | default('')) }}"
configuration_net_uuid: "{{ ('LAN-' ~ idx ~ '-' ~ hostname) | ansible.builtin.to_uuid }}" configuration_net_uuid: "{{ ('LAN-' ~ idx ~ '-' ~ hostname) | ansible.builtin.to_uuid }}"
ansible.builtin.template: ansible.builtin.template:
src: network.j2 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: vars:
_debian_release_map: _debian_release_map:
"10": buster
"11": bullseye
"12": bookworm "12": bookworm
"13": trixie "13": trixie
unstable: sid unstable: sid
_ubuntu_release_map: _ubuntu_release_map:
ubuntu: questing ubuntu: questing
ubuntu-lts: noble ubuntu-lts: resolute
ansible.builtin.template: ansible.builtin.template:
src: "{{ os | replace('-lts', '') }}.sources.list.j2" src: "{{ os | replace('-lts', '') }}.sources.list.j2"
dest: /mnt/etc/apt/sources.list dest: /mnt/etc/apt/sources.list
mode: "0644" mode: "0644"
- name: Ensure apt performance configuration persists - name: Ensure apt performance and content-proxy configuration
when: os_family == 'Debian'
ansible.builtin.copy: ansible.builtin.copy:
dest: /mnt/etc/apt/apt.conf.d/99performance dest: /mnt/etc/apt/apt.conf.d/99performance
content: | content: |
Acquire::Retries "3"; Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10"; Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false"; 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" 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,45 @@
---
# 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) }}"
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 register: configuration_setfiles_result
changed_when: configuration_setfiles_result.rc == 0 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 # Fedora: setfiles segfaults during bootstrap chroot relabeling, so SELinux
# is left permissive and expected to relabel on first boot. # is left permissive and expected to relabel on first boot.
- name: Disable SELinux - name: Disable SELinux

View File

@@ -4,11 +4,20 @@
vars: vars:
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}" _autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.set_fact: ansible.builtin.set_fact:
# KDE resolves to the plasmalogin unit on Arch/Fedora44+ (Plasma 6.6), else sddm.
_desktop_dm: >- _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) if (system_cfg.features.desktop.display_manager | length > 0)
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('')) else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default(''))
)
}} }}
_desktop_session: "{{ system_cfg.features.desktop.session | default('') }}" _desktop_session: "{{ system_cfg.features.desktop.session | default('') }}"
# Explicit session wins, else the per-environment command. Single source of # Explicit session wins, else the per-environment command. Single source of
@@ -31,11 +40,9 @@
vars: vars:
configuration_systemd_services: >- configuration_systemd_services: >-
{{ {{
['NetworkManager'] ['NetworkManager', _configuration_platform.time_sync_service]
+ (['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 [])
+ ([_configuration_platform.ssh_service] if system_cfg.features.ssh.enabled | bool else []) + ([_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 []) + (['bluetooth'] if system_cfg.features.desktop.enabled | bool else [])
}} }}
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ item }}" ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ item }}"
@@ -47,11 +54,24 @@
and 'No such file or directory' not in (configuration_enable_service_result.stderr | default('')) and 'No such file or directory' not in (configuration_enable_service_result.stderr | default(''))
and 'does not exist' not in (configuration_enable_service_result.stderr | default('')) and 'does not exist' not in (configuration_enable_service_result.stderr | default(''))
- name: Check for the EL qemu-guest-agent RPC allow-list
ansible.builtin.stat:
path: /mnt/etc/sysconfig/qemu-ga
register: configuration_qga_sysconfig
- name: Allow clone-stamping RPCs in the EL qemu-guest-agent allow-list
when: configuration_qga_sysconfig.stat.exists
ansible.builtin.replace:
path: /mnt/etc/sysconfig/qemu-ga
regexp: '^(FILTER_RPC_ARGS="--allow-rpcs=(?:(?!guest-exec)[^"])*)"'
replace: '\1,guest-exec,guest-exec-status,guest-file-open,guest-file-close,guest-file-read,guest-file-write"'
- name: Enable display manager for selected desktop - name: Enable display manager for selected desktop
when: when:
- _configuration_platform.init_system == 'systemd' - _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool - system_cfg.features.desktop.enabled | bool
- _desktop_dm | length > 0 - _desktop_dm | length > 0
- _desktop_dm != 'ly'
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ _desktop_dm }}" ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ _desktop_dm }}"
register: configuration_enable_dm_result register: configuration_enable_dm_result
changed_when: configuration_enable_dm_result.rc == 0 changed_when: configuration_enable_dm_result.rc == 0
@@ -62,22 +82,40 @@
or 'No such file or directory' in (configuration_enable_dm_result.stderr | default('')) or 'No such file or directory' in (configuration_enable_dm_result.stderr | default(''))
or 'does not exist' in (configuration_enable_dm_result.stderr | default('')) or 'does not exist' in (configuration_enable_dm_result.stderr | default(''))
- name: Activate UFW firewall - name: Enable ly on its tty
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
when: when:
- _configuration_platform.init_system == 'systemd' - _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool - system_cfg.features.desktop.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl set-default graphical.target" - _desktop_dm == 'ly'
register: _desktop_target_result vars:
changed_when: _desktop_target_result.rc == 0 _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 - name: Enable PipeWire user services globally
when: when:
@@ -134,8 +172,7 @@
- _desktop_dm == 'gdm' - _desktop_dm == 'gdm'
- _desktop_autologin_user | length > 0 - _desktop_autologin_user | length > 0
vars: vars:
# Debian's gdm3 reads /etc/gdm3/daemon.conf; RedHat/Arch GDM read # Debian gdm3 reads daemon.conf; RedHat/Arch gdm read custom.conf.
# /etc/gdm/custom.conf. The keys are identical, only the path differs.
_gdm_dir: "/mnt/etc/{{ 'gdm3' if os_family == 'Debian' else 'gdm' }}" _gdm_dir: "/mnt/etc/{{ 'gdm3' if os_family == 'Debian' else 'gdm' }}"
_gdm_conf: "{{ 'daemon.conf' if os_family == 'Debian' else 'custom.conf' }}" _gdm_conf: "{{ 'daemon.conf' if os_family == 'Debian' else 'custom.conf' }}"
block: block:
@@ -151,16 +188,20 @@
dest: "{{ _gdm_dir }}/{{ _gdm_conf }}" dest: "{{ _gdm_dir }}/{{ _gdm_conf }}"
mode: "0644" 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: when:
- _configuration_platform.init_system == 'systemd' - _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool - system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'sddm' - _desktop_dm in ['sddm', 'plasmalogin']
- _desktop_autologin_user | length > 0 - _desktop_autologin_user | length > 0
vars:
_autologin_conf_dir: "/mnt/etc/{{ 'plasmalogin.conf.d' if _desktop_dm == 'plasmalogin' else 'sddm.conf.d' }}"
block: block:
- name: Ensure SDDM config directory exists - name: Ensure KDE login-manager config directory exists
ansible.builtin.file: ansible.builtin.file:
path: /mnt/etc/sddm.conf.d path: "{{ _autologin_conf_dir }}"
state: directory state: directory
mode: "0755" mode: "0755"
@@ -179,8 +220,29 @@
{%- set names = _kde_wayland_sessions.files | map(attribute='path') | map('basename') | list -%} {%- set names = _kde_wayland_sessions.files | map(attribute='path') | map('basename') | list -%}
{{ 'plasma.desktop' if 'plasma.desktop' in names else (names | first | default('')) }} {{ '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: ansible.builtin.template:
src: sddm-autologin.conf.j2 src: sddm-autologin.conf.j2
dest: /mnt/etc/sddm.conf.d/10-autologin.conf dest: "{{ _autologin_conf_dir }}/10-autologin.conf"
mode: "0644" 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

@@ -15,8 +15,7 @@
validate: /usr/sbin/visudo --check --file=%s validate: /usr/sbin/visudo --check --file=%s
- name: Deploy per-user sudoers rules - name: Deploy per-user sudoers rules
# Jinja truthiness: bool true / a rule string => deploy; false / '' / unset => skip. when: (item.value.sudo | default(false)) not in [false, '', none]
when: item.value.sudo | default(false)
vars: vars:
configuration_sudoers_rule: >- configuration_sudoers_rule: >-
{{ item.value.sudo if item.value.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }} {{ item.value.sudo if item.value.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }}

View File

@@ -26,17 +26,22 @@
- name: Create user accounts - name: Create user accounts
vars: vars:
configuration_user_group: "{{ _configuration_platform.user_group }}"
# plaintext is hashed; a pre-computed crypt hash ($6$/$y$/...) passes through. # plaintext is hashed; a pre-computed crypt hash ($6$/$y$/...) passes through.
configuration_user_pw: >- configuration_user_pw: >-
{{ item.value.password if (item.value.password | string)[:1] == '$' {{ item.value.password if (item.value.password | string)[:1] == '$'
else item.value.password | password_hash('sha512') }} else item.value.password | password_hash('sha512') }}
configuration_user_groups: >-
{{ item.value.groups | default(
[_configuration_platform.user_group]
if (item.value.sudo | default(false)) not in [false, '', none]
else []) }}
configuration_useradd_cmd: >- configuration_useradd_cmd: >-
{{ chroot_command }} /usr/sbin/useradd --create-home --user-group {{ chroot_command }} /usr/sbin/useradd --create-home --user-group
--uid {{ 1000 + _idx }} --uid {{ 1000 + _idx }}
--groups {{ configuration_user_group }} {{ item.key }} {{ ('--groups ' ~ (configuration_user_groups | join(','))) if (configuration_user_groups | length) > 0 else '' }}
{{ ('--password ' ~ configuration_user_pw) if (item.value.password | default('') | string | length > 0) else '' }} {{ ('--password ' ~ configuration_user_pw) if (item.value.password | default('') | string | length > 0) else '' }}
--shell {{ item.value.shell | default('/bin/bash') }} --shell {{ item.value.shell | default('/bin/bash') }}
{{ item.key }}
ansible.builtin.command: "{{ configuration_useradd_cmd }}" ansible.builtin.command: "{{ configuration_useradd_cmd }}"
loop: "{{ system_cfg.users | dict2items }}" loop: "{{ system_cfg.users | dict2items }}"
loop_control: loop_control:

View File

@@ -1,7 +1,7 @@
# Managed by Ansible. # Managed by Ansible.
{% set release = _debian_release_map[os_version | string] | default('trixie') %} {% set release = _debian_release_map[os_version | string] | default('trixie') %}
{% set mirror = system_cfg.mirror %} {% set mirror = system_cfg.content.url | default('http://deb.debian.org/debian', true) %}
{% set components = 'main contrib non-free' ~ (' non-free-firmware' if (os_version | string) not in ['10', '11'] else '') %} {% set components = 'main contrib non-free non-free-firmware' %}
deb {{ mirror }} {{ release }} {{ components }} deb {{ mirror }} {{ release }} {{ components }}
deb-src {{ 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 }} uuid={{ configuration_net_uuid }}
type=ethernet type=ethernet
autoconnect-priority=10 autoconnect-priority=10
{% if configuration_iface_name | length > 0 %}
interface-name={{ configuration_iface_name }}
{% endif %}
[ipv4]
{% set iface = configuration_iface %} {% 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 dns_list = configuration_dns_list %}
{% set search_list = configuration_dns_search %} {% set search_list = configuration_dns_search %}
{% if iface.ip | default('') | string | length %} {% 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. # Managed by Ansible.
{% set release = _ubuntu_release_map[os] | default('noble') %} {% set release = _ubuntu_release_map[os] | default('resolute') %}
{% set mirror = system_cfg.mirror %} {% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %} {% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }} deb {{ mirror }} {{ release }} {{ components }}

View File

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

View File

@@ -1,16 +1,21 @@
--- ---
# Connection and timing
environment_wait_timeout: 180 environment_wait_timeout: 180
environment_wait_delay: 5 environment_wait_delay: 5
# Pacman installer settings
environment_parallel_downloads: 20 environment_parallel_downloads: 20
environment_pacman_lock_timeout: 120 environment_pacman_lock_timeout: 120
environment_pacman_retries: 4 environment_pacman_retries: 4
environment_pacman_retry_delay: 15 environment_pacman_retry_delay: 15
# PCI vendor IDs -> vendor codes used by hardware detection. # Installer-tool libraries whose soname may have bumped past the ISO. Each one's
# Only vendors that drive distinct firmware/driver packages are mapped. # 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: environment_pci_vendor_map:
"8086": intel "8086": intel
"1002": amd "1002": amd
@@ -29,8 +34,8 @@ environment_pci_vendor_map:
"1cf3": cirrus "1cf3": cirrus
"13d7": cirrus "13d7": cirrus
# USB vendor IDs of fingerprint readers supported by libfprint / fprintd. # USB vendor IDs of fingerprint readers supported by libfprint / fprintd,
# Lowercase, four-digit hex; matched against `lsusb` output. # matched against `lsusb` output.
environment_fingerprint_vendor_ids: environment_fingerprint_vendor_ids:
- "06cb" # Synaptics (modern ThinkPad/Dell) - "06cb" # Synaptics (modern ThinkPad/Dell)
- "138a" # Validity Sensors (older ThinkPad) - "138a" # Validity Sensors (older ThinkPad)
@@ -41,3 +46,15 @@ environment_fingerprint_vendor_ids:
- "08ff" # AuthenTec (legacy) - "08ff" # AuthenTec (legacy)
- "147e" # Upek (legacy) - "147e" # Upek (legacy)
- "1491" # Futronic - "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. # A user-supplied override profile skips detection (golden-image flow: bake an
# # image with a fixed profile).
# 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.
- name: Resolve hardware detection requirement - name: Resolve hardware detection requirement
ansible.builtin.set_fact: ansible.builtin.set_fact:
_hardware_detection_needed: >- _hardware_detection_needed: >-
@@ -37,7 +21,12 @@
gpus: "{{ _hardware_profile_override.gpus | default([]) | map('lower') | list }}" gpus: "{{ _hardware_profile_override.gpus | default([]) | map('lower') | list }}"
nvidia_supports_open: "{{ _hardware_profile_override.nvidia_supports_open | default(true) | bool }}" nvidia_supports_open: "{{ _hardware_profile_override.nvidia_supports_open | default(true) | bool }}"
wireless: "{{ _hardware_profile_override.wireless | default([]) | map('lower') | list }}" 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 }}" 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 - name: Detect hardware from live host
when: when:
@@ -61,71 +50,7 @@
failed_when: false failed_when: false
- name: Resolve detected hardware profile - name: Resolve detected hardware profile
vars: ansible.builtin.include_tasks: _resolve_hardware_profile.yml
_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 }}"
- name: Initialize empty hardware profile when detection skipped - name: Initialize empty hardware profile when detection skipped
when: not (_hardware_detection_needed | bool) when: not (_hardware_detection_needed | bool)
@@ -135,7 +60,14 @@
gpus: [] gpus: []
nvidia_supports_open: true nvidia_supports_open: true
wireless: [] wireless: []
audio: []
fingerprint: false 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 - name: Report active hardware profile
when: _hardware_detection_needed | bool when: _hardware_detection_needed | bool
@@ -146,4 +78,7 @@
gpus={{ hardware_profile_active.gpus | default([]) | join(',') | default('-', true) }} gpus={{ hardware_profile_active.gpus | default([]) | join(',') | default('-', true) }}
{{ '(open-supported)' if hardware_profile_active.nvidia_supports_open | bool else '(legacy)' }}, {{ '(open-supported)' if hardware_profile_active.nvidia_supports_open | bool else '(legacy)' }},
wireless={{ hardware_profile_active.wireless | default([]) | join(',') | default('-', true) }}, 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 }}" timeout: "{{ environment_pacman_lock_timeout }}"
changed_when: false 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: when:
- not (custom_iso | bool) - 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: community.general.pacman:
update_cache: true update_cache: true
force: true name: "{{ environment_pacman_closure }}"
name: "{{ item.name }}"
state: latest state: latest
loop: register: environment_tool_install
- { name: glibc } until: environment_tool_install is succeeded
- { 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 }}"
retries: "{{ environment_pacman_retries }}" retries: "{{ environment_pacman_retries }}"
delay: "{{ environment_pacman_retry_delay }}" delay: "{{ environment_pacman_retry_delay }}"
@@ -45,28 +73,25 @@
mode: "0755" mode: "0755"
- name: Detect RHEL ISO device - name: Detect RHEL ISO device
ansible.builtin.command: lsblk -rno NAME,TYPE ansible.builtin.command: lsblk -rbno NAME,TYPE,SIZE
register: environment_lsblk_result register: environment_lsblk_result
changed_when: false changed_when: false
- name: Select RHEL ISO device - name: Select RHEL ISO device
vars: vars:
_rom_devices: >- _roms: >-
{{ {%- set out = [] -%}
environment_lsblk_result.stdout_lines {%- for line in environment_lsblk_result.stdout_lines -%}
| map('split', ' ') {%- set p = line.split() -%}
| selectattr('1', 'equalto', 'rom') {%- if (p | length) >= 3 and p[1] == 'rom' -%}
| map('first') {%- set _ = out.append({'name': p[0], 'size': p[2] | int}) -%}
| map('regex_replace', '^', '/dev/') {%- endif -%}
| list {%- endfor -%}
}} {{ out }}
ansible.builtin.set_fact: ansible.builtin.set_fact:
environment_rhel_iso_device: >- environment_rhel_iso_device: >-
{{ {{ ('/dev/' ~ (_roms | sort(attribute='size') | last).name)
_rom_devices[-1] if (_roms | length) > 0 else '/dev/sr1' }}
if _rom_devices | length > 1
else (_rom_devices[0] | default('/dev/sr1'))
}}
- name: Mount RHEL ISO - name: Mount RHEL ISO
ansible.posix.mount: ansible.posix.mount:
@@ -76,10 +101,8 @@
opts: "ro,loop" opts: "ro,loop"
state: mounted state: mounted
# Security note: RPM Sequoia signature policy is relaxed to allow # RPM Sequoia signature policy is relaxed because the Arch ISO host does not
# bootstrapping RHEL-family distros from the Arch ISO, where the # trust target-distro GPG keys; the target's own rpm re-verifies after reboot.
# host rpm/dnf does not trust target distro GPG keys. Package
# integrity is verified by the target system's own rpm after reboot.
- name: Create RPM macros directory - name: Create RPM macros directory
when: is_rhel | bool when: is_rhel | bool
ansible.builtin.file: 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] [appstream]
name=AlmaLinux $releasever - AppStream name=AlmaLinux $releasever - AppStream
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/appstream mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/appstream
# baseurl=https://repo.almalinux.org/almalinux/$releasever/AppStream/$basearch/os/ # baseurl=https://repo.almalinux.org/almalinux/$releasever/AppStream/$basearch/os/
enabled=1 enabled=1
gpgcheck=1 gpgcheck=0
countme=1 countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400 metadata_expire=86400
@@ -14,7 +15,7 @@ name=AlmaLinux $releasever - BaseOS
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/baseos mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/baseos
# baseurl=https://repo.almalinux.org/almalinux/$releasever/BaseOS/$basearch/os/ # baseurl=https://repo.almalinux.org/almalinux/$releasever/BaseOS/$basearch/os/
enabled=1 enabled=1
gpgcheck=1 gpgcheck=0
countme=1 countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400 metadata_expire=86400
@@ -25,7 +26,7 @@ name=AlmaLinux $releasever - Extras
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/extras mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/extras
# baseurl=https://repo.almalinux.org/almalinux/$releasever/extras/$basearch/os/ # baseurl=https://repo.almalinux.org/almalinux/$releasever/extras/$basearch/os/
enabled=1 enabled=1
gpgcheck=1 gpgcheck=0
countme=1 countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400 metadata_expire=86400
@@ -36,7 +37,7 @@ name=AlmaLinux $releasever - HighAvailability
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/highavailability mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/highavailability
# baseurl=https://repo.almalinux.org/almalinux/$releasever/HighAvailability/$basearch/os/ # baseurl=https://repo.almalinux.org/almalinux/$releasever/HighAvailability/$basearch/os/
enabled=1 enabled=1
gpgcheck=1 gpgcheck=0
countme=1 countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400 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] [rhel{{ os_version_major }}-baseos]
name=RHEL {{ os_version_major }} BaseOS name=RHEL {{ os_version_major }} BaseOS
baseurl=file:///usr/local/install/redhat/dvd/BaseOS baseurl={{ _baseurl }}/BaseOS
enabled=1 enabled=1
gpgcheck=0 gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release 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] [rhel{{ os_version_major }}-appstream]
name=RHEL {{ os_version_major }} AppStream name=RHEL {{ os_version_major }} AppStream
baseurl=file:///usr/local/install/redhat/dvd/AppStream baseurl={{ _baseurl }}/AppStream
enabled=1 enabled=1
gpgcheck=0 gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release 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] [baseos]
name=Rocky Linux $releasever - BaseOS name=Rocky Linux $releasever - BaseOS
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=BaseOS-$releasever mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=BaseOS-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/BaseOS/$basearch/os/ #baseurl=http://dl.rockylinux.org/$contentdir/$releasever/BaseOS/$basearch/os/
gpgcheck=1 gpgcheck=0
enabled=1 enabled=1
countme=1 countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever
@@ -13,7 +14,7 @@ enabled_metadata=1
name=Rocky Linux $releasever - AppStream name=Rocky Linux $releasever - AppStream
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=AppStream-$releasever mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=AppStream-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/AppStream/$basearch/os/ #baseurl=http://dl.rockylinux.org/$contentdir/$releasever/AppStream/$basearch/os/
gpgcheck=1 gpgcheck=0
enabled=1 enabled=1
countme=1 countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever 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: os_family_rhel:
- almalinux - almalinux
- fedora - fedora
@@ -10,8 +9,7 @@ os_family_debian:
- ubuntu - ubuntu
- ubuntu-lts - ubuntu-lts
# OS -> family mapping - aligns with the main project's ansible_os_family pattern. # OS -> family, so roles do platform_config lookups instead of is_rhel when-chains.
# Enables platform_config dict lookups per role instead of inline when: is_rhel chains.
os_family_map: os_family_map:
almalinux: RedHat almalinux: RedHat
archlinux: Archlinux archlinux: Archlinux
@@ -40,6 +38,8 @@ hypervisor_defaults:
url: "" url: ""
username: "" username: ""
password: "" password: ""
token_id: ""
token_secret: ""
node: "" node: ""
storage: "" storage: ""
datacenter: "" datacenter: ""
@@ -58,6 +58,8 @@ system_defaults:
version: "" version: ""
filesystem: "ext4" filesystem: "ext4"
name: "" name: ""
# consumed by the golden produce/deploy wrappers, not the bootstrap itself
source: ""
id: "" id: ""
cpus: 0 cpus: 0
memory: 0 # MiB memory: 0 # MiB
@@ -76,7 +78,23 @@ system_defaults:
timezone: "Europe/Vienna" timezone: "Europe/Vienna"
locale: "en_US.UTF-8" locale: "en_US.UTF-8"
keymap: "us" keymap: "us"
mirror: "" # source: dvd|mirror|satellite|none ('' -> family default: rhel=dvd, else mirror;
# almalinux/rocky/fedora have public mirrors, rhel does not).
# 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: [] packages: []
disks: [] disks: []
users: {} users: {}
@@ -101,13 +119,18 @@ system_defaults:
bits: 512 bits: 512
pbkdf: "argon2id" pbkdf: "argon2id"
features: features:
# On only for the clone-deploy golden path; off keeps ansible-direct + smaller image.
cloud_init: false
cis: cis:
enabled: false 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: selinux:
enabled: true enabled: true
firewall: firewall:
enabled: true enabled: true
backend: "firewalld" # firewalld|ufw backend: "" # '' -> family default (EL/arch=firewalld, debian/ubuntu=ufw); override: firewalld|ufw
toolkit: "nftables" # nftables|iptables toolkit: "nftables" # nftables|iptables
ssh: ssh:
enabled: true enabled: true
@@ -118,9 +141,6 @@ system_defaults:
banner: banner:
motd: false motd: false
sudo: true 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: chroot:
tool: "arch-chroot" # arch-chroot|chroot|systemd-nspawn tool: "arch-chroot" # arch-chroot|chroot|systemd-nspawn
initramfs: initramfs:
@@ -128,7 +148,7 @@ system_defaults:
desktop: desktop:
enabled: false enabled: false
environment: "" # gnome|kde|sway|hyprland 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 autologin: false # false | username from system.users
session: "" # session name/command for the autologin user session: "" # session name/command for the autologin user
groups: [] # opt-in package groups (keys of desktop_package_groups) groups: [] # opt-in package groups (keys of desktop_package_groups)
@@ -143,17 +163,30 @@ system_defaults:
nvidia_driver: "auto" # auto | open | proprietary | nouveau nvidia_driver: "auto" # auto | open | proprietary | nouveau
peripherals: peripherals:
enabled: "auto" # auto = follows desktop.enabled enabled: "auto" # auto = follows desktop.enabled
fingerprint: "auto" fingerprint: "auto" # auto|true|false (auto = install when detected)
webcam: "auto" 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 displaylink: false
hardware: 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. # Drives data-driven validation. Virtual types also require a network bridge or interfaces.
# All virtual types additionally require network bridge or interfaces.
hypervisor_required_fields: hypervisor_required_fields:
proxmox: proxmox:
hypervisor: [url, username, password, node, storage] hypervisor: [url, username, node, storage]
system: [id] system: [id]
vmware: vmware:
hypervisor: [url, username, password, datacenter, storage] hypervisor: [url, username, password, datacenter, storage]
@@ -165,8 +198,13 @@ hypervisor_required_fields:
hypervisor: [] hypervisor: []
system: [] system: []
# Hypervisor-to-disk device prefix mapping for virtual machines. # Used when content.url is empty.
# Physical installs must set system.disks[].device explicitly. 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: hypervisor_disk_device_map:
libvirt: "/dev/vd" libvirt: "/dev/vd"
xen: "/dev/xvd" 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 if (system_raw.name | default('') | string | trim | length) > 0
else inventory_hostname 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: ansible.builtin.set_fact:
system_cfg: system_cfg:
# --- Identity & platform ---
type: "{{ system_type }}" type: "{{ system_type }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}" 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 }}" version: "{{ system_raw.version | default('') | string }}"
filesystem: "{{ system_raw.filesystem | default('') | string | lower }}" filesystem: "{{ system_raw.filesystem | default('') | string | lower }}"
name: "{{ system_name }}" name: "{{ system_name }}"
id: "{{ system_raw.id | default('') | string }}" id: "{{ system_raw.id | default('') | string }}"
# --- VM sizing (ignored for physical) ---
cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}" cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}"
memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}" memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}"
balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}" balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}"
# --- Network --- # Flat fields and interfaces[] describe the same primary NIC: each is
# Flat fields (bridge, ip, etc.) and interfaces[] express the same primary NIC. # backfilled from the other so consumers reading either form still work.
# 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.
network: network:
bridge: >- bridge: >-
{{ {{
@@ -87,16 +77,24 @@
else [] else []
) )
}} }}
# --- Locale & environment ---
timezone: "{{ system_raw.timezone | string }}" timezone: "{{ system_raw.timezone | string }}"
locale: "{{ system_raw.locale | string }}" locale: "{{ system_raw.locale | string }}"
keymap: "{{ system_raw.keymap | string }}" keymap: "{{ system_raw.keymap | string }}"
mirror: >- content:
{{ # Family defaults for empty source/url are applied by _apply_family_defaults.yml.
system_raw.mirror | string | trim source: "{{ system_raw.content.source | default('') | string | lower | trim }}"
if (system_raw.mirror | default('') | string | trim | length) > 0 url: "{{ system_raw.content.url | default('') | string | trim }}"
else _mirror_defaults[system_raw.os | default('') | string | lower] | default('') 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: >- path: >-
{{ {{
(system_raw.path | default('') | string) (system_raw.path | default('') | string)
@@ -114,13 +112,11 @@
| reject('equalto', '') | reject('equalto', '')
| list | list
}} }}
# --- Storage & accounts ---
disks: "{{ system_raw.disks | default([]) }}" disks: "{{ system_raw.disks | default([]) }}"
users: "{{ system_raw.users | default({}) }}" users: "{{ system_raw.users | default({}) }}"
root: root:
password: "{{ system_raw.root.password | string }}" password: "{{ system_raw.root.password | string }}"
shell: "{{ system_raw.root.shell | default('/bin/bash') | string }}" shell: "{{ system_raw.root.shell | default('/bin/bash') | string }}"
# --- LUKS disk encryption ---
luks: luks:
enabled: "{{ system_raw.luks.enabled | bool }}" enabled: "{{ system_raw.luks.enabled | bool }}"
passphrase: "{{ system_raw.luks.passphrase | string }}" passphrase: "{{ system_raw.luks.passphrase | string }}"
@@ -138,15 +134,19 @@
iter: "{{ system_raw.luks.iter | int }}" iter: "{{ system_raw.luks.iter | int }}"
bits: "{{ system_raw.luks.bits | int }}" bits: "{{ system_raw.luks.bits | int }}"
pbkdf: "{{ system_raw.luks.pbkdf | string }}" pbkdf: "{{ system_raw.luks.pbkdf | string }}"
# --- Feature flags ---
features: features:
cloud_init: "{{ system_raw.features.cloud_init | default(false) | bool }}"
cis: cis:
enabled: "{{ system_raw.features.cis.enabled | bool }}" 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: selinux:
enabled: "{{ system_raw.features.selinux.enabled | bool }}" enabled: "{{ system_raw.features.selinux.enabled | bool }}"
firewall: firewall:
enabled: "{{ system_raw.features.firewall.enabled | bool }}" 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 }}" toolkit: "{{ system_raw.features.firewall.toolkit | string | lower }}"
ssh: ssh:
enabled: "{{ system_raw.features.ssh.enabled | bool }}" enabled: "{{ system_raw.features.ssh.enabled | bool }}"
@@ -157,9 +157,6 @@
banner: banner:
motd: "{{ system_raw.features.banner.motd | bool }}" motd: "{{ system_raw.features.banner.motd | bool }}"
sudo: "{{ system_raw.features.banner.sudo | 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: chroot:
tool: "{{ system_raw.features.chroot.tool | string }}" tool: "{{ system_raw.features.chroot.tool | string }}"
initramfs: initramfs:
@@ -206,23 +203,44 @@
if (system_raw.features.peripherals.enabled | string | lower) == 'auto' if (system_raw.features.peripherals.enabled | string | lower) == 'auto'
else (system_raw.features.peripherals.enabled | bool) else (system_raw.features.peripherals.enabled | bool)
}} }}
# fingerprint/webcam stay tri-state ('auto'|'true'|'false') because the # Kept tri-state ('auto'|'true'|'false'): 'auto' resolves at install time from detection.
# 'auto' branch is resolved at install time using detection results.
fingerprint: >- fingerprint: >-
{{ {{
'auto' 'auto'
if (system_raw.features.peripherals.fingerprint | string | lower) == 'auto' if (system_raw.features.peripherals.fingerprint | string | lower) == 'auto'
else (system_raw.features.peripherals.fingerprint | bool | string | lower) else (system_raw.features.peripherals.fingerprint | bool | string | lower)
}} }}
webcam: >- camera: >-
{{ {{
'auto' 'auto'
if (system_raw.features.peripherals.webcam | string | lower) == 'auto' if (system_raw.features.peripherals.camera | string | lower) == 'auto'
else (system_raw.features.peripherals.webcam | bool | string | lower) 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 }}" displaylink: "{{ system_raw.features.peripherals.displaylink | bool }}"
hardware: hardware:
profile: "{{ system_raw.features.hardware.profile | default({}) }}" 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 }}" hostname: "{{ system_name }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}" 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 }}" os_version: "{{ system_raw.version | default('') | string }}"

View File

@@ -44,7 +44,7 @@
label: "system.features.{{ item }}" label: "system.features.{{ item }}"
ansible.builtin.assert: ansible.builtin.assert:
that: 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." fail_msg: "system.features.{{ item }} must be a dictionary."
quiet: true quiet: true

View File

@@ -1,8 +1,6 @@
--- ---
# Centralized normalization - all input dicts (system, hypervisor, disks) # Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
# are normalized here into system_cfg, hypervisor_cfg, etc. # roles consume the computed facts directly with no per-role _normalize (except CIS).
# Downstream roles consume these computed facts directly and do NOT need
# per-role _normalize.yml (except CIS, which has its own input dict).
- name: Global defaults loaded - name: Global defaults loaded
ansible.builtin.debug: ansible.builtin.debug:
msg: Global defaults loaded. msg: Global defaults loaded.
@@ -27,11 +25,15 @@
_proxmox_auth: _proxmox_auth:
api_host: "{{ hypervisor_cfg.url }}" api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}" 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: _proxmox_auth_node:
api_host: "{{ hypervisor_cfg.url }}" api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}" 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 }}" node: "{{ hypervisor_cfg.node }}"
no_log: true no_log: true
@@ -61,6 +63,12 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
os_version_major: "{{ (os_version | string).split('.')[0] }}" 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 - name: Set chroot command wrapper
ansible.builtin.set_fact: ansible.builtin.set_fact:
chroot_command: >- chroot_command: >-

View File

@@ -1,10 +1,7 @@
--- ---
# Two code paths: # Fresh run normalizes raw `system` input. A pre-computed system_cfg (from the main
# 1. Fresh run (system_cfg undefined): normalize from raw `system` input. # project's deploy_iac) is instead merged with system_defaults to fill the fields
# 2. Pre-computed (system_cfg already set, e.g. from main project's deploy_iac): # bootstrap expects, then convenience facts are derived.
# 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).
- name: Normalize system and disk configuration - name: Normalize system and disk configuration
when: system_cfg is not defined when: system_cfg is not defined
block: block:
@@ -50,25 +47,6 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
system_cfg: "{{ system_defaults | combine(system | default({}), recursive=True) | combine(system_cfg, recursive=True) }}" 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) - name: Populate primary network fields from first interface (pre-computed)
when: when:
- system_cfg is defined - system_cfg is defined
@@ -105,3 +83,8 @@
- system_cfg is defined - system_cfg is defined
- install_drive is not defined - install_drive is not defined
ansible.builtin.include_tasks: _normalize_disks.yml 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 quiet: true
- name: Validate system.features leaf schemas - name: Validate system.features leaf schemas
loop: "{{ system_defaults.features | dict2items }}" loop: "{{ system_defaults.features | dict2items | selectattr('value', 'mapping') }}"
loop_control: loop_control:
label: "system.features.{{ item.key }}" label: "system.features.{{ item.key }}"
vars: vars:
@@ -121,18 +121,18 @@
- >- - >-
os_version is not defined or (os_version | string | length) == 0 os_version is not defined or (os_version | string | length) == 0
or ( or (
os == "debian" and (os_version | string) in ["10", "11", "12", "13", "unstable"] os == "debian" and (os_version | string) in ["12", "13", "unstable"]
) or ( ) 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 ( ) or (
os in ["rocky", "almalinux"] os in ["rocky", "almalinux"]
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$") and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or ( ) or (
os == "rhel" os == "rhel"
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$") and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or ( ) or (
os == "ubuntu" os == "ubuntu"
and (os_version | string) is match("^(2[0-9])\\.04$") and (os_version | string) is match("^(2[0-9])\\.(04|10)$")
) or ( ) or (
os == "ubuntu-lts" os == "ubuntu-lts"
and (os_version | string) is match("^(2[0-9])\\.04$") and (os_version | string) is match("^(2[0-9])\\.04$")
@@ -148,8 +148,8 @@
- name: Validate RHEL ISO requirement - name: Validate RHEL ISO requirement
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- os != "rhel" or (rhel_iso is defined and (rhel_iso | string | length) > 0) - 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." fail_msg: "rhel_iso is required when os=rhel unless content.source is mirror."
quiet: true quiet: true
- name: Validate hypervisor-specific required fields - name: Validate hypervisor-specific required fields
@@ -166,6 +166,21 @@
label: "hypervisor.{{ item }}" label: "hypervisor.{{ item }}"
no_log: true 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) - name: Validate VMware placement (cluster or node required, mutually exclusive)
when: when:
- system_cfg.type == "virtual" - system_cfg.type == "virtual"
@@ -241,15 +256,21 @@
- system_cfg.features.gpu.nvidia_driver in ["auto", "open", "proprietary", "nouveau"] - system_cfg.features.gpu.nvidia_driver in ["auto", "open", "proprietary", "nouveau"]
- system_cfg.features.peripherals.enabled is defined - system_cfg.features.peripherals.enabled is defined
- system_cfg.features.peripherals.fingerprint in ["auto", "true", "false"] - 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.peripherals.displaylink is defined
- system_cfg.features.hardware.profile is mapping - 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: >- fail_msg: >-
Invalid hardware feature flags. firmware.enabled/microcode, Invalid hardware feature flags. firmware.enabled/microcode,
peripherals.enabled and peripherals.displaylink must be bool (or 'auto' peripherals.enabled and peripherals.displaylink must be bool (or 'auto'
sentinel for firmware); gpu.nvidia_driver in sentinel for firmware); gpu.nvidia_driver in
[auto|open|proprietary|nouveau]; peripherals.fingerprint and [auto|open|proprietary|nouveau]; peripherals.fingerprint/camera/audio/
peripherals.webcam in [auto|true|false]; hardware.profile must be a dict. bluetooth in [auto|true|false]; hardware.profile must be a dict.
quiet: true quiet: true
- name: Validate desktop environment - name: Validate desktop environment
@@ -262,7 +283,7 @@
or os_family_map[os] | default('') == "Archlinux" or os_family_map[os] | default('') == "Archlinux"
- >- - >-
system_cfg.features.desktop.display_manager | default('') | length == 0 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" system_cfg.features.desktop.display_manager | default('') != "greetd"
or system_cfg.features.desktop.environment in ["sway", "hyprland"] or system_cfg.features.desktop.environment in ["sway", "hyprland"]
@@ -271,15 +292,23 @@
or system_cfg.features.desktop.display_manager | default('') in ["", "gdm"] or system_cfg.features.desktop.display_manager | default('') in ["", "gdm"]
- >- - >-
system_cfg.features.desktop.environment != "kde" 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: >- fail_msg: >-
Invalid desktop config: environment '{{ system_cfg.features.desktop.environment }}' Invalid desktop config: environment '{{ system_cfg.features.desktop.environment }}'
for os_family '{{ os_family_map[os] | default('Unknown') }}', for os_family '{{ os_family_map[os] | default('Unknown') }}',
display_manager '{{ system_cfg.features.desktop.display_manager | default('') }}'. display_manager '{{ system_cfg.features.desktop.display_manager | default('') }}'.
gnome and kde are available on all families; sway and hyprland are Archlinux only. 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: 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 gnome->gdm, kde->plasma-login-manager on Arch/Fedora44+ else sddm,
installed, so a mismatched override fails at enable time. 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 quiet: true
- name: Validate desktop autologin - name: Validate desktop autologin

View File

@@ -0,0 +1,243 @@
---
- name: Collect the boot media this run attaches
ansible.builtin.set_fact:
system_check_media: >-
{{ [{'name': 'boot_iso', 'value': boot_iso}]
+ ([{'name': 'rhel_iso', 'value': rhel_iso}]
if (rhel_iso | default('') | string | length > 0) else []) }}
- name: Verify vCenter boot media and placement target
when: hypervisor_type == "vmware"
delegate_to: localhost
become: false
module_defaults:
group/community.vmware.vmware:
hostname: "{{ hypervisor_cfg.url }}"
username: "{{ hypervisor_cfg.username }}"
password: "{{ hypervisor_cfg.password }}"
validate_certs: "{{ hypervisor_cfg.certs | bool }}"
block:
- name: Assert the boot media is a datastore path
ansible.builtin.assert:
that:
- item.value is match('^\\[[^\\]]+\\]\\s*\\S')
fail_msg: |
{{ item.name }} is "{{ item.value }}", which is not a vSphere datastore path.
Set {{ item.name }} to the form "[datastore] folder/file.iso",
for example "[ssdmsa_lun1] ISO/SGBoot-2026.06.09-x86_64.iso".
quiet: true
loop: "{{ system_check_media }}"
loop_control:
label: "{{ item.name }}"
- name: Resolve the datastores this run depends on
ansible.builtin.set_fact:
system_check_datastores_needed: >-
{%- set ns = namespace(refs=[{'source': 'hypervisor.storage',
'datastore': hypervisor_cfg.storage, 'path': ''}]) -%}
{%- for media in system_check_media -%}
{%- set ns.refs = ns.refs + [{
'source': media.name,
'datastore': media.value | regex_replace('^\\[([^\\]]+)\\].*$', '\\1'),
'path': media.value | regex_replace('^\\[[^\\]]+\\]\\s*', '')
}] -%}
{%- endfor -%}
{{ ns.refs }}
- name: Query the ESXi placement host
when: hypervisor_cfg.node | default('') | length > 0
community.vmware.vmware_host_facts:
esxi_hostname: "{{ hypervisor_cfg.node }}"
register: system_check_esxi
delegate_facts: true
failed_when: false
changed_when: false
no_log: true
- name: Assert the ESXi placement host is readable
when: hypervisor_cfg.node | default('') | length > 0
ansible.builtin.assert:
that:
- system_check_esxi.ansible_facts is defined
fail_msg: |
ESXi host {{ hypervisor_cfg.node }} (hypervisor.node) could not be read from vCenter {{ hypervisor_cfg.url }}.
{{ system_check_esxi.msg | default('Unknown error') }}
Set hypervisor.node to an ESXi host in datacenter {{ hypervisor_cfg.datacenter }},
or clear hypervisor.node and set hypervisor.cluster instead.
quiet: true
- name: Assert the ESXi placement host can take a new VM
when: hypervisor_cfg.node | default('') | length > 0
ansible.builtin.assert:
that:
- system_check_esxi.ansible_facts.ansible_host_connection_state == "connected"
- not (system_check_esxi.ansible_facts.ansible_in_maintenance_mode | bool)
fail_msg: |
ESXi host {{ hypervisor_cfg.node }} (hypervisor.node) cannot take a new VM:
connection state is {{ system_check_esxi.ansible_facts.ansible_host_connection_state }},
maintenance mode is {{ system_check_esxi.ansible_facts.ansible_in_maintenance_mode }}.
Wait for the host to come back, or set hypervisor.node to another host.
quiet: true
- name: Query the datastores of the placement cluster
when: hypervisor_cfg.node | default('') | length == 0
community.vmware.vmware_datastore_info:
cluster: "{{ hypervisor_cfg.cluster }}"
register: system_check_cluster
failed_when: false
changed_when: false
no_log: true
- name: Assert the placement cluster is readable
when: hypervisor_cfg.node | default('') | length == 0
ansible.builtin.assert:
that:
- system_check_cluster.datastores is defined
fail_msg: |
Cluster {{ hypervisor_cfg.cluster }} (hypervisor.cluster) could not be read from vCenter {{ hypervisor_cfg.url }}.
{{ system_check_cluster.msg | default('Unknown error') }}
Set hypervisor.cluster to a cluster in datacenter {{ hypervisor_cfg.datacenter }},
or clear hypervisor.cluster and pin hypervisor.node instead.
quiet: true
- name: Resolve the datastores available at the placement target
ansible.builtin.set_fact:
system_check_target: >-
{{ ('ESXi host ' ~ hypervisor_cfg.node ~ ' (hypervisor.node)')
if (hypervisor_cfg.node | default('') | length > 0)
else ('cluster ' ~ hypervisor_cfg.cluster ~ ' (hypervisor.cluster)') }}
system_check_datastores_available: >-
{{ (system_check_esxi.ansible_facts.ansible_datastore | map(attribute='name') | list)
if (hypervisor_cfg.node | default('') | length > 0)
else (system_check_cluster.datastores | map(attribute='name') | list) }}
- name: Assert every datastore this run needs is available at the placement target
ansible.builtin.assert:
that:
- item.datastore in system_check_datastores_available
fail_msg: |
Datastore {{ item.datastore }} (from {{ item.source }}) is not available on {{ system_check_target }}.
Datastores available there: {{ system_check_datastores_available | join(', ') }}.
Point {{ item.source }} at one of those datastores, or move placement to a host or cluster
that mounts {{ item.datastore }}.
quiet: true
loop: "{{ system_check_datastores_needed }}"
loop_control:
label: "{{ item.source }}: {{ item.datastore }}"
- name: Query the boot media on its datastore
community.vmware.vsphere_file:
datacenter: "{{ hypervisor_cfg.datacenter }}"
datastore: "{{ item.datastore }}"
path: "{{ item.path }}"
state: file
register: system_check_media_files
failed_when: false
changed_when: false
loop: "{{ system_check_datastores_needed | rejectattr('source', 'equalto', 'hypervisor.storage') | list }}"
loop_control:
label: "{{ item.source }}"
- name: Assert the boot media exists on its datastore
ansible.builtin.assert:
that:
- (item.status | default(0)) != 404
fail_msg: |
{{ item.item.source }} points at "[{{ item.item.datastore }}] {{ item.item.path }}",
which does not exist on datastore {{ item.item.datastore }}.
Set {{ item.item.source }} to a file that exists there, normally the current SGBoot ISO.
quiet: true
loop: "{{ system_check_media_files.results }}"
loop_control:
label: "{{ item.item.source }}"
- name: Verify Proxmox boot media
when: hypervisor_type == "proxmox"
delegate_to: localhost
become: false
module_defaults:
community.proxmox.proxmox_storage_contents_info:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
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: Assert the boot media is a storage volume id
ansible.builtin.assert:
that:
- item.value is match('^[^:]+:iso/\\S')
fail_msg: |
{{ item.name }} is "{{ item.value }}", which is not a Proxmox volume id.
Set {{ item.name }} to the form "<storage>:iso/<file>.iso",
for example "local:iso/SGBoot-2026.06.09-x86_64.iso".
quiet: true
loop: "{{ system_check_media }}"
loop_control:
label: "{{ item.name }}"
- name: Query the ISO volumes on the Proxmox node
community.proxmox.proxmox_storage_contents_info:
node: "{{ hypervisor_cfg.node }}"
storage: "{{ item.value | regex_replace('^([^:]+):.*$', '\\1') }}"
content: iso
register: system_check_proxmox_media
failed_when: false
changed_when: false
loop: "{{ system_check_media }}"
loop_control:
label: "{{ item.name }}"
- name: Assert the boot media storage serves ISOs on the Proxmox node
ansible.builtin.assert:
that:
- item.proxmox_storage_content is defined
fail_msg: |
Storage {{ item.item.value | regex_replace('^([^:]+):.*$', '\\1') }} (from {{ item.item.name }})
does not serve ISO content on Proxmox node {{ hypervisor_cfg.node }}.
{{ item.msg | default('Unknown error') }}
Point {{ item.item.name }} at a storage that is enabled for ISO images on that node,
or change hypervisor.node.
quiet: true
loop: "{{ system_check_proxmox_media.results }}"
loop_control:
label: "{{ item.item.name }}"
- name: Assert the boot media exists on the Proxmox node
ansible.builtin.assert:
that:
- item.item.value in (item.proxmox_storage_content | map(attribute='volid') | list)
fail_msg: |
{{ item.item.name }} points at "{{ item.item.value }}", which does not exist on Proxmox node
{{ hypervisor_cfg.node }}.
ISO volumes on that storage: {{ item.proxmox_storage_content | map(attribute='volid') | join(', ') }}.
Set {{ item.item.name }} to one of those volumes.
quiet: true
loop: "{{ system_check_proxmox_media.results }}"
loop_control:
label: "{{ item.item.name }}"
- name: Verify libvirt boot media
when: hypervisor_type == "libvirt"
delegate_to: localhost
become: false
block:
- name: Stat the libvirt boot media
ansible.builtin.stat:
path: "{{ item.value }}"
register: system_check_libvirt_media
loop: "{{ system_check_media }}"
loop_control:
label: "{{ item.name }}"
- name: Assert the libvirt boot media exists
ansible.builtin.assert:
that:
- item.stat.exists
fail_msg: |
{{ item.item.name }} points at "{{ item.item.value }}", which does not exist on the libvirt host.
Set {{ item.item.name }} to the path of an ISO file that exists there.
quiet: true
loop: "{{ system_check_libvirt_media.results }}"
loop_control:
label: "{{ item.item.name }}"

View File

@@ -41,7 +41,9 @@
community.proxmox.proxmox_vm_info: community.proxmox.proxmox_vm_info:
api_host: "{{ hypervisor_cfg.url }}" api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}" 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: block:
- name: Query Proxmox for existing VM - name: Query Proxmox for existing VM
community.proxmox.proxmox_vm_info: community.proxmox.proxmox_vm_info:
@@ -130,3 +132,7 @@
To avoid data loss, the playbook will not overwrite or delete existing VMs. To avoid data loss, the playbook will not overwrite or delete existing VMs.
Please choose a different hostname or remove the existing VM manually before proceeding. Please choose a different hostname or remove the existing VM manually before proceeding.
quiet: true quiet: true
- name: Verify boot media and placement target
when: system_cfg.type == "virtual"
ansible.builtin.include_tasks: _placement.yml

View File

@@ -20,7 +20,9 @@
community.proxmox.proxmox_kvm: community.proxmox.proxmox_kvm:
api_host: "{{ hypervisor_cfg.url }}" api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}" 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 }}" node: "{{ hypervisor_cfg.node }}"
vmid: "{{ system_cfg.id | default(omit, true) }}" vmid: "{{ system_cfg.id | default(omit, true) }}"
name: "{{ hostname }}" name: "{{ hostname }}"

View File

@@ -17,8 +17,15 @@
community.vmware.vmware_guest: "{{ _vmware_auth }}" community.vmware.vmware_guest: "{{ _vmware_auth }}"
community.vmware.vmware_guest_tpm: "{{ _vmware_auth }}" community.vmware.vmware_guest_tpm: "{{ _vmware_auth }}"
vmware.vmware.vm_powerstate: "{{ _vmware_auth }}" vmware.vmware.vm_powerstate: "{{ _vmware_auth }}"
vmware.vmware.folder: "{{ _vmware_auth }}"
block: 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 - name: Create VM in vCenter
vars: vars:
virtualization_vmware_networks: >- virtualization_vmware_networks: >-
@@ -98,7 +105,6 @@
fail_msg: "Failed to add vTPM2 to VM '{{ hostname }}'. LUKS with TPM2 requires a virtual TPM device." fail_msg: "Failed to add vTPM2 to VM '{{ hostname }}'. LUKS with TPM2 requires a virtual TPM device."
quiet: true quiet: true
# vmware.vmware: modern collection for power operations
- name: Start VM in vCenter - name: Start VM in vCenter
when: virtualization_tpm2_enabled | bool when: virtualization_tpm2_enabled | bool
vmware.vmware.vm_powerstate: 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 (rhel=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 custom_iso: false
hypervisor: hypervisor:
@@ -85,6 +84,9 @@ system:
features: features:
cis: cis:
enabled: false 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: selinux:
enabled: true enabled: true
firewall: firewall: