Compare commits

...
5 Commits
14 changed files with 243 additions and 39 deletions

No files matched your search

+2
View File
@@ -9,3 +9,5 @@ vars_libvirt.yml
vars_proxmox.yml vars_proxmox.yml
.sisyphus/ .sisyphus/
.ansible/
+3
View File
@@ -1,6 +1,9 @@
--- ---
extends: default extends: default
ignore:
- .ansible/
rules: rules:
document-start: disable document-start: disable
line-length: line-length:
+1 -1
View File
@@ -217,7 +217,7 @@ When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`
#### `system.users` #### `system.users`
Dict keyed by username. At least one user must have a `password` (used for SSH access during bootstrap). Users without a password get locked accounts (key-only auth). Dict keyed by username. At least one user must have a non-empty `password`: the first such user, in declaration order, is the account bootstrap logs in as after the reboot and the cloud-init and Proxmox `ciuser` primary user. Users without a password get locked accounts (key-only auth).
```yaml ```yaml
system: system:
+1
View File
@@ -1,5 +1,6 @@
[defaults] [defaults]
hash_behaviour = merge hash_behaviour = merge
roles_path = roles
interpreter_python = auto_silent interpreter_python = auto_silent
deprecation_warnings = False deprecation_warnings = False
host_key_checking = False host_key_checking = False
+3 -5
View File
@@ -119,15 +119,13 @@
when: when:
- post_reboot_can_connect | bool - post_reboot_can_connect | bool
no_log: true no_log: true
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact: ansible.builtin.set_fact:
ansible_connection: ssh ansible_connection: ssh
ansible_host: "{{ system_cfg.network.ip }}" ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22 ansible_port: 22
ansible_user: "{{ _primary.key }}" ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ _primary.value.password }}" ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ _primary.value.password }}" ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3 ansible_python_interpreter: /usr/bin/python3
@@ -8,11 +8,10 @@
that: that:
- system is mapping - system is mapping
- system.network is not defined or system.network 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.root is not defined or system.root is mapping
- system.luks is not defined or system.luks is mapping - system.luks is not defined or system.luks is mapping
- system.features is not defined or system.features 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 quiet: true
- name: Validate DNS lists (not strings) - 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." fail_msg: "system.network.dns.servers and system.network.dns.search must be lists, not strings."
quiet: true 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 - name: Validate system features input types
when: system.features is defined when: system.features is defined
loop: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}" loop: "{{ system_defaults.features | dict2items | map(attribute='key') | list }}"
+14 -5
View File
@@ -47,6 +47,17 @@
ansible.builtin.set_fact: ansible.builtin.set_fact:
system_cfg: "{{ system_cfg | combine({'path': hypervisor_cfg.folder | string}, recursive=True) }}" system_cfg: "{{ system_cfg | combine({'path': hypervisor_cfg.folder | string}, recursive=True) }}"
# Guarded so a malformed users input reaches the validation message, not a filter error.
- name: Pick the primary user (first with a password)
vars:
_first: >-
{{ (system_cfg.users if system_cfg.users is mapping else {}) | dict2items
| selectattr('value', 'mapping') | selectattr('value.password', 'defined')
| rejectattr('value.password', 'equalto', '') | first | default({}) }}
ansible.builtin.set_fact:
bootstrap_primary_user: "{{ {'name': _first.key, 'password': _first.value.password} if _first else {} }}"
no_log: true
- name: Validate variables - name: Validate variables
ansible.builtin.include_tasks: validation.yml ansible.builtin.include_tasks: validation.yml
@@ -89,14 +100,12 @@
when: when:
- system_cfg.type == "virtual" - system_cfg.type == "virtual"
- hypervisor_type != "vmware" - hypervisor_type != "vmware"
vars:
_primary: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first) }}"
ansible.builtin.set_fact: ansible.builtin.set_fact:
ansible_host: "{{ system_cfg.network.ip }}" ansible_host: "{{ system_cfg.network.ip }}"
ansible_port: 22 ansible_port: 22
ansible_user: "{{ _primary.key }}" ansible_user: "{{ bootstrap_primary_user.name }}"
ansible_password: "{{ _primary.value.password }}" ansible_password: "{{ bootstrap_primary_user.password }}"
ansible_become_password: "{{ _primary.value.password }}" ansible_become_password: "{{ bootstrap_primary_user.password }}"
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
no_log: true no_log: true
+26 -7
View File
@@ -65,6 +65,27 @@
fail_msg: "Unsupported system.{{ item }} keys: {{ sub_unknown | join(', ') }}" fail_msg: "Unsupported system.{{ item }} keys: {{ sub_unknown | join(', ') }}"
quiet: true 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 - name: Validate system.luks.tpm2 schema
vars: vars:
tpm2_input: >- tpm2_input: >-
@@ -317,7 +338,7 @@
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}" _autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.assert: ansible.builtin.assert:
that: 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: >- fail_msg: >-
desktop.autologin must be false or a username string present in desktop.autologin must be false or a username string present in
system.users; got '{{ _autologin }}'. Bool true is not accepted - the system.users; got '{{ _autologin }}'. Bool true is not accepted - the
@@ -354,15 +375,13 @@
quiet: true quiet: true
- name: Validate at least one user with a password is defined - 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: ansible.builtin.assert:
that: that:
- system_cfg.users | default([]) | length > 0 - bootstrap_primary_user | length > 0
- _pw_users | length > 0 fail_msg: >-
fail_msg: "At least one user with a non-empty password must be defined in system.users." 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 quiet: true
no_log: true
- name: Validate DNS servers is a list - name: Validate DNS servers is a list
when: when:
+2 -2
View File
@@ -35,8 +35,8 @@
{%- endfor -%} {%- endfor -%}
{{ out }} {{ out }}
community.proxmox.proxmox_kvm: community.proxmox.proxmox_kvm:
ciuser: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).key }}" ciuser: "{{ bootstrap_primary_user.name }}"
cipassword: "{{ (system_cfg.users | dict2items | selectattr('value.password', 'defined') | first).value.password }}" cipassword: "{{ bootstrap_primary_user.password }}"
ciupgrade: false ciupgrade: false
vmid: "{{ system_cfg.id }}" vmid: "{{ system_cfg.id }}"
name: "{{ hostname }}" name: "{{ hostname }}"
@@ -14,8 +14,9 @@ users:
{% elif _sudo | bool %} {% elif _sudo | bool %}
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
{% endif %} {% endif %}
{% if attrs.password | default('') | length > 0 %} {% set _pw = attrs.password | default('') %}
passwd: "{{ attrs.password | password_hash('sha512') }}" {% if _pw | length > 0 %}
passwd: "{{ _pw if (_pw | string)[:1] == '$' else _pw | password_hash('sha512') }}"
lock_passwd: false lock_passwd: false
{% else %} {% else %}
lock_passwd: true lock_passwd: true
+1 -1
View File
@@ -34,4 +34,4 @@ merge_fixtures:
disable: [nvidia, intel] # drop the nvidia GPU and every intel-vendor contribution disable: [nvidia, intel] # drop the nvidia GPU and every intel-vendor contribution
expect_profile: { fingerprint: false, bluetooth: false } expect_profile: { fingerprint: false, bluetooth: false }
expect_contains: [mesa, vulkan-icd-loader] expect_contains: [mesa, vulkan-icd-loader]
expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-other] expect_excludes: [nvidia-open-dkms, intel-ucode, vulkan-intel, sof-firmware, linux-firmware-intel]
+1 -1
View File
@@ -5,7 +5,7 @@ package_fixtures:
- name: arch-intel-audio-bt-fp - name: arch-intel-audio-bt-fp
os_family: Archlinux os_family: Archlinux
profile: { cpu: intel, gpus: [intel], wireless: [intel], audio: [intel], fingerprint: true, bluetooth: true, camera: { uvc: true, ipu6: false } } profile: { cpu: intel, gpus: [intel], wireless: [intel], audio: [intel], fingerprint: true, bluetooth: true, camera: { uvc: true, ipu6: false } }
expect_contains: [intel-ucode, linux-firmware-other, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils] expect_contains: [intel-ucode, linux-firmware-intel, mesa, vulkan-icd-loader, vulkan-intel, sof-firmware, alsa-ucm-conf, bluez, bluez-utils, fprintd, libfprint, v4l-utils]
expect_excludes: [nvidia-open-dkms, evdi] expect_excludes: [nvidia-open-dkms, evdi]
- name: debian-amd-audio-no-bt-no-fp - name: debian-amd-audio-no-bt-no-fp
+62
View File
@@ -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"
+123
View File
@@ -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