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