diff --git a/.github/workflows/molecule.yml b/.github/workflows/molecule.yml new file mode 100644 index 000000000..034feebbf --- /dev/null +++ b/.github/workflows/molecule.yml @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +name: Molecule + +# Unlike the MASH role repositories, where one repository holds one role, every +# role here lives in the same repository. Running every scenario on every push +# would be unaffordable, so a first job works out which roles the push actually +# touched and the matrix is built from that. A push that changes documentation, +# or a role with no scenario yet, runs nothing at all. +on: # yamllint disable-line rule:truthy + push: + paths: + - "roles/custom/**" + - ".github/workflows/molecule.yml" + pull_request: + paths: + - "roles/custom/**" + - ".github/workflows/molecule.yml" + workflow_dispatch: + inputs: + role: + description: "Single role to test (directory name under roles/custom), or empty for all roles that have a scenario" + required: false + type: string + +permissions: + contents: read + +jobs: + detect: + name: Work out which roles to test + runs-on: ubuntu-latest + + # Same rule as the MASH repositories: a pull request from a branch of this + # repository would otherwise run everything twice, once for the push and + # once for the pull request. + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name != github.repository + + outputs: + roles: ${{ steps.detect.outputs.roles }} + + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Detect roles with a Molecule scenario that this change touches + id: detect + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BEFORE_SHA: ${{ github.event.before }} + INPUT_ROLE: ${{ inputs.role }} + run: | + set -euo pipefail + + have_scenario() { + [ -f "roles/custom/$1/molecule/default/molecule.yml" ] + } + + # An explicit request through workflow_dispatch wins over detection. + if [ -n "${INPUT_ROLE}" ]; then + if have_scenario "${INPUT_ROLE}"; then + printf 'roles=["%s"]\n' "${INPUT_ROLE}" >> "$GITHUB_OUTPUT" + else + echo "No scenario at roles/custom/${INPUT_ROLE}/molecule/default" >&2 + exit 1 + fi + exit 0 + fi + + # A hand-triggered run with no role named, and any run where the diff + # base is unusable (a new branch, a force push, the very first commit), + # falls back to every role that has a scenario. That is the safe + # direction to fail in: too much testing rather than too little. + base="" + case "${EVENT_NAME}" in + pull_request) base="${BASE_SHA}" ;; + push) + if [ -n "${BEFORE_SHA}" ] && [ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then + base="${BEFORE_SHA}" + fi + ;; + esac + + if [ -n "${base}" ]; then + changed="$(git diff --name-only "${base}" HEAD -- 'roles/custom/*' || true)" + candidates="$(printf '%s\n' "${changed}" | awk -F/ 'NF>2 {print $3}' | sort -u)" + echo "Changed roles: ${candidates:-none}" + else + candidates="$(find roles/custom -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)" + echo "No usable diff base; considering every role" + fi + + selected="" + for role in ${candidates}; do + if have_scenario "${role}"; then + selected="${selected} ${role}" + fi + done + + if [ -z "${selected}" ]; then + echo "Nothing to test" + echo 'roles=[]' >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Testing:${selected}" + json="$(printf '%s\n' ${selected} | jq -R . | jq -c -s .)" + echo "roles=${json}" >> "$GITHUB_OUTPUT" + + molecule: + name: "Molecule: ${{ matrix.role }}" + runs-on: ubuntu-latest + + needs: detect + if: needs.detect.outputs.roles != '[]' + + strategy: + matrix: + role: ${{ fromJson(needs.detect.outputs.roles) }} + fail-fast: false + + env: + MOLECULE_DISTRO: ubuntu2604 + PY_COLORS: "1" + ANSIBLE_FORCE_COLOR: "1" + + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Install test dependencies + run: python3 -m pip install -r roles/custom/${{ matrix.role }}/molecule/requirements.txt + + - name: Run Molecule + working-directory: roles/custom/${{ matrix.role }} + run: molecule test --scenario-name default diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/converge.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/converge.yml new file mode 100644 index 000000000..6f31cdbb9 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/converge.yml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# The devture base roles carry the variables this role reads +# (`devture_systemd_docker_base_*`, `devture_playbook_help_*`), the same way +# they do when the playbook runs. `matrix-base` is deliberately NOT included: +# it does far more than this role needs, and the two variables it would supply +# are set directly in molecule.yml instead. +- name: Include roles for matrix-alertmanager-receiver Molecule tests + hosts: all + become: true + gather_facts: true + tasks: + - name: Include roles for matrix-alertmanager-receiver Molecule tests + ansible.builtin.include_role: + name: "{{ role_name }}" + public: true + loop: + - com.devture.ansible.role.playbook_help + - com.devture.ansible.role.systemd_docker_base + - "custom/{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}" + loop_control: + loop_var: role_name + +# The role installs the unit but does not start it - in the playbook that is +# `systemd_service_manager`'s job - so the scenario starts it here. +- name: Ensure matrix-alertmanager-receiver is started + hosts: all + become: true + gather_facts: false + tasks: + - name: Ensure systemd daemon is reloaded + ansible.builtin.systemd_service: + daemon_reload: true + + - name: Ensure matrix-alertmanager-receiver systemd service is started + ansible.builtin.systemd_service: + name: matrix-alertmanager-receiver.service + state: started diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/molecule.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/molecule.yml new file mode 100644 index 000000000..f4cc17671 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/molecule.yml @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +dependency: + name: galaxy + options: + requirements-file: requirements.yml + force: true +driver: + name: docker +platforms: + - name: matrix-alertmanager-receiver-${MOLECULE_DISTRO:-ubuntu2604}-default + image: "geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2604}-ansible:latest" + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + pre_build_image: true +provisioner: + name: ansible + config_options: + defaults: + callback_result_format: yaml + inventory: + group_vars: + all: + # This role lives inside the playbook, so the variables the playbook + # would normally supply from `matrix-base` and from group_vars have to + # be provided here instead. These are the ones the role actually reads. + matrix_base_data_path: /matrix + matrix_domain: molecule.local + matrix_user_name: matrix + matrix_group_name: matrix + # Deliberately not 1000: the ubuntu2604 image already has a user there, + # so a distinct id proves the role used the one it was given. + matrix_user_uid: 1234 + matrix_user_gid: 1234 + + matrix_alertmanager_receiver_hostname: alertmanager-receiver.molecule.local + matrix_alertmanager_receiver_path_prefix: / + matrix_alertmanager_receiver_container_network: matrix-alertmanager-receiver-molecule + + # verify.yml runs as its own play, where the role's defaults are out + # of scope, so the paths it reads are pinned here as literals. They + # match what the role derives from matrix_base_data_path above. + matrix_alertmanager_receiver_base_path: /matrix/alertmanager-receiver + matrix_alertmanager_receiver_config_path: /matrix/alertmanager-receiver/config + + # Traefik is not deployed in this scenario, so the labels the role would + # render for it are switched off and their absence is asserted instead. + matrix_alertmanager_receiver_container_labels_traefik_enabled: false + + # Deliberately different from the role's own defaults (port 12345, + # metrics disabled, alerts under /alerts), so that `verify.yml` can tell + # what the role rendered apart from what the application would have done + # on its own. + matrix_alertmanager_receiver_config_http_port: 12399 + matrix_alertmanager_receiver_config_http_metrics_enabled: true + matrix_alertmanager_receiver_config_http_metrics_path: /molecule-metrics + matrix_alertmanager_receiver_config_http_alerts_path_prefix: /molecule-alerts + + # The homeserver IS reached at startup - the service fetches its joined + # rooms and exits 1 if that fails - so prepare.yml stands up a stub for it + matrix_alertmanager_receiver_config_matrix_homeserver_url: http://matrix.molecule.local:8008 + matrix_alertmanager_receiver_config_matrix_user_id: "@alertmanager:molecule.local" + matrix_alertmanager_receiver_config_matrix_access_token: molecule_access_token_4f2a91 + matrix_alertmanager_receiver_config_matrix_room_mapping: + molecule-room: "!molecule-room-id:molecule.local" + env: + # Workaround for https://github.com/ansible/molecule/issues/4391 + ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/../..:/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:~/.ansible/roles +scenario: + test_sequence: + - dependency + - cleanup + - destroy + - syntax + - create + - prepare + - converge + - idempotence + - verify + - cleanup + - destroy +verifier: + name: ansible diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml new file mode 100644 index 000000000..6d749fac1 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare matrix-alertmanager-receiver Molecule tests + hosts: all + become: true + gather_facts: true + tasks: + - name: Ensure apt cache is updated + ansible.builtin.apt: + update_cache: true + cache_valid_time: 600 + when: ansible_os_family == 'Debian' + + - name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-requests + - fuse-overlayfs + state: present + + - name: Ensure Docker is installed + ansible.builtin.include_role: + name: ansible-role-docker + vars: + docker_daemon_options: + storage-driver: fuse-overlayfs + + # The role's file tasks set owner/group by name, and Ansible resolves those + # through the passwd database - so they have to exist before it runs. In a + # real deployment `matrix-base` creates them. + - name: Ensure the matrix group exists + ansible.builtin.group: + name: "{{ matrix_group_name }}" + gid: "{{ matrix_user_gid }}" + state: present + + - name: Ensure the matrix user exists + ansible.builtin.user: + name: "{{ matrix_user_name }}" + uid: "{{ matrix_user_uid }}" + group: "{{ matrix_group_name }}" + create_home: false + system: true + state: present + + - name: Ensure the base data path exists + ansible.builtin.file: + path: "{{ matrix_base_data_path }}" + state: directory + owner: "{{ matrix_user_name }}" + group: "{{ matrix_group_name }}" + mode: "0750" + + - name: Ensure the container network the role attaches to exists + ansible.builtin.command: + argv: + - docker + - network + - create + - "{{ matrix_alertmanager_receiver_container_network }}" + register: matrix_alertmanager_receiver_molecule_network + changed_when: matrix_alertmanager_receiver_molecule_network.rc == 0 + failed_when: + - matrix_alertmanager_receiver_molecule_network.rc != 0 + - "'already exists' not in matrix_alertmanager_receiver_molecule_network.stderr" + + # matrix-alertmanager-receiver contacts the homeserver while starting up - + # it fetches /_matrix/client/v3/joined_rooms to resolve its room mapping - + # and exits 1 if that fails. So a homeserver has to exist for the service + # to come up at all. A stub is enough: the scenario is testing this role, + # not Synapse, and it keeps the run offline and fast. + - name: Ensure the Matrix homeserver stub script exists + ansible.builtin.copy: + dest: /root/matrix-homeserver-stub.py + mode: "0755" + content: | + import json + from http.server import BaseHTTPRequestHandler, HTTPServer + + ROOMS = {"joined_rooms": ["{{ matrix_alertmanager_receiver_config_matrix_room_mapping['molecule-room'] }}"]} + + class Handler(BaseHTTPRequestHandler): + def _send(self, payload): + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.endswith("/joined_rooms"): + self._send(ROOMS) + else: + self._send({}) + + def do_POST(self): + self._send({"event_id": "$molecule-event-id"}) + + def log_message(self, *args): + pass + + HTTPServer(("0.0.0.0", 8008), Handler).serve_forever() + + - name: Ensure the Matrix homeserver stub is running on the role's network + ansible.builtin.command: + argv: + - docker + - run + - --detach + - --rm + - --name=matrix-homeserver-stub + - --network={{ matrix_alertmanager_receiver_container_network }} + - --network-alias=matrix.molecule.local + - --volume=/root/matrix-homeserver-stub.py:/stub.py:ro + - docker.io/library/python:3.13-alpine + - python3 + - /stub.py + register: matrix_alertmanager_receiver_molecule_stub + changed_when: matrix_alertmanager_receiver_molecule_stub.rc == 0 + failed_when: + - matrix_alertmanager_receiver_molecule_stub.rc != 0 + - "'already in use' not in matrix_alertmanager_receiver_molecule_stub.stderr" diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml new file mode 100644 index 000000000..013cbca29 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml @@ -0,0 +1,20 @@ +--- +roles: + - name: ansible-role-docker + src: https://github.com/geerlingguy/ansible-role-docker + scm: git + version: 8.0.0 + + - name: com.devture.ansible.role.playbook_help + src: https://github.com/devture/com.devture.ansible.role.playbook_help + scm: git + version: main + + - name: com.devture.ansible.role.systemd_docker_base + src: https://github.com/devture/com.devture.ansible.role.systemd_docker_base + scm: git + version: v1.5.0-0 + +collections: + - name: community.docker + version: 5.2.2 diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml.license b/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml.license new file mode 100644 index 000000000..dbb307901 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/requirements.yml.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Slavi Pantaleev + +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/verify.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/verify.yml new file mode 100644 index 000000000..ce01ecb2c --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/verify.yml @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Verify matrix-alertmanager-receiver + hosts: all + become: true + gather_facts: false + + tasks: + # The version is read out of the role's own defaults rather than pinned in + # molecule.yml, so that the assertion further down compares the running + # image against what defaults/main.yml actually ships. Pinning it here + # would make that assertion compare the scenario with itself. + - name: Load the role's defaults under a separate name + ansible.builtin.include_vars: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml" + name: matrix_alertmanager_receiver_role_defaults + + - name: Wait for the matrix-alertmanager-receiver service to become active + ansible.builtin.systemd_service: + name: matrix-alertmanager-receiver.service + register: matrix_alertmanager_receiver_service + until: matrix_alertmanager_receiver_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + # `Restart=always` means a crash-looping container still reports `active`, + # so the restart counter is checked alongside it. Asserted as `is defined` + # too, because `| int` turns a missing property into 0 and would pass + # vacuously on a systemd that does not expose it. + - name: Assert the service is active and has not been restarting + ansible.builtin.assert: + that: + - matrix_alertmanager_receiver_service.status.ActiveState == 'active' + - matrix_alertmanager_receiver_service.status.NRestarts is defined + - matrix_alertmanager_receiver_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-alertmanager-receiver.service is + {{ matrix_alertmanager_receiver_service.status.ActiveState | default('unknown') }} + after {{ matrix_alertmanager_receiver_service.status.NRestarts | default('?') }} + automatic restart(s) + success_msg: "matrix-alertmanager-receiver.service is active and has not restarted" + + # Probed from inside the container network rather than from the host: the + # role publishes no host port, exactly as it does in a real deployment, + # where Traefik reaches it over the network instead. + - name: Wait for matrix-alertmanager-receiver to answer on the port the role configured + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_alertmanager_receiver_container_network }} + - docker.io/curlimages/curl:8.11.1 + - --silent + - --show-error + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - "http://matrix-alertmanager-receiver:{{ matrix_alertmanager_receiver_config_http_port }}{{ matrix_alertmanager_receiver_config_http_metrics_path }}" + register: matrix_alertmanager_receiver_metrics + changed_when: false + until: "'HTTP_STATUS=200' in matrix_alertmanager_receiver_metrics.stdout" + retries: 24 + delay: 5 + failed_when: false + + # The port and the metrics path are both non-default in this scenario, so a + # 200 here is only reachable if the configuration the role rendered is what + # the process is actually running on. + - name: Assert the configured port and metrics path reached the process + ansible.builtin.assert: + that: + - "'HTTP_STATUS=200' in matrix_alertmanager_receiver_metrics.stdout" + fail_msg: >- + matrix-alertmanager-receiver did not serve metrics on port + {{ matrix_alertmanager_receiver_config_http_port }} at + {{ matrix_alertmanager_receiver_config_http_metrics_path }} + ({{ matrix_alertmanager_receiver_metrics.stdout | default('no output') }}) + success_msg: >- + matrix-alertmanager-receiver serves metrics on the configured port and path + + - name: Assert the metrics endpoint is really Prometheus metrics + ansible.builtin.assert: + that: + - "'# HELP' in matrix_alertmanager_receiver_metrics.stdout" + fail_msg: >- + The metrics endpoint answered, but did not return Prometheus metrics + success_msg: "The metrics endpoint returns Prometheus metrics" + + # A negative control for the assertion above: the role's own default metrics + # path must NOT answer, or a 200 on the configured path would prove nothing + # about the configuration having been applied. + - name: Ask for the role's default metrics path, which this scenario moved away from + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_alertmanager_receiver_container_network }} + - docker.io/curlimages/curl:8.11.1 + - --silent + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - "http://matrix-alertmanager-receiver:{{ matrix_alertmanager_receiver_config_http_port }}/metrics" + register: matrix_alertmanager_receiver_default_path + changed_when: false + failed_when: false + + - name: Assert the default metrics path does not answer + ansible.builtin.assert: + that: + - "'HTTP_STATUS=200' not in matrix_alertmanager_receiver_default_path.stdout" + fail_msg: >- + /metrics answered as well, so serving on + {{ matrix_alertmanager_receiver_config_http_metrics_path }} does not + prove the role's configuration reached the process + ({{ matrix_alertmanager_receiver_default_path.stdout | default('no output') }}) + success_msg: "Only the configured metrics path answers" + + - name: Read the configuration file the role rendered + ansible.builtin.slurp: + src: "{{ matrix_alertmanager_receiver_config_path }}/config.yml" + register: matrix_alertmanager_receiver_config_file + + - name: Assert the rendered configuration carries this scenario's values + ansible.builtin.assert: + that: + - matrix_alertmanager_receiver_config_matrix_user_id in matrix_alertmanager_receiver_config_rendered + - matrix_alertmanager_receiver_config_matrix_access_token in matrix_alertmanager_receiver_config_rendered + - "'molecule-room' in matrix_alertmanager_receiver_config_rendered" + fail_msg: "The rendered configuration does not carry the scenario's Matrix settings" + success_msg: "The rendered configuration carries the scenario's Matrix settings" + vars: + matrix_alertmanager_receiver_config_rendered: "{{ matrix_alertmanager_receiver_config_file.content | b64decode }}" + + - name: Assert the running container is the image the role pins + ansible.builtin.command: + argv: + - docker + - container + - inspect + - matrix-alertmanager-receiver + - --format + - "{{ '{{' }} .Config.Image {{ '}}' }}" + register: matrix_alertmanager_receiver_image + changed_when: false + + - name: Assert the image carries the version defaults/main.yml pins + ansible.builtin.assert: + that: + - matrix_alertmanager_receiver_role_defaults.matrix_alertmanager_receiver_version in matrix_alertmanager_receiver_image.stdout + fail_msg: >- + The running container is {{ matrix_alertmanager_receiver_image.stdout }}, + which does not carry the pinned version {{ matrix_alertmanager_receiver_role_defaults.matrix_alertmanager_receiver_version }} + success_msg: "The running container is the version defaults/main.yml pins" + + - name: Read the labels the role rendered + ansible.builtin.slurp: + src: "{{ matrix_alertmanager_receiver_base_path }}/labels" + register: matrix_alertmanager_receiver_labels + + - name: Assert no Traefik labels are emitted while Traefik support is disabled + ansible.builtin.assert: + that: + - "'traefik.' not in (matrix_alertmanager_receiver_labels.content | b64decode)" + fail_msg: >- + Traefik labels were emitted even though + matrix_alertmanager_receiver_container_labels_traefik_enabled is false + success_msg: "No Traefik labels are emitted while Traefik support is disabled" diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt b/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt new file mode 100644 index 000000000..d1e469764 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt @@ -0,0 +1,4 @@ +ansible==14.1.0 +molecule==26.6.0 +molecule-plugins==26.7.8 +docker==7.2.0 diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt.license b/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt.license new file mode 100644 index 000000000..dbb307901 --- /dev/null +++ b/roles/custom/matrix-alertmanager-receiver/molecule/requirements.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Slavi Pantaleev + +SPDX-License-Identifier: AGPL-3.0-or-later