diff --git a/roles/global_defaults/meta/argument_specs.yml b/roles/global_defaults/meta/argument_specs.yml new file mode 100644 index 0000000..06096b9 --- /dev/null +++ b/roles/global_defaults/meta/argument_specs.yml @@ -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 `` 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: + "": + 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} diff --git a/roles/global_defaults/tasks/_normalize_system.yml b/roles/global_defaults/tasks/_normalize_system.yml index 94e5f6b..70c82cb 100644 --- a/roles/global_defaults/tasks/_normalize_system.yml +++ b/roles/global_defaults/tasks/_normalize_system.yml @@ -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: >- diff --git a/roles/global_defaults/tasks/_validate_input.yml b/roles/global_defaults/tasks/_validate_input.yml deleted file mode 100644 index 1b7ab65..0000000 --- a/roles/global_defaults/tasks/_validate_input.yml +++ /dev/null @@ -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 diff --git a/roles/global_defaults/tasks/main.yml b/roles/global_defaults/tasks/main.yml index e123d20..d96d309 100644 --- a/roles/global_defaults/tasks/main.yml +++ b/roles/global_defaults/tasks/main.yml @@ -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. diff --git a/roles/global_defaults/tasks/system.yml b/roles/global_defaults/tasks/system.yml index e372eae..c5f5da5 100644 --- a/roles/global_defaults/tasks/system.yml +++ b/roles/global_defaults/tasks/system.yml @@ -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 diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index 63d4665..f56cc5f 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -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'." diff --git a/tests/users/users_fixtures.yml b/tests/users/users_fixtures.yml index ab3d5d7..4549b75 100644 --- a/tests/users/users_fixtures.yml +++ b/tests/users/users_fixtures.yml @@ -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: