feat(global_defaults): validate user input against one argument spec on both paths

This commit is contained in:
2026-10-05 18:05:05 +02:00
parent f0b580cd3d
commit 2d1a875577
7 changed files with 259 additions and 172 deletions
@@ -0,0 +1,248 @@
---
# 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}
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: str}
cpus: {type: int}
memory: {type: int}
balloon: {type: int}
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: str}
ip: {type: str}
prefix: {type: str}
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: str}
ip: {type: str}
prefix: {type: str}
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: float}
device: {type: str}
storage: {type: str}
format: {type: str}
bus: {type: str}
slot: {type: str}
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}
@@ -1,7 +1,7 @@
---
- name: Build normalized system configuration
vars:
system_raw: "{{ system_defaults | combine(system, recursive=True) }}"
system_raw: "{{ system_defaults | combine(system | default({}), 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: >-
@@ -1,43 +0,0 @@
---
- 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.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) 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 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
View File
@@ -1,6 +1,11 @@
---
# 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.
-3
View File
@@ -5,9 +5,6 @@
- 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
-120
View File
@@ -26,111 +26,6 @@
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.users is a dict keyed by username
ansible.builtin.assert:
that:
- system_cfg.users is mapping
fail_msg: >-
system.users must be a dict keyed by username, e.g. users: {alice: {password: ...,
keys: [...], sudo: true}}. A list of {name: ...} entries is not accepted.
quiet: true
- name: Validate system.users entries
loop: "{{ system_cfg.users | dict2items }}"
loop_control:
label: "{{ item.key }}"
ansible.builtin.assert:
that:
- item.key | string | length > 0
- item.value is mapping
- ('keys' not in item.value) or (item.value['keys'] is iterable and item.value['keys'] is not string)
fail_msg: "system.users.{{ item.key }} must be a dict, and its keys a list of SSH public keys."
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:
@@ -383,18 +278,6 @@
as the first such user after the reboot.
quiet: 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
when: system_cfg.type == "virtual"
ansible.builtin.assert:
@@ -486,13 +369,10 @@
loop_control:
label: "{{ item | to_json }}"
# A string here would iterate into one address per character in every network renderer.
- name: Validate network interfaces extra_addresses
when: item.extra_addresses is defined
ansible.builtin.assert:
that:
- item.extra_addresses is not string
- item.extra_addresses is iterable
- 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'."
+5 -5
View File
@@ -52,7 +52,7 @@ users_fixtures:
- name: ops
password: pw
expect:
fail: system.users must be a dict keyed by username
fail: "system.users: expected dict, got list"
- name: list-rejected-precomputed
precomputed: true
@@ -61,14 +61,14 @@ users_fixtures:
- name: ops
password: pw
expect:
fail: system.users must be a dict keyed by username
fail: "system.users: expected dict, got list"
- name: scalar-entry-rejected
system:
users:
ops: pw
expect:
fail: system.users.ops must be a dict
fail: "system.users.ops: expected dict, got str"
- name: scalar-entry-rejected-precomputed
precomputed: true
@@ -76,7 +76,7 @@ users_fixtures:
users:
ops: pw
expect:
fail: system.users.ops must be a dict
fail: "system.users.ops: expected dict, got str"
- name: keys-string-rejected
system:
@@ -85,7 +85,7 @@ users_fixtures:
password: pw
keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops
expect:
fail: its keys a list of SSH public keys
fail: "system.users.ops.keys: expected list, got str"
- name: no-password-rejected
system: