# 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"