From e2f248e28ded30f578a693577c084b6f8aa4fe9e Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Thu, 10 Sep 2026 21:40:31 +0200 Subject: [PATCH] test: verify the role with molecule The role was not covered by any automated test, so regressions in the drop-in file handling only surfaced on real hosts. The scenario starts one container per supported distribution family and covers all four conditional branches of tasks/main.yaml: a user, a user acting as another user, a group and a group acting as another user. Beside the created rules the verification asserts that a rule declared as absent is removed again, that the drop-in directory is included exactly once and that visudo accepts the resulting configuration, because a rejected drop-in file invalidates every rule of the directory. Co-authored-by: Copilot --- .gitea/workflows/molecule.yaml | 30 +++++++++++ molecule/default/collections.yml | 5 ++ molecule/default/converge.yml | 33 ++++++++++++ molecule/default/create.yml | 25 +++++++++ molecule/default/destroy.yml | 19 +++++++ molecule/default/molecule.yml | 24 +++++++++ molecule/default/prepare.yml | 59 +++++++++++++++++++++ molecule/default/requirements.yml | 4 ++ molecule/default/verify.yml | 85 +++++++++++++++++++++++++++++++ 9 files changed, 284 insertions(+) create mode 100644 .gitea/workflows/molecule.yaml 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/.gitea/workflows/molecule.yaml b/.gitea/workflows/molecule.yaml new file mode 100644 index 0000000..d0b3eeb --- /dev/null +++ b/.gitea/workflows/molecule.yaml @@ -0,0 +1,30 @@ +name: Molecule + +on: + pull_request: + types: [ "opened", "reopened", "synchronize" ] + push: + branches: [ '**' ] + tags-ignore: [ '**' ] + +permissions: + contents: read + +jobs: + molecule: + name: Molecule + runs-on: ubuntu-latest-amd64 + steps: + # The scenario includes the role by its name, so the directory must be named like the role and not like the + # repository. Its parent is used as roles path. + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: sudo + - name: Install molecule + run: | + apt update --yes + apt install --yes python3-pip + pip3 install --break-system-packages molecule docker + - name: Run molecule + run: molecule test + working-directory: sudo 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..908f5a9 --- /dev/null +++ b/molecule/default/converge.yml @@ -0,0 +1,33 @@ +--- + +- name: Converge + hosts: all + vars: + sudo_users_sudoers: + # A user without runas, restricted to a single command. + - user: molecule-alice + commands: + - /usr/bin/systemctl restart nginx + nopassword: true + # A user that acts as another user and stores its rule under a custom filename. + - user: molecule-bob + runas: molecule-alice + filename: molecule-bob-as-alice + nopassword: true + # A group that has to authenticate itself. + - group: molecule-ops + commands: + - /usr/bin/id + nopassword: false + # A group that acts as root. + - group: molecule-admins + runas: root + filename: molecule-admins-as-root + nopassword: true + - user: molecule-obsolete + state: absent + tasks: + # The role is included by the name of its directory, which molecule put on the roles path. + - name: Include the role sudo + ansible.builtin.include_role: + name: sudo diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..b7b5e8c --- /dev/null +++ b/molecule/default/create.yml @@ -0,0 +1,25 @@ +--- + +- name: Create + hosts: localhost + gather_facts: false + 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" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..fc178d7 --- /dev/null +++ b/molecule/default/destroy.yml @@ -0,0 +1,19 @@ +--- + +- 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 }}" + + - 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..38f3ace --- /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: sudo-archlinux + image: docker.io/library/archlinux:base +- name: sudo-debian + image: docker.io/library/debian:13 +- name: sudo-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..e57cca5 --- /dev/null +++ b/molecule/default/prepare.yml @@ -0,0 +1,59 @@ +--- + +- name: Prepare + hosts: all + gather_facts: false + vars: + # The base images ship neither a python interpreter for ansible nor the package manager bindings the role relies on. + _bootstrap: | + set -eu + if command -v pacman > /dev/null; then + pacman --sync --refresh --noconfirm python shadow + elif command -v apt-get > /dev/null; then + apt-get update + apt-get install --yes passwd python3 python3-apt + else + dnf install --assumeyes python3 python3-libdnf5 shadow-utils + fi + tasks: + # The raw command is wrapped explicitly, because the bootstrap relies on shell builtins. + - name: Bootstrap the python interpreter and the package manager bindings + ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}" + changed_when: true + +- name: Seed the objects the converge refers to + hosts: all + tasks: + - name: Create the groups the converge grants permissions to + ansible.builtin.group: + name: "{{ item }}" + state: present + loop: + - molecule-admins + - molecule-ops + + - name: Create the users the converge grants permissions to + ansible.builtin.user: + name: "{{ item }}" + group: users + state: present + loop: + - molecule-alice + - molecule-bob + + # The removal path of the role can only be observed on a drop-in file that exists before the role runs. + - name: Create the drop-in directory the seeded file lives in + ansible.builtin.file: + path: /etc/sudoers.d + state: directory + owner: root + group: root + mode: "0750" + + - name: Seed the drop-in file that the converge removes again + ansible.builtin.copy: + content: "molecule-alice ALL=NOPASSWD: /usr/bin/true\n" + dest: /etc/sudoers.d/molecule-obsolete + owner: root + group: root + mode: "0440" 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..fe6fc4d --- /dev/null +++ b/molecule/default/verify.yml @@ -0,0 +1,85 @@ +--- + +- name: Verify + hosts: all + vars: + # The exact spacing the sudoers module emits differs between releases, so only the tokens are matched. + _expected_rules: + molecule-alice: 'molecule-alice\s+ALL=\s*NOPASSWD:\s*/usr/bin/systemctl restart nginx' + molecule-bob-as-alice: 'molecule-bob\s+ALL=\(molecule-alice\)\s*NOPASSWD:\s*ALL' + molecule-ops: '%molecule-ops\s+ALL=\s*/usr/bin/id' + molecule-admins-as-root: '%molecule-admins\s+ALL=\(root\)\s*NOPASSWD:\s*ALL' + tasks: + - name: Stat the drop-in directory and the removed drop-in file + ansible.builtin.stat: + path: "{{ item }}" + register: _directory + loop: + - /etc/sudoers.d + - /etc/sudoers.d/molecule-obsolete + + - name: Assert that the drop-in directory exists and that the obsolete drop-in file is gone + ansible.builtin.assert: + that: + - _directory.results[0].stat.isdir + - _directory.results[0].stat.mode == '0750' + - not _directory.results[1].stat.exists + fail_msg: "/etc/sudoers.d is not owned by the role or the file molecule-obsolete was not removed" + + - name: Stat the drop-in files of the declared rules + ansible.builtin.stat: + path: "/etc/sudoers.d/{{ item.key }}" + register: _rule_files + loop: "{{ _expected_rules | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Assert that every drop-in file exists and is only readable by root + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.mode == '0440' + - item.stat.pw_name == 'root' + - item.stat.gr_name == 'root' + fail_msg: >- + /etc/sudoers.d/{{ 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 0440 and root:root + loop: "{{ _rule_files.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Read the drop-in files of the declared rules + ansible.builtin.slurp: + src: "/etc/sudoers.d/{{ item.key }}" + register: _rule_contents + loop: "{{ _expected_rules | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Assert that every drop-in file holds the declared rule + ansible.builtin.assert: + that: item.content | b64decode is search(item.item.value) + fail_msg: >- + /etc/sudoers.d/{{ item.item.key }} contains + {{ item.content | b64decode | trim }} instead of a rule matching {{ item.item.value }} + loop: "{{ _rule_contents.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Read /etc/sudoers + ansible.builtin.slurp: + src: /etc/sudoers + register: _sudoers + + - name: Assert that the drop-in directory is included exactly once + vars: + _includedir: "{{ (_sudoers.content | b64decode).splitlines() | select('search', 'includedir\\s+/etc/sudoers.d') }}" + ansible.builtin.assert: + that: _includedir == ['#includedir /etc/sudoers.d'] + fail_msg: "/etc/sudoers includes the drop-in directory as {{ _includedir }}" + + # A drop-in file that sudo rejects would lock out every rule of the directory. + - name: Assert that sudo is installed and parses the resulting configuration + ansible.builtin.command: + cmd: visudo --check --strict + changed_when: false