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>
This commit is contained in:
2026-09-10 09:54:00 +02:00
co-authored by Copilot
parent 00f465e5e1
commit a343205fd3
10 changed files with 393 additions and 1 deletions
+2 -1
View File
@@ -1 +1,2 @@
.ansible
.ansible
molecule/default/files/ssh
+40
View File
@@ -17,6 +17,46 @@ ansible-galaxy collection install -r requirements.yml
The role manages users, groups and their home directories, so it has to be executed with `become: true`.
## Tests
The role is tested with [Molecule](https://ansible.readthedocs.io/projects/molecule/). The scenario starts one docker
container per supported distribution family, applies the role, asserts that a second run reports no change and finally
verifies the created users and groups, the permissions and the content of the managed files and that a user declared
as `absent` is gone again. 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 `molecule create` and removed again during
`molecule destroy`, so no private key is kept in the repository.
Molecule ships only its `default` driver, therefore `docker` is required besides molecule itself. The collections are
declared in `molecule/default/collections.yml` and installed by molecule.
```bash
pip install molecule docker
```
The complete sequence creates the containers, tests them and removes them afterwards.
```bash
molecule test
```
While working on the role the containers are better kept alive.
```bash
# create the containers and apply the role
molecule converge
# run the assertions of molecule/default/verify.yml against the running containers
molecule verify
# open a shell in one of the containers
molecule login --host unix-users-debian
# remove the containers
molecule destroy
```
## Examples
### User and group
+5
View File
@@ -0,0 +1,5 @@
---
collections:
- name: community.docker
- name: community.general
+64
View File
@@ -0,0 +1,64 @@
---
# A btrfs home is not covered, a container has no btrfs filesystem to create a subvolume on.
- name: Converge
hosts: all
vars:
unix_groups:
molecule-alice:
# An unquoted gid is an integer, the role has to cope with that.
gid: 4242
state: present
molecule-bob:
state: present
molecule-obsolete:
state: absent
unix_users:
molecule-alice:
state: present
name: Alice
uid: 4242
group: molecule-alice
home: /home/molecule-alice
shell: /bin/bash
password: alice
email: alice@example.local
ssh:
config:
- Host: "*"
StrictHostKeyChecking: "no"
authorized_keys:
- filename: molecule.pub
command: "/usr/bin/true"
envs:
- key: EDITOR
value: vi
private_keys:
- molecule.ed25519.key
netrc:
- machine: hostname.local
login: alice
password: secret
shell_rc_files:
- file: molecule.bashrc
aliases:
- key: dcd
value: docker compose down
envs:
- export: true
key: PATH
value: "${HOME}/bin:${PATH}"
functions:
- name: foo
value: "echo \"bar\""
# Bob declares nothing optional, so none of the optional files may show up in his home.
molecule-bob:
state: present
group: molecule-bob
molecule-dave:
state: absent
tasks:
# The role is included by the name of its directory, which molecule put on the roles path.
- name: Include the role unix-users
ansible.builtin.include_role:
name: unix-users
+49
View File
@@ -0,0 +1,49 @@
---
- name: Create
hosts: localhost
gather_facts: false
vars:
_private_key: "{{ molecule_scenario_directory }}/files/ssh/private_keys/molecule.ed25519.key"
_authorized_key: "{{ molecule_scenario_directory }}/files/ssh/authorized_keys/molecule.pub"
tasks:
- name: Start a container per platform
community.docker.docker_container:
name: "{{ item.name }}"
image: "{{ item.image }}"
command: "sleep infinity"
state: started
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
- name: Write the instance config
ansible.builtin.copy:
content: |
{% for platform in molecule_yml.platforms %}
- instance: {{ platform.name }}
connection: community.docker.docker
{% endfor %}
dest: "{{ molecule_instance_config }}"
mode: "0600"
- name: Create the fixture directories
ansible.builtin.file:
path: "{{ item | dirname }}"
state: directory
mode: "0700"
loop:
- "{{ _private_key }}"
- "{{ _authorized_key }}"
# The key pair is generated instead of committed, private key material does not belong into a repository.
- name: Generate the ssh key pair the role is fed with
ansible.builtin.command:
cmd: "ssh-keygen -t ed25519 -N '' -C molecule -f {{ _private_key }}"
creates: "{{ _private_key }}"
- name: Offer the public key as authorized key fixture
ansible.builtin.copy:
src: "{{ _private_key }}.pub"
dest: "{{ _authorized_key }}"
mode: "0644"
+25
View File
@@ -0,0 +1,25 @@
---
- name: Destroy
hosts: localhost
gather_facts: false
tasks:
- name: Remove the container of every platform
community.docker.docker_container:
name: "{{ item.name }}"
state: absent
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
# The generated key pair is not kept around, a stale private key in the working tree is asking for trouble.
- name: Remove the ssh fixtures
ansible.builtin.file:
path: "{{ molecule_scenario_directory }}/files/ssh"
state: absent
- name: Empty the instance config
ansible.builtin.copy:
content: "[]"
dest: "{{ molecule_instance_config }}"
mode: "0600"
+24
View File
@@ -0,0 +1,24 @@
---
driver:
name: default
options:
managed: true
login_cmd_template: "docker exec --interactive --tty {instance} bash"
platforms:
- name: unix-users-archlinux
image: docker.io/library/archlinux:base
- name: unix-users-debian
image: docker.io/library/debian:13
- name: unix-users-fedora
image: registry.fedoraproject.org/fedora:43
provisioner:
name: ansible
# The role under test is the project directory itself, so its parent has to be on the roles path.
env:
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/.."
config_options:
defaults:
interpreter_python: auto_silent
+37
View File
@@ -0,0 +1,37 @@
---
- name: Prepare
hosts: all
gather_facts: false
vars:
# The base images ship neither a python interpreter for ansible nor ssh-keygen, which the role shells out to.
_bootstrap: |
set -eu
if command -v pacman > /dev/null; then
pacman --sync --refresh --noconfirm openssh python shadow
elif command -v apt-get > /dev/null; then
apt-get update
apt-get install --yes openssh-client passwd python3
else
dnf install --assumeyes openssh-clients python3 shadow-utils
fi
tasks:
# The raw command is wrapped explicitly, because the bootstrap relies on shell builtins.
- name: Bootstrap the python interpreter and the tools required by the role
ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}"
changed_when: true
# The removal paths of the role can only be observed on objects that exist before the role runs.
- name: Seed the objects the converge removes again
hosts: all
tasks:
- name: Create the group that the converge removes
ansible.builtin.group:
name: molecule-obsolete
state: present
- name: Create the user that the converge removes
ansible.builtin.user:
name: molecule-dave
group: users
state: present
+4
View File
@@ -0,0 +1,4 @@
---
# The role has no role dependencies, but molecule warns about the missing file.
roles: []
+143
View File
@@ -0,0 +1,143 @@
---
- 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"