From b72fba0924d68305d75073d520c695cbe55954c0 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Mon, 7 Sep 2026 22:06:53 +0200 Subject: [PATCH] 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 --- molecule/default/converge.yml | 19 ++++++++ molecule/default/create.yml | 25 ++++++++++ molecule/default/destroy.yml | 19 ++++++++ molecule/default/molecule.yml | 24 ++++++++++ molecule/default/prepare.yml | 22 +++++++++ molecule/default/verify.yml | 88 +++++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+) 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/verify.yml diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml new file mode 100644 index 0000000..5cfda12 --- /dev/null +++ b/molecule/default/converge.yml @@ -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 diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..60a8e84 --- /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 + 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" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..8149947 --- /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 + 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" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml new file mode 100644 index 0000000..cd79f04 --- /dev/null +++ b/molecule/default/molecule.yml @@ -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 diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml new file mode 100644 index 0000000..a3ddda3 --- /dev/null +++ b/molecule/default/prepare.yml @@ -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 diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml new file mode 100644 index 0000000..9a53958 --- /dev/null +++ b/molecule/default/verify.yml @@ -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"