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