From 74caca915e7dd134c001ae6655f66e84f133966b Mon Sep 17 00:00:00 2001 From: Sandwich Date: Mon, 5 Oct 2026 16:54:28 +0200 Subject: [PATCH] fix(global_defaults): validate system.users as the documented dict on both paths --- ansible.cfg | 1 + .../global_defaults/tasks/_validate_input.yml | 16 +-- roles/global_defaults/tasks/validation.yml | 33 ++++- tests/users/test_users.yml | 62 +++++++++ tests/users/users_fixtures.yml | 123 ++++++++++++++++++ 5 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 tests/users/test_users.yml create mode 100644 tests/users/users_fixtures.yml diff --git a/ansible.cfg b/ansible.cfg index 83b968b..788306d 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,5 +1,6 @@ [defaults] hash_behaviour = merge +roles_path = roles interpreter_python = auto_silent deprecation_warnings = False host_key_checking = False diff --git a/roles/global_defaults/tasks/_validate_input.yml b/roles/global_defaults/tasks/_validate_input.yml index f4a8080..1b7ab65 100644 --- a/roles/global_defaults/tasks/_validate_input.yml +++ b/roles/global_defaults/tasks/_validate_input.yml @@ -8,11 +8,10 @@ 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." + fail_msg: "system and its nested keys (network, root, luks, features) must be dictionaries." quiet: true - name: Validate DNS lists (not strings) @@ -24,19 +23,6 @@ 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 }}" diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index 1a7b9a1..63d4665 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -65,6 +65,27 @@ 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: >- @@ -317,7 +338,7 @@ _autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}" ansible.builtin.assert: that: - - _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users | map(attribute='name')) + - _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users) fail_msg: >- desktop.autologin must be false or a username string present in system.users; got '{{ _autologin }}'. Bool true is not accepted - the @@ -354,15 +375,13 @@ quiet: true - name: Validate at least one user with a password is defined - vars: - _pw_users: "{{ system_cfg.users | selectattr('password', 'defined') | map(attribute='password') | map('string') | reject('equalto', '') | list }}" ansible.builtin.assert: that: - - system_cfg.users | default([]) | length > 0 - - _pw_users | length > 0 - fail_msg: "At least one user with a non-empty password must be defined in system.users." + - 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. quiet: true - no_log: true - name: Validate DNS servers is a list when: diff --git a/tests/users/test_users.yml b/tests/users/test_users.yml new file mode 100644 index 0000000..ecfcc26 --- /dev/null +++ b/tests/users/test_users.yml @@ -0,0 +1,62 @@ +--- +# 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" diff --git a/tests/users/users_fixtures.yml b/tests/users/users_fixtures.yml new file mode 100644 index 0000000..ab3d5d7 --- /dev/null +++ b/tests/users/users_fixtures.yml @@ -0,0 +1,123 @@ +--- +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 must be a dict keyed by username + + - name: list-rejected-precomputed + precomputed: true + system: + users: + - name: ops + password: pw + expect: + fail: system.users must be a dict keyed by username + + - name: scalar-entry-rejected + system: + users: + ops: pw + expect: + fail: system.users.ops must be a dict + + - name: scalar-entry-rejected-precomputed + precomputed: true + system: + users: + ops: pw + expect: + fail: system.users.ops must be a dict + + - name: keys-string-rejected + system: + users: + ops: + password: pw + keys: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture ops + expect: + fail: its keys a list of SSH public keys + + - 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