diff --git a/main.yml b/main.yml index c133d61..a2808d2 100644 --- a/main.yml +++ b/main.yml @@ -39,33 +39,24 @@ no_log: true vars: system_input: "{{ system | default({}) }}" - system_users_input: "{{ system_input.users | default([]) }}" - system_first_user: >- - {{ - system_users_input[0] - if (system_users_input is iterable and system_users_input is not string - and system_users_input is not mapping and system_users_input | length > 0) - else {} - }} + system_users_input: "{{ system_input.users | default({}) }}" + _first_entry: "{{ system_users_input | dict2items | first | default({'key': '', 'value': {}}) }}" + _first_name: "{{ _first_entry.key }}" + _first_attrs: "{{ _first_entry.value if _first_entry.value is mapping else {} }}" system_root_input: "{{ (system_input.root | default({})) if (system_input.root is mapping) else {} }}" prompt_user_name: "{{ user_name | default(system_user_name | default(''), true) | string }}" prompt_user_key: "{{ user_public_key | default(user_key | default(system_user_key | default(''), true), true) | string | trim }}" prompt_user_password: "{{ user_password | default(system_user_password | default(''), true) | string }}" prompt_root_password: "{{ root_password | default(system_root_password | default(''), true) | string }}" - resolved_user: - name: >- - {{ - system_first_user.name | string - if (system_first_user.name | default('') | string | length) > 0 - else prompt_user_name - }} + resolved_name: "{{ _first_name if (_first_name | length > 0) else prompt_user_name }}" + resolved_attrs: keys: >- {{ - system_first_user['keys'] - if (system_first_user['keys'] is defined - and system_first_user['keys'] is iterable - and system_first_user['keys'] is not string - and system_first_user['keys'] | length > 0) + _first_attrs['keys'] + if (_first_attrs['keys'] is defined + and _first_attrs['keys'] is iterable + and _first_attrs['keys'] is not string + and _first_attrs['keys'] | length > 0) else ( [prompt_user_key] if (prompt_user_key | length > 0) @@ -74,8 +65,8 @@ }} password: >- {{ - system_first_user.password | string - if (system_first_user.password | default('') | string | length) > 0 + _first_attrs.password | string + if (_first_attrs.password | default('') | string | length) > 0 else prompt_user_password }} ansible.builtin.set_fact: @@ -84,14 +75,7 @@ system_input | combine( { - 'users': ( - [resolved_user] - + (system_users_input[1:] - if (system_users_input is sequence - and system_users_input is not string - and system_users_input | length > 1) - else []) - ), + 'users': system_users_input | combine({resolved_name: (_first_attrs | combine(resolved_attrs, recursive=True))}), 'root': { 'password': ( (system_root_input.password | default('') | string | length) > 0 @@ -206,10 +190,12 @@ 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_user: "{{ system_cfg.users[0].name }}" - ansible_password: "{{ system_cfg.users[0].password }}" - ansible_become_password: "{{ system_cfg.users[0].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 diff --git a/roles/configuration/tasks/sudo.yml b/roles/configuration/tasks/sudo.yml index 3e21682..10a64f7 100644 --- a/roles/configuration/tasks/sudo.yml +++ b/roles/configuration/tasks/sudo.yml @@ -15,15 +15,15 @@ validate: /usr/sbin/visudo --check --file=%s - name: Deploy per-user sudoers rules - when: item.sudo | default(false) + when: item.value.sudo | default(false) vars: configuration_sudoers_rule: >- - {{ item.sudo if item.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }} + {{ item.value.sudo if item.value.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }} ansible.builtin.copy: - content: "{{ item.name }} {{ configuration_sudoers_rule }}\n" - dest: "/mnt/etc/sudoers.d/{{ item.name }}" + content: "{{ item.key }} {{ configuration_sudoers_rule }}\n" + dest: "/mnt/etc/sudoers.d/{{ item.key }}" mode: "0440" validate: /usr/sbin/visudo --check --file=%s - loop: "{{ system_cfg.users }}" + loop: "{{ system_cfg.users | dict2items }}" loop_control: - label: "{{ item.name }}" + label: "{{ item.key }}" diff --git a/roles/configuration/tasks/users.yml b/roles/configuration/tasks/users.yml index e1dd280..1eefb9c 100644 --- a/roles/configuration/tasks/users.yml +++ b/roles/configuration/tasks/users.yml @@ -26,44 +26,43 @@ - name: Create user accounts vars: configuration_user_group: "{{ _configuration_platform.user_group }}" - # UID starts at 1000; safe for fresh installs only configuration_useradd_cmd: >- {{ chroot_command }} /usr/sbin/useradd --create-home --user-group - --uid {{ 1000 + ansible_loop.index0 }} - --groups {{ configuration_user_group }} {{ item.name }} - --password {{ item.password | password_hash('sha512') }} --shell {{ item.shell | default('/bin/bash') }} + --uid {{ 1000 + _idx }} + --groups {{ configuration_user_group }} {{ item.key }} + {{ ('--password ' ~ (item.value.password | password_hash('sha512'))) if (item.value.password | default('') | string | length > 0) else '' }} + --shell {{ item.value.shell | default('/bin/bash') }} ansible.builtin.command: "{{ configuration_useradd_cmd }}" - loop: "{{ system_cfg.users }}" + loop: "{{ system_cfg.users | dict2items }}" loop_control: - extended: true - label: "{{ item.name }}" + index_var: _idx + label: "{{ item.key }}" register: configuration_user_result changed_when: configuration_user_result.rc == 0 no_log: true - name: Ensure .ssh directory exists - when: "'keys' in item and item['keys'] is iterable and item['keys'] is not string and item['keys'] | length > 0" + when: (item.value['keys'] | default([]) | length) > 0 ansible.builtin.file: - path: "/mnt/home/{{ item.name }}/.ssh" + path: "/mnt/home/{{ item.key }}/.ssh" state: directory - owner: "{{ 1000 + ansible_loop.index0 }}" - group: "{{ 1000 + ansible_loop.index0 }}" + owner: "{{ 1000 + _idx }}" + group: "{{ 1000 + _idx }}" mode: "0700" - loop: "{{ system_cfg.users }}" + loop: "{{ system_cfg.users | dict2items }}" loop_control: - extended: true - label: "{{ item.name }}" + index_var: _idx + label: "{{ item.key }}" -- name: Add SSH public keys to authorized_keys - vars: - configuration_uid: "{{ 1000 + (system_cfg.users | map(attribute='name') | list).index(item.0.name) }}" - ansible.builtin.lineinfile: - path: "/mnt/home/{{ item.0.name }}/.ssh/authorized_keys" - line: "{{ item.1 }}" - owner: "{{ configuration_uid }}" - group: "{{ configuration_uid }}" +- name: Deploy SSH authorized_keys + when: (item.value['keys'] | default([]) | length) > 0 + ansible.builtin.copy: + content: "{{ item.value['keys'] | join('\n') }}\n" + dest: "/mnt/home/{{ item.key }}/.ssh/authorized_keys" + owner: "{{ 1000 + _idx }}" + group: "{{ 1000 + _idx }}" mode: "0600" - create: true - loop: "{{ system_cfg.users | subelements('keys', skip_missing=True) }}" + loop: "{{ system_cfg.users | dict2items }}" loop_control: - label: "{{ item.0.name }}: {{ item.1[:40] }}..." + index_var: _idx + label: "{{ item.key }}" diff --git a/roles/global_defaults/defaults/main.yml b/roles/global_defaults/defaults/main.yml index 9ae760f..4ef8a5b 100644 --- a/roles/global_defaults/defaults/main.yml +++ b/roles/global_defaults/defaults/main.yml @@ -85,7 +85,7 @@ system_defaults: mirror: "" packages: [] disks: [] - users: [] + users: {} root: password: "" shell: "/bin/bash" diff --git a/roles/global_defaults/tasks/_normalize_system.yml b/roles/global_defaults/tasks/_normalize_system.yml index b96f076..b71b59b 100644 --- a/roles/global_defaults/tasks/_normalize_system.yml +++ b/roles/global_defaults/tasks/_normalize_system.yml @@ -96,7 +96,7 @@ }} # --- Storage & accounts --- disks: "{{ system_raw.disks | default([]) }}" - users: "{{ system_raw.users | default([]) }}" + users: "{{ system_raw.users | default({}) }}" root: password: "{{ system_raw.root.password | string }}" shell: "{{ system_raw.root.shell | default('/bin/bash') | string }}" diff --git a/roles/global_defaults/tasks/_validate_input.yml b/roles/global_defaults/tasks/_validate_input.yml index 3f52df5..48cf5d7 100644 --- a/roles/global_defaults/tasks/_validate_input.yml +++ b/roles/global_defaults/tasks/_validate_input.yml @@ -25,17 +25,17 @@ quiet: true - name: Validate system.users entries - when: system.users is defined and system.users | length > 0 + when: system.users is defined and system.users is mapping 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." + - item.value is mapping + - item.key | string | length > 0 + - item.value['keys'] is not defined 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 }}" + loop: "{{ system.users | dict2items }}" loop_control: - label: "{{ item.name | default('(unnamed)') }}" + label: "{{ item.key }}" - name: Validate system features input types when: system.features is defined diff --git a/roles/global_defaults/tasks/main.yml b/roles/global_defaults/tasks/main.yml index bba32b0..7344b9a 100644 --- a/roles/global_defaults/tasks/main.yml +++ b/roles/global_defaults/tasks/main.yml @@ -81,10 +81,12 @@ when: - system_cfg.type == "virtual" - hypervisor_type != "vmware" + vars: + _primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}" ansible.builtin.set_fact: - ansible_user: "{{ system_cfg.users[0].name }}" - ansible_password: "{{ system_cfg.users[0].password }}" - ansible_become_password: "{{ system_cfg.users[0].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 diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index eda9916..4682a38 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -261,13 +261,16 @@ fail_msg: "Invalid system sizing. Check system.cpus, system.memory, and system.disks[0].size." quiet: true -- name: Validate at least one user is defined +- 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: - - system_cfg.users | default([]) | length > 0 - - system_cfg.users[0].name is defined and (system_cfg.users[0].name | string | length) > 0 - - system_cfg.users[0].password is defined and (system_cfg.users[0].password | string | length) > 0 - fail_msg: "At least one user with a name and password must be defined in system.users[]." + - 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 diff --git a/roles/virtualization/tasks/proxmox.yml b/roles/virtualization/tasks/proxmox.yml index 7123f21..f49af93 100644 --- a/roles/virtualization/tasks/proxmox.yml +++ b/roles/virtualization/tasks/proxmox.yml @@ -35,8 +35,8 @@ {%- endfor -%} {{ out }} community.proxmox.proxmox_kvm: - ciuser: "{{ system_cfg.users[0].name }}" - cipassword: "{{ system_cfg.users[0].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 }}"