test: add a molecule scenario covering three distribution families

The role was only linted statically so far, which is why every bug of the recent analysis passed the ci
unnoticed. The scenario converges the role in podman containers of Archlinux, Debian and Fedora, checks
idempotence and then verifies the result.

Verification covers the chain via openssl verify, the file modes of keys, certificates and directories,
the number of certificates in the fullchain of the client, its subject alternative names and the anchor in
the systems trust store. Root and intermediate use a passphrase so the protected code paths are exercised
as well.

Molecule ships only the default driver, so create and destroy are provided as playbooks. Three details
were needed to make it work. The connection has to be declared in the instance config, since molecule
ignores ansible_connection_options of the driver. Raw commands are passed through sh explicitly, because
the podman connection plugin splits them instead of using a shell. And the roles path has to point at the
parent of the project directory, which is the role itself.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-09-07 22:30:23 +02:00
co-authored by Copilot
parent a4be99b28b
commit b72fba0924
6 changed files with 197 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
---
- name: Converge
hosts: all
# Passphrases are fixtures, they exercise the protected code paths of the role.
vars:
certificate_authority_root_ca_common_name: "Molecule Root CA"
certificate_authority_root_ca_tls_key_passphrase: "molecule-root-ca"
certificate_authority_intermediate_ca_common_name: "Molecule Intermediate CA"
certificate_authority_intermediate_ca_tls_key_passphrase: "molecule-intermediate-ca"
certificate_authority_client_skip: false
certificate_authority_client_common_name: "molecule.example.local"
certificate_authority_client_subject_alternative_names:
- "DNS:molecule.example.local"
- "IP:10.11.12.13"
tasks:
- name: Include the role certificate_authority
ansible.builtin.include_role:
name: certificate_authority
+25
View File
@@ -0,0 +1,25 @@
---
- name: Create
hosts: localhost
gather_facts: false
tasks:
- name: Start a container per platform
containers.podman.podman_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: containers.podman.podman
{% endfor %}
dest: "{{ molecule_instance_config }}"
mode: "0600"
+19
View File
@@ -0,0 +1,19 @@
---
- name: Destroy
hosts: localhost
gather_facts: false
tasks:
- name: Remove the container of every platform
containers.podman.podman_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"
+24
View File
@@ -0,0 +1,24 @@
---
driver:
name: default
options:
managed: true
login_cmd_template: "podman exec --interactive --tty {instance} bash"
platforms:
- name: certificate-authority-archlinux
image: docker.io/library/archlinux:base
- name: certificate-authority-debian
image: docker.io/library/debian:13
- name: certificate-authority-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
+22
View File
@@ -0,0 +1,22 @@
---
- name: Prepare
hosts: all
gather_facts: false
vars:
# The base images ship neither a python interpreter for ansible nor the tools the role shells out to.
_bootstrap: |
set -eu
if command -v pacman > /dev/null; then
pacman --sync --refresh --noconfirm ca-certificates gawk openssl python
elif command -v apt-get > /dev/null; then
apt-get update
apt-get install --yes ca-certificates gawk openssl python3
else
dnf install --assumeyes ca-certificates gawk openssl python3
fi
tasks:
# The podman connection plugin splits raw commands instead of passing them to a shell.
- name: Bootstrap the python interpreter and the tools required by the role
ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}"
changed_when: true
+88
View File
@@ -0,0 +1,88 @@
---
- name: Verify
hosts: all
vars:
_root_ca_path: "/etc/ansible-playbook/pki/ca"
_intermediate_ca_path: "/etc/ansible-playbook/pki/intermediate"
_client_path: "/etc/ansible-playbook/pki/client"
# cert.pem and cert-req.pem are left out on purpose, the role does not pin their mode.
_expected_modes:
/etc/ansible-playbook/pki/ca: "0755"
/etc/ansible-playbook/pki/ca/privkey.pem: "0600"
/etc/ansible-playbook/pki/ca/all.pem: "0600"
/etc/ansible-playbook/pki/intermediate: "0755"
/etc/ansible-playbook/pki/intermediate/privkey.pem: "0600"
/etc/ansible-playbook/pki/intermediate/chain.pem: "0644"
/etc/ansible-playbook/pki/intermediate/fullchain.pem: "0644"
/etc/ansible-playbook/pki/intermediate/all.pem: "0600"
/etc/ansible-playbook/pki/client: "0755"
/etc/ansible-playbook/pki/client/privkey.pem: "0600"
/etc/ansible-playbook/pki/client/chain.pem: "0644"
/etc/ansible-playbook/pki/client/fullchain.pem: "0644"
/etc/ansible-playbook/pki/client/all.pem: "0600"
_trust_store_anchor:
Archlinux: "/etc/ca-certificates/trust-source/anchors/Molecule_Root_CA.pem"
Debian: "/usr/local/share/ca-certificates/Molecule_Root_CA.crt"
RedHat: "/etc/pki/ca-trust/source/anchors/Molecule_Root_CA.pem"
tasks:
- name: Stat the generated files
ansible.builtin.stat:
path: "{{ item.key }}"
register: _pki_files
loop: "{{ _expected_modes | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Assert that the generated files exist with the expected mode
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.mode == item.item.value
fail_msg: "{{ item.item.key }} has mode {{ item.stat.mode | default('none') }} instead of {{ item.item.value }}"
loop: "{{ _pki_files.results }}"
loop_control:
label: "{{ item.item.key }}"
- name: Verify the client certificate against the root certificate authority
ansible.builtin.command:
cmd: >-
openssl verify
-CAfile {{ _root_ca_path }}/cert.pem
-untrusted {{ _intermediate_ca_path }}/cert.pem
{{ _client_path }}/cert.pem
changed_when: false
- name: Read the fullchain file of the client
ansible.builtin.slurp:
src: "{{ _client_path }}/fullchain.pem"
register: _client_fullchain
- name: Assert that the fullchain of the client holds the complete chain and ends with a newline
vars:
_content: "{{ _client_fullchain.content | b64decode }}"
ansible.builtin.assert:
that:
- _content | regex_findall('BEGIN CERTIFICATE') | length == 3
- _content.endswith('\n')
fail_msg: "unexpected content in {{ _client_path }}/fullchain.pem"
- name: Read the subject alternative names of the client certificate
community.crypto.x509_certificate_info:
path: "{{ _client_path }}/cert.pem"
register: _client_cert_info
- name: Assert that the requested subject alternative names are present
ansible.builtin.assert:
that: _client_cert_info.subject_alt_name | sort == ['DNS:molecule.example.local', 'IP:10.11.12.13']
fail_msg: "unexpected subject alternative names {{ _client_cert_info.subject_alt_name }}"
- name: Stat the anchor in the systems trust store
ansible.builtin.stat:
path: "{{ _trust_store_anchor[ansible_facts['os_family']] }}"
register: _anchor
- name: Assert that the root certificate authority was imported into the systems trust store
ansible.builtin.assert:
that: _anchor.stat.exists
fail_msg: "{{ _trust_store_anchor[ansible_facts['os_family']] }} is missing"