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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
|
||||
collections:
|
||||
- name: community.docker
|
||||
- name: community.general
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
|
||||
# The role has no role dependencies, but molecule warns about the missing file.
|
||||
roles: []
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user