From ba77afce3bbf4d3e9a6c52f5f84a1cc0fdcbe4ba Mon Sep 17 00:00:00 2001 From: Shimon Shtein Date: Mon, 8 Jun 2026 17:44:20 +0300 Subject: [PATCH] Add molecule tests to certificates role --- .config/molecule/config.yml | 45 +++++++ DEVELOPMENT.md | 17 +++ development/requirements.txt | 1 + docs/developer/testing.md | 13 ++ pytest.ini | 7 ++ .../molecule/common/tasks/set_hostnames.yml | 6 + .../common/tasks/verify_custom_server.yml | 111 ++++++++++++++++++ .../molecule/common/tasks/verify_default.yml | 36 ++++++ .../common/tasks/verify_invariant.yml | 7 ++ .../molecule/common/vars/verify.yml | 17 +++ .../molecule/custom_server/converge.yml | 17 +++ .../molecule/custom_server/molecule.yml | 7 ++ .../molecule/custom_server/verify.yml | 9 ++ .../molecule/default/converge.yml | 8 ++ .../molecule/default/molecule.yml | 6 + .../certificates/molecule/default/verify.yml | 9 ++ tests/conftest.py | 23 ++++ tests/molecule_roles/test_roles.py | 63 ++++++++++ 18 files changed, 402 insertions(+) create mode 100644 .config/molecule/config.yml create mode 100644 pytest.ini create mode 100644 src/roles/certificates/molecule/common/tasks/set_hostnames.yml create mode 100644 src/roles/certificates/molecule/common/tasks/verify_custom_server.yml create mode 100644 src/roles/certificates/molecule/common/tasks/verify_default.yml create mode 100644 src/roles/certificates/molecule/common/tasks/verify_invariant.yml create mode 100644 src/roles/certificates/molecule/common/vars/verify.yml create mode 100644 src/roles/certificates/molecule/custom_server/converge.yml create mode 100644 src/roles/certificates/molecule/custom_server/molecule.yml create mode 100644 src/roles/certificates/molecule/custom_server/verify.yml create mode 100644 src/roles/certificates/molecule/default/converge.yml create mode 100644 src/roles/certificates/molecule/default/molecule.yml create mode 100644 src/roles/certificates/molecule/default/verify.yml create mode 100644 tests/molecule_roles/test_roles.py diff --git a/.config/molecule/config.yml b/.config/molecule/config.yml new file mode 100644 index 000000000..c95a2a222 --- /dev/null +++ b/.config/molecule/config.yml @@ -0,0 +1,45 @@ +--- +dependency: + name: galaxy + options: + requirements-file: ${MOLECULE_PROJECT_DIRECTORY}/../../requirements.yml + +driver: + name: podman + +platforms: + - name: quadlet + image: registry.access.redhat.com/ubi9/ubi + volumes: + - "${MOLECULE_PROJECT_DIRECTORY}/../../../:/vagrant:Z" + tmpfs: + /tmp: exec + /run: rw,noexec,nosuid,nodev + +provisioner: + name: ansible + env: + ANSIBLE_COLLECTIONS_PATH: ${MOLECULE_PROJECT_DIRECTORY}/../../../build/collections/foremanctl + ANSIBLE_COLLECTIONS_SCAN_SYS_PATH: "false" + inventory: + group_vars: + all: + certificates_ca_password: molecule-test-password + config_options: + defaults: + roles_path: ${MOLECULE_PROJECT_DIRECTORY}/.. + +verifier: + name: ansible + +scenario: + test_sequence: + - cleanup + - destroy + - syntax + - create + - converge + - idempotence + - verify + - cleanup + - destroy diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 21d73de00..59cad699d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -73,6 +73,23 @@ pytest tests/postgresql_test.py pytest tests/foreman_test.py::test_foreman_service ``` +Run Molecule role tests (requires `./setup-environment`): + +``` +# run all tests, including molecule +pytest --molecule +#run a specific molecule test +pytest --molecule tests/molecule_roles/test_roles.py::test_molecule_role[certificates[default]] +``` +Running molecule tests directly is also possible with: + +``` +cd src/roles/ +molecule test --all +``` + +Shared Molecule settings (Podman driver, collections path, test sequence) are in `.config/molecule/config.yml`. Per-role converge and verify steps belong under `src/roles//molecule/common/`; each scenario only sets variables in `molecule.yml`. + Additonally, you can run [smoker](https://github.com/theforeman/smoker) based tests with: ``` diff --git a/development/requirements.txt b/development/requirements.txt index 064847d1b..167a68ad4 100644 --- a/development/requirements.txt +++ b/development/requirements.txt @@ -4,6 +4,7 @@ jinja2 paramiko passlib ruff +molecule>=24.0.0 pytest-testinfra pytest-durations python-dateutil diff --git a/docs/developer/testing.md b/docs/developer/testing.md index 22c9122b8..d983217db 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -28,6 +28,19 @@ Integration tests run against Vagrant VMs defined in the [`Vagrantfile`](../../V Testinfra fixtures in `tests/conftest.py` open Paramiko sessions through that SSH config, so `server`, `client`, and `database` are live hosts, not local stubs. +### Molecule role tests + +Roles with scenarios under `src/roles//molecule/` can be tested from the repository root: + +```bash +source .venv/bin/activate +pytest --molecule +``` + +This discovers every `molecule//molecule.yml` and runs `molecule test -s ` for that role. Without `--molecule`, those tests are skipped and the usual integration tests run instead. + +Shared Molecule settings (Podman driver, collections path, test sequence) live in [`.config/molecule/config.yml`](../../.config/molecule/config.yml). Role-specific converge and verify steps are shared under `src/roles//molecule/common/`; each scenario only overrides variables in its `molecule.yml` and wires playbooks to the common task files. + ### CI GitHub Actions mirrors the same workflow: start VMs, deploy, run tests. The [`.github/workflows/test.yml`](../../.github/workflows/test.yml) matrix covers combinations of certificate source, database mode, security profile, and base box. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..0a074ca6f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +# Ignore molecule scenario directories during normal test discovery +norecursedirs = molecule .molecule build .venv .git .ansible .vagrant +markers = + molecule: Molecule role tests (run with pytest --molecule) + iop: tests requiring IOP to be enabled diff --git a/src/roles/certificates/molecule/common/tasks/set_hostnames.yml b/src/roles/certificates/molecule/common/tasks/set_hostnames.yml new file mode 100644 index 000000000..dfdb5c7cb --- /dev/null +++ b/src/roles/certificates/molecule/common/tasks/set_hostnames.yml @@ -0,0 +1,6 @@ +--- +- name: Set certificate hostnames from VM FQDN + ansible.builtin.set_fact: + certificates_hostnames: + - "{{ ansible_facts['fqdn'] }}" + - localhost diff --git a/src/roles/certificates/molecule/common/tasks/verify_custom_server.yml b/src/roles/certificates/molecule/common/tasks/verify_custom_server.yml new file mode 100644 index 000000000..7a931a759 --- /dev/null +++ b/src/roles/certificates/molecule/common/tasks/verify_custom_server.yml @@ -0,0 +1,111 @@ +--- +- name: Read internal CA certificate subject + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/ca.crt + -noout -subject + register: certificates_internal_ca_subject + changed_when: false + +- name: Read custom server CA certificate subject + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/server-ca.crt + -noout -subject + register: certificates_server_ca_subject + changed_when: false + +- name: Verify custom server CA differs from internal CA + ansible.builtin.assert: + that: + - certificates_internal_ca_subject.stdout != certificates_server_ca_subject.stdout + fail_msg: Custom server CA should have a different subject than the internal CA + +- name: Read server certificate issuer + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}.crt + -noout -issuer + register: certificates_server_issuer + changed_when: false + +- name: Verify server certificate is issued by custom server CA + ansible.builtin.assert: + that: + - certificates_server_issuer.stdout == certificates_server_ca_subject.stdout.replace('subject=', 'issuer=') + fail_msg: Server certificate should be issued by the custom server CA + +- name: Read client certificate issuer + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}-client.crt + -noout -issuer + register: certificates_client_issuer + changed_when: false + +- name: Verify client certificate is issued by internal CA + ansible.builtin.assert: + that: + - certificates_client_issuer.stdout == certificates_internal_ca_subject.stdout.replace('subject=', 'issuer=') + fail_msg: Client certificate should be issued by the internal CA + +- name: Read localhost certificate issuer + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/localhost.crt + -noout -issuer + register: certificates_localhost_issuer + changed_when: false + +- name: Verify localhost certificate is issued by internal CA + ansible.builtin.assert: + that: + - certificates_localhost_issuer.stdout == certificates_internal_ca_subject.stdout.replace('subject=', 'issuer=') + fail_msg: Localhost certificate should be issued by the internal CA + +- name: Verify server certificate chains to CA bundle + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca-bundle.crt + {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}.crt + register: certificates_server_verify + changed_when: false + failed_when: "'OK' not in certificates_server_verify.stdout" + +- name: Verify client certificate chains to internal CA + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca.crt + {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}-client.crt + register: certificates_client_verify + changed_when: false + failed_when: "'OK' not in certificates_client_verify.stdout" + +- name: Verify localhost server certificate chains to internal CA + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca.crt + {{ certificates_ca_directory_certs }}/localhost.crt + register: certificates_localhost_verify + changed_when: false + failed_when: "'OK' not in certificates_localhost_verify.stdout" + +- name: List CA bundle certificate subjects + ansible.builtin.shell: | + set -o pipefail + awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' {{ certificates_ca_directory_certs }}/ca-bundle.crt \ + | openssl crl2pkcs7 -nocrl -certfile /dev/stdin \ + | openssl pkcs7 -print_certs -noout -text \ + | grep 'Subject:' + args: + executable: /bin/bash + register: certificates_bundle_subjects + changed_when: false + +- name: Verify CA bundle contains internal and custom server CAs + ansible.builtin.assert: + that: + - certificates_internal_ca_subject.stdout.split('=', 1)[1] in certificates_bundle_subjects.stdout + - certificates_server_ca_subject.stdout.split('=', 1)[1] in certificates_bundle_subjects.stdout + - certificates_bundle_subjects.stdout | regex_findall('Subject:') | length == 2 + fail_msg: CA bundle should contain exactly the internal CA and custom server CA diff --git a/src/roles/certificates/molecule/common/tasks/verify_default.yml b/src/roles/certificates/molecule/common/tasks/verify_default.yml new file mode 100644 index 000000000..40da09872 --- /dev/null +++ b/src/roles/certificates/molecule/common/tasks/verify_default.yml @@ -0,0 +1,36 @@ +--- +- name: Verify CA certificate subject + ansible.builtin.command: + cmd: >- + openssl x509 -in {{ certificates_ca_directory_certs }}/ca.crt + -noout -subject + register: certificates_ca_subject + changed_when: false + failed_when: "'Foreman Self-signed CA' not in certificates_ca_subject.stdout" + +- name: Verify server certificate chains to CA + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca.crt + {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}.crt + register: certificates_server_verify + changed_when: false + failed_when: "'OK' not in certificates_server_verify.stdout" + +- name: Verify client certificate chains to CA + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca.crt + {{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}-client.crt + register: certificates_client_verify + changed_when: false + failed_when: "'OK' not in certificates_client_verify.stdout" + +- name: Verify localhost server certificate chains to CA + ansible.builtin.command: + cmd: >- + openssl verify -CAfile {{ certificates_ca_directory_certs }}/ca.crt + {{ certificates_ca_directory_certs }}/localhost.crt + register: certificates_localhost_verify + changed_when: false + failed_when: "'OK' not in certificates_localhost_verify.stdout" diff --git a/src/roles/certificates/molecule/common/tasks/verify_invariant.yml b/src/roles/certificates/molecule/common/tasks/verify_invariant.yml new file mode 100644 index 000000000..fdb4c09e4 --- /dev/null +++ b/src/roles/certificates/molecule/common/tasks/verify_invariant.yml @@ -0,0 +1,7 @@ +--- +- name: Check expected certificate files exist + ansible.builtin.stat: + path: "{{ item }}" + register: certificates_file_stats + failed_when: not certificates_file_stats.stat.exists + loop: "{{ certificates_expected_files }}" diff --git a/src/roles/certificates/molecule/common/vars/verify.yml b/src/roles/certificates/molecule/common/vars/verify.yml new file mode 100644 index 000000000..3270b9c1d --- /dev/null +++ b/src/roles/certificates/molecule/common/vars/verify.yml @@ -0,0 +1,17 @@ +--- +certificates_test_hostname: "{{ ansible_facts['fqdn'] }}" +certificates_ca_directory_certs: "{{ certificates_ca_directory }}/certs" +certificates_ca_directory_keys: "{{ certificates_ca_directory }}/private" +certificates_expected_files: + - "{{ certificates_ca_directory_certs }}/ca.crt" + - "{{ certificates_ca_directory_certs }}/server-ca.crt" + - "{{ certificates_ca_directory_certs }}/ca-bundle.crt" + - "{{ certificates_ca_directory_keys }}/ca.key" + - "{{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}.crt" + - "{{ certificates_ca_directory_keys }}/{{ certificates_test_hostname }}.key" + - "{{ certificates_ca_directory_certs }}/{{ certificates_test_hostname }}-client.crt" + - "{{ certificates_ca_directory_keys }}/{{ certificates_test_hostname }}-client.key" + - "{{ certificates_ca_directory_certs }}/localhost.crt" + - "{{ certificates_ca_directory_keys }}/localhost.key" + - "{{ certificates_ca_directory_certs }}/localhost-client.crt" + - "{{ certificates_ca_directory_keys }}/localhost-client.key" diff --git a/src/roles/certificates/molecule/custom_server/converge.yml b/src/roles/certificates/molecule/custom_server/converge.yml new file mode 100644 index 000000000..ed72886d7 --- /dev/null +++ b/src/roles/certificates/molecule/custom_server/converge.yml @@ -0,0 +1,17 @@ +--- +- name: Converge + hosts: quadlet + become: true + vars: + molecule_custom_source_directory: /vagrant/tests/fixtures/foreman-certificate-check/certs/ + pre_tasks: + - ansible.builtin.import_tasks: ../common/tasks/set_hostnames.yml + + - name: Set custom server certificate paths + ansible.builtin.set_fact: + certificates_custom_server_certificate: "{{ molecule_custom_source_directory }}foreman.example.com.crt" + certificates_custom_server_key: "{{ molecule_custom_source_directory }}foreman.example.com.key" + certificates_custom_server_ca_certificate: "{{ molecule_custom_source_directory }}ca.crt" + + roles: + - role: certificates diff --git a/src/roles/certificates/molecule/custom_server/molecule.yml b/src/roles/certificates/molecule/custom_server/molecule.yml new file mode 100644 index 000000000..6675a710f --- /dev/null +++ b/src/roles/certificates/molecule/custom_server/molecule.yml @@ -0,0 +1,7 @@ +--- +provisioner: + inventory: + group_vars: + all: + certificates_source: custom_server + certificates_ca_directory: /var/lib/foremanctl/molecule-certs-custom diff --git a/src/roles/certificates/molecule/custom_server/verify.yml b/src/roles/certificates/molecule/custom_server/verify.yml new file mode 100644 index 000000000..67fb51262 --- /dev/null +++ b/src/roles/certificates/molecule/custom_server/verify.yml @@ -0,0 +1,9 @@ +--- +- name: Verify + hosts: quadlet + gather_facts: true + vars_files: + - ../common/vars/verify.yml + tasks: + - ansible.builtin.import_tasks: ../common/tasks/verify_invariant.yml + - ansible.builtin.import_tasks: ../common/tasks/verify_custom_server.yml diff --git a/src/roles/certificates/molecule/default/converge.yml b/src/roles/certificates/molecule/default/converge.yml new file mode 100644 index 000000000..0ff75aae9 --- /dev/null +++ b/src/roles/certificates/molecule/default/converge.yml @@ -0,0 +1,8 @@ +--- +- name: Converge + hosts: quadlet + become: true + pre_tasks: + - ansible.builtin.import_tasks: ../common/tasks/set_hostnames.yml + roles: + - role: certificates diff --git a/src/roles/certificates/molecule/default/molecule.yml b/src/roles/certificates/molecule/default/molecule.yml new file mode 100644 index 000000000..a6aee022a --- /dev/null +++ b/src/roles/certificates/molecule/default/molecule.yml @@ -0,0 +1,6 @@ +--- +provisioner: + inventory: + group_vars: + all: + certificates_ca_directory: /var/lib/foremanctl/molecule-certs diff --git a/src/roles/certificates/molecule/default/verify.yml b/src/roles/certificates/molecule/default/verify.yml new file mode 100644 index 000000000..3de82167c --- /dev/null +++ b/src/roles/certificates/molecule/default/verify.yml @@ -0,0 +1,9 @@ +--- +- name: Verify + hosts: quadlet + gather_facts: true + vars_files: + - ../common/vars/verify.yml + tasks: + - ansible.builtin.import_tasks: ../common/tasks/verify_invariant.yml + - ansible.builtin.import_tasks: ../common/tasks/verify_default.yml diff --git a/tests/conftest.py b/tests/conftest.py index 9bf66ec3b..7e16784ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,12 @@ def enabled_features(self): def pytest_addoption(parser): parser.addoption("--certificate-source", action="store", default="default", choices=('default', 'installer', 'custom_server'), help="Certificate source used during deployment") parser.addoption("--database-mode", action="store", default="internal", choices=('internal', 'external'), help="Whether the database is internal or external") + parser.addoption( + "--molecule", + action="store_true", + default=False, + help="Run Molecule role tests under src/roles/ (requires ./setup-environment and ./forge vms start)", + ) @pytest.fixture(scope="module") @@ -238,6 +244,10 @@ def wait_for_metadata_generate(foremanapi): def pytest_configure(config): config.addinivalue_line("markers", "feature(name): mark a test as requiring a feature") + config.addinivalue_line( + "markers", + "molecule: Molecule role tests (selected with --molecule)", + ) config.user_parameters = UserParameters(config) @@ -253,6 +263,19 @@ def pytest_runtest_setup(item): pytest.skip(f"test requires feature(s) {missing!r}") +def pytest_collection_modifyitems(config, items): + molecule_enabled = config.getoption("--molecule") + + if not molecule_enabled: + skip_molecule = pytest.mark.skip( + reason="molecule role tests require --molecule", + ) + for item in items: + if item.get_closest_marker("molecule"): + item.add_marker(skip_molecule) + + + class ResolveAdapter(HTTPAdapter): def __init__(self, target_ip, *args, **kwargs): self.target_ip = target_ip diff --git a/tests/molecule_roles/test_roles.py b/tests/molecule_roles/test_roles.py new file mode 100644 index 000000000..e11618442 --- /dev/null +++ b/tests/molecule_roles/test_roles.py @@ -0,0 +1,63 @@ +"""Run Molecule scenarios for Ansible roles under src/roles/.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +ROLES_DIR = REPO_ROOT / "src" / "roles" + +pytestmark = pytest.mark.molecule + + +def discover_molecule_scenarios() -> list[tuple[str, str, Path]]: + scenarios: list[tuple[str, str, Path]] = [] + if not ROLES_DIR.is_dir(): + return scenarios + + for role_dir in sorted(ROLES_DIR.iterdir()): + if not role_dir.is_dir(): + continue + molecule_dir = role_dir / "molecule" + if not molecule_dir.is_dir(): + continue + for scenario_dir in sorted(molecule_dir.iterdir()): + if scenario_dir.is_dir() and (scenario_dir / "molecule.yml").is_file(): + scenarios.append((role_dir.name, scenario_dir.name, role_dir)) + return scenarios + + +def pytest_generate_tests(metafunc): + if {"role", "scenario", "role_dir"} <= set(metafunc.fixturenames): + params = discover_molecule_scenarios() + metafunc.parametrize( + ("role", "scenario", "role_dir"), + params, + ids=[f"{role}[{scenario}]" for role, scenario, _ in params], + ) + + +def test_molecule_role(role: str, scenario: str, role_dir: Path) -> None: + role_molecule_config = role_dir / "molecule" / "config.yml" + cmd = [sys.executable, "-m", "molecule", "test", "-s", scenario] + if role_molecule_config.is_file(): + cmd.extend(["-c", str(role_molecule_config)]) + result = subprocess.run( + cmd, + cwd=role_dir, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + output = "\n".join( + part for part in (result.stdout, result.stderr) if part + ).strip() + pytest.fail( + f"molecule test failed for role {role!r} scenario {scenario!r} " + f"(exit {result.returncode})\n{output}" + )