diff --git a/roles/global_defaults/tasks/_normalize_disks.yml b/roles/global_defaults/tasks/_normalize_disks.yml new file mode 100644 index 0000000..747d33b --- /dev/null +++ b/roles/global_defaults/tasks/_normalize_disks.yml @@ -0,0 +1,100 @@ +--- +- name: Normalize system disks input + vars: + system_disks: "{{ system_cfg.disks | default([]) }}" + system_disk_letter_map: "abcdefghijklmnopqrstuvwxyz" + system_disk_device_prefix: >- + {{ + {'libvirt': '/dev/vd', 'xen': '/dev/xvd', 'proxmox': '/dev/sd', 'vmware': '/dev/sd'}.get(hypervisor_type, '') + if system_cfg.type == 'virtual' + else '' + }} + block: + - name: Validate system disks structure + ansible.builtin.assert: + that: + - system_disks is sequence + - (system_disks | length) <= 26 + fail_msg: "system.disks must be a list with at most 26 entries." + quiet: true + + - name: Validate system disk entries + ansible.builtin.assert: + that: + - item is mapping + - item.mount is not defined or item.mount is mapping + fail_msg: "Each disk entry must be a dictionary, and disk.mount (if set) must be a dictionary." + quiet: true + loop: "{{ system_disks }}" + loop_control: + label: "{{ item | to_json }}" + + - name: Initialize normalized disk list + ansible.builtin.set_fact: + system_disks_cfg: [] + + - name: Build normalized system disk configuration + vars: + disk_idx: "{{ ansible_loop.index0 }}" + disk_letter: "{{ system_disk_letter_map[disk_idx] }}" + disk_cfg_base: "{{ system_disk_defaults | combine(item, recursive=True) }}" + disk_mount: "{{ system_disk_defaults.mount | combine((disk_cfg_base.mount | default({})), recursive=True) }}" + disk_mount_path: "{{ (disk_mount.path | default('') | string) | trim }}" + disk_mount_fstype: >- + {{ + disk_mount.fstype + if (disk_mount.fstype | default('') | string | length) > 0 + else ('ext4' if disk_mount_path | length > 0 else '') + }} + disk_device: >- + {{ + disk_cfg_base.device + if (disk_cfg_base.device | string | length) > 0 + else ( + (system_disk_device_prefix ~ disk_letter) + if system_cfg.type == 'virtual' + else '' + ) + }} + disk_partition: >- + {{ + disk_device ~ ('p1' if (disk_device | regex_search('\\d$')) else '1') + if disk_device | length > 0 + else '' + }} + ansible.builtin.set_fact: + system_disks_cfg: >- + {{ + system_disks_cfg + [ + disk_cfg_base + | combine( + { + 'device': disk_device, + 'mount': { + 'path': disk_mount_path, + 'fstype': disk_mount_fstype, + 'label': disk_mount.label | default('') | string, + 'opts': disk_mount.opts | default('defaults') | string + }, + 'partition': disk_partition + }, + recursive=True + ) + ] + }} + loop: "{{ system_disks }}" + loop_control: + loop_var: item + extended: true + label: "{{ item | to_json }}" + + - name: Update system configuration with normalized disks + ansible.builtin.set_fact: + system_cfg: "{{ system_cfg | combine({'disks': system_disks_cfg}, recursive=True) }}" + + - name: Set install_drive from primary disk + when: + - system_disks_cfg | length > 0 + - system_disks_cfg[0].device | string | length > 0 + ansible.builtin.set_fact: + install_drive: "{{ system_disks_cfg[0].device }}" diff --git a/roles/global_defaults/tasks/_normalize_system.yml b/roles/global_defaults/tasks/_normalize_system.yml new file mode 100644 index 0000000..cda5a71 --- /dev/null +++ b/roles/global_defaults/tasks/_normalize_system.yml @@ -0,0 +1,143 @@ +--- +- name: Build normalized system configuration + vars: + system_raw: "{{ system_defaults | combine(system, recursive=True) }}" + system_type: "{{ system_raw.type | string | lower }}" + system_os_input: "{{ system_raw.os | default('') | string | lower }}" + system_name: >- + {{ + system_raw.name | string | trim + if (system_raw.name | default('') | string | trim | length) > 0 + else inventory_hostname + }} + ansible.builtin.set_fact: + system_cfg: + type: "{{ system_type }}" + os: "{{ system_os_input if system_os_input | length > 0 else ('archlinux' if system_type == 'physical' else '') }}" + version: "{{ system_raw.version | default('') | string }}" + filesystem: "{{ system_raw.filesystem | default('') | string | lower }}" + name: "{{ system_name }}" + id: "{{ system_raw.id | default('') | string }}" + cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}" + memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}" + balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}" + network: + bridge: "{{ system_raw.network.bridge | default('') | string }}" + vlan: "{{ system_raw.network.vlan | default('') | string }}" + ip: "{{ system_raw.network.ip | default('') | string }}" + prefix: >- + {{ + (system_raw.network.prefix | int | string) + if (system_raw.network.prefix | default('') | string | length) > 0 + else '' + }} + gateway: "{{ system_raw.network.gateway | default('') | string }}" + dns: + servers: "{{ system_raw.network.dns.servers | default([]) }}" + search: "{{ system_raw.network.dns.search | default([]) }}" + interfaces: >- + {{ + system_raw.network.interfaces + if (system_raw.network.interfaces | default([]) | length > 0) + else ( + [{ + 'name': '', + 'bridge': system_raw.network.bridge | default('') | string, + 'vlan': system_raw.network.vlan | default('') | string, + 'ip': system_raw.network.ip | default('') | string, + 'prefix': ( + (system_raw.network.prefix | int | string) + if (system_raw.network.prefix | default('') | string | length) > 0 + else '' + ), + 'gateway': system_raw.network.gateway | default('') | string + }] + if (system_raw.network.bridge | default('') | string | length > 0) + else [] + ) + }} + timezone: "{{ system_raw.timezone | default('Europe/Vienna') | string }}" + locale: "{{ system_raw.locale | default('en_US.UTF-8') | string }}" + keymap: "{{ system_raw.keymap | default('us') | string }}" + path: "{{ system_raw.path | default('') | string }}" + packages: >- + {{ + ( + system_raw.packages + if system_raw.packages is iterable and system_raw.packages is not string + else (system_raw.packages | string).split(',') + ) + | map('trim') + | reject('equalto', '') + | list + }} + disks: "{{ system_raw.disks | default([]) }}" + users: "{{ system_raw.users | default([]) }}" + root: + password: "{{ system_raw.root.password | string }}" + luks: + enabled: "{{ system_raw.luks.enabled | bool }}" + passphrase: "{{ system_raw.luks.passphrase | string }}" + mapper: "{{ system_raw.luks.mapper | string }}" + auto: "{{ system_raw.luks.auto | bool }}" + method: "{{ system_raw.luks.method | string | lower }}" + tpm2: + device: "{{ system_raw.luks.tpm2.device | string }}" + pcrs: "{{ system_raw.luks.tpm2.pcrs | string }}" + keysize: "{{ system_raw.luks.keysize | int }}" + options: "{{ system_raw.luks.options | string }}" + type: "{{ system_raw.luks.type | string }}" + cipher: "{{ system_raw.luks.cipher | string }}" + hash: "{{ system_raw.luks.hash | string }}" + iter: "{{ system_raw.luks.iter | int }}" + bits: "{{ system_raw.luks.bits | int }}" + pbkdf: "{{ system_raw.luks.pbkdf | string }}" + urandom: "{{ system_raw.luks.urandom | bool }}" + verify: "{{ system_raw.luks.verify | bool }}" + features: + cis: + enabled: "{{ system_raw.features.cis.enabled | bool }}" + selinux: + enabled: "{{ system_raw.features.selinux.enabled | bool }}" + firewall: + enabled: "{{ system_raw.features.firewall.enabled | bool }}" + backend: "{{ system_raw.features.firewall.backend | string | lower }}" + toolkit: "{{ system_raw.features.firewall.toolkit | string | lower }}" + ssh: + enabled: "{{ system_raw.features.ssh.enabled | bool }}" + zstd: + enabled: "{{ system_raw.features.zstd.enabled | bool }}" + swap: + enabled: "{{ system_raw.features.swap.enabled | bool }}" + banner: + motd: "{{ system_raw.features.banner.motd | bool }}" + sudo: "{{ system_raw.features.banner.sudo | bool }}" + rhel_repo: + source: "{{ system_raw.features.rhel_repo.source | default('iso') | string | lower }}" + url: "{{ system_raw.features.rhel_repo.url | default('') | string }}" + chroot: + tool: "{{ system_raw.features.chroot.tool | string }}" + hostname: "{{ system_name }}" + os: "{{ system_os_input if system_os_input | length > 0 else ('archlinux' if system_type == 'physical' else '') }}" + os_version: "{{ system_raw.version | default('') | string }}" + no_log: true + +- name: Populate primary network fields from first interface + when: + - system_cfg.network.interfaces | length > 0 + - system_cfg.network.bridge | default('') | string | length == 0 + vars: + _primary: "{{ system_cfg.network.interfaces[0] }}" + ansible.builtin.set_fact: + system_cfg: >- + {{ + system_cfg | combine({ + 'network': system_cfg.network | combine({ + 'bridge': _primary.bridge | default(''), + 'vlan': _primary.vlan | default(''), + 'ip': _primary.ip | default(''), + 'prefix': _primary.prefix | default(''), + 'gateway': _primary.gateway | default('') + }) + }, recursive=True) + }} diff --git a/roles/global_defaults/tasks/_validate_input.yml b/roles/global_defaults/tasks/_validate_input.yml new file mode 100644 index 0000000..3f52df5 --- /dev/null +++ b/roles/global_defaults/tasks/_validate_input.yml @@ -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 iterable and system.users is not string and system.users is not 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; system.users must be a list." + 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 | length > 0 + ansible.builtin.assert: + that: + - item is mapping + - item.name is defined and (item.name | string | length) > 0 + - item['keys'] is not defined or (item['keys'] is iterable and item['keys'] is not string) + fail_msg: "Each system.users[] entry must be a dict with 'name'; 'keys' must be a list." + quiet: true + loop: "{{ system.users }}" + loop_control: + label: "{{ item.name | default('(unnamed)') }}" + +- 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.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/system.yml b/roles/global_defaults/tasks/system.yml index 342fc89..dbfe222 100644 --- a/roles/global_defaults/tasks/system.yml +++ b/roles/global_defaults/tasks/system.yml @@ -1,300 +1,9 @@ --- -- name: Ensure system input is a dictionary - ansible.builtin.set_fact: - system: "{{ system | default({}) }}" +- name: Validate raw system input types + ansible.builtin.include_tasks: _validate_input.yml -- 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 iterable and system.users is not string and system.users is not 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; system.users must be a list." - quiet: true +- name: Normalize system configuration + ansible.builtin.include_tasks: _normalize_system.yml -- 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 | length > 0 - ansible.builtin.assert: - that: - - item is mapping - - item.name is defined and (item.name | string | length) > 0 - - item['keys'] is not defined or (item['keys'] is iterable and item['keys'] is not string) - fail_msg: "Each system.users[] entry must be a dict with 'name'; 'keys' must be a list." - quiet: true - loop: "{{ system.users }}" - loop_control: - label: "{{ item.name | default('(unnamed)') }}" - -- 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.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 - -- name: Build normalized system configuration - vars: - system_raw: "{{ system_defaults | combine(system, recursive=True) }}" - system_type: "{{ system_raw.type | string | lower }}" - system_os_input: "{{ system_raw.os | default('') | string | lower }}" - system_name: >- - {{ - system_raw.name | string | trim - if (system_raw.name | default('') | string | trim | length) > 0 - else inventory_hostname - }} - ansible.builtin.set_fact: - system_cfg: - type: "{{ system_type }}" - os: "{{ system_os_input if system_os_input | length > 0 else ('archlinux' if system_type == 'physical' else '') }}" - version: "{{ system_raw.version | default('') | string }}" - filesystem: "{{ system_raw.filesystem | default('') | string | lower }}" - name: "{{ system_name }}" - id: "{{ system_raw.id | default('') | string }}" - cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}" - memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}" - balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}" - network: - bridge: "{{ system_raw.network.bridge | default('') | string }}" - vlan: "{{ system_raw.network.vlan | default('') | string }}" - ip: "{{ system_raw.network.ip | default('') | string }}" - prefix: >- - {{ - (system_raw.network.prefix | int | string) - if (system_raw.network.prefix | default('') | string | length) > 0 - else '' - }} - gateway: "{{ system_raw.network.gateway | default('') | string }}" - dns: - servers: "{{ system_raw.network.dns.servers | default([]) }}" - search: "{{ system_raw.network.dns.search | default([]) }}" - interfaces: >- - {{ - system_raw.network.interfaces - if (system_raw.network.interfaces | default([]) | length > 0) - else ( - [{ - 'name': '', - 'bridge': system_raw.network.bridge | default('') | string, - 'vlan': system_raw.network.vlan | default('') | string, - 'ip': system_raw.network.ip | default('') | string, - 'prefix': ( - (system_raw.network.prefix | int | string) - if (system_raw.network.prefix | default('') | string | length) > 0 - else '' - ), - 'gateway': system_raw.network.gateway | default('') | string - }] - if (system_raw.network.bridge | default('') | string | length > 0) - else [] - ) - }} - timezone: "{{ system_raw.timezone | default('Europe/Vienna') | string }}" - locale: "{{ system_raw.locale | default('en_US.UTF-8') | string }}" - keymap: "{{ system_raw.keymap | default('us') | string }}" - path: "{{ system_raw.path | default('') | string }}" - packages: >- - {{ - ( - system_raw.packages - if system_raw.packages is iterable and system_raw.packages is not string - else (system_raw.packages | string).split(',') - ) - | map('trim') - | reject('equalto', '') - | list - }} - disks: "{{ system_raw.disks | default([]) }}" - users: "{{ system_raw.users | default([]) }}" - root: - password: "{{ system_raw.root.password | string }}" - luks: - enabled: "{{ system_raw.luks.enabled | bool }}" - passphrase: "{{ system_raw.luks.passphrase | string }}" - mapper: "{{ system_raw.luks.mapper | string }}" - auto: "{{ system_raw.luks.auto | bool }}" - method: "{{ system_raw.luks.method | string | lower }}" - tpm2: - device: "{{ system_raw.luks.tpm2.device | string }}" - pcrs: "{{ system_raw.luks.tpm2.pcrs | string }}" - keysize: "{{ system_raw.luks.keysize | int }}" - options: "{{ system_raw.luks.options | string }}" - type: "{{ system_raw.luks.type | string }}" - cipher: "{{ system_raw.luks.cipher | string }}" - hash: "{{ system_raw.luks.hash | string }}" - iter: "{{ system_raw.luks.iter | int }}" - bits: "{{ system_raw.luks.bits | int }}" - pbkdf: "{{ system_raw.luks.pbkdf | string }}" - urandom: "{{ system_raw.luks.urandom | bool }}" - verify: "{{ system_raw.luks.verify | bool }}" - features: - cis: - enabled: "{{ system_raw.features.cis.enabled | bool }}" - selinux: - enabled: "{{ system_raw.features.selinux.enabled | bool }}" - firewall: - enabled: "{{ system_raw.features.firewall.enabled | bool }}" - backend: "{{ system_raw.features.firewall.backend | string | lower }}" - toolkit: "{{ system_raw.features.firewall.toolkit | string | lower }}" - ssh: - enabled: "{{ system_raw.features.ssh.enabled | bool }}" - zstd: - enabled: "{{ system_raw.features.zstd.enabled | bool }}" - swap: - enabled: "{{ system_raw.features.swap.enabled | bool }}" - banner: - motd: "{{ system_raw.features.banner.motd | bool }}" - sudo: "{{ system_raw.features.banner.sudo | bool }}" - rhel_repo: - source: "{{ system_raw.features.rhel_repo.source | default('iso') | string | lower }}" - url: "{{ system_raw.features.rhel_repo.url | default('') | string }}" - chroot: - tool: "{{ system_raw.features.chroot.tool | string }}" - hostname: "{{ system_name }}" - os: "{{ system_os_input if system_os_input | length > 0 else ('archlinux' if system_type == 'physical' else '') }}" - os_version: "{{ system_raw.version | default('') | string }}" - no_log: true - -- name: Populate primary network fields from first interface - when: - - system_cfg.network.interfaces | length > 0 - - system_cfg.network.bridge | default('') | string | length == 0 - vars: - _primary: "{{ system_cfg.network.interfaces[0] }}" - ansible.builtin.set_fact: - system_cfg: >- - {{ - system_cfg | combine({ - 'network': system_cfg.network | combine({ - 'bridge': _primary.bridge | default(''), - 'vlan': _primary.vlan | default(''), - 'ip': _primary.ip | default(''), - 'prefix': _primary.prefix | default(''), - 'gateway': _primary.gateway | default('') - }) - }, recursive=True) - }} - -- name: Normalize system disks input - vars: - system_disks: "{{ system_cfg.disks | default([]) }}" - system_disk_letter_map: "abcdefghijklmnopqrstuvwxyz" - system_disk_device_prefix: >- - {{ - {'libvirt': '/dev/vd', 'xen': '/dev/xvd', 'proxmox': '/dev/sd', 'vmware': '/dev/sd'}.get(hypervisor_type, '') - if system_cfg.type == 'virtual' - else '' - }} - block: - - name: Validate system disks structure - ansible.builtin.assert: - that: - - system_disks is sequence - - (system_disks | length) <= 26 - fail_msg: "system.disks must be a list with at most 26 entries." - quiet: true - - - name: Validate system disk entries - ansible.builtin.assert: - that: - - item is mapping - - item.mount is not defined or item.mount is mapping - fail_msg: "Each disk entry must be a dictionary, and disk.mount (if set) must be a dictionary." - quiet: true - loop: "{{ system_disks }}" - loop_control: - label: "{{ item | to_json }}" - - - name: Initialize normalized disk list - ansible.builtin.set_fact: - system_disks_cfg: [] - - - name: Build normalized system disk configuration - vars: - disk_idx: "{{ ansible_loop.index0 }}" - disk_letter: "{{ system_disk_letter_map[disk_idx] }}" - disk_cfg_base: "{{ system_disk_defaults | combine(item, recursive=True) }}" - disk_mount: "{{ system_disk_defaults.mount | combine((disk_cfg_base.mount | default({})), recursive=True) }}" - disk_mount_path: "{{ (disk_mount.path | default('') | string) | trim }}" - disk_mount_fstype: >- - {{ - disk_mount.fstype - if (disk_mount.fstype | default('') | string | length) > 0 - else ('ext4' if disk_mount_path | length > 0 else '') - }} - disk_device: >- - {{ - disk_cfg_base.device - if (disk_cfg_base.device | string | length) > 0 - else ( - (system_disk_device_prefix ~ disk_letter) - if system_cfg.type == 'virtual' - else '' - ) - }} - disk_partition: >- - {{ - disk_device ~ ('p1' if (disk_device | regex_search('\\d$')) else '1') - if disk_device | length > 0 - else '' - }} - ansible.builtin.set_fact: - system_disks_cfg: >- - {{ - system_disks_cfg + [ - disk_cfg_base - | combine( - { - 'device': disk_device, - 'mount': { - 'path': disk_mount_path, - 'fstype': disk_mount_fstype, - 'label': disk_mount.label | default('') | string, - 'opts': disk_mount.opts | default('defaults') | string - }, - 'partition': disk_partition - }, - recursive=True - ) - ] - }} - loop: "{{ system_disks }}" - loop_control: - loop_var: item - extended: true - label: "{{ item | to_json }}" - - - name: Update system configuration with normalized disks - ansible.builtin.set_fact: - system_cfg: "{{ system_cfg | combine({'disks': system_disks_cfg}, recursive=True) }}" - - - name: Set install_drive from primary disk - when: - - system_disks_cfg | length > 0 - - system_disks_cfg[0].device | string | length > 0 - ansible.builtin.set_fact: - install_drive: "{{ system_disks_cfg[0].device }}" +- name: Normalize disk configuration + ansible.builtin.include_tasks: _normalize_disks.yml