Files
volker.raschekandCopilot a343205fd3 test(molecule): cover the role with a molecule scenario
The role changed a lot and none of it was verified against a real system so far. The scenario starts one container per
supported distribution family, applies the role and asserts afterwards that the users and groups exist as declared,
that the managed files carry the documented mode, owner and content, that a user without optional settings does not
receive any of the optional files and that a user declared as absent is gone again.

The idempotence step is the actual reason for the scenario. The deterministic password salt and the btrfs device
lookup were changed to stop reporting a change on every run, and only a second converge proves that.

A btrfs home is not covered, because a container has no btrfs filesystem to create a subvolume on.

The ssh key pair the scenario feeds into the role is generated during create and removed again during destroy, so no
private key material ends up in the repository. The generated files are ignored for the case that a destroy never
runs.

Co-authored-by: Copilot <copilot@github.com>
2026-09-10 09:54:00 +02:00

144 lines
5.9 KiB
YAML

---
- name: Verify
hosts: all
vars:
_alice_home: /home/molecule-alice
_bob_home: /home/molecule-bob
_expected_modes:
/home/molecule-alice/.ssh: "0700"
/home/molecule-alice/.ssh/authorized_keys: "0600"
/home/molecule-alice/.ssh/config: "0644"
/home/molecule-alice/.ssh/molecule.ed25519.key: "0600"
/home/molecule-alice/.ssh/molecule.ed25519.key.pub: "0644"
/home/molecule-alice/.forward: "0644"
/home/molecule-alice/.netrc: "0600"
/home/molecule-alice/.bashrc.d: "0755"
/home/molecule-alice/.bashrc.d/molecule.bashrc: "0644"
/home/molecule-alice/.cache: "0755"
/home/molecule-alice/.config: "0755"
/home/molecule-alice/.local/share: "0755"
/home/molecule-alice/.local/state: "0755"
# Only files the role writes are listed, .bashrc originates from the lineinfile task.
_expected_contents:
/home/molecule-alice/.forward: "alice@example.local"
/home/molecule-alice/.netrc: "machine hostname.local login alice password secret"
/home/molecule-alice/.ssh/config: " StrictHostKeyChecking no"
/home/molecule-alice/.bashrc.d/molecule.bashrc: "alias dcd='docker compose down'"
/home/molecule-alice/.bashrc: "source \"/home/molecule-alice/.bashrc.d/molecule.bashrc\""
tasks:
- name: Stat the files of molecule-alice
ansible.builtin.stat:
path: "{{ item.key }}"
register: _alice_files
loop: "{{ _expected_modes | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Assert that the files of molecule-alice exist with the expected mode and owner
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.mode == item.item.value
- item.stat.pw_name == 'molecule-alice'
- item.stat.gr_name == 'molecule-alice'
fail_msg: >-
{{ item.item.key }} has mode {{ item.stat.mode | default('none') }} and owner
{{ item.stat.pw_name | default('none') }}:{{ item.stat.gr_name | default('none') }}
instead of {{ item.item.value }} and molecule-alice:molecule-alice
loop: "{{ _alice_files.results }}"
loop_control:
label: "{{ item.item.key }}"
- name: Read the files of molecule-alice
ansible.builtin.slurp:
src: "{{ item.key }}"
register: _alice_contents
loop: "{{ _expected_contents | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Assert that the files of molecule-alice hold the configured values
ansible.builtin.assert:
that: item.item.value in (item.content | b64decode).splitlines()
fail_msg: "{{ item.item.key }} does not contain the line {{ item.item.value }}"
loop: "{{ _alice_contents.results }}"
loop_control:
label: "{{ item.item.key }}"
- name: Read the authorized_keys and the extracted public key of molecule-alice
ansible.builtin.slurp:
src: "{{ item }}"
register: _alice_keys
loop:
- "{{ _alice_home }}/.ssh/authorized_keys"
- "{{ _alice_home }}/.ssh/molecule.ed25519.key.pub"
- name: Assert that the authorized key carries its options and matches the extracted public key
vars:
_authorized_key: "{{ (_alice_keys.results[0].content | b64decode).splitlines() | select('search', 'ssh-ed25519') | first }}"
_public_key: "{{ _alice_keys.results[1].content | b64decode | trim }}"
ansible.builtin.assert:
that:
- _authorized_key.startswith('command="/usr/bin/true",environment="EDITOR=vi" ')
- _public_key.startswith('ssh-ed25519 ')
- _public_key.split()[1] == _authorized_key.split()[2]
fail_msg: "the authorized key of molecule-alice does not match the key extracted from its private key"
- name: Read the user and group database
ansible.builtin.getent:
database: "{{ item }}"
loop:
- passwd
- group
- shadow
- name: Assert that molecule-alice was created as declared
vars:
_entry: "{{ ansible_facts['getent_passwd']['molecule-alice'] }}"
ansible.builtin.assert:
that:
- _entry[1] == '4242'
- _entry[2] == '4242'
- _entry[3] == 'Alice'
- _entry[4] == _alice_home
- _entry[5] == '/bin/bash'
fail_msg: "molecule-alice was created as {{ _entry }}"
- name: Assert that the integer gid of the group molecule-alice was applied
ansible.builtin.assert:
that: ansible_facts['getent_group']['molecule-alice'][1] == '4242'
fail_msg: "the group molecule-alice has gid {{ ansible_facts['getent_group']['molecule-alice'][1] }}"
# A random salt would produce a new hash on every run, which the idempotence step would report as a change.
- name: Assert that the password hash is derived from a deterministic salt
vars:
_expected_hash: "{{ 'alice' | password_hash('sha512', 'molecule-alice' | hash('sha512') | truncate(16, true, '')) }}"
ansible.builtin.assert:
that: ansible_facts['getent_shadow']['molecule-alice'][0] == _expected_hash
fail_msg: "the password hash of molecule-alice is not reproducible and therefore changes on every run"
- name: Assert that molecule-dave and the group molecule-obsolete were removed
ansible.builtin.assert:
that:
- "'molecule-dave' not in ansible_facts['getent_passwd']"
- "'molecule-obsolete' not in ansible_facts['getent_group']"
fail_msg: "the removal of molecule-dave or of the group molecule-obsolete did not happen"
- name: Stat the home of molecule-bob and the files he did not ask for
ansible.builtin.stat:
path: "{{ item }}"
register: _bob_files
loop:
- "{{ _bob_home }}"
- "{{ _bob_home }}/.ssh"
- "{{ _bob_home }}/.forward"
- "{{ _bob_home }}/.netrc"
- name: Assert that molecule-bob got a home but none of the optional files
ansible.builtin.assert:
that:
- _bob_files.results[0].stat.exists
- not _bob_files.results[1:] | map(attribute='stat.exists') | select | list
fail_msg: "molecule-bob has files that were never declared for him"