mirror of
https://github.com/spantaleev/matrix-docker-ansible-deploy.git
synced 2026-08-29 12:03:14 +00:00
They were hard-wrapped at 80 characters, broke mid-parenthesis, and spent lines restating what the code below them does. Rewrapped at natural boundaries instead, with the narration dropped and only the reasons, gotchas and surprises kept. Section dividers stay - they delineate long plays rather than narrate them. Comments only; no scenario behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
364 lines
17 KiB
YAML
364 lines
17 KiB
YAML
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
---
|
|
# Proves Hookshot starts on 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.
|
|
#
|
|
# Deliberately does NOT configure GitHub, GitLab, Jira or Figma. Each needs an account
|
|
# and a credential on a third-party service, which is 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 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:
|
|
# From the role's own defaults rather than pinned in molecule.yml, so the version
|
|
# assertion compares the running image against what the role ships, not against the
|
|
# scenario. The default ports come from here for the same reason: "these ports stay
|
|
# closed" 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 unreadable config still reports
|
|
# `active`, so the restart counter is checked too. 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 differs from what Hookshot would use on its own AND from the role's defaults,
|
|
# so finding them here rules out 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"
|
|
|
|
# The `listeners` list is the role's own construction, assembled from a handful of
|
|
# independent switches. Getting it wrong is invisible in a "did it start" test,
|
|
# hence asserting the whole shape 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 }}"
|
|
|
|
# GitLab is the interesting one: the role turns it ON by default, so its absence
|
|
# 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 role generates the registration; Hookshot only consumes it. Worth checking
|
|
# on its own, as it is the half of the handshake the homeserver reads.
|
|
- 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 built from the container name and the appservice port. It has to agree
|
|
# with `bridge.port` in config.yml, or the two halves 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 derives from the generic webhook prefix, and the GitLab namespace
|
|
# is conditional on that service being enabled. Checks both 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, 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
|
|
|
|
# An unknown webhook id draws a JSON body from the generic-webhook handler that no
|
|
# other component would produce. An Express "Cannot POST" page would mean the port is
|
|
# Hookshot's but the generic webhooks service was never mounted on it; a refused
|
|
# connection would mean the listener 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.
|
|
- 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`.
|
|
# 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 had the scenario's configuration never
|
|
# reached the process. If any of them answers, the probes above prove much less than
|
|
# they appear to.
|
|
- 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"
|