From 7e8b3466b69f1985780e3f6dbb085ed40c98cade Mon Sep 17 00:00:00 2001 From: Slavi Pantaleev Date: Thu, 27 Aug 2026 16:28:38 +0300 Subject: [PATCH] Add a Molecule scenario for hookshot, and the openssl host command to the shared context Hookshot is the widest HTTP surface of the bridges here - an appservice port plus a `listeners` list whose entries appear or vanish with independent switches - so the scenario asserts on that surface rather than on "it started". The scenario configures NO third-party service. GitHub, GitLab, Jira and Figma each need an account and a credential somewhere else, which is where a scenario stops testing this repository (docs/molecule-testing.md). GitLab, which the role turns on by default, is switched off and its absence from both config.yml and the registration's namespaces is asserted. The generic webhooks listener needs no credential from anyone, and is what gets exercised live: a POST to an unknown hook id comes back as `{"ok":false,"error":"Webhook not found"}`, which only Hookshot's own generic-webhook handler produces - an Express "Cannot POST" page there would mean the port is Hookshot's but the service was never mounted on it. Every port the scenario sets differs from both the role's default and Hookshot's own, and the run asserts both halves: the webhooks, metrics and appservice ports it chose all answer, and 9000/9001/9003/9993 - what the role would have used instead - all refuse. Metrics are off in the role's defaults, so that listener exists only because the scenario asked for it, and /metrics answers with `hookshot_*` series that nothing else could have produced. config.yml and registration.yml are parsed as YAML and asserted structurally, so a value landing under the wrong key cannot pass as a substring match. Two things the role made necessary: - `tasks/main.yml` opens with a block tagged `reset-hookshot-encryption`, and Ansible runs tagged tasks unless tags are actually selected - so any tagless play hits it, and it stops `matrix-hookshot.service` before that service exists. The scenario skips the tag, which is what the playbook effectively does by always running with `--tags=setup-all,start`. - The role reads `matrix_host_command_openssl`, which matrix-base defines. matrix-bridge-appservice-irc reads it too, so it goes in the shared playbook context rather than into this scenario. Falsified by pointing the webhooks listener at 127.0.0.1 in the role's config template - a change no file-level assertion can see. Every config and registration assertion still passed, and the run failed at "Assert the generic webhooks service is mounted on that listener" with "Port 9741 did not answer as Hookshot's generic webhooks service". Reverted afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT --- molecule-shared/playbook-context.yml | 10 + .../molecule/default/converge.yml | 36 ++ .../molecule/default/molecule.yml | 123 ++++++ .../molecule/default/prepare.yml | 84 ++++ .../molecule/default/requirements.yml | 1 + .../molecule/default/verify.yml | 373 ++++++++++++++++++ 6 files changed, 627 insertions(+) create mode 100644 roles/custom/matrix-bridge-hookshot/molecule/default/converge.yml create mode 100644 roles/custom/matrix-bridge-hookshot/molecule/default/molecule.yml create mode 100644 roles/custom/matrix-bridge-hookshot/molecule/default/prepare.yml create mode 120000 roles/custom/matrix-bridge-hookshot/molecule/default/requirements.yml create mode 100644 roles/custom/matrix-bridge-hookshot/molecule/default/verify.yml diff --git a/molecule-shared/playbook-context.yml b/molecule-shared/playbook-context.yml index 4e56480e6..18f4d874c 100644 --- a/molecule-shared/playbook-context.yml +++ b/molecule-shared/playbook-context.yml @@ -38,6 +38,16 @@ matrix_user_gid: 1234 # scenario wants. matrix_admin: '' +# --- Host commands (matrix-base) ------------------------------------------- +# +# Some roles shell out to a host binary through this indirection instead of +# naming it directly (matrix-bridge-hookshot and matrix-bridge-appservice-irc +# both generate a key with it). matrix-base's defaults are what supplies the +# value in a real run; those roles install the binary themselves, by including +# matrix-base's `ensure_openssl_installed` tasks. + +matrix_host_command_openssl: "/usr/bin/env openssl" + # --- Bridge-wide switches (matrix-base) ------------------------------------ # # Every bridge role reads these, so they live here rather than in each bridge's diff --git a/roles/custom/matrix-bridge-hookshot/molecule/default/converge.yml b/roles/custom/matrix-bridge-hookshot/molecule/default/converge.yml new file mode 100644 index 000000000..1865d1b98 --- /dev/null +++ b/roles/custom/matrix-bridge-hookshot/molecule/default/converge.yml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Include roles for hookshot Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: true + tasks: + - name: Include roles for hookshot 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 + +- name: Ensure hookshot 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 the hookshot systemd service is started + ansible.builtin.systemd_service: + name: matrix-hookshot.service + state: started diff --git a/roles/custom/matrix-bridge-hookshot/molecule/default/molecule.yml b/roles/custom/matrix-bridge-hookshot/molecule/default/molecule.yml new file mode 100644 index 000000000..d259ab296 --- /dev/null +++ b/roles/custom/matrix-bridge-hookshot/molecule/default/molecule.yml @@ -0,0 +1,123 @@ +# 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: hookshot-${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 + options: + # The role's `tasks/main.yml` opens with a block tagged + # `reset-hookshot-encryption`, and Ansible runs tagged tasks unless tags are + # actually selected - so a tagless play (which is what Molecule runs) hits + # it. It stops `matrix-hookshot.service` before that service exists, which + # fails on a fresh host. Skipping the tag is what the playbook effectively + # does by always running with `--tags=setup-all,start`. + # + # `molecule-notest,notest` are Molecule's own defaults, repeated here + # because this key replaces them rather than adding to them. + skip-tags: molecule-notest,notest,reset-hookshot-encryption + inventory: + group_vars: + all: + matrix_bridge_hookshot_container_network: hookshot-molecule + + # The stub prepare.yml stands up. Hookshot contacts it while starting; + # it is not a real homeserver and nothing is asserted about it. + matrix_bridge_hookshot_homeserver_address: http://matrix.molecule.local:8008 + + # Appservice tokens. These are what Hookshot and the homeserver would + # authenticate to each other with; here they only have to reach the + # rendered configuration and the registration file. + matrix_bridge_hookshot_appservice_token: molecule_as_token_4f2a91 + matrix_bridge_hookshot_homeserver_token: molecule_hs_token_9b3e77 + + # Deliberately different from the role's default (`hookshot`), so the + # registration's sender_localpart can only have come from the role. + # prepare.yml tells the homeserver stub to claim this same user id. + matrix_bridge_hookshot_bot_localpart: molecule-hookshot + + # Hookshot's HTTP surface is the point of this scenario. Every port + # below differs from BOTH the role's default and Hookshot's own, so an + # answer on one of them can only mean the role's configuration reached + # the process. verify.yml also asserts that nothing answers on the + # defaults these replace (9993 appservice, 9000 webhooks, 9001 metrics). + matrix_bridge_hookshot_appservice_port: 9772 + matrix_bridge_hookshot_webhook_port: 9741 + + # Off in the role's defaults. Enabling it makes the role render a second + # entry in Hookshot's `listeners` list, which is the cheapest listener to + # assert *content* on: /metrics answers in a format nothing else would. + matrix_bridge_hookshot_metrics_enabled: true + matrix_bridge_hookshot_metrics_port: 9752 + + # On in the role's defaults. Turned off here so that the absence of a + # listener can be asserted too - the widgets port staying closed is what + # tells "the role rendered the listener list" apart from "Hookshot binds + # everything anyway". + matrix_bridge_hookshot_widgets_enabled: false + + # No third-party service is configured in this scenario - see the header + # of verify.yml. GitLab is the one the role enables by default, so it is + # explicitly switched off and its absence from the rendered config and + # registration is asserted. + matrix_bridge_hookshot_gitlab_enabled: false + + # The generic webhooks listener is the only part of Hookshot that needs + # no account anywhere, so it is what this scenario exercises live. The + # prefix differs from the role's default (`_webhooks_`). + matrix_bridge_hookshot_generic_userIdPrefix: _molecule_hook_ # noqa var-naming + + # Neither the role's default (600) nor Hookshot's own (600). + matrix_bridge_hookshot_feeds_pollIntervalSeconds: 907 # noqa var-naming + + # The role defaults to `warn`; Hookshot itself defaults to `info`. This + # is a third value, so finding it in config.yml cannot be a coincidence. + matrix_bridge_hookshot_logging_level: debug + + # Traefik is not deployed here, so the labels the role would render for + # it are switched off and their absence is asserted instead. + matrix_bridge_hookshot_container_labels_traefik_enabled: false + + # verify.yml runs as its own play, where role defaults are out of scope, + # so what it reads is pinned here. These two match the role's own + # defaults on purpose - they name things (a path, a container) rather + # than configure them, and nothing is asserted *about* them. + matrix_bridge_hookshot_base_path: /matrix/hookshot + matrix_bridge_hookshot_identifier: matrix-hookshot + 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-bridge-hookshot/molecule/default/prepare.yml b/roles/custom/matrix-bridge-hookshot/molecule/default/prepare.yml new file mode 100644 index 000000000..4c38489ca --- /dev/null +++ b/roles/custom/matrix-bridge-hookshot/molecule/default/prepare.yml @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare hookshot Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml" + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + 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 first. matrix-base + # creates them in a real deployment. + - 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_bridge_hookshot_container_network }}" + register: hookshot_molecule_network + changed_when: hookshot_molecule_network.rc == 0 + failed_when: + - hookshot_molecule_network.rc != 0 + - "'already exists' not in hookshot_molecule_network.stderr" + + # Hookshot contacts the homeserver as it starts, and - being an appservice - + # refuses to run if /whoami answers with a user other than the bot it was + # configured as, so the stub is told to claim the localpart the scenario set. + # It is not being asked to bridge anything, and nothing is asserted about it; + # see molecule-shared/homeserver-stub.py. + - name: Ensure the homeserver stub is running + ansible.builtin.include_tasks: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/homeserver-stub.yml" + vars: + molecule_shared_stub_network: "{{ matrix_bridge_hookshot_container_network }}" + molecule_shared_stub_server_name: molecule.local + molecule_shared_stub_user_id: "@{{ matrix_bridge_hookshot_bot_localpart }}:molecule.local" diff --git a/roles/custom/matrix-bridge-hookshot/molecule/default/requirements.yml b/roles/custom/matrix-bridge-hookshot/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bridge-hookshot/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bridge-hookshot/molecule/default/verify.yml b/roles/custom/matrix-bridge-hookshot/molecule/default/verify.yml new file mode 100644 index 000000000..bb3a3f774 --- /dev/null +++ b/roles/custom/matrix-bridge-hookshot/molecule/default/verify.yml @@ -0,0 +1,373 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# What this proves: Hookshot starts, accepts the config.yml and registration.yml +# the role rendered, and opens exactly the HTTP listeners that configuration +# described - on the ports the role put there, and not on the ones it did not. +# +# What it deliberately does NOT do: configure GitHub, GitLab, Jira or Figma. +# Every one of those needs an account and a credential on a third-party service, +# which is the line where a scenario stops testing this repository and starts +# testing a fake (see docs/molecule-testing.md). The generic webhooks listener +# needs no credential from anyone, so it is the one that gets exercised live. +- name: Verify hookshot + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml" + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: false + + tasks: + # Read from the role's own defaults rather than pinned in molecule.yml, so + # the version assertion below compares the running image against what the + # role ships instead of against the scenario itself. The default ports come + # from here for the same reason: the "these ports stay closed" assertion is + # only meaningful against the ports the role would otherwise have used. + - name: Load the role's defaults under a separate name + ansible.builtin.include_vars: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml" + name: hookshot_role_defaults + + - name: Wait for the hookshot service to become active + ansible.builtin.systemd_service: + name: matrix-hookshot.service + register: hookshot_service + until: hookshot_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + # `Restart=always` means a bridge crash-looping on a configuration it cannot + # read still reports `active`, so the restart counter is checked too. It is + # asserted `is defined` because `| int` turns a missing property into 0 and + # would pass vacuously. + - name: Assert the service is active and has not been restarting + ansible.builtin.assert: + that: + - hookshot_service.status.ActiveState == 'active' + - hookshot_service.status.NRestarts is defined + - hookshot_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-hookshot.service is + {{ hookshot_service.status.ActiveState | default('unknown') }} + after {{ hookshot_service.status.NRestarts | default('?') }} + automatic restart(s) + success_msg: "matrix-hookshot.service is active and has not restarted" + + # ------------------------------------------------------------------ + # The rendered configuration + # ------------------------------------------------------------------ + + - name: Read the configuration the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bridge_hookshot_base_path }}/config.yml" + register: hookshot_config_file + + - name: Parse the rendered configuration + ansible.builtin.set_fact: + hookshot_config: "{{ hookshot_config_file.content | b64decode | from_yaml }}" + + # Each of these differs from what Hookshot would use on its own AND from + # what the role defaults to, so finding them here means the role's + # configuration is what Hookshot is running on rather than a coincidence. + - name: Assert the rendered configuration carries this scenario's values + ansible.builtin.assert: + that: + - hookshot_config.bridge.domain == matrix_domain + - hookshot_config.bridge.url == matrix_bridge_hookshot_homeserver_address + - hookshot_config.bridge.port | int == matrix_bridge_hookshot_appservice_port | int + - hookshot_config.generic.userIdPrefix == matrix_bridge_hookshot_generic_userIdPrefix + - hookshot_config.feeds.pollIntervalSeconds | int == matrix_bridge_hookshot_feeds_pollIntervalSeconds | int + - hookshot_config.logging.level == matrix_bridge_hookshot_logging_level + - hookshot_config.metrics.enabled | bool + fail_msg: "The rendered configuration does not carry the scenario's values" + success_msg: "The rendered configuration carries the scenario's values" + + # Hookshot's `listeners` list is the role's own construction: it decides + # which resources get a port at all, from a handful of independent switches. + # Getting this wrong is invisible in a "did it start" test, which is why it + # is asserted as a whole rather than key by key. + - name: Assert the role rendered exactly the listeners the scenario asked for + ansible.builtin.assert: + that: + - hookshot_config.listeners | length == 2 + - hookshot_listener_ports == [matrix_bridge_hookshot_webhook_port | int, matrix_bridge_hookshot_metrics_port | int] + - hookshot_config.listeners | map(attribute='resources') | flatten == ['webhooks', 'metrics'] + fail_msg: >- + The rendered listeners are {{ hookshot_config.listeners }}, not the + webhooks and metrics listeners this scenario configured + success_msg: "The role rendered exactly the webhooks and metrics listeners" + vars: + hookshot_listener_ports: "{{ hookshot_config.listeners | map(attribute='port') | map('int') | list }}" + + # No third-party service is configured here, so none of their sections may + # appear. GitLab is the interesting one: the role turns it ON by default, so + # its absence is what proves the scenario's switch reached the template. + - name: Assert no third-party service section was rendered + ansible.builtin.assert: + that: + - "'gitlab' not in hookshot_config" + - "'github' not in hookshot_config" + - "'jira' not in hookshot_config" + - "'figma' not in hookshot_config" + fail_msg: >- + The rendered configuration contains a third-party service section + ({{ hookshot_config.keys() | list }}); this scenario configures none + success_msg: "No third-party service section was rendered" + + - name: Assert no widgets section was rendered while widgets are disabled + ansible.builtin.assert: + that: + - "'widgets' not in hookshot_config" + fail_msg: >- + A widgets section was rendered even though + matrix_bridge_hookshot_widgets_enabled is false + success_msg: "No widgets section was rendered while widgets are disabled" + + # ------------------------------------------------------------------ + # The rendered registration + # ------------------------------------------------------------------ + + # The registration file is the half of the appservice handshake the + # homeserver reads. The role generates it, Hookshot only consumes it, so it + # is worth checking on its own. + - name: Read the appservice registration the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bridge_hookshot_base_path }}/registration.yml" + register: hookshot_registration_file + + - name: Parse the rendered registration + ansible.builtin.set_fact: + hookshot_registration: "{{ hookshot_registration_file.content | b64decode | from_yaml }}" + + # `url` is where the homeserver would push transactions, and the role builds + # it out of the container name and the appservice port. It has to agree with + # `bridge.port` in config.yml or the two halves would silently disagree. + - name: Assert the registration carries the scenario's tokens, bot and callback URL + ansible.builtin.assert: + that: + - hookshot_registration.as_token == matrix_bridge_hookshot_appservice_token + - hookshot_registration.hs_token == matrix_bridge_hookshot_homeserver_token + - hookshot_registration.sender_localpart == matrix_bridge_hookshot_bot_localpart + - hookshot_registration.url == 'http://' + matrix_bridge_hookshot_identifier + ':' + (matrix_bridge_hookshot_appservice_port | string) + fail_msg: "The appservice registration does not carry the scenario's tokens, bot and callback URL" + success_msg: "The appservice registration carries the scenario's tokens, bot and callback URL" + + # The user namespace is derived from the generic webhook prefix, and the + # GitLab namespace is conditional on the service being enabled - so this + # checks that the two switches reach the registration, not just config.yml. + - name: Assert the registration namespaces follow the enabled services + ansible.builtin.assert: + that: + - hookshot_registration_user_regexes | select('search', matrix_bridge_hookshot_generic_userIdPrefix) | list | length == 1 + - hookshot_registration_user_regexes | select('search', '_gitlab_') | list | length == 0 + fail_msg: >- + The registration's user namespaces are + {{ hookshot_registration_user_regexes }}, which do not follow the + services this scenario enabled + success_msg: "The registration's user namespaces follow the enabled services" + vars: + hookshot_registration_user_regexes: "{{ hookshot_registration.namespaces.users | map(attribute='regex') | list }}" + + # ------------------------------------------------------------------ + # The listeners, live + # ------------------------------------------------------------------ + + # A helper container is used because the role publishes no host port, exactly + # as in a real deployment; see docs/molecule-testing.md. + - name: Wait for the webhooks listener to answer on the port the role configured + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bridge_hookshot_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --max-time + - "5" + - --request + - POST + - --header + - "Content-Type: application/json" + - --data + - "{}" + - --write-out + - "|HTTP_STATUS=%{http_code}" + - "http://{{ matrix_bridge_hookshot_identifier }}:{{ matrix_bridge_hookshot_webhook_port }}/webhook/molecule-no-such-hook" + register: hookshot_webhooks_probe + changed_when: false + until: "'HTTP_STATUS=000' not in hookshot_webhooks_probe.stdout" + retries: 24 + delay: 5 + failed_when: false + + # Hookshot answers an unknown webhook id from its generic-webhook handler, + # with a JSON body no other component would produce. An Express "Cannot POST" + # page here would mean the port is Hookshot's but the generic webhooks + # service was never mounted on it; a refused connection would mean the + # listener the role described was never opened at all. + - name: Assert the generic webhooks service is mounted on that listener + ansible.builtin.assert: + that: + - "'\"ok\":false' in hookshot_webhooks_probe.stdout" + - "'Webhook not found' in hookshot_webhooks_probe.stdout" + - "'HTTP_STATUS=404' in hookshot_webhooks_probe.stdout" + fail_msg: >- + Port {{ matrix_bridge_hookshot_webhook_port }} did not answer as + Hookshot's generic webhooks service + ({{ hookshot_webhooks_probe.stdout | default('no output') }}) + success_msg: "The generic webhooks service answers on the port the role configured" + + # Metrics are OFF in the role's defaults, so this listener exists only + # because the scenario asked for it - and /metrics answers in a format + # nothing else on that port could have produced. + - name: Probe the metrics listener on the port the role configured + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bridge_hookshot_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --max-time + - "5" + - --write-out + - "|HTTP_STATUS=%{http_code}" + - "http://{{ matrix_bridge_hookshot_identifier }}:{{ matrix_bridge_hookshot_metrics_port }}/metrics" + register: hookshot_metrics_probe + changed_when: false + failed_when: false + + - name: Assert the metrics listener serves Hookshot's own metrics + ansible.builtin.assert: + that: + - "'HTTP_STATUS=200' in hookshot_metrics_probe.stdout" + - "'hookshot_webhooks_http_request' in hookshot_metrics_probe.stdout" + fail_msg: >- + Port {{ matrix_bridge_hookshot_metrics_port }} did not serve Hookshot's + metrics ({{ hookshot_metrics_probe.stdout | default('no output') | truncate(200) }}) + success_msg: "The metrics listener serves Hookshot's own metrics" + + # The appservice port is not in `listeners` - it comes from `bridge.port` - + # so it is a separate socket, opened by a separate part of the config. + - name: Probe the appservice port the role configured + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bridge_hookshot_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --max-time + - "5" + - --write-out + - "|HTTP_STATUS=%{http_code}" + - "http://{{ matrix_bridge_hookshot_identifier }}:{{ matrix_bridge_hookshot_appservice_port }}/_matrix/app/v1/ping" + register: hookshot_appservice_probe + changed_when: false + failed_when: false + + - name: Assert the appservice API answers on the port the role configured + ansible.builtin.assert: + that: + - "'HTTP_STATUS=000' not in hookshot_appservice_probe.stdout" + - "'errcode' in hookshot_appservice_probe.stdout" + fail_msg: >- + Port {{ matrix_bridge_hookshot_appservice_port }} did not answer as a + Matrix appservice + ({{ hookshot_appservice_probe.stdout | default('no output') }}) + success_msg: "The appservice API answers on the port the role configured" + + # The other half of the story. Every port above is one the scenario chose; + # these are the ones the role and Hookshot would have used if the scenario's + # configuration had never reached the process. If any of them answers, then + # a passing probe above proves much less than it looks like it does. + - name: Probe the ports the role's defaults would have used + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bridge_hookshot_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --max-time + - "5" + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - "http://{{ matrix_bridge_hookshot_identifier }}:{{ item.port }}/" + register: hookshot_closed_probes + changed_when: false + failed_when: false + loop: + - port: "{{ hookshot_role_defaults.matrix_bridge_hookshot_appservice_port }}" + what: the appservice port the role defaults to + - port: "{{ hookshot_role_defaults.matrix_bridge_hookshot_webhook_port }}" + what: the webhooks port the role defaults to + - port: "{{ hookshot_role_defaults.matrix_bridge_hookshot_metrics_port }}" + what: the metrics port the role defaults to + - port: "{{ hookshot_role_defaults.matrix_bridge_hookshot_widgets_port }}" + what: the widgets port, whose listener this scenario disabled + loop_control: + label: "{{ item.port }} - {{ item.what }}" + + - name: Assert nothing listens on the ports the role's defaults would have used + ansible.builtin.assert: + that: + - hookshot_closed_probes.results | rejectattr('stdout', 'search', 'HTTP_STATUS=000') | list | length == 0 + fail_msg: >- + Something answered on + {{ hookshot_closed_probes.results | rejectattr('stdout', 'search', 'HTTP_STATUS=000') | map(attribute='item') | list }}, + so the ports this scenario configured are not the only ones Hookshot + is listening on + success_msg: >- + Nothing listens on the ports the role's defaults would have used + ({{ hookshot_closed_probes.results | map(attribute='item.port') | list | join(', ') }}) + + # ------------------------------------------------------------------ + # The container the role started + # ------------------------------------------------------------------ + + - name: Read the image of the running container + ansible.builtin.command: + argv: + - docker + - container + - inspect + - "{{ matrix_bridge_hookshot_identifier }}" + - --format + - "{{ '{{' }} .Config.Image {{ '}}' }}" + register: hookshot_image + changed_when: false + + - name: Assert the running container is the version defaults/main.yml pins + ansible.builtin.assert: + that: + - hookshot_role_defaults.matrix_bridge_hookshot_version | string in hookshot_image.stdout + fail_msg: >- + The running container is {{ hookshot_image.stdout }}, which does not + carry the pinned version + {{ hookshot_role_defaults.matrix_bridge_hookshot_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_bridge_hookshot_base_path }}/labels" + register: hookshot_labels + + - name: Assert no Traefik labels are emitted while Traefik support is disabled + ansible.builtin.assert: + that: + - "'traefik.' not in (hookshot_labels.content | b64decode)" + fail_msg: >- + Traefik labels were emitted even though + matrix_bridge_hookshot_container_labels_traefik_enabled is false + success_msg: "No Traefik labels are emitted while Traefik support is disabled"