Compare commits

..
68 Commits
Author SHA1 Message Date
sandwich 4fff9f8d80 fix(virtualization): create vmware target folder before vm deploy 2026-06-04 18:17:50 +02:00
sandwich 7f12a0f3d8 feat: accept proxmox API-token auth alongside password 2026-05-31 12:40:31 +02:00
sandwich ceb2237bbb fix(encryption): add tpm2-tss dracut module explicitly for TPM2 LUKS 2026-05-31 12:39:24 +02:00
sandwich 477c8379c4 fix(configuration): enable per-family time-sync and skip sudo-rs lecture 2026-05-31 12:30:26 +02:00
sandwich 579c499c02 feat(configuration): multi-backend networking, bind by match not MAC 2026-05-31 12:25:53 +02:00
sandwich 89e366d0f0 fix: EL10 PAM and crypto readiness via authselect profile and DEFAULT policy 2026-05-28 17:30:57 +02:00
sandwich 6fe843355e fix(bootstrap): keep package cache off the 2 GiB CIS /var during install 2026-05-28 17:26:25 +02:00
sandwich 441876fab9 refactor(global_defaults): single source of truth for family-default resolution 2026-05-28 17:25:23 +02:00
sandwich 00acd4d200 refactor(configuration): consolidate firewall into one phase-aware path 2026-05-27 05:28:00 +02:00
sandwich d922efd2e4 feat: uniform system.content source schema across installers and repositories 2026-05-27 05:15:32 +02:00
sandwich 939c5c741f feat: golden-image build support (cloud-init on EL, selinux relabel, SSH wait) 2026-05-27 05:05:55 +02:00
sandwich 2c35409519 feat(cis): add selectable profile and per-rule hardening toggles 2026-05-25 04:37:33 +02:00
sandwich d2a19cfd5c feat(hardware): auto-detect audio, bluetooth, camera with declarative override 2026-05-25 04:36:21 +02:00
sandwich 44f5adc682 feat(bootstrap): per-os desktop apps, KDE plasma-login-manager and DM resolution 2026-05-25 04:30:53 +02:00
sandwich 0185797af9 fix(environment): co-upgrade soname closure when installing installer tools 2026-05-25 03:54:12 +02:00
sandwich e0ecf628cd fix(bootstrap): deploy all non-EOL core distros (keyrings, repos, versions) 2026-05-25 03:52:44 +02:00
sandwich 37df881daa docs: refresh bootstrap examples and README 2026-05-30 18:05:14 +02:00
sandwich 55b21eae5d fix: encryption, partitioning, cis and virtualization hardening 2026-05-30 18:05:14 +02:00
sandwich b1e938b7f0 fix(users): accept plaintext or pre-hashed passwords uniformly 2026-05-30 18:05:05 +02:00
sandwich c843f5289b feat: hardware/firmware/gpu/peripherals detection and packages 2026-05-30 18:05:05 +02:00
sandwich 9757ed3785 feat: complete wayland desktop deployment (gnome/kde/sway/hyprland) 2026-05-30 18:05:05 +02:00
sandwich 876e90ce2b refactor: trim bootstrap OS support to core three distro families 2026-05-30 18:04:00 +02:00
sandwich 7c44cb1ff0 docs(bootstrap): fix users dict format in examples, sync schema defaults, document secure_boot/rhel_repo 2026-05-30 09:25:34 +02:00
sandwich 5d0630a386 refactor(global_defaults): drop orphan luks.urandom/verify and aur feature, bump fedora to 45 2026-05-30 09:25:34 +02:00
sandwich 3eaf918a53 fix(lint): convert sshd restart to handler, add pipefail to btrfs subvol set 2026-05-30 09:25:34 +02:00
sandwich 382e82ff85 fix(configuration): tolerate missing units, gate Secure Boot to supported OSes, fix clevis install per family 2026-05-30 09:25:34 +02:00
sandwich db7dc53bd7 docs(bootstrap): document firmware/gpu/peripherals/hardware features 2026-05-30 09:25:34 +02:00
sandwich 7d45f25a7e feat(bootstrap): install vendor-matched hardware packages 2026-05-30 09:25:34 +02:00
sandwich 3880b8f41e feat(environment): detect cpu/gpu/wireless/fingerprint hardware 2026-05-30 09:25:34 +02:00
sandwich dc3c4a901f feat(global_defaults): firmware/gpu/peripherals/hardware schema 2026-05-30 09:25:34 +02:00
sandwich 6bfaa0aa2b fix(configuration): guard user keys access to avoid dict.keys() method collision 2026-04-16 15:37:13 +02:00
sandwich 5dc0424acb feat(cleanup): enroll Secure Boot keys in VM NVRAM after OS installation 2026-04-16 15:37:13 +02:00
sandwich c12332a853 feat(virtualization): enable TPM2 emulation for Secure Boot VMs 2026-04-16 15:37:13 +02:00
sandwich 5ace4577a2 feat(configuration): auto-bind PCR 7 when Secure Boot and FDE are both enabled 2026-04-16 15:37:13 +02:00
sandwich c0ff01ef40 feat(configuration): add Secure Boot tasks for shim and sbctl 2026-04-16 15:37:13 +02:00
sandwich 57417514e3 feat(configuration): override EFI loader to shim when Secure Boot enabled 2026-04-02 04:34:47 +02:00
sandwich 0928588c1f feat(bootstrap): add Secure Boot conditional packages for Debian, Ubuntu, and Arch 2026-04-02 04:34:16 +02:00
sandwich 6d622f2db4 feat(global_defaults): add secure_boot feature toggle with normalization 2026-04-02 04:33:07 +02:00
sandwich b11d65a6f3 docs(bootstrap): document desktop, initramfs, and FDE features with SSH keepalive config 2026-04-01 15:07:58 +02:00
sandwich 3623fc292c feat(configuration): generic FDE with systemd-cryptenroll, clevis fallback, and configurable initramfs 2026-04-01 15:07:58 +02:00
sandwich dfca7ec94b fix(configuration): RedHat EFI grub wrapper with btrfs subvol prefix and boot order 2026-04-01 15:07:58 +02:00
sandwich e8be84bf49 fix(partitioning): set btrfs default subvolume and restrict @pkg to Arch 2026-04-01 15:07:58 +02:00
sandwich 322cc0b1ce fix(bootstrap): resolve interface-only network, sshd penalties, dnf scriptlets, and EFI cleanup 2026-04-01 15:07:58 +02:00
sandwich 4b38754f8b feat(bootstrap): add desktop environment support with configurable DE, DM, and display target 2026-04-01 15:07:58 +02:00
sandwich a6bc7ffe04 fix(configuration): use /boot/grub2/grub.cfg for RedHat EFI grub config 2026-03-25 16:03:39 +01:00
sandwich c529e71ebc feat(packages): add needrestart to Debian and Ubuntu package lists 2026-03-20 18:06:14 +01:00
sandwich cb46de2b6d feat(bootstrap): add full package upgrade step for Debian and Ubuntu 2026-03-20 18:05:04 +01:00
sandwich 9169117b25 fix(vim): use vimscript comment syntax for blockinfile markers in vimrc 2026-03-20 18:00:12 +01:00
sandwich 6c94c519fb fix(sudo): use explicit string check instead of bool conditional for sudo field 2026-03-20 17:31:49 +01:00
sandwich efd96a42b8 fix(connection): set ansible_port explicitly at every connection transition 2026-03-20 17:31:49 +01:00
sandwich 68661c3cca fix(vmware): use primary ansible_* vars for vmware_tools connection plugin precedence 2026-03-20 17:31:49 +01:00
sandwich 1db20c7ac0 fix(vmware): use empty password for vmware_tools during live ISO bootstrap 2026-03-20 17:31:49 +01:00
sandwich 7b155b427b fix(users): update cloud-init template and input validation for dict users 2026-03-20 17:31:49 +01:00
sandwich ca8721e98f refactor(prompts): remove vars_prompt, require users defined in inventory 2026-03-20 17:31:49 +01:00
sandwich cdb2559d8f fix(prompts): add default values to vars_prompt to skip in non-interactive mode 2026-03-20 17:31:49 +01:00
sandwich 443f6623df refactor(users): change system.users from list to dict keyed by username 2026-03-20 17:31:49 +01:00
sandwich 6cf418fe00 fix(configuration): make root password, user keys, and sudo all optional 2026-03-20 17:31:49 +01:00
sandwich 47ec5fe621 fix(cloud-init): handle missing keys and make sudo conditional 2026-03-20 17:31:49 +01:00
sandwich 240f945cce fix(cleanup): remove ansible_become override that blocks swapoff/umount 2026-03-20 17:31:49 +01:00
sandwich 663a04556f feat(global_defaults): add system.features.aur schema for validation passthrough 2026-03-20 17:31:49 +01:00
sandwich 6febd1acf1 refactor(virtualization): extract shared Xen disk definitions 2026-03-12 12:27:18 +01:00
sandwich 008187860c refactor: remove unnecessary changed_when from set_fact tasks 2026-03-12 12:25:45 +01:00
sandwich cd1be6b5e1 refactor(partitioning): remove redundant blockdev --rereadpt calls 2026-03-12 12:25:15 +01:00
sandwich 15be6149fd refactor(partitioning): remove unused register variables 2026-03-12 12:24:59 +01:00
sandwich ca29ad200d chore: suppress args[module] false positives from variable-based module_defaults 2026-03-12 12:12:27 +01:00
sandwich 8079099cee fix(cleanup): add no_log to Proxmox VM restart task 2026-03-12 12:12:27 +01:00
sandwich 9e79185b07 fix(virtualization): add missing changed_when to Xen VM stop task 2026-03-12 12:12:27 +01:00
sandwich b88bf2860f fix(configuration): replace fail+ignore_errors with debug for TPM2 fallback warning 2026-03-12 12:12:27 +01:00
49 changed files with 233 additions and 1466 deletions

No files matched your search

-3
View File
@@ -9,6 +9,3 @@ vars_libvirt.yml
vars_proxmox.yml
.sisyphus/
.ansible/
__pycache__/
-3
View File
@@ -1,9 +1,6 @@
---
extends: default
ignore:
- .ansible/
rules:
document-start: disable
line-length:
+3 -11
View File
@@ -183,7 +183,7 @@ Top-level variables outside `system`/`hypervisor`.
#### `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.
Uniform package content source, family-resolved. `source: ''` defaults to `dvd` on EL and `mirror` on Debian/Ubuntu/Arch. Satellite values come from inventory/vault only, never committed code.
| Key | Type | Default | Description |
| -------------------------- | ------ | -------------- | ----------------------------------------------------------------- |
@@ -213,11 +213,11 @@ Uniform package content source, family-resolved. `source: ''` defaults to `dvd`
| `dns.search` | list | `[]` | Search domains (must be a YAML list) |
| `interfaces` | list | `[]` | Multi-NIC config (overrides flat fields above) |
When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`, `vlan`) are auto-wrapped into a single-entry list. When `interfaces` is set, it takes precedence. Each entry supports: `name`, `bridge` (required), `vlan`, `ip`, `prefix`, `gateway`, and `extra_addresses`: further IPv4 CIDRs (`192.0.2.10/24`) on the same NIC beside `ip`, written by every network backend. Proxmox `ipconfigN` carries one address per NIC, so on Proxmox the extras arrive with the installed network config, not cloud-init.
When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`, `vlan`) are auto-wrapped into a single-entry list. When `interfaces` is set, it takes precedence. Each entry supports: `name`, `bridge` (required), `vlan`, `ip`, `prefix`, `gateway`.
#### `system.users`
Dict keyed by username. At least one user must have a non-empty `password`: the first such user, in declaration order, is the account bootstrap logs in as after the reboot and the cloud-init and Proxmox `ciuser` primary user. Users without a password get locked accounts (key-only auth).
Dict keyed by username. At least one user must have a `password` (used for SSH access during bootstrap). Users without a password get locked accounts (key-only auth).
```yaml
system:
@@ -238,11 +238,6 @@ system:
| `password` | string | -- | User password (required for at least one user) |
| `keys` | list | `[]` | SSH public keys |
| `sudo` | bool/string | -- | `true` for NOPASSWD ALL, or custom sudoers string |
| `groups` | list | derived | Supplementary groups; empty means the platform sudo group when `sudo` is set |
| `shell` | string | `/bin/bash` | Login shell |
| `password_login` | bool | `false` | Read by the main repository's SSH hardening, ignored here |
| `uid` | int | -- | Read by the main repository's user actions; the bootstrap assigns `1000 + index` |
| `disabled` | bool | `false` | Read by the main repository's user actions, ignored here |
Users must be defined in inventory. The dict format enables additive merging across inventory layers with `hash_behaviour=merge`.
@@ -543,13 +538,10 @@ When `hypervisor.type: vmware` uses the `vmware_tools` connection:
`system.disks[0]` is the OS disk (no `mount.path`). Additional entries define data disks.
On VMware, `device` is resolved on the target from the SCSI address instead: entry N is the disk at target N on the first SCSI controller (N+1 from index 7 on, since unit 7 is reserved). The kernel's `sdX` order is not stable between the install environment and the installed system, so a letter-derived path can point at another disk.
| Key | Type | Description |
| ------------- | ------ | ------------------------------------------------------ |
| `size` | number | Disk size in GB (required for virtual) |
| `device` | string | Block device path (required for physical data disks) |
| `storage` | string | Proxmox storage for this disk; empty uses `hypervisor.storage` |
| `partition` | string | Derived from `device` during normalization (not user input) |
| `mount.path` | string | Mount point (additional disks only) |
| `mount.fstype`| string | `btrfs`, `ext4`, or `xfs` |
-2
View File
@@ -1,7 +1,5 @@
[defaults]
hash_behaviour = merge
roles_path = roles
action_plugins = plugins/action
interpreter_python = auto_silent
deprecation_warnings = False
host_key_checking = False
+5 -3
View File
@@ -119,13 +119,15 @@
when:
- post_reboot_can_connect | bool
no_log: true
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact:
ansible_connection: ssh
ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22
ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_user: "{{ _primary.key }}"
ansible_password: "{{ _primary.value.password }}"
ansible_become_password: "{{ _primary.value.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
-152
View File
@@ -1,152 +0,0 @@
"""Strict validation of user-facing variable dicts against role argument specs.
Each spec file's ``argument_specs.inputs.options`` declares top-level variables. Every one the host
defines is checked without Ansible's lenient coercion: a string is never a list, bool or dict. A dict
option whose only sub-option is a placeholder such as ``<name>`` is a collection keyed by name; each
entry is checked against that sub-option, and a null entry means the entry is not managed. A ``raw``
option that declares ``options`` is a role toggle: ``true``, ``false``, or the dict those options
describe. Messages name the key path
and the types involved but never a value, so secrets cannot leak into job output.
"""
import glob
from collections.abc import Mapping
from ansible.errors import AnsibleError
from ansible.parsing.vault import EncryptedString
from ansible.plugins.action import ActionBase
SPEC_KEYS = {"type", "required", "default", "choices", "elements", "options", "description", "no_log"}
UNRESOLVED = object()
# Numbers pass where a string is declared and digit strings where an int is: the roles read those
# through `| string` and `| int` unchanged. An empty string is the contract's unset int, as in
# `system.vlan: ""`. A string posing as a list, bool or dict never passes.
TYPES = {
"str": lambda v: isinstance(v, (str, int, float, EncryptedString)) and not isinstance(v, bool),
"int": lambda v: (isinstance(v, int) and not isinstance(v, bool)) or (isinstance(v, str) and (v == "" or v.isdigit())),
"float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"bool": lambda v: isinstance(v, bool),
"list": lambda v: isinstance(v, list),
"dict": lambda v: isinstance(v, Mapping),
"raw": lambda _: True,
}
def _kind(value):
if value is None:
return "null"
# bool before int: bool is an int subclass.
for name, cls in (("bool", bool), ("dict", Mapping), ("str", (str, EncryptedString)), ("int", int),
("float", float), ("list", list)):
if isinstance(value, cls):
return name
return type(value).__name__
def _check_spec(options, path):
for key, spec in options.items():
unknown = set(spec) - SPEC_KEYS
if unknown:
raise AnsibleError(f"{path}.{key}: unsupported spec attributes {', '.join(sorted(unknown))}")
if spec.get("type", "str") not in TYPES:
raise AnsibleError(f"{path}.{key}: unsupported spec type {spec['type']}")
if "options" in spec:
_check_spec(spec["options"], f"{path}.{key}")
class _Validator:
def __init__(self, templar):
self.templar = templar
self.errors = []
def resolve(self, value):
# Task vars arrive untemplated. Render one node at a time so a vaulted leaf is never decrypted;
# a node that cannot render here (no vault secret, a runtime-only variable) is left unchecked.
if isinstance(value, str) and self.templar.is_template(value):
try:
return self.templar.template(value)
except AnsibleError:
return UNRESOLVED
return value
def check(self, spec, value, path):
value = self.resolve(value)
if value is UNRESOLVED:
return
kind = spec.get("type", "str")
if kind == "raw" and "options" in spec and not isinstance(value, (bool, Mapping)):
self.errors.append(f"{path}: expected bool or dict, got {_kind(value)}")
return
if not TYPES[kind](value):
keyed = len(spec.get("options", {})) == 1 and next(iter(spec["options"])).startswith("<")
self.errors.append(f"{path}: expected {'dict keyed by name' if keyed else kind}, got {_kind(value)}")
return
if "choices" in spec and value not in spec["choices"]:
shown = "" if spec.get("no_log") else f", got {value!r}"
self.errors.append(f"{path}: must be one of {', '.join(map(str, spec['choices']))}{shown}")
# Keyed on the value, not the declared type, so a `raw` option ("false, or a dict of...") is
# still checked whenever it holds a dict or a list.
if "options" in spec and isinstance(value, Mapping):
self.check_options(spec["options"], value, path)
elif "elements" in spec and isinstance(value, list):
element = {"type": spec["elements"]}
if "options" in spec:
element["options"] = spec["options"]
for index, item in enumerate(value):
self.check(element, item, f"{path}[{index}]")
def check_options(self, options, value, path):
if len(options) == 1 and next(iter(options)).startswith("<"):
entry_spec = next(iter(options.values()))
for name, entry in value.items():
if entry is not None:
self.check(entry_spec, entry, f"{path}.{name}")
return
for key in value:
if key not in options:
accepted = ", ".join(sorted(options)) or "no keys"
self.errors.append(f"{path}.{key}: unsupported key, {path} accepts {accepted}")
for key, spec in options.items():
if key in value:
self.check(spec, value[key], f"{path}.{key}")
elif spec.get("required"):
self.errors.append(f"{path}.{key}: required key is missing")
class ActionModule(ActionBase):
_requires_connection = False
def run(self, tmp=None, task_vars=None):
result = super().run(tmp, task_vars)
task_vars = task_vars or {}
validator = _Validator(self._templar)
declared = {}
paths = []
# A pattern (`.../*/roles/*/meta/argument_specs.yml`) picks up every role's spec without a list
# to keep in sync; one that matches nothing is a broken path, not an empty set.
specs = self._task.args["specs"]
if not isinstance(specs, list):
raise AnsibleError(f"specs must be a list of spec file paths, got {type(specs).__name__}")
for entry in specs:
matches = sorted(glob.glob(entry)) if glob.has_magic(entry) else [entry]
if not matches:
raise AnsibleError(f"no spec file matches {entry}")
paths.extend(matches)
for path in paths:
options = self._loader.load_from_file(path)["argument_specs"]["inputs"]["options"]
_check_spec(options, path)
for name, spec in options.items():
if name in declared:
raise AnsibleError(f"{name} is declared by both {declared[name]} and {path}")
declared[name] = path
if name in task_vars:
validator.check(spec, task_vars[name], name)
elif spec.get("required"):
validator.errors.append(f"{name}: required variable is not defined")
if validator.errors:
result.update(failed=True, errors=validator.errors,
msg="Invalid input variables:\n" + "\n".join(validator.errors))
else:
result.update(changed=False, msg="All input variables are valid")
return result
+1 -1
View File
@@ -9,7 +9,7 @@ bootstrap_hardware_packages:
amd: [amd-ucode]
firmware_base: []
firmware:
intel: [linux-firmware-intel] # iwlwifi (WiFi) + i915/xe (GPU) + ibt (BT) firmware live here
intel: [linux-firmware-other] # iwlwifi + i915 firmware live here
amd: [linux-firmware-amdgpu]
nvidia: [linux-firmware-nvidia]
atheros: [linux-firmware-atheros]
-2
View File
@@ -206,7 +206,6 @@ bootstrap_debian:
- openssh-server
- python3
- xfsprogs
- xz-utils
extra:
- bat
- curl
@@ -259,7 +258,6 @@ bootstrap_ubuntu:
- openssh-server
- python3
- xfsprogs
- xz-utils
extra:
- bash-completion
- bat
-2
View File
@@ -3,5 +3,3 @@
cleanup_verify_boot: true
cleanup_boot_timeout: 300
cleanup_remove_on_failure: true
cleanup_poweroff_retries: 24
cleanup_poweroff_delay: 5
+2 -25
View File
@@ -6,30 +6,7 @@
module_defaults:
community.proxmox.proxmox_disk: "{{ _proxmox_auth }}"
community.proxmox.proxmox_kvm: "{{ _proxmox_auth_node }}"
community.proxmox.proxmox_vm_info: "{{ _proxmox_auth_node }}"
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
community.proxmox.proxmox_disk:
name: "{{ hostname }}"
@@ -44,8 +21,8 @@
failed_when: false
no_log: true
- name: Boot the installed OS
- name: Start the VM
community.proxmox.proxmox_kvm:
vmid: "{{ system_cfg.id }}"
state: started
state: restarted
no_log: true
+1 -18
View File
@@ -5,26 +5,9 @@
become: false
module_defaults:
community.vmware.vmware_guest: "{{ _vmware_auth }}"
community.vmware.vmware_guest_info: "{{ _vmware_auth }}"
vmware.vmware.vm_powerstate: "{{ _vmware_auth }}"
no_log: true
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
community.vmware.vmware_guest:
name: "{{ hostname }}"
@@ -58,7 +41,7 @@
}}
failed_when: false
- name: Boot the installed OS
- name: Start VM in vCenter
vmware.vmware.vm_powerstate:
name: "{{ hostname }}"
state: powered-on
+15 -27
View File
@@ -18,24 +18,13 @@
register: configuration_bootloader_result
changed_when: configuration_bootloader_result.rc == 0
- name: Remove stale EFI boot entries for this OS
# UEFI NVRAM entries survive a disk wipe (they live in firmware, not on disk).
# A prior install leaves a dead entry, and a case-insensitive duplicate label
# ("ArchLinux" vs "archlinux") would otherwise win the boot order - the firmware
# then falls through to removable media. Delete every entry carrying our label,
# then recreate exactly one clean entry.
ansible.builtin.shell:
cmd: >-
set -o pipefail;
efibootmgr | grep -iP '^Boot[0-9A-F]{4}\*?\s+{{ _efi_vendor }}\s*$'
| grep -oP '^Boot\K[0-9A-F]{4}'
| xargs -r -I{} efibootmgr -b {} -B
executable: /bin/bash
register: _efi_gc_result
changed_when: _efi_gc_result.stdout | length > 0
failed_when: false
- name: Check existing EFI boot entries
ansible.builtin.command: efibootmgr
register: configuration_efi_entries
changed_when: false
- name: Create EFI boot entry (efibootmgr -c prepends it to BootOrder)
- name: Ensure EFI boot entry exists
when: ('* ' + _efi_vendor) not in configuration_efi_entries.stdout
ansible.builtin.command: >-
efibootmgr -c
-L '{{ _efi_vendor }}'
@@ -45,16 +34,15 @@
register: configuration_efi_entry_result
changed_when: configuration_efi_entry_result.rc == 0
- name: Install removable EFI fallback loader
# Firmware-agnostic fallback (\EFI\BOOT\BOOTX64.EFI): many consumer firmwares
# ignore or reset custom NVRAM entries and boot only the removable path.
when: _configuration_platform.grub_install
ansible.builtin.command: >-
{{ chroot_command }} /usr/bin/install -Dm644
{{ partitioning_efi_mountpoint }}/EFI/{{ _efi_vendor }}/{{ _efi_loader }}
{{ partitioning_efi_mountpoint }}/EFI/BOOT/BOOTX64.EFI
register: _efi_fallback_result
changed_when: _efi_fallback_result.rc == 0
- name: Set installed OS as first EFI boot entry
ansible.builtin.shell:
cmd: >-
set -o pipefail &&
efibootmgr | grep -i '{{ _efi_vendor }}' | grep -oP 'Boot\K[0-9A-F]+' | head -1
| xargs -I{} efibootmgr -o {}
executable: /bin/bash
register: _efi_bootorder_result
changed_when: _efi_bootorder_result.rc == 0
- name: Ensure lvm2 for non btrfs filesystems
when: os == "archlinux" and system_cfg.filesystem != "btrfs"
-15
View File
@@ -12,21 +12,6 @@
and 'No such file or directory' not in (_firewall_enable.stderr | default(''))
and 'does not exist' not in (_firewall_enable.stderr | default(''))
# Distro base sets ship the daemon preset-enabled (RHEL @core pulls firewalld in),
# so opting out has to disable the unit, not merely skip enabling it.
- name: Disable the firewall daemon in the install chroot
when:
- firewall_phase == 'install'
- _configuration_platform.init_system == 'systemd'
- not system_cfg.features.firewall.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl disable {{ system_cfg.features.firewall.backend }}"
register: _firewall_disable
changed_when: "'Removed' in (_firewall_disable.stderr | default(''))"
failed_when: >-
_firewall_disable.rc != 0
and 'No such file or directory' not in (_firewall_disable.stderr | default(''))
and 'does not exist' not in (_firewall_disable.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
+1 -5
View File
@@ -43,14 +43,11 @@
}}
grub_root_flags: >-
{{ ['rootflags=subvol=@'] if system_cfg.filesystem == 'btrfs' else [] }}
grub_selinux_args: >-
{{ [] if system_cfg.features.selinux.enabled | bool else ['selinux=0'] }}
# 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_lvm_args | join(' ')) ~ ' ' ~ (grub_selinux_args | join(' ')) ~ ' '
~ (_hardware_profile_kernel_params | default([]) | join(' '))) | trim
((grub_lvm_args | join(' ')) ~ ' ' ~ (_hardware_profile_kernel_params | default([]) | join(' '))) | trim
}}
grub_kernel_cmdline_base: >-
{{
@@ -60,7 +57,6 @@
+ ['ro']
+ grub_lvm_args
+ grub_root_flags
+ grub_selinux_args
+ (_hardware_profile_kernel_params | default([]))
)
| join(' ')
@@ -37,6 +37,7 @@
org_id: "{{ system_cfg.content.satellite.org }}"
activationkey: "{{ system_cfg.content.satellite.activation_key }}"
environment: "{{ system_cfg.content.satellite.environment | default(omit, true) }}"
auto_attach: true
force_register: true
server_proxy_hostname: "{{ (system_cfg.content.proxy | urlsplit('hostname')) | default(omit, true) }}"
server_proxy_port: "{{ (system_cfg.content.proxy | urlsplit('port')) | default(omit, true) }}"
+3 -5
View File
@@ -22,12 +22,10 @@
mode: "0644"
# Fedora: setfiles segfaults during bootstrap chroot relabeling, so SELinux
# is left permissive and expected to relabel on first boot. Opting out writes
# disabled here and selinux=0 on the kernel cmdline (grub.yml), the only form
# EL9 honours.
- name: Set the SELinux mode
# is left permissive and expected to relabel on first boot.
- name: Disable SELinux
when: os == "fedora" or not system_cfg.features.selinux.enabled | bool
ansible.builtin.lineinfile:
path: /mnt/etc/selinux/config
regexp: ^SELINUX=
line: "SELINUX={{ 'permissive' if system_cfg.features.selinux.enabled | bool else 'disabled' }}"
line: SELINUX=permissive
-12
View File
@@ -54,18 +54,6 @@
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(''))
- 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
when:
- _configuration_platform.init_system == 'systemd'
+2 -1
View File
@@ -15,7 +15,8 @@
validate: /usr/sbin/visudo --check --file=%s
- name: Deploy per-user sudoers rules
when: (item.value.sudo | default(false)) not in [false, '', none]
# Jinja truthiness: bool true / a rule string => deploy; false / '' / unset => skip.
when: item.value.sudo | default(false)
vars:
configuration_sudoers_rule: >-
{{ item.value.sudo if item.value.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }}
+2 -7
View File
@@ -26,22 +26,17 @@
- name: Create user accounts
vars:
configuration_user_group: "{{ _configuration_platform.user_group }}"
# plaintext is hashed; a pre-computed crypt hash ($6$/$y$/...) passes through.
configuration_user_pw: >-
{{ item.value.password if (item.value.password | string)[:1] == '$'
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: >-
{{ chroot_command }} /usr/sbin/useradd --create-home --user-group
--uid {{ 1000 + _idx }}
{{ ('--groups ' ~ (configuration_user_groups | join(','))) if (configuration_user_groups | length) > 0 else '' }}
--groups {{ configuration_user_group }} {{ item.key }}
{{ ('--password ' ~ configuration_user_pw) if (item.value.password | default('') | string | length > 0) else '' }}
--shell {{ item.value.shell | default('/bin/bash') }}
{{ item.key }}
ansible.builtin.command: "{{ configuration_useradd_cmd }}"
loop: "{{ system_cfg.users | dict2items }}"
loop_control:
-3
View File
@@ -19,9 +19,6 @@ interface-name=en*;eth*;
{% set search_list = configuration_dns_search %}
{% if iface.ip | default('') | string | length %}
address1={{ iface.ip }}/{{ iface.prefix }}{{ (',' ~ iface.gateway) if (iface.gateway | default('') | string | length) else '' }}
{% for address in iface.extra_addresses | default([]) %}
address{{ loop.index + 1 }}={{ address }}
{% endfor %}
method=manual
{% else %}
method=auto
@@ -16,11 +16,6 @@ iface {{ ifname }} inet static
{% if loop.index0 == 0 and configuration_dns_search %}
dns-search {{ configuration_dns_search | join(' ') }}
{% endif %}
{# One stanza per extra address: classic ifupdown takes one address per stanza, ifupdown2 merges them. #}
{% for address in iface.extra_addresses | default([]) %}
iface {{ ifname }} inet static
address {{ address }}
{% endfor %}
{% else %}
iface {{ ifname }} inet dhcp
{% endif %}
@@ -9,9 +9,6 @@ network:
{% if iface.ip | default('') | string | length %}
addresses:
- {{ iface.ip }}/{{ iface.prefix }}
{% for address in iface.extra_addresses | default([]) %}
- {{ address }}
{% endfor %}
{% if iface.gateway | default('') | string | length %}
routes:
- to: default
@@ -11,9 +11,6 @@ Type=ether
[Network]
{% if iface.ip | default('') | string | length %}
Address={{ iface.ip }}/{{ iface.prefix }}
{% for address in iface.extra_addresses | default([]) %}
Address={{ address }}
{% endfor %}
{% if iface.gateway | default('') | string | length %}
Gateway={{ iface.gateway }}
{% endif %}
+15 -12
View File
@@ -73,25 +73,28 @@
mode: "0755"
- name: Detect RHEL ISO device
ansible.builtin.command: lsblk -rbno NAME,TYPE,SIZE
ansible.builtin.command: lsblk -rno NAME,TYPE
register: environment_lsblk_result
changed_when: false
- name: Select RHEL ISO device
vars:
_roms: >-
{%- set out = [] -%}
{%- for line in environment_lsblk_result.stdout_lines -%}
{%- set p = line.split() -%}
{%- if (p | length) >= 3 and p[1] == 'rom' -%}
{%- set _ = out.append({'name': p[0], 'size': p[2] | int}) -%}
{%- endif -%}
{%- endfor -%}
{{ out }}
_rom_devices: >-
{{
environment_lsblk_result.stdout_lines
| map('split', ' ')
| selectattr('1', 'equalto', 'rom')
| map('first')
| map('regex_replace', '^', '/dev/')
| list
}}
ansible.builtin.set_fact:
environment_rhel_iso_device: >-
{{ ('/dev/' ~ (_roms | sort(attribute='size') | last).name)
if (_roms | length) > 0 else '/dev/sr1' }}
{{
_rom_devices[-1]
if _rom_devices | length > 1
else (_rom_devices[0] | default('/dev/sr1'))
}}
- name: Mount RHEL ISO
ansible.posix.mount:
+1 -2
View File
@@ -78,8 +78,7 @@ system_defaults:
timezone: "Europe/Vienna"
locale: "en_US.UTF-8"
keymap: "us"
# source: dvd|mirror|satellite|none ('' -> family default: rhel=dvd, else mirror;
# almalinux/rocky/fedora have public mirrors, rhel does not).
# source: dvd|mirror|satellite|none ('' -> family default: EL=dvd, else mirror).
# satellite values come from inventory/vault only, never committed code.
content:
source: ""
@@ -1,261 +0,0 @@
---
# Validated by the validate_inputs action, not by Ansible's role argument check: no tasks file is named
# `inputs`. A sub-option named `<name>` marks a dict keyed by name.
argument_specs:
inputs:
short_description: User input read by the bootstrap and, through the main repository, by every role
options:
boot_iso: {type: str}
rhel_iso: {type: str}
custom_iso: {type: bool}
thirdparty_tasks: {type: str}
partitioning_efi_size_mib: {type: int}
partitioning_boot_size_mib: {type: int}
partitioning_separate_boot: {type: bool}
partitioning_boot_fs_fstype: {type: str}
partitioning_use_full_disk: {type: bool}
cleanup_verify_boot: {type: bool}
cleanup_boot_timeout: {type: int}
cleanup_remove_on_failure: {type: bool}
note: {type: str}
hypervisor:
type: dict
options:
type: {type: str, choices: [libvirt, proxmox, vmware, xen, none]}
url: {type: str}
username: {type: str}
password: {type: str, no_log: true}
token_id: {type: str}
token_secret: {type: str, no_log: true}
node: {type: str}
cluster: {type: str}
datacenter: {type: str}
storage: {type: str}
folder: {type: str}
certs: {type: bool}
ssh: {type: bool}
system:
type: dict
options:
type: {type: str, choices: [virtual, physical, vm]}
os: {type: str}
version: {type: str}
filesystem: {type: str, choices: [btrfs, ext4, xfs]}
name: {type: str}
source: {type: str}
id: {type: int}
cpus: {type: int}
memory: {type: int}
balloon: {type: int}
cores: {type: int}
config: {type: str, choices: [none, current, pending]}
network_info: {type: bool}
network_config: {type: str}
guest_path: {type: str}
path: {type: str}
timezone: {type: str}
locale: {type: str}
keymap: {type: str}
packages: {type: list, elements: str}
network:
type: dict
options:
bridge: {type: str}
vlan: {type: int}
ip: {type: str}
prefix: {type: int}
gateway: {type: str}
dns:
type: dict
options:
servers: {type: list, elements: str}
search: {type: list, elements: str}
interfaces:
type: list
elements: dict
options:
name: {type: str}
bridge: {type: str}
vlan: {type: int}
ip: {type: str}
prefix: {type: int}
gateway: {type: str}
extra_addresses: {type: list, elements: str}
parent: {type: str}
method: {type: str}
bridge_ports: {type: raw}
bridge_vids: {type: raw}
bridge_stp: {type: bool}
bridge_fd: {type: int}
bridge_vlan_aware: {type: bool}
bridge_pvid: {type: str}
disks:
type: list
elements: dict
options:
size: {type: int}
device: {type: str}
storage: {type: str}
format: {type: str}
bus: {type: str, choices: ["", scsi, virtio, sata, ide]}
slot: {type: int}
path: {type: str}
target: {type: str}
mount:
type: dict
options:
path: {type: str}
fstype: {type: str, choices: ["", btrfs, ext4, xfs]}
label: {type: str}
opts: {type: str}
users:
type: dict
options:
"<name>":
type: dict
options:
password: {type: str, no_log: true}
keys: {type: list, elements: str}
sudo: {type: raw}
groups: {type: list, elements: str}
shell: {type: str}
password_login: {type: bool}
uid: {type: int}
disabled: {type: bool}
root:
type: dict
options:
password: {type: str, no_log: true}
shell: {type: str}
content:
type: dict
options:
source: {type: str, choices: ["", dvd, mirror, satellite, none]}
url: {type: str}
proxy: {type: str}
gpgcheck: {type: bool}
satellite:
type: dict
options:
host: {type: str}
ip: {type: str}
org: {type: str}
activation_key: {type: str, no_log: true}
ca_url: {type: str}
service_level: {type: str}
environment: {type: str}
install: {type: bool}
luks:
type: dict
options:
enabled: {type: bool}
passphrase: {type: str, no_log: true}
mapper: {type: str}
auto: {type: bool}
method: {type: str, choices: [tpm2, keyfile]}
tpm2:
type: dict
options:
device: {type: str}
pcrs: {type: raw}
keysize: {type: int}
options: {type: str}
type: {type: str}
cipher: {type: str}
hash: {type: str}
iter: {type: int}
bits: {type: int}
pbkdf: {type: str}
features:
type: dict
options:
cloud_init: {type: bool}
cis:
type: dict
options:
enabled: {type: bool}
profile: {type: str, choices: [default, l1, l2]}
rules: {type: dict}
params: {type: dict}
selinux:
type: dict
options:
enabled: {type: bool}
firewall:
type: dict
options:
enabled: {type: bool}
backend: {type: str, choices: ["", firewalld, ufw]}
toolkit: {type: str, choices: [nftables, iptables]}
ssh:
type: dict
options:
enabled: {type: bool}
zstd:
type: dict
options:
enabled: {type: bool}
swap:
type: dict
options:
enabled: {type: bool}
banner:
type: dict
options:
motd: {type: bool}
sudo: {type: bool}
chroot:
type: dict
options:
tool: {type: str, choices: [arch-chroot, chroot, systemd-nspawn]}
initramfs:
type: dict
options:
generator: {type: str, choices: ["", dracut, mkinitcpio, initramfs-tools]}
desktop:
type: dict
options:
enabled: {type: bool}
environment: {type: str, choices: ["", gnome, kde, sway, hyprland]}
display_manager: {type: str, choices: ["", gdm, sddm, plasma-login-manager, greetd, ly]}
autologin: {type: raw}
session: {type: str}
groups: {type: list, elements: str}
secure_boot:
type: dict
options:
enabled: {type: bool}
method: {type: str, choices: ["", sbctl, uki]}
firmware:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
microcode: {type: raw, choices: [true, false, auto]}
gpu:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
nvidia_driver: {type: str, choices: [auto, open, proprietary, nouveau]}
peripherals:
type: dict
options:
enabled: {type: raw, choices: [true, false, auto]}
fingerprint: {type: raw, choices: [true, false, auto]}
camera: {type: raw, choices: [true, false, auto]}
audio: {type: raw, choices: [true, false, auto]}
bluetooth: {type: raw, choices: [true, false, auto]}
displaylink: {type: bool}
hardware:
type: dict
options:
profile: {type: dict}
cpu: {type: str}
gpus: {type: list, elements: str}
wireless: {type: list, elements: str}
audio: {type: list, elements: str}
camera: {type: dict}
fingerprint: {type: bool}
bluetooth: {type: bool}
packages: {type: dict}
disable: {type: list, elements: str}
kernel_params: {type: list, elements: str}
@@ -94,7 +94,6 @@
- name: Set install_drive from primary disk
when:
- install_drive is not defined
- system_disks_cfg | length > 0
- system_disks_cfg[0].device | string | length > 0
ansible.builtin.set_fact:
@@ -1,7 +1,7 @@
---
- name: Build normalized system configuration
vars:
system_raw: "{{ system_defaults | combine(system | default({}), recursive=True) }}"
system_raw: "{{ system_defaults | combine(system, recursive=True) }}"
system_type: "{{ 'virtual' if (system_raw.type | string | lower) in ['vm', 'virtual'] else (system_raw.type | string | lower) }}"
system_os_input: "{{ system_raw.os | default('') | string | lower }}"
system_name: >-
@@ -0,0 +1,57 @@
---
- name: Ensure system input is a dictionary
ansible.builtin.set_fact:
system: "{{ system | default({}) }}"
- name: Validate system input types
ansible.builtin.assert:
that:
- system is mapping
- system.network is not defined or system.network is mapping
- system.users is not defined or system.users is mapping
- system.root is not defined or system.root is mapping
- system.luks is not defined or system.luks is mapping
- system.features is not defined or system.features is mapping
fail_msg: "system and its nested keys (network, root, luks, features, users) must be dictionaries."
quiet: true
- name: Validate DNS lists (not strings)
when: system.network is defined and system.network.dns is defined
ansible.builtin.assert:
that:
- system.network.dns.servers is not defined or (system.network.dns.servers is iterable and system.network.dns.servers is not string)
- system.network.dns.search is not defined or (system.network.dns.search is iterable and system.network.dns.search is not string)
fail_msg: "system.network.dns.servers and system.network.dns.search must be lists, not strings."
quiet: true
- name: Validate system.users entries
when: system.users is defined and system.users is mapping and system.users | length > 0
ansible.builtin.assert:
that:
- item.value is mapping
- item.key | string | length > 0
- ('keys' not in item.value) or (item.value['keys'] is iterable and item.value['keys'] is not string)
fail_msg: "Each system.users entry must be a dict keyed by username; 'keys' must be a list."
quiet: true
loop: "{{ system.users | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Validate system features input types
when: system.features is defined
loop: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}"
loop_control:
label: "system.features.{{ item }}"
ansible.builtin.assert:
that:
- (system_defaults.features[item] is not mapping) or ((system.features[item] | default({})) is mapping)
fail_msg: "system.features.{{ item }} must be a dictionary."
quiet: true
- name: Validate system LUKS TPM2 input type
when: system.luks is defined and system.luks is mapping
ansible.builtin.assert:
that:
- system.luks.tpm2 is not defined or system.luks.tpm2 is mapping
fail_msg: "system.luks.tpm2 must be a dictionary."
quiet: true
+5 -19
View File
@@ -1,11 +1,6 @@
---
# Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
# roles consume the computed facts directly with no per-role _normalize (except CIS).
- name: Validate user input
validate_inputs:
specs:
- "{{ role_path }}/meta/argument_specs.yml"
- name: Global defaults loaded
ansible.builtin.debug:
msg: Global defaults loaded.
@@ -52,17 +47,6 @@
ansible.builtin.set_fact:
system_cfg: "{{ system_cfg | combine({'path': hypervisor_cfg.folder | string}, recursive=True) }}"
# Guarded so a malformed users input reaches the validation message, not a filter error.
- name: Pick the primary user (first with a password)
vars:
_first: >-
{{ (system_cfg.users if system_cfg.users is mapping else {}) | dict2items
| selectattr('value', 'mapping') | selectattr('value.password', 'defined')
| rejectattr('value.password', 'equalto', '') | first | default({}) }}
ansible.builtin.set_fact:
bootstrap_primary_user: "{{ {'name': _first.key, 'password': _first.value.password} if _first else {} }}"
no_log: true
- name: Validate variables
ansible.builtin.include_tasks: validation.yml
@@ -105,12 +89,14 @@
when:
- system_cfg.type == "virtual"
- hypervisor_type != "vmware"
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact:
ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22
ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_user: "{{ _primary.key }}"
ansible_password: "{{ _primary.value.password }}"
ansible_become_password: "{{ _primary.value.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
no_log: true
+7 -4
View File
@@ -5,6 +5,9 @@
- name: Normalize system and disk configuration
when: system_cfg is not defined
block:
- name: Validate raw system input types
ansible.builtin.include_tasks: _validate_input.yml
- name: Normalize system configuration
ansible.builtin.include_tasks: _normalize_system.yml
@@ -32,12 +35,10 @@
}, recursive=True)
}}
# Keyed on os, not hostname: inventories set hostname themselves, and doing so
# would otherwise skip enrichment and leave os undefined for validation.
- name: Check if pre-computed system_cfg needs enrichment
when: system_cfg is defined
ansible.builtin.set_fact:
_bootstrap_needs_enrichment: "{{ os is not defined }}"
_bootstrap_needs_enrichment: "{{ hostname is not defined }}"
- name: Merge pre-computed system_cfg with bootstrap system_defaults
when:
@@ -78,7 +79,9 @@
os_version: "{{ system_cfg.version | default('') | string }}"
- name: Normalize disk configuration (pre-computed system_cfg)
when: system_cfg is defined
when:
- system_cfg is defined
- install_drive is not defined
ansible.builtin.include_tasks: _normalize_disks.yml
# Runs on every path before validation, so an empty firewall.backend / content.source
+104 -16
View File
@@ -26,6 +26,90 @@
fail_msg: "hypervisor.type must be one of: libvirt, proxmox, vmware, xen when system.type=virtual."
quiet: true
- name: Validate hypervisor schema
vars:
hypervisor_allowed_keys: "{{ hypervisor_defaults | dict2items | map(attribute='key') | list }}"
hypervisor_keys: "{{ (hypervisor | default({})) | dict2items | map(attribute='key') | list }}"
hypervisor_unknown_keys: "{{ hypervisor_keys | difference(hypervisor_allowed_keys) }}"
ansible.builtin.assert:
that:
- hypervisor_unknown_keys | length == 0
fail_msg: "Unsupported hypervisor keys: {{ hypervisor_unknown_keys | join(', ') }}"
quiet: true
- name: Validate system schema
vars:
system_allowed_keys: "{{ system_defaults | dict2items | map(attribute='key') | list }}"
system_keys: "{{ (system | default({})) | dict2items | map(attribute='key') | list }}"
system_unknown_keys: "{{ system_keys | difference(system_allowed_keys) }}"
ansible.builtin.assert:
that:
- system_unknown_keys | length == 0
fail_msg: "Unsupported system keys: {{ system_unknown_keys | join(', ') }}."
quiet: true
- name: Validate system sub-dict schemas
loop:
- network
- root
- luks
loop_control:
label: "system.{{ item }}"
vars:
sub_input: "{{ (system[item] | default({})) | dict2items | map(attribute='key') | list }}"
sub_allowed: "{{ system_defaults[item] | dict2items | map(attribute='key') | list }}"
sub_unknown: "{{ sub_input | difference(sub_allowed) }}"
ansible.builtin.assert:
that:
- sub_unknown | length == 0
fail_msg: "Unsupported system.{{ item }} keys: {{ sub_unknown | join(', ') }}"
quiet: true
- name: Validate system.luks.tpm2 schema
vars:
tpm2_input: >-
{{
(system.luks if (system.luks is defined and system.luks is mapping) else {}).tpm2
| default({})
}}
tpm2_allowed_keys: "{{ system_defaults.luks.tpm2 | dict2items | map(attribute='key') | list }}"
tpm2_unknown: "{{ (tpm2_input | dict2items | map(attribute='key') | list) | difference(tpm2_allowed_keys) }}"
ansible.builtin.assert:
that:
- system.luks is not defined or system.luks.tpm2 is not defined or system.luks.tpm2 is mapping
- tpm2_unknown | length == 0
fail_msg: "Unsupported system.luks.tpm2 keys: {{ tpm2_unknown | join(', ') }}"
quiet: true
- name: Validate system.features schema
vars:
features_allowed_keys: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}"
features_unknown: >-
{{
((system.features | default({})) | dict2items | map(attribute='key') | list)
| difference(features_allowed_keys)
}}
ansible.builtin.assert:
that:
- features_unknown | length == 0
fail_msg: "Unsupported system.features keys: {{ features_unknown | join(', ') }}"
quiet: true
- name: Validate system.features leaf schemas
loop: "{{ system_defaults.features | dict2items | selectattr('value', 'mapping') }}"
loop_control:
label: "system.features.{{ item.key }}"
vars:
feature_input: "{{ (system.features | default({}))[item.key] | default({}) }}"
feature_allowed_keys: "{{ item.value | dict2items | map(attribute='key') | list }}"
feature_unknown: "{{ (feature_input | dict2items | map(attribute='key') | list) | difference(feature_allowed_keys) }}"
ansible.builtin.assert:
that:
- feature_input is mapping
- feature_unknown | length == 0
fail_msg: "Unsupported system.features.{{ item.key }} keys: {{ feature_unknown | join(', ') }}"
quiet: true
- name: Validate OS and version inputs
ansible.builtin.assert:
that:
@@ -270,12 +354,28 @@
quiet: true
- name: Validate at least one user with a password is defined
vars:
_pw_users: "{{ system_cfg.users | dict2items | selectattr('value.password', 'defined') | list }}"
ansible.builtin.assert:
that:
- bootstrap_primary_user | length > 0
fail_msg: >-
At least one user in system.users needs a non-empty password: bootstrap logs in
as the first such user after the reboot.
- system_cfg.users | default({}) | length > 0
- _pw_users | length > 0
- _pw_users[0].key | string | length > 0
- _pw_users[0].value.password | string | length > 0
fail_msg: "At least one user with a password must be defined in system.users."
quiet: true
no_log: true
- name: Validate DNS servers is a list
when:
- system_cfg.network.dns.servers is defined
- system_cfg.network.dns.servers | length > 0
ansible.builtin.assert:
that:
- system_cfg.network.dns.servers is iterable
- system_cfg.network.dns.servers is not string
- system_cfg.network.dns.servers is not mapping
fail_msg: "system.network.dns.servers must be a list."
quiet: true
- name: Validate all virtual disks have a positive size
@@ -369,18 +469,6 @@
loop_control:
label: "{{ item | to_json }}"
- name: Validate network interfaces extra_addresses
when: item.extra_addresses is defined
ansible.builtin.assert:
that:
- item.ip | default('') | string | length > 0
- item.extra_addresses | reject('match', '^(\\d{1,3}\\.){3}\\d{1,3}/\\d{1,2}$') | list | length == 0
fail_msg: "system.network.interfaces[].extra_addresses must be a list of IPv4 CIDRs (192.0.2.10/24) on an entry that also sets 'ip'."
quiet: true
loop: "{{ system_cfg.network.interfaces | default([]) }}"
loop_control:
label: "{{ item | to_json }}"
- name: Validate hostname format
ansible.builtin.assert:
that:
@@ -1,61 +0,0 @@
---
# The virtualization role attaches system.disks to SCSI controller 0 in list order, and
# vSphere never gives a disk unit 7 (the controller's own address). The kernel's sdX order
# is not that order and differs between the install environment and the installed OS, so
# a disk derived as /dev/sd<letter of its index> can be the wrong one. Resolve by target.
- name: List disks with their SCSI address
ansible.builtin.command: lsblk -dnpo NAME,TYPE,HCTL
register: partitioning_vmware_lsblk
changed_when: false
- name: Map SCSI targets to devices
vars:
_scsi: >-
{%- set out = [] -%}
{%- for line in partitioning_vmware_lsblk.stdout_lines -%}
{%- set p = line.split() -%}
{%- if (p | length) == 3 and p[1] == 'disk' and p[2] is match('^\d+:0:\d+:0$') -%}
{%- set _ = out.append({'name': p[0], 'host': p[2].split(':')[0], 'target': p[2].split(':')[2]}) -%}
{%- endif -%}
{%- endfor -%}
{{ out }}
ansible.builtin.set_fact:
partitioning_vmware_hosts: "{{ _scsi | map(attribute='host') | unique | list }}"
partitioning_vmware_targets: "{{ _scsi | items2dict(key_name='target', value_name='name') }}"
partitioning_vmware_target_count: "{{ _scsi | length }}"
- name: Assert the system disks sit on one SCSI controller with unique targets
ansible.builtin.assert:
that:
- partitioning_vmware_hosts | length == 1
- partitioning_vmware_targets | length == partitioning_vmware_target_count | int
fail_msg: >-
Expected every disk on one SCSI controller with unique targets, got
{{ partitioning_vmware_lsblk.stdout_lines | select('search', ' disk ') | list }}.
quiet: true
- name: Assert each system disk has a device at its SCSI target
vars:
_target: "{{ (idx if idx < 7 else idx + 1) | string }}"
ansible.builtin.assert:
that:
- _target in partitioning_vmware_targets
fail_msg: "system.disks[{{ idx }}] ({{ item.size }} GB) expects a disk at SCSI target {{ _target }}, none found."
quiet: true
loop: "{{ system_cfg.disks }}"
loop_control:
index_var: idx
label: "{{ idx }}"
- name: Point system disks and install_drive at the resolved devices
vars:
_disks: >-
{%- set out = [] -%}
{%- for d in system_cfg.disks -%}
{%- set dev = partitioning_vmware_targets[(loop.index0 if loop.index0 < 7 else loop.index0 + 1) | string] -%}
{%- set _ = out.append(d | combine({'device': dev, 'partition': dev ~ '1'})) -%}
{%- endfor -%}
{{ out }}
ansible.builtin.set_fact:
system_cfg: "{{ system_cfg | combine({'disks': _disks}) }}"
install_drive: "{{ _disks[0].device }}"
-6
View File
@@ -1,10 +1,4 @@
---
- name: Resolve VMware disks by SCSI target
when:
- hypervisor_type == "vmware"
- system_cfg.type == "virtual"
ansible.builtin.include_tasks: _resolve_vmware_disks.yml
- name: Detect system sizing
ansible.builtin.include_tasks: _detect_sizing.yml
-253
View File
@@ -1,253 +0,0 @@
---
- 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
until: system_check_esxi.ansible_facts is defined
retries: 3
delay: 10
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
until: system_check_cluster.datastores is defined
retries: 3
delay: 10
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
# A missing file fails with status 404; a dropped vCenter connection fails with status None.
until: system_check_media_files is not failed or system_check_media_files.status is not none
retries: 3
delay: 10
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 }}"
-9
View File
@@ -82,11 +82,6 @@
name: "{{ hostname }}"
folder: "{{ system_cfg.path if system_cfg.path | length > 0 else omit }}"
register: system_check_vmware_check_result
until: >-
not system_check_vmware_check_result.failed
or (system_check_vmware_check_result.msg is search('non-existing VM'))
retries: 3
delay: 10
failed_when: false
changed_when: false
no_log: true
@@ -137,7 +132,3 @@
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.
quiet: true
- name: Verify boot media and placement target
when: system_cfg.type == "virtual"
ansible.builtin.include_tasks: _placement.yml
+2 -2
View File
@@ -35,8 +35,8 @@
{%- endfor -%}
{{ out }}
community.proxmox.proxmox_kvm:
ciuser: "{{ bootstrap_primary_user.name }}"
cipassword: "{{ bootstrap_primary_user.password }}"
ciuser: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).key }}"
cipassword: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).value.password }}"
ciupgrade: false
vmid: "{{ system_cfg.id }}"
name: "{{ hostname }}"
@@ -12,9 +12,6 @@ network:
{% if has_static %}
addresses:
- "{{ iface.ip }}/{{ iface.prefix }}"
{% for address in iface.extra_addresses | default([]) %}
- "{{ address }}"
{% endfor %}
{% if iface.gateway | default('') | string | length %}
routes:
- to: default
@@ -14,9 +14,8 @@ users:
{% elif _sudo | bool %}
sudo: "ALL=(ALL) NOPASSWD:ALL"
{% endif %}
{% set _pw = attrs.password | default('') %}
{% if _pw | length > 0 %}
passwd: "{{ _pw if (_pw | string)[:1] == '$' else _pw | password_hash('sha512') }}"
{% if attrs.password | default('') | length > 0 %}
passwd: "{{ attrs.password | password_hash('sha512') }}"
lock_passwd: false
{% else %}
lock_passwd: true
+1 -1
View File
@@ -1,6 +1,6 @@
---
# Raw system.content input -> expected normalized system_cfg.content. Covers family
# source defaults (rhel=dvd, else mirror), url pass-through, satellite inputs, and the
# source defaults (EL=dvd, else mirror), url pass-through, satellite inputs, and the
# Arch reflector lock-out (dropped only when a custom mirror url is set).
content_fixtures:
- name: arch-mirror-url-drops-reflector
+1 -1
View File
@@ -34,4 +34,4 @@ merge_fixtures:
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-intel]
expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-other]
+1 -1
View File
@@ -5,7 +5,7 @@ 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-intel, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils]
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
-22
View File
@@ -1,22 +0,0 @@
---
- name: "Validate the fixture ({{ inf.name }})"
block:
- name: "Run the validator ({{ inf.name }})"
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
vars:
fixture: "{{ inf.value }}"
register: inputs_result
rescue:
- name: "Keep the failed result ({{ inf.name }})"
ansible.builtin.set_fact:
inputs_result: "{{ ansible_failed_result }}"
- name: "Assert the reported violations ({{ inf.name }})"
ansible.builtin.assert:
that:
- (inputs_result.errors | default([])) | sort == inf.expect | sort
- "'topsecret-value' not in (inputs_result | to_json)"
fail_msg: "[inputs {{ inf.name }}] expected {{ inf.expect }}, got {{ inputs_result.errors | default([]) }}"
success_msg: "[inputs {{ inf.name }}] OK"
-47
View File
@@ -1,47 +0,0 @@
---
argument_specs:
inputs:
options:
fixture:
type: dict
options:
name:
type: str
required: true
enabled:
type: bool
count:
type: int
servers:
type: list
elements: str
kind:
type: str
choices: [a, b]
secret:
type: str
no_log: true
gate:
type: raw
options:
port:
type: int
disks:
type: list
elements: dict
options:
size:
type: int
required: true
users:
type: dict
options:
"<user>":
type: dict
options:
password:
type: str
no_log: true
keys:
type: list
elements: str
-18
View File
@@ -1,18 +0,0 @@
---
# Vaulted with a discarded password: the validator must handle a value it cannot decrypt.
fixture:
name: web01
secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
62363835313830363035333361613236373662303039376639396136336438616331336565393631
6637376163346430386663393661386561316537306662350a663563326164376164396230616361
33646564346532643735396239626133333162353839666135343030643836313938363466383439
3130353636666365310a353064373139323836373163336563353861356333316534386238333664
3663
servers: !vault |
$ANSIBLE_VAULT;1.1;AES256
65626131373433633432363261356534613135386461626535393539656631323531346661643233
3961366535643630386462303038636231373239343130310a343565336461633532316136643139
30306461616232663763633630353661376130323536346637633964373239343664303533396236
6165623262633131330a303965643439626335663130376237393362363231346537643334633163
3835
-122
View File
@@ -1,122 +0,0 @@
---
inputs_fixtures:
- name: valid
value:
name: web01
enabled: true
count: "3"
servers: [10.0.0.1, 10.0.0.2]
kind: a
disks:
- size: 30
users:
alice:
password: pw
keys: [ssh-ed25519 AAAA alice]
retired: ~
expect: []
- name: empty-int-is-unset
value:
name: web01
count: ""
expect: []
- name: list-for-dict
value:
name: web01
users:
- name: alice
expect: ["fixture.users: expected dict keyed by name, got list"]
- name: comma-string-for-list
value:
name: web01
servers: 10.0.0.1,10.0.0.2
expect: ["fixture.servers: expected list, got str"]
- name: yes-string-for-bool
value:
name: web01
enabled: "yes"
expect: ["fixture.enabled: expected bool, got str"]
- name: typo-key
value:
name: web01
userz: {}
expect: ["fixture.userz: unsupported key, fixture accepts count, disks, enabled, gate, kind, name, secret, servers, users"]
- name: wrong-element-type
value:
name: web01
disks:
- size: 30
- size: big
expect: ["fixture.disks[1].size: expected int, got str"]
- name: missing-required
value:
enabled: false
expect: ["fixture.name: required key is missing"]
- name: choice-mismatch
value:
name: web01
kind: c
expect: ["fixture.kind: must be one of a, b, got 'c'"]
- name: secrets-never-echoed
value:
name: web01
secret: [topsecret-value]
users:
alice:
password: [topsecret-value]
expect:
- "fixture.secret: expected str, got list"
- "fixture.users.alice.password: expected str, got list"
- name: keyed-entry-not-a-dict
value:
name: web01
users:
alice: pw
expect: ["fixture.users.alice: expected dict, got str"]
- name: two-violations-at-once
value:
name: web01
enabled: "true"
userz: {}
expect:
- "fixture.userz: unsupported key, fixture accepts count, disks, enabled, gate, kind, name, secret, servers, users"
- "fixture.enabled: expected bool, got str"
- name: raw-gate-false
value:
name: web01
gate: false
expect: []
- name: raw-gate-dict-checked
value:
name: web01
gate:
port: http
portz: 1
expect:
- "fixture.gate.portz: unsupported key, fixture.gate accepts port"
- "fixture.gate.port: expected int, got str"
- name: raw-gate-true
value:
name: web01
gate: true
expect: []
- name: raw-gate-string-rejected
value:
name: web01
gate: "yes"
expect: ["fixture.gate: expected bool or dict, got str"]
-108
View File
@@ -1,108 +0,0 @@
---
# Run: ansible-playbook tests/inputs/test_inputs.yml
- name: Strict input validator fixture tests
hosts: localhost
gather_facts: false
connection: local
vars_files:
- inputs_fixtures.yml
tasks:
- name: Run each input fixture
ansible.builtin.include_tasks: _assert_inputs.yml
loop: "{{ inputs_fixtures }}"
loop_control:
loop_var: inf
label: "{{ inf.name }}"
- name: Expand a spec pattern
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_*.yml"
vars:
fixture:
name: web01
servers: [10.0.0.1]
register: inputs_pattern_result
- name: Assert the pattern matched the fixture spec
ansible.builtin.assert:
that:
- inputs_pattern_result is succeeded
success_msg: "[inputs pattern] OK"
- name: Refuse a pattern that matches nothing
block:
- name: Run the validator with a pattern that matches no file
validate_inputs:
specs:
- "{{ playbook_dir }}/nothing_*.yml"
register: inputs_nomatch_result
rescue:
- name: Keep the no-match failure
ansible.builtin.set_fact:
inputs_nomatch_result: "{{ ansible_failed_result }}"
- name: Assert the empty pattern was refused
ansible.builtin.assert:
that:
- inputs_nomatch_result.msg is search('no spec file matches')
fail_msg: "[inputs no-match] got {{ inputs_nomatch_result.msg | default('') }}"
success_msg: "[inputs no-match] OK"
- name: Refuse a spec list given as a string
block:
- name: Run the validator with specs as a string
validate_inputs:
specs: "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_string_result
rescue:
- name: Keep the string-specs failure
ansible.builtin.set_fact:
inputs_string_result: "{{ ansible_failed_result }}"
- name: Assert the string was refused
ansible.builtin.assert:
that:
- inputs_string_result.msg is search('specs must be a list')
fail_msg: "[inputs string] got {{ inputs_string_result.msg | default('') }}"
success_msg: "[inputs string] OK"
- name: Refuse a variable declared by two specs
block:
- name: Run the validator with the fixture spec twice
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
- "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_duplicate_result
rescue:
- name: Keep the duplicate-declaration failure
ansible.builtin.set_fact:
inputs_duplicate_result: "{{ ansible_failed_result }}"
- name: Assert the duplicate declaration was refused
ansible.builtin.assert:
that:
- inputs_duplicate_result.msg is search('fixture is declared by both')
fail_msg: "[inputs duplicate] got {{ inputs_duplicate_result.msg | default('') }}"
success_msg: "[inputs duplicate] OK"
- name: Validate a host var holding values that cannot be decrypted
block:
- name: Run the validator on the vaulted host var
validate_inputs:
specs:
- "{{ playbook_dir }}/fixture_spec.yml"
register: inputs_vault_result
rescue:
- name: Keep the failed vault result
ansible.builtin.set_fact:
inputs_vault_result: "{{ ansible_failed_result }}"
- name: Assert a vaulted string passes and a vaulted non-list is reported
ansible.builtin.assert:
that:
- >-
inputs_vault_result.errors | default([]) == ['fixture.servers: expected list, got str']
fail_msg: "[inputs vault] got {{ inputs_vault_result.errors | default([]) }}"
success_msg: "[inputs vault] OK"
-62
View File
@@ -1,62 +0,0 @@
---
# Run: ansible-playbook tests/users/test_users.yml
# One host per fixture: set_fact state cannot be cleared between loop iterations.
- name: Register one host per users fixture
hosts: localhost
gather_facts: false
connection: local
vars_files:
- users_fixtures.yml
tasks:
- name: Add the fixture host
ansible.builtin.add_host:
name: "{{ uf.name }}"
groups: users_fixtures
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"
expect: "{{ uf.expect }}"
system: "{{ _system }}"
system_cfg: "{{ _system if uf.precomputed | default(false) else omit }}"
vars:
_system: "{{ users_fixture_system | combine(uf.system, {'name': uf.name}, recursive=True) }}"
loop: "{{ users_fixtures }}"
loop_control:
loop_var: uf
label: "{{ uf.name }}"
- name: Run global_defaults against each users fixture
hosts: users_fixtures
gather_facts: false
vars:
hypervisor:
type: libvirt
url: qemu:///system
storage: default
boot_iso: /var/lib/libvirt/images/fixture.iso
tasks:
- name: Normalize and validate
block:
- name: Load global defaults
ansible.builtin.include_role:
name: global_defaults
- name: Record the pass
ansible.builtin.set_fact:
users_outcome: pass
rescue:
- name: Record the failure
ansible.builtin.set_fact:
users_outcome: >-
{{ ([ansible_failed_result.msg | default('')]
+ (ansible_failed_result.results | default([]) | map(attribute='msg', default='') | list))
| join(' ') }}
- name: Assert the expected outcome
ansible.builtin.assert:
that:
- >-
(users_outcome == 'pass' and bootstrap_primary_user.name == expect.primary)
if expect.primary is defined
else (expect.fail in users_outcome)
fail_msg: "[{{ inventory_hostname }}] expected {{ expect }}, got: {{ users_outcome }}"
success_msg: "[{{ inventory_hostname }}] OK"
-123
View File
@@ -1,123 +0,0 @@
---
users_fixture_system:
type: virtual
os: debian
version: "13"
cpus: 2
memory: 2048
disks:
- size: 30
network:
interfaces:
- bridge: br0
ip: 192.0.2.10
prefix: 24
gateway: 192.0.2.1
root:
password: rootpw
# precomputed: hand system_cfg over the way deploy_iac does, skipping the raw input checks.
users_fixtures:
- name: dict-first-password-wins
system:
users:
keyonly:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture keyonly
blank:
password: ""
ops:
password: pw
sudo: true
later:
password: pw2
expect:
primary: ops
- name: dict-first-password-wins-precomputed
precomputed: true
system:
users:
keyonly:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture keyonly
ops:
password: pw
expect:
primary: ops
- name: list-rejected
system:
users:
- name: ops
password: pw
expect:
fail: "system.users: expected dict keyed by name, got list"
- name: list-rejected-precomputed
precomputed: true
system:
users:
- name: ops
password: pw
expect:
fail: "system.users: expected dict keyed by name, got list"
- name: scalar-entry-rejected
system:
users:
ops: pw
expect:
fail: "system.users.ops: expected dict, got str"
- name: scalar-entry-rejected-precomputed
precomputed: true
system:
users:
ops: pw
expect:
fail: "system.users.ops: expected dict, got str"
- name: keys-string-rejected
system:
users:
ops:
password: pw
keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
expect:
fail: "system.users.ops.keys: expected list, got str"
- name: no-password-rejected
system:
users:
ops:
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
expect:
fail: needs a non-empty password
- name: autologin-user-accepted
system:
users:
ops:
password: pw
features:
desktop:
enabled: true
environment: gnome
autologin: ops
expect:
primary: ops
- name: autologin-unknown-user-rejected
system:
users:
ops:
password: pw
features:
desktop:
enabled: true
environment: gnome
autologin: ghost
expect:
fail: desktop.autologin must be false or a username