From a343205fd3d289d83487abe07d1ffe5b403dcae4 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Thu, 10 Sep 2026 09:54:00 +0200 Subject: [PATCH] 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 --- .gitignore | 3 +- README.md | 40 +++++++++ molecule/default/collections.yml | 5 ++ molecule/default/converge.yml | 64 +++++++++++++ molecule/default/create.yml | 49 ++++++++++ molecule/default/destroy.yml | 25 ++++++ molecule/default/molecule.yml | 24 +++++ molecule/default/prepare.yml | 37 ++++++++ molecule/default/requirements.yml | 4 + molecule/default/verify.yml | 143 ++++++++++++++++++++++++++++++ 10 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 molecule/default/collections.yml create mode 100644 molecule/default/converge.yml create mode 100644 molecule/default/create.yml create mode 100644 molecule/default/destroy.yml create mode 100644 molecule/default/molecule.yml create mode 100644 molecule/default/prepare.yml create mode 100644 molecule/default/requirements.yml create mode 100644 molecule/default/verify.yml diff --git a/.gitignore b/.gitignore index feaf22e..2d60434 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.ansible \ No newline at end of file +.ansible +molecule/default/files/ssh \ No newline at end of file diff --git a/README.md b/README.md index dbc0f2f..68353fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/molecule/default/collections.yml b/molecule/default/collections.yml new file mode 100644 index 0000000..f8a15ca --- /dev/null +++ b/molecule/default/collections.yml @@ -0,0 +1,5 @@ +--- + +collections: +- name: community.docker +- name: community.general diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml new file mode 100644 index 0000000..39bdc11 --- /dev/null +++ b/molecule/default/converge.yml @@ -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 diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..1e3aa7c --- /dev/null +++ b/molecule/default/create.yml @@ -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" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..bcd19e4 --- /dev/null +++ b/molecule/default/destroy.yml @@ -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" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml new file mode 100644 index 0000000..cc4c10d --- /dev/null +++ b/molecule/default/molecule.yml @@ -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 diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml new file mode 100644 index 0000000..e900388 --- /dev/null +++ b/molecule/default/prepare.yml @@ -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 diff --git a/molecule/default/requirements.yml b/molecule/default/requirements.yml new file mode 100644 index 0000000..86b874d --- /dev/null +++ b/molecule/default/requirements.yml @@ -0,0 +1,4 @@ +--- + +# The role has no role dependencies, but molecule warns about the missing file. +roles: [] diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml new file mode 100644 index 0000000..4df9331 --- /dev/null +++ b/molecule/default/verify.yml @@ -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"