Matrix Docker Ansible eploy
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

382 строки
20 KiB

  1. # SPDX-FileCopyrightText: 2026 Slavi Pantaleev
  2. #
  3. # SPDX-License-Identifier: AGPL-3.0-or-later
  4. ---
  5. # Proves the bridge accepts role-rendered config and registration, starts without a
  6. # Google account, initializes real Postgres, and exposes its authenticated login API.
  7. - name: Verify mautrix-gvoice
  8. hosts: all
  9. become: true
  10. vars_files:
  11. - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml"
  12. - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml"
  13. vars:
  14. mautrix_gvoice_config: "{{ mautrix_gvoice_config_file.content | b64decode | from_yaml }}"
  15. mautrix_gvoice_registration: "{{ mautrix_gvoice_registration_file.content | b64decode | from_yaml }}"
  16. mautrix_gvoice_labels_rendered: "{{ mautrix_gvoice_labels_file.content | b64decode }}"
  17. mautrix_gvoice_expected_public_address: http://gvoice-api.molecule.local/bridges/gvoice-api
  18. gather_facts: false
  19. tasks:
  20. - name: Load the role's defaults under a separate name
  21. ansible.builtin.include_vars:
  22. file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml"
  23. name: mautrix_gvoice_role_defaults
  24. - name: Wait for the mautrix-gvoice service to become active
  25. ansible.builtin.systemd_service:
  26. name: matrix-mautrix-gvoice.service
  27. register: mautrix_gvoice_service
  28. until: mautrix_gvoice_service.status.ActiveState == 'active'
  29. retries: 30
  30. delay: 5
  31. failed_when: false
  32. - name: Assert the service is active and has not been restarting
  33. ansible.builtin.assert:
  34. that:
  35. - mautrix_gvoice_service.status.ActiveState == 'active'
  36. - mautrix_gvoice_service.status.NRestarts is defined
  37. - mautrix_gvoice_service.status.NRestarts | int == 0
  38. fail_msg: >-
  39. matrix-mautrix-gvoice.service is {{ mautrix_gvoice_service.status.ActiveState | default('unknown') }}
  40. after {{ mautrix_gvoice_service.status.NRestarts | default('?') }} restart(s)
  41. success_msg: "matrix-mautrix-gvoice.service is active and has not restarted"
  42. - name: Wait for the bridge liveness endpoint
  43. ansible.builtin.command:
  44. argv:
  45. - docker
  46. - run
  47. - --rm
  48. - --network={{ matrix_bridge_mautrix_gvoice_container_network }}
  49. - "{{ molecule_shared_image_curl }}"
  50. - --silent
  51. - --output
  52. - /dev/null
  53. - --write-out
  54. - "HTTP_STATUS=%{http_code}"
  55. - http://matrix-mautrix-gvoice:8080/_matrix/mau/live
  56. register: mautrix_gvoice_live
  57. changed_when: false
  58. until: mautrix_gvoice_live.rc == 0 and mautrix_gvoice_live.stdout == 'HTTP_STATUS=200'
  59. retries: 24
  60. delay: 5
  61. failed_when: false
  62. - name: Wait for the bridge readiness endpoint
  63. ansible.builtin.command:
  64. argv:
  65. - docker
  66. - run
  67. - --rm
  68. - --network={{ matrix_bridge_mautrix_gvoice_container_network }}
  69. - "{{ molecule_shared_image_curl }}"
  70. - --silent
  71. - --output
  72. - /dev/null
  73. - --write-out
  74. - "HTTP_STATUS=%{http_code}"
  75. - http://matrix-mautrix-gvoice:8080/_matrix/mau/ready
  76. register: mautrix_gvoice_ready
  77. changed_when: false
  78. until: mautrix_gvoice_ready.rc == 0 and mautrix_gvoice_ready.stdout == 'HTTP_STATUS=200'
  79. retries: 24
  80. delay: 5
  81. failed_when: false
  82. - name: Assert the bridge is live and ready on the configured appservice port
  83. ansible.builtin.assert:
  84. that:
  85. - mautrix_gvoice_live.rc == 0
  86. - mautrix_gvoice_live.stdout == 'HTTP_STATUS=200'
  87. - mautrix_gvoice_ready.rc == 0
  88. - mautrix_gvoice_ready.stdout == 'HTTP_STATUS=200'
  89. fail_msg: >-
  90. Health probes returned live={{ mautrix_gvoice_live.stdout | default('none') }} and
  91. ready={{ mautrix_gvoice_ready.stdout | default('none') }}
  92. success_msg: "The bridge is live and ready on the configured appservice port"
  93. # This reaches the network connector's login-flow handler but supplies no Google
  94. # credentials and starts no third-party login.
  95. - name: Query the authenticated provisioning login flows
  96. ansible.builtin.command:
  97. argv:
  98. - docker
  99. - run
  100. - --rm
  101. - --network={{ matrix_bridge_mautrix_gvoice_container_network }}
  102. - "{{ molecule_shared_image_curl }}"
  103. - --silent
  104. - --show-error
  105. - --fail-with-body
  106. - --header
  107. - "Authorization: Bearer {{ matrix_bridge_mautrix_gvoice_provisioning_shared_secret }}"
  108. - "http://matrix-mautrix-gvoice:8080/_matrix/provision/v3/login/flows?user_id=%40alice%3Amolecule.local"
  109. register: mautrix_gvoice_login_flows
  110. changed_when: false
  111. - name: Query the provisioning API with an invalid secret
  112. ansible.builtin.command:
  113. argv:
  114. - docker
  115. - run
  116. - --rm
  117. - --network={{ matrix_bridge_mautrix_gvoice_container_network }}
  118. - "{{ molecule_shared_image_curl }}"
  119. - --silent
  120. - --output
  121. - /dev/null
  122. - --write-out
  123. - "HTTP_STATUS=%{http_code}"
  124. - --header
  125. - "Authorization: Bearer definitely-wrong-molecule-secret"
  126. - "http://matrix-mautrix-gvoice:8080/_matrix/provision/v3/login/flows?user_id=%40alice%3Amolecule.local"
  127. register: mautrix_gvoice_login_flows_unauthorized
  128. changed_when: false
  129. failed_when: false
  130. - name: Assert the live provisioning API enforces auth and advertises cookie login
  131. ansible.builtin.assert:
  132. that:
  133. - mautrix_gvoice_login_flows.rc == 0
  134. - (mautrix_gvoice_login_flows.stdout | from_json).flows is sequence
  135. - (mautrix_gvoice_login_flows.stdout | from_json).flows | length == 1
  136. - (mautrix_gvoice_login_flows.stdout | from_json)['flows'][0]['id'] == 'cookies'
  137. - mautrix_gvoice_login_flows_unauthorized.rc == 0
  138. - mautrix_gvoice_login_flows_unauthorized.stdout == 'HTTP_STATUS=401'
  139. fail_msg: "The provisioning API did not enforce its secret or advertise cookie login"
  140. success_msg: "The provisioning API enforces its secret and advertises cookie login"
  141. - name: Read the configuration the role rendered
  142. ansible.builtin.slurp:
  143. src: "{{ matrix_bridge_mautrix_gvoice_config_path }}/config.yaml"
  144. register: mautrix_gvoice_config_file
  145. - name: Assert the rendered configuration carries the appservice identity
  146. ansible.builtin.assert:
  147. that:
  148. - mautrix_gvoice_config.homeserver.address == matrix_bridge_mautrix_gvoice_homeserver_address
  149. - mautrix_gvoice_config.homeserver.domain == matrix_bridge_mautrix_gvoice_homeserver_domain
  150. - mautrix_gvoice_config.appservice.id == 'gvoice'
  151. - mautrix_gvoice_config.appservice.address == 'http://matrix-mautrix-gvoice:8080'
  152. - mautrix_gvoice_config.appservice.public_address == mautrix_gvoice_expected_public_address
  153. - mautrix_gvoice_config.appservice.bot.username == matrix_bridge_mautrix_gvoice_appservice_bot_username
  154. - mautrix_gvoice_config.appservice.as_token == matrix_bridge_mautrix_gvoice_appservice_token
  155. - mautrix_gvoice_config.appservice.hs_token == matrix_bridge_mautrix_gvoice_homeserver_token
  156. - mautrix_gvoice_config.appservice.username_template == matrix_bridge_mautrix_gvoice_appservice_username_template
  157. fail_msg: "The rendered configuration does not carry the scenario's appservice identity"
  158. success_msg: "The rendered configuration carries the scenario's appservice identity"
  159. - name: Assert the rendered configuration carries non-default bridge behavior
  160. ansible.builtin.assert:
  161. that:
  162. - mautrix_gvoice_config.bridge.command_prefix == matrix_bridge_mautrix_gvoice_command_prefix
  163. - not mautrix_gvoice_config.bridge.personal_filtering_spaces
  164. - not mautrix_gvoice_config.matrix.federate_rooms
  165. - mautrix_gvoice_config.network.displayname_template == matrix_bridge_mautrix_gvoice_network_displayname_template
  166. - mautrix_gvoice_config.provisioning.shared_secret == matrix_bridge_mautrix_gvoice_provisioning_shared_secret
  167. - mautrix_gvoice_config.logging.min_level == matrix_bridge_mautrix_gvoice_logging_level
  168. fail_msg: "The rendered configuration does not carry the scenario's bridge behavior"
  169. success_msg: "The rendered configuration carries the scenario's bridge behavior"
  170. - name: Assert the rendered configuration carries the non-default backfill policy
  171. ansible.builtin.assert:
  172. that:
  173. - not mautrix_gvoice_config.backfill.enabled
  174. - mautrix_gvoice_config.backfill.max_initial_messages == 19
  175. - mautrix_gvoice_config.backfill.max_catchup_messages == 31
  176. - mautrix_gvoice_config.backfill.unread_hours_threshold == 67
  177. - mautrix_gvoice_config.backfill.threads.max_initial_messages == 11
  178. fail_msg: "The rendered configuration does not carry the scenario's backfill policy"
  179. success_msg: "The rendered configuration carries the scenario's backfill policy"
  180. - name: Assert the configuration points at the scenario's Postgres database
  181. ansible.builtin.assert:
  182. that:
  183. - mautrix_gvoice_config.database.type == matrix_bridge_mautrix_gvoice_database_engine
  184. - matrix_bridge_mautrix_gvoice_database_username in mautrix_gvoice_config.database.uri
  185. - matrix_bridge_mautrix_gvoice_database_hostname in mautrix_gvoice_config.database.uri
  186. - matrix_bridge_mautrix_gvoice_database_name in mautrix_gvoice_config.database.uri
  187. fail_msg: >-
  188. database.uri is {{ mautrix_gvoice_config.database.uri | default('unset') }}, which
  189. was not composed from the scenario's connection settings
  190. success_msg: "The configuration points at the scenario's Postgres database"
  191. - name: Read the appservice registration the role rendered
  192. ansible.builtin.slurp:
  193. src: "{{ matrix_bridge_mautrix_gvoice_config_path }}/registration.yaml"
  194. register: mautrix_gvoice_registration_file
  195. - name: Assert the registration carries the configured handshake values
  196. ansible.builtin.assert:
  197. that:
  198. - mautrix_gvoice_registration.id == 'gvoice'
  199. - mautrix_gvoice_registration.as_token == matrix_bridge_mautrix_gvoice_appservice_token
  200. - mautrix_gvoice_registration.hs_token == matrix_bridge_mautrix_gvoice_homeserver_token
  201. - mautrix_gvoice_registration.sender_localpart == '_bot_' + matrix_bridge_mautrix_gvoice_appservice_bot_username
  202. - mautrix_gvoice_registration.url == 'http://matrix-mautrix-gvoice:8080'
  203. fail_msg: "The registration does not carry the configured handshake values"
  204. success_msg: "The registration carries the configured handshake values"
  205. - name: Assert the registration namespaces cover only GVoice ghosts and the bridge bot
  206. ansible.builtin.assert:
  207. that:
  208. - mautrix_gvoice_ghost_regex | length > 0
  209. - mautrix_gvoice_bot_regex | length > 0
  210. - mautrix_gvoice_ghost_mxid is match(mautrix_gvoice_ghost_regex)
  211. - mautrix_gvoice_wrong_ghost_mxid is not match(mautrix_gvoice_ghost_regex)
  212. - mautrix_gvoice_bot_mxid is match(mautrix_gvoice_bot_regex)
  213. fail_msg: "The registration namespaces do not cover only GVoice ghosts and the bot"
  214. success_msg: "The registration namespaces cover only GVoice ghosts and the bot"
  215. vars:
  216. mautrix_gvoice_user_regexes: "{{ mautrix_gvoice_registration.namespaces.users | map(attribute='regex') | list }}"
  217. mautrix_gvoice_ghost_regex: "{{ mautrix_gvoice_user_regexes | select('search', 'gvoice_') | first | default('') }}"
  218. mautrix_gvoice_bot_regex: "{{ mautrix_gvoice_user_regexes | reject('search', 'gvoice_') | first | default('') }}"
  219. mautrix_gvoice_ghost_mxid: "@gvoice_15551234567:{{ matrix_bridge_mautrix_gvoice_homeserver_domain }}"
  220. mautrix_gvoice_wrong_ghost_mxid: "@googlechat_15551234567:{{ matrix_bridge_mautrix_gvoice_homeserver_domain }}"
  221. mautrix_gvoice_bot_mxid: "@{{ matrix_bridge_mautrix_gvoice_appservice_bot_username }}:{{ matrix_bridge_mautrix_gvoice_homeserver_domain }}"
  222. - name: List the tables the bridge created in Postgres
  223. ansible.builtin.command:
  224. argv:
  225. - docker
  226. - exec
  227. - matrix-postgres-molecule
  228. - psql
  229. - --username={{ matrix_bridge_mautrix_gvoice_database_username }}
  230. - --dbname={{ matrix_bridge_mautrix_gvoice_database_name }}
  231. - --tuples-only
  232. - --no-align
  233. - "--command=SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
  234. register: mautrix_gvoice_tables
  235. changed_when: false
  236. - name: Assert the bridge migrated its schema into the configured database
  237. ansible.builtin.assert:
  238. that:
  239. - mautrix_gvoice_tables.rc == 0
  240. - "'version' in mautrix_gvoice_table_names"
  241. - "'portal' in mautrix_gvoice_table_names"
  242. - "'user_login' in mautrix_gvoice_table_names"
  243. - "'message' in mautrix_gvoice_table_names"
  244. - "'gvoice_version' in mautrix_gvoice_table_names"
  245. - "'gvoice_login_prefix' in mautrix_gvoice_table_names"
  246. - mautrix_gvoice_table_names | length >= 19
  247. fail_msg: >-
  248. The bridge did not migrate its schema into {{ matrix_bridge_mautrix_gvoice_database_name }}
  249. (found {{ mautrix_gvoice_table_names | length }} table(s))
  250. success_msg: "The bridge migrated its schema into the configured database"
  251. vars:
  252. mautrix_gvoice_table_names: "{{ mautrix_gvoice_tables.stdout_lines | select | list }}"
  253. - name: Read the labels the role rendered
  254. ansible.builtin.slurp:
  255. src: "{{ matrix_bridge_mautrix_gvoice_base_path }}/labels"
  256. register: mautrix_gvoice_labels_file
  257. - name: Assert the labels route the public endpoint to the appservice port
  258. ansible.builtin.assert:
  259. that:
  260. - "'traefik.enable=true' in mautrix_gvoice_labels_rendered"
  261. - "'traefik.docker.network=' + matrix_bridge_mautrix_gvoice_container_network in mautrix_gvoice_labels_rendered"
  262. - "'traefik.http.services.matrix-mautrix-gvoice-exposure.loadbalancer.server.port=8080' in mautrix_gvoice_labels_rendered"
  263. - "'traefik.http.routers.matrix-mautrix-gvoice-exposure.rule=Host(`gvoice-api.molecule.local`) && PathPrefix(`/bridges/gvoice-api`)' in mautrix_gvoice_labels_rendered"
  264. - "'traefik.http.middlewares.matrix-mautrix-gvoice-exposure-strip-prefix.stripprefix.prefixes=/bridges/gvoice-api' in mautrix_gvoice_labels_rendered"
  265. - "'traefik.http.routers.matrix-mautrix-gvoice-exposure.entrypoints=web' in mautrix_gvoice_labels_rendered"
  266. - "'traefik.http.routers.matrix-mautrix-gvoice-exposure.tls=false' in mautrix_gvoice_labels_rendered"
  267. - "'molecule.gvoice.coverage=enabled' in mautrix_gvoice_labels_rendered"
  268. fail_msg: "The rendered labels do not carry the scenario's exposure configuration"
  269. success_msg: "The rendered labels carry the scenario's exposure configuration"
  270. - name: Inspect the running bridge container
  271. ansible.builtin.command:
  272. argv:
  273. - docker
  274. - container
  275. - inspect
  276. - matrix-mautrix-gvoice
  277. register: mautrix_gvoice_container_inspect
  278. changed_when: false
  279. - name: Parse the running bridge container inspection
  280. ansible.builtin.set_fact:
  281. mautrix_gvoice_container: "{{ (mautrix_gvoice_container_inspect.stdout | from_json) | first }}"
  282. - name: Assert the running container uses the exact image pinned by the role
  283. ansible.builtin.assert:
  284. that:
  285. - mautrix_gvoice_container.Config.Image == mautrix_gvoice_expected_image
  286. fail_msg: >-
  287. The running container uses {{ mautrix_gvoice_container.Config.Image }}, expected
  288. {{ mautrix_gvoice_expected_image }}
  289. success_msg: "The running container uses the exact image pinned by the role"
  290. vars:
  291. mautrix_gvoice_expected_image: >-
  292. {{ mautrix_gvoice_role_defaults.matrix_bridge_mautrix_gvoice_container_image_registry_prefix_upstream_default }}mautrix/gvoice:{{ mautrix_gvoice_role_defaults.matrix_bridge_mautrix_gvoice_version }}
  293. - name: Assert the running container uses the playbook-supplied runtime
  294. ansible.builtin.assert:
  295. that:
  296. - mautrix_gvoice_container.Config.User == (matrix_user_uid | string) + ':' + (matrix_user_gid | string)
  297. - mautrix_gvoice_container.Config.WorkingDir == '/data'
  298. - mautrix_gvoice_container.Config.Cmd == ['/usr/bin/mautrix-gvoice', '-c', '/config/config.yaml', '-r', '/config/registration.yaml', '--no-update']
  299. - mautrix_gvoice_container.HostConfig.RestartPolicy.Name == 'no'
  300. - mautrix_gvoice_container.HostConfig.AutoRemove
  301. - mautrix_gvoice_container.HostConfig.LogConfig.Type == 'none'
  302. fail_msg: "The running container does not use the role's exact identity, command and workdir"
  303. success_msg: "The running container uses the role's exact identity, command and workdir"
  304. - name: Assert the running container has the intended privilege isolation
  305. ansible.builtin.assert:
  306. that:
  307. - "'ALL' in mautrix_gvoice_container.HostConfig.CapDrop"
  308. - not mautrix_gvoice_container.HostConfig.Privileged
  309. fail_msg: "The running container does not have the intended privilege isolation"
  310. success_msg: "The running container drops all capabilities and is unprivileged"
  311. - name: Assert the running container carries the role's two bind mounts
  312. ansible.builtin.assert:
  313. that:
  314. - mautrix_gvoice_config_mount | length > 0
  315. - mautrix_gvoice_config_mount.Source == matrix_bridge_mautrix_gvoice_config_path
  316. - not mautrix_gvoice_config_mount.RW
  317. - mautrix_gvoice_data_mount | length > 0
  318. - mautrix_gvoice_data_mount.Source == matrix_bridge_mautrix_gvoice_data_path
  319. - mautrix_gvoice_data_mount.RW
  320. - mautrix_gvoice_container.Mounts | length == 2
  321. fail_msg: "The running container does not carry the role's exact config and data mounts"
  322. success_msg: "The running container carries its read-only config and writable data mounts"
  323. vars:
  324. mautrix_gvoice_config_mount: >-
  325. {{ mautrix_gvoice_container.Mounts
  326. | selectattr('Destination', 'equalto', '/config')
  327. | first | default({}) }}
  328. mautrix_gvoice_data_mount: >-
  329. {{ mautrix_gvoice_container.Mounts
  330. | selectattr('Destination', 'equalto', '/data')
  331. | first | default({}) }}
  332. - name: Assert the rendered labels are attached to the running container
  333. ansible.builtin.assert:
  334. that:
  335. - mautrix_gvoice_container.Config.Labels['traefik.enable'] == 'true'
  336. - mautrix_gvoice_container.Config.Labels['traefik.docker.network'] == matrix_bridge_mautrix_gvoice_container_network
  337. - mautrix_gvoice_container.Config.Labels['traefik.http.services.matrix-mautrix-gvoice-exposure.loadbalancer.server.port'] == '8080'
  338. - mautrix_gvoice_container.Config.Labels['molecule.gvoice.coverage'] == 'enabled'
  339. fail_msg: "The running container does not carry the labels the role rendered"
  340. success_msg: "The running container carries the labels the role rendered"
  341. - name: Assert the running container has the exact network and port exposure
  342. ansible.builtin.assert:
  343. that:
  344. - mautrix_gvoice_container.HostConfig.NetworkMode == matrix_bridge_mautrix_gvoice_container_network
  345. - matrix_bridge_mautrix_gvoice_container_network in mautrix_gvoice_container.NetworkSettings.Networks
  346. - mautrix_gvoice_container.NetworkSettings.Networks | length == 1
  347. - mautrix_gvoice_container.HostConfig.PortBindings | default({}, true) | length == 0
  348. fail_msg: >-
  349. The container has unexpected networks or host ports:
  350. networks={{ mautrix_gvoice_container.NetworkSettings.Networks.keys() | list }},
  351. ports={{ mautrix_gvoice_container.HostConfig.PortBindings | default({}) }}
  352. success_msg: "The container uses only its dedicated network and publishes no host ports"