From 38dc3c04316d4f6ecebe8f3149511df36cfeaae1 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:01:57 +0200 Subject: [PATCH 01/13] fix(discourse): find the administrator by username, then move its e-mail Keyed on the e-mail, the bootstrap missed an administrator whose address changed and built a second user with the same username, which Discourse refused with "Username must be unique" on echoserver. Keyed on the username it converges the existing row and moves the e-mail onto it, so the readback that looks the account up by e-mail still finds it. Same blob as 3fcbadefd3 on feature/store-i18n. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-discourse/files/ruby/ensure_admin_user.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/roles/web-app-discourse/files/ruby/ensure_admin_user.rb b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb index 15bb55151e..523c47b9ca 100644 --- a/roles/web-app-discourse/files/ruby/ensure_admin_user.rb +++ b/roles/web-app-discourse/files/ruby/ensure_admin_user.rb @@ -5,8 +5,9 @@ email = ENV.fetch("DISCOURSE_ADMIN_EMAIL") username = ENV.fetch("DISCOURSE_ADMIN_USERNAME") password = ENV.fetch("DISCOURSE_ADMIN_PASSWORD") -user = User.find_by_email(email) || User.new(email: email) +user = User.find_by_username(username) || User.new user.username = username +user.email = email user.password = password unless user.confirm_password?(password) user.active = true user.save! From 723818cbd79f6ed2da84e51295c2eabf08c801d7 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:30:58 +0200 Subject: [PATCH 02/13] fix(sys-service): unlock only this version's timers and prune the rest SYS_SERVICE_VERSION_SUFFIX names the '..' infix every versioned unit carries. The unlock phase re-enables only the timers that end in it, in one systemctl enable --now, so the legacy sys-ctl-* timers and older versions stay off after a deploy instead of running beside their successors. Masking at lockdown stays out: systemd refuses to mask a unit whose file lives in /etc/systemd/system, and a runtime mask under /run is shadowed by it. The prune phase matched nothing: inside a folded scalar '\\.' reaches the regex as a literal backslash, so the old-version lists stayed empty and no unit was ever removed. It now escapes the software domain and the version infix with regex_escape. Co-Authored-By: Claude Opus 5.5 (1M context) --- group_vars/all/06_daemons.yml | 1 + .../tasks/02_unlock.yml | 22 ++++++------------- roles/sys-service/tasks/01_prune.yml | 17 +++++--------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/group_vars/all/06_daemons.yml b/group_vars/all/06_daemons.yml index dd7debfabf..d439f213a9 100644 --- a/group_vars/all/06_daemons.yml +++ b/group_vars/all/06_daemons.yml @@ -3,6 +3,7 @@ # # Meta SYS_SERVICE_SUFFIX: ".{{ SOFTWARE_DOMAIN }}.service" +SYS_SERVICE_VERSION_SUFFIX: ".{{ lookup('version') | lower }}.{{ SOFTWARE_DOMAIN }}" SYS_SERVICE_CLEANUP_BACKUPS: "{{ lookup('unit_name', 'sys-ctl-cln-bkps' ) }}" SYS_SERVICE_CLEANUP_BACKUPS_FAILED: "{{ lookup('unit_name', 'sys-ctl-cln-faild-bkps' ) }}" diff --git a/roles/sys-service-terminator/tasks/02_unlock.yml b/roles/sys-service-terminator/tasks/02_unlock.yml index 1b9e8b8af3..f4390cc866 100644 --- a/roles/sys-service-terminator/tasks/02_unlock.yml +++ b/roles/sys-service-terminator/tasks/02_unlock.yml @@ -12,29 +12,21 @@ changed_when: false failed_when: false -- name: "🚀 DEPLOY | Build timer list (unlock phase)" +- name: "🚀 DEPLOY | Build the timer list of this version (unlock phase)" ansible.builtin.set_fact: deploy_timers_unlock: >- {{ deploy_timer_units_unlock.stdout_lines | default([]) + | reject('search', ' masked') | map('split', ' ') | map('first') + | select('search', (SYS_SERVICE_VERSION_SUFFIX ~ '.timer') | regex_escape ~ '$') | list }} -- name: "🚀 DEPLOY | Unmask all {{ SOFTWARE_DOMAIN }} timers" +- name: "🚀 DEPLOY | Enable and start the {{ SOFTWARE_DOMAIN }} timers of this version" become: true - ansible.builtin.systemd: - name: "{{ item }}" - masked: false - loop: "{{ deploy_timers_unlock }}" - when: deploy_timers_unlock | length > 0 - -- name: "🚀 DEPLOY | Enable and start all {{ SOFTWARE_DOMAIN }} timers" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - enabled: true - state: started - loop: "{{ deploy_timers_unlock }}" + ansible.builtin.command: + argv: "{{ ['systemctl', 'enable', '--now'] + deploy_timers_unlock }}" + changed_when: true when: deploy_timers_unlock | length > 0 diff --git a/roles/sys-service/tasks/01_prune.yml b/roles/sys-service/tasks/01_prune.yml index f897846892..9f43c167b2 100644 --- a/roles/sys-service/tasks/01_prune.yml +++ b/roles/sys-service/tasks/01_prune.yml @@ -2,18 +2,13 @@ # Remove/stop/disable systemd units that belong to this SOFTWARE_NAME, # but are not for the current version. -- name: "🤔 sys-service | Determine current software/version (lowercase)" - ansible.builtin.set_fact: - sys_service_sw_lc: "{{ SOFTWARE_NAME | string | trim | lower }}" - sys_service_ver_lc: "{{ lookup('version') | string | trim | lower }}" - - name: "📊 sys-service | Collect {{ SOFTWARE_NAME }} timers (all versions)" become: true ansible.builtin.command: argv: - systemctl - list-unit-files - - "*.{{ sys_service_sw_lc }}*.timer" + - "*.{{ SOFTWARE_DOMAIN }}*.timer" - --no-legend - --no-pager register: sys_service_all_timers @@ -26,7 +21,7 @@ argv: - systemctl - list-unit-files - - "*.{{ sys_service_sw_lc }}*.service" + - "*.{{ SOFTWARE_DOMAIN }}*.service" - --no-legend - --no-pager register: sys_service_all_services @@ -40,8 +35,8 @@ (sys_service_all_timers.stdout_lines | default([])) | map('split', ' ') | map('first') - | select('search', '\\.' ~ sys_service_sw_lc ~ '(?:@)?\\.timer$') - | reject('search', '\\.' ~ sys_service_ver_lc ~ '\\.' ~ sys_service_sw_lc) + | select('search', ('.' ~ SOFTWARE_DOMAIN) | regex_escape ~ '@?[.]timer$') + | reject('search', SYS_SERVICE_VERSION_SUFFIX | regex_escape) | list }} sys_service_old_units_services: >- @@ -49,8 +44,8 @@ (sys_service_all_services.stdout_lines | default([])) | map('split', ' ') | map('first') - | select('search', '\\.' ~ sys_service_sw_lc ~ '(?:@)?\\.service$') - | reject('search', '\\.' ~ sys_service_ver_lc ~ '\\.' ~ sys_service_sw_lc) + | select('search', ('.' ~ SOFTWARE_DOMAIN) | regex_escape ~ '@?[.]service$') + | reject('search', SYS_SERVICE_VERSION_SUFFIX | regex_escape) | list }} From ef246a6e0314d050570efebcd6675465b9ba7a53 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:34:13 +0200 Subject: [PATCH 03/13] refactor(unit_name): build unit names from SYS_SERVICE_VERSION_SUFFIX The lookup assembled '..' itself, a second copy of the infix the unlock and prune phases now read from the SPOT. It reads SYS_SERVICE_VERSION_SUFFIX instead and refuses one that does not start with a dot or carries an empty segment. The direct version-plugin fallback goes with it: it existed because the plugin rendered the untrusted literal "{{ lookup('version') }}", while the SPOT arrives as a trusted group_vars string. Every unit name rendered from group_vars is byte-identical before and after. The timer description names the versioned service it starts instead of the legacy unversioned name. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/lookup/unit_name.py | 67 +++------- roles/sys-timer/templates/dummy.timer.j2 | 2 +- .../python/plugins/lookup/test_unit_name.py | 123 +++++------------- 3 files changed, 52 insertions(+), 140 deletions(-) diff --git a/plugins/lookup/unit_name.py b/plugins/lookup/unit_name.py index 1aecd539c6..e3c88db926 100644 --- a/plugins/lookup/unit_name.py +++ b/plugins/lookup/unit_name.py @@ -10,8 +10,7 @@ # lookup('unit_name', 'alarm@') -> alarm.1.2.3.infinito.nexus@.service # # Requirements: -# - SOFTWARE_DOMAIN must be available in vars (inventory/group_vars/role vars). -# - Version is read via lookup('version') (your existing version lookup plugin). +# - SYS_SERVICE_VERSION_SUFFIX (group_vars/all/06_daemons.yml) must be available in vars. from __future__ import annotations @@ -45,33 +44,6 @@ def _render_if_template(templar, value, var_name: str): return rendered -def _resolve_version(templar, variables): - try: - version = templar.template("{{ lookup('version') }}") - except TypeError: - version = templar.template("{{ lookup('version') }}") - - if _looks_like_template(version): - try: - try: - from plugins.lookup.version import LookupModule as VersionLookupModule - except ModuleNotFoundError: - from version import LookupModule as VersionLookupModule - - version_lookup = VersionLookupModule() - version_values = version_lookup.run([], variables=variables) - if isinstance(version_values, list): - version = version_values[0] if version_values else "" - else: - version = version_values - except Exception as exc: - raise AnsibleError( - "unit_name lookup: failed to resolve version via lookup('version')." - ) from exc - - return version - - def _normalize_suffix(suffix) -> str: """ Normalize suffix input to a systemd unit suffix string: @@ -115,22 +87,17 @@ def _strip_sys_ctl_prefix(systemctl_id: str) -> str: return systemctl_id -def _build_unit_name( - systemctl_id: str, version: str, software_domain: str, suffix -) -> str: +def _build_unit_name(systemctl_id: str, version_suffix: str, suffix) -> str: sid = _strip_sys_ctl_prefix(_lower_required(systemctl_id, "systemctl_id")) if not sid: raise AnsibleError( "unit_name lookup: normalized systemctl_id is empty after stripping prefix." ) - ver = _lower_required(version, "version") - sw = _lower_required(software_domain, "SOFTWARE_DOMAIN") sfx = _normalize_suffix(suffix) if sid.endswith("@"): - base = sid[:-1] - return f"{base}.{ver}.{sw}@{sfx}" - return f"{sid}.{ver}.{sw}{sfx}" + return f"{sid[:-1]}{version_suffix}@{sfx}" + return f"{sid}{version_suffix}{sfx}" class LookupModule(LookupBase): @@ -152,26 +119,22 @@ class LookupModule(LookupBase): ) available = getattr(self._templar, "available_variables", {}) or {} - software_domain = available.get("SOFTWARE_DOMAIN") - if not software_domain: + version_suffix = available.get("SYS_SERVICE_VERSION_SUFFIX") + if not version_suffix: raise AnsibleError( - "unit_name lookup: SOFTWARE_DOMAIN is not defined in the variable context." + "unit_name lookup: SYS_SERVICE_VERSION_SUFFIX is not defined in the variable context." ) - software_domain = _render_if_template( - self._templar, software_domain, "SOFTWARE_DOMAIN" + version_suffix = _lower_required( + _render_if_template( + self._templar, version_suffix, "SYS_SERVICE_VERSION_SUFFIX" + ), + "SYS_SERVICE_VERSION_SUFFIX", ) - - version = _resolve_version(self._templar, variables) - if not version or not str(version).strip(): + if not version_suffix.startswith(".") or "" in version_suffix[1:].split("."): raise AnsibleError( - "unit_name lookup: lookup('version') returned an empty value." + f"unit_name lookup: SYS_SERVICE_VERSION_SUFFIX must read '..', got '{version_suffix}'." ) suffix = kwargs.get("suffix") - results = [] - results.extend( - _build_unit_name(term, version, software_domain, suffix) for term in terms - ) - - return results + return [_build_unit_name(term, version_suffix, suffix) for term in terms] diff --git a/roles/sys-timer/templates/dummy.timer.j2 b/roles/sys-timer/templates/dummy.timer.j2 index 2df3a2e01e..9854d1bab5 100644 --- a/roles/sys-timer/templates/dummy.timer.j2 +++ b/roles/sys-timer/templates/dummy.timer.j2 @@ -1,5 +1,5 @@ [Unit] -Description=Timer to start {{ system_service_timer_service }}{{ SYS_SERVICE_SUFFIX }} +Description=Timer to start {{ lookup('unit_name', system_service_timer_service) }} [Timer] OnCalendar={{ system_service_on_calendar }} diff --git a/tests/unit/python/plugins/lookup/test_unit_name.py b/tests/unit/python/plugins/lookup/test_unit_name.py index f1a1e53621..3a031a1f76 100644 --- a/tests/unit/python/plugins/lookup/test_unit_name.py +++ b/tests/unit/python/plugins/lookup/test_unit_name.py @@ -1,13 +1,3 @@ -# -# Unit tests for plugins/lookup/unit_name.py using Python's built-in unittest. -# -# Run with: -# python -m unittest -v -# -# Notes: -# - These tests do NOT require a full Ansible runtime; we mock the templar. -# - They validate the naming format and error handling. - import unittest from ansible.errors import AnsibleError @@ -16,73 +6,54 @@ from plugins.lookup.unit_name import LookupModule class DummyTemplar: - """Minimal templar stub to satisfy the lookup plugin.""" - - def __init__(self, available_variables=None, version="1.2.3"): + def __init__(self, available_variables=None, rendered=None): self.available_variables = available_variables or {} - self._version = version + self._rendered = rendered or {} def template(self, s: str, **kwargs): - if "lookup('version')" in s: - return self._version - if "{{ SOFTWARE_NAME | lower }}" in s: - return str(self.available_variables.get("SOFTWARE_NAME", "")).lower() - return s + return self._rendered.get(s, s) class UnitNameLookupTests(unittest.TestCase): - def _mk_lookup( - self, software_domain="infinito.nexus", version="1.2.3" - ) -> LookupModule: + def _mk_lookup(self, version_suffix=".2.0.0.infinito.nexus", rendered=None): lm = LookupModule() - lm._templar = DummyTemplar( - available_variables={"SOFTWARE_DOMAIN": software_domain}, - version=version, + available_variables={"SYS_SERVICE_VERSION_SUFFIX": version_suffix}, + rendered=rendered, ) - lm.set_options = lambda *args, **kwargs: None - return lm def test_default_suffix_service(self): - lm = self._mk_lookup(software_domain="Infinito.Nexus", version="2.0.0") - res = lm.run(["svc-foo"]) + res = self._mk_lookup().run(["svc-foo"]) self.assertEqual(res, ["svc-foo.2.0.0.infinito.nexus.service"]) def test_sys_ctl_prefix_is_removed(self): - lm = self._mk_lookup(software_domain="Infinito.Nexus", version="2.0.0") - res = lm.run(["sys-ctl-cln-bkps"]) + res = self._mk_lookup().run(["sys-ctl-cln-bkps"]) self.assertEqual(res, ["cln-bkps.2.0.0.infinito.nexus.service"]) def test_explicit_timer_suffix_with_dot(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["svc-foo"], suffix=".timer") + res = self._mk_lookup().run(["svc-foo"], suffix=".timer") self.assertEqual(res, ["svc-foo.2.0.0.infinito.nexus.timer"]) def test_explicit_timer_suffix_without_dot(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["svc-foo"], suffix="timer") + res = self._mk_lookup().run(["svc-foo"], suffix="timer") self.assertEqual(res, ["svc-foo.2.0.0.infinito.nexus.timer"]) def test_suffix_false_means_no_suffix(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["svc-foo"], suffix=False) + res = self._mk_lookup().run(["svc-foo"], suffix=False) self.assertEqual(res, ["svc-foo.2.0.0.infinito.nexus"]) def test_template_unit_id_endswith_at(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["alarm@"]) + res = self._mk_lookup().run(["alarm@"]) self.assertEqual(res, ["alarm.2.0.0.infinito.nexus@.service"]) def test_template_unit_id_endswith_at_with_sys_ctl_prefix(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["sys-ctl-alm-compose@"], suffix=False) + res = self._mk_lookup().run(["sys-ctl-alm-compose@"], suffix=False) self.assertEqual(res, ["alm-compose.2.0.0.infinito.nexus@"]) def test_multiple_terms(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version="2.0.0") - res = lm.run(["a", "b"], suffix=".timer") + res = self._mk_lookup().run(["a", "b"], suffix=".timer") self.assertEqual( res, [ @@ -92,63 +63,41 @@ class UnitNameLookupTests(unittest.TestCase): ) def test_missing_terms_raises(self): - lm = self._mk_lookup() with self.assertRaises(AnsibleError) as ctx: - lm.run([]) + self._mk_lookup().run([]) self.assertIn("at least one term", str(ctx.exception)) - def test_missing_software_name_raises(self): + def test_missing_version_suffix_raises(self): lm = LookupModule() - lm._templar = DummyTemplar(available_variables={}, version="1.2.3") + lm._templar = DummyTemplar(available_variables={}) lm.set_options = lambda *args, **kwargs: None - with self.assertRaises(AnsibleError) as ctx: lm.run(["svc-foo"]) - self.assertIn("SOFTWARE_DOMAIN is not defined", str(ctx.exception)) + self.assertIn("SYS_SERVICE_VERSION_SUFFIX is not defined", str(ctx.exception)) - def test_empty_version_raises(self): - lm = self._mk_lookup(software_domain="infinito.nexus", version=" ") - with self.assertRaises(AnsibleError) as ctx: - lm.run(["svc-foo"]) - self.assertIn("lookup('version') returned an empty value", str(ctx.exception)) - - def test_templated_software_domain_is_rendered(self): - lm = LookupModule() - lm._templar = DummyTemplar( - available_variables={ - "SOFTWARE_DOMAIN": "{{ SOFTWARE_NAME | lower }}", - "SOFTWARE_NAME": "Infinito.Nexus", - }, - version="2.0.0", - ) - lm.set_options = lambda *args, **kwargs: None - - res = lm.run(["svc-foo"]) - self.assertEqual(res, ["svc-foo.2.0.0.infinito.nexus.service"]) - - def test_unresolved_software_domain_template_raises(self): - lm = LookupModule() - lm._templar = DummyTemplar( - available_variables={"SOFTWARE_DOMAIN": "{{ UNKNOWN_VAR | lower }}"}, - version="2.0.0", - ) - lm.set_options = lambda *args, **kwargs: None - - with self.assertRaises(AnsibleError) as ctx: - lm.run(["svc-foo"]) - self.assertIn("unresolved template for 'SOFTWARE_DOMAIN'", str(ctx.exception)) - - def test_version_fallback_direct_lookup_plugin(self): + def test_templated_version_suffix_is_rendered_and_lowered(self): + template = ".{{ lookup('version') | lower }}.{{ SOFTWARE_DOMAIN }}" lm = self._mk_lookup( - software_domain="infinito.nexus", version="{{ lookup('version') }}" + version_suffix=template, + rendered={template: ".2.0.0.Infinito.Nexus"}, ) - res = lm.run(["svc-foo"]) + self.assertEqual(lm.run(["svc-foo"]), ["svc-foo.2.0.0.infinito.nexus.service"]) - self.assertRegex( - res[0], - r"^svc-foo\.[0-9A-Za-z._+-]+\.infinito\.nexus\.service$", + def test_unresolved_version_suffix_template_raises(self): + lm = self._mk_lookup(version_suffix="{{ UNKNOWN_VAR }}") + with self.assertRaises(AnsibleError) as ctx: + lm.run(["svc-foo"]) + self.assertIn( + "unresolved template for 'SYS_SERVICE_VERSION_SUFFIX'", str(ctx.exception) ) + def test_malformed_version_suffix_raises(self): + for malformed in ("2.0.0.infinito.nexus", "..infinito.nexus"): + lm = self._mk_lookup(version_suffix=malformed) + with self.assertRaises(AnsibleError) as ctx: + lm.run(["svc-foo"]) + self.assertIn("must read '..'", str(ctx.exception)) + if __name__ == "__main__": unittest.main() From 7fff480122c2cce2ddeead9bf66b9b3c14ddadd1 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:35:53 +0200 Subject: [PATCH 04/13] perf(sys-dns-cloudflare-records): apply the records in parallel on the controller 5ecca32b60 moved the records to localhost and dropped async, because async on the target host paid one SSH round trip per poll. On the controller that cost is gone, so A/AAAA and CNAME/MX/TXT run as async jobs again and are collected by the shared utils/async/reap.yml, applied to localhost, with the same tolerance for 'An identical record already exists' and 81058. full-1 spent 40.7 s on 28 A/AAAA records one at a time. SRV stays synchronous and best effort. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../sys-dns-cloudflare-records/tasks/main.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/roles/sys-dns-cloudflare-records/tasks/main.yml b/roles/sys-dns-cloudflare-records/tasks/main.yml index 51f8328391..5cd6972afe 100644 --- a/roles/sys-dns-cloudflare-records/tasks/main.yml +++ b/roles/sys-dns-cloudflare-records/tasks/main.yml @@ -22,6 +22,9 @@ loop_control: { label: "{{ item.type }} {{ item.name }} -> {{ item.content }}" } delegate_to: localhost become: false + # rationale: async; the jobs run on the controller and are reaped there below, before SRV and the rest of the play + async: "{{ ASYNC_TIME }}" + poll: "{{ ASYNC_POLL }}" no_log: "{{ cloudflare_no_log | bool }}" register: _cf_a changed_when: false @@ -44,6 +47,9 @@ loop_control: { label: "{{ item.type }} {{ item.name }} -> {{ item.value }}" } delegate_to: localhost become: false + # rationale: async; the jobs run on the controller and are reaped there below, before SRV and the rest of the play + async: "{{ ASYNC_TIME }}" + poll: "{{ ASYNC_POLL }}" no_log: "{{ cloudflare_no_log | bool }}" register: _cf_cname changed_when: false @@ -51,6 +57,19 @@ - _cf_cname.failed - _cf_cname.msg is not search('An identical record already exists|81058') +- name: âŗ Reap the Cloudflare record jobs on the controller + ansible.builtin.include_tasks: + file: utils/async/reap.yml + apply: + delegate_to: localhost + become: false + vars: + async_reap_jobs: "{{ _cf_a.results + _cf_cname.results }}" + async_reap_label: "Cloudflare records" + async_reap_tolerate: + - "An identical record already exists" + - "81058" + - name: 📌 Apply SRV community.general.cloudflare_dns: api_token: "{{ lookup('api', 'cloudflare.api_token') }}" From 47160d755e248602d51085308e5b31795c8e0232 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:41:33 +0200 Subject: [PATCH 05/13] fix(tls): serve every vhost the certificate of its own domain ssl_credentials.j2 resolved the certificate by application_id, so every vhost of an application got the lineage of its primary domain. echoserver serves webmail.infinito.nexus the CN=academy.infinito.nexus certificate that names mail.infinito.nexus but not webmail, and browsers refuse it with a hostname mismatch. The template and the letsencrypt existence check now resolve by the vhost's domain, so a secondary vhost points at its own live/ link and a missing link triggers the issuance instead of a mismatch. Primary vhosts resolve to the same files as before in every TLS mode and flavor. The LDAP stream passes its primary domain explicitly. Onion domains stay on the application lineage, and the app-scoped self-signed path always names the application leaf, because neither has a per-domain certificate. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/lookup/cert.py | 24 +++++++---- roles/svc-db-openldap/tasks/00_core.yml | 2 + roles/sys-front-tls-letsencrypt/vars/main.yml | 6 +-- .../templates/ssl_credentials.j2 | 8 ++-- tests/unit/python/plugins/lookup/test_cert.py | 40 +++++++++++++++++++ 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/plugins/lookup/cert.py b/plugins/lookup/cert.py index 4d1fcc0568..9e46da3628 100644 --- a/plugins/lookup/cert.py +++ b/plugins/lookup/cert.py @@ -20,6 +20,7 @@ from utils.tls_common import ( as_str, collect_domains_for_app, is_domain_term, + is_onion_domain, norm_domain, override_san_list, redirect_targets, @@ -27,6 +28,7 @@ from utils.tls_common import ( resolve_enabled, resolve_le_name, resolve_mode, + resolve_primary_domain_from_app, resolve_term, uniq_preserve, want_get, @@ -147,12 +149,15 @@ class LookupModule(LookupBase): ) flavor = as_str(require(variables, "CERTBOT_FLAVOR", str)) - le_domain = ( - norm_domain(term) - if flavor in PER_DOMAIN_LIVE_FLAVORS - and is_domain_term(term, forced_mode, err_prefix="cert") - else primary_domain - ) + domain_term = is_domain_term(term, forced_mode, err_prefix="cert") + if domain_term and is_onion_domain(term): + le_domain = norm_domain( + resolve_primary_domain_from_app(domains, app_id, err_prefix="cert") + ) + elif domain_term and flavor in PER_DOMAIN_LIVE_FLAVORS: + le_domain = norm_domain(term) + else: + le_domain = primary_domain le_name = resolve_le_name(app, le_domain) cert_id = le_name @@ -201,8 +206,11 @@ class LookupModule(LookupBase): else: cert_id = app_id - cert_file = _join(ss_base, app_id, primary_domain, LE_FULLCHAIN) - key_file = _join(ss_base, app_id, primary_domain, LE_PRIVKEY) + app_primary = norm_domain( + resolve_primary_domain_from_app(domains, app_id, err_prefix="cert") + ) + cert_file = _join(ss_base, app_id, app_primary, LE_FULLCHAIN) + key_file = _join(ss_base, app_id, app_primary, LE_PRIVKEY) all_domains = collect_domains_for_app( domains, app_id, err_prefix="cert" diff --git a/roles/svc-db-openldap/tasks/00_core.yml b/roles/svc-db-openldap/tasks/00_core.yml index 2b2abe2b2d..dacc174613 100644 --- a/roles/svc-db-openldap/tasks/00_core.yml +++ b/roles/svc-db-openldap/tasks/00_core.yml @@ -15,6 +15,8 @@ stack_host_template: src: "nginx.stream.conf.j2" dest: "{{ lookup('nginx','directories.configuration.streams') }}{{ lookup('domain',application_id) }}.conf" + vars: + domain: "{{ lookup('domain', application_id) }}" notify: restart-openresty when: OPENLDAP_NETWORK_SWITCH_PUBLIC | bool diff --git a/roles/sys-front-tls-letsencrypt/vars/main.yml b/roles/sys-front-tls-letsencrypt/vars/main.yml index 4a546ba1d8..6be34d1797 100644 --- a/roles/sys-front-tls-letsencrypt/vars/main.yml +++ b/roles/sys-front-tls-letsencrypt/vars/main.yml @@ -1,6 +1,6 @@ --- _tls_enabled: "{{ lookup('tls', application_id, 'enabled') }}" _tls_mode: "{{ lookup('tls', application_id, 'mode') }}" -_tls_domain: "{{ lookup('tls', application_id, 'domains.primary') }}" -_cert_file: "{{ lookup('cert', application_id, 'files.cert') }}" -_cert_key: "{{ lookup('cert', application_id, 'files.key') }}" +_tls_domain: "{{ lookup('tls', domain, 'domains.primary') }}" +_cert_file: "{{ lookup('cert', domain, 'files.cert') }}" +_cert_key: "{{ lookup('cert', domain, 'files.key') }}" diff --git a/roles/sys-front-tls/templates/ssl_credentials.j2 b/roles/sys-front-tls/templates/ssl_credentials.j2 index 736866f279..bf85be7a8f 100644 --- a/roles/sys-front-tls/templates/ssl_credentials.j2 +++ b/roles/sys-front-tls/templates/ssl_credentials.j2 @@ -1,5 +1,5 @@ -ssl_certificate {{ lookup('cert', application_id, 'files.cert') }}; -ssl_certificate_key {{ lookup('cert', application_id, 'files.key') }}; -{% if lookup('cert', application_id, 'files.ca') | default('') | length > 0 %} -ssl_trusted_certificate {{ lookup('cert', application_id, 'files.ca') }}; +ssl_certificate {{ lookup('cert', domain, 'files.cert') }}; +ssl_certificate_key {{ lookup('cert', domain, 'files.key') }}; +{% if lookup('cert', domain, 'files.ca') | default('') | length > 0 %} +ssl_trusted_certificate {{ lookup('cert', domain, 'files.ca') }}; {% endif %} diff --git a/tests/unit/python/plugins/lookup/test_cert.py b/tests/unit/python/plugins/lookup/test_cert.py index cef3b905f4..50ffed3683 100644 --- a/tests/unit/python/plugins/lookup/test_cert.py +++ b/tests/unit/python/plugins/lookup/test_cert.py @@ -194,6 +194,46 @@ class TestCertPlanLookup(unittest.TestCase): "/etc/infinito.nexus/selfsigned/web-app-b/b.example/privkey.pem", ) + def test_primary_domain_term_matches_the_application_term(self): + cases = [ + ("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="san")), + ("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="dedicated")), + ("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="wildcard")), + ("web-app-b", "b.example", dict(self.vars)), + ("web-app-b", "b.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")), + ("web-app-c", "c.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")), + ] + for app_id, primary, v in cases: + by_app = self.lookup.run([app_id, "files"], variables=v)[0] + by_domain = self.lookup.run([primary, "files"], variables=v)[0] + self.assertEqual(by_domain, by_app, (app_id, primary)) + + def test_selfsigned_app_scope_secondary_domain_uses_the_app_leaf(self): + v = dict(self.vars, TLS_SELFSIGNED_SCOPE="app") + cases = [ + ("b-alt.example", "/etc/infinito.nexus/selfsigned/web-app-b/b.example"), + ("api.c.example", "/etc/infinito.nexus/selfsigned/web-app-c/c.example"), + ] + for term, leaf in cases: + out = self.lookup.run([term, "files"], variables=v)[0] + self.assertEqual(out["cert"], f"{leaf}/fullchain.pem") + self.assertEqual(out["key"], f"{leaf}/privkey.pem") + + def test_letsencrypt_secondary_domain_gets_its_own_lineage_link(self): + v = dict( + self.vars, + applications=dict(self.applications, **{"web-app-b": {}}), + ) + out = self.lookup.run(["b-alt.example", "files.cert"], variables=v)[0] + self.assertEqual(out, "/etc/letsencrypt/live/b-alt.example/fullchain.pem") + + def test_letsencrypt_onion_domain_keeps_the_app_lineage(self): + domains = dict(self.domains, **{"web-app-a": ["a.example", "a.node.onion"]}) + for flavor in ("san", "dedicated", "wildcard"): + v = dict(self.vars, CERTBOT_FLAVOR=flavor, domains=domains) + out = self.lookup.run(["a.node.onion", "files.cert"], variables=v)[0] + self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem") + def test_selfsigned_san_override(self): v = dict(self.vars) v["TLS_SELFSIGNED_SCOPE"] = "app" From 2da30ad2c79c193cb4905762c6c9e33d95a5fb1f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 23 Oct 2026 23:43:04 +0200 Subject: [PATCH 06/13] perf(sys-svc-cln-domains): remove the unserved nginx configs in one call The cleanup removed each unserved config with its own file task; matrix-2 deleted 80 that way. One rm -f -v -- over the filtered list replaces the loop, a debug task lists what it removed, and restart-openresty still fires only when something went. With nothing to remove both tasks skip. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tasks/nginx_configs.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/roles/sys-svc-cln-domains/tasks/nginx_configs.yml b/roles/sys-svc-cln-domains/tasks/nginx_configs.yml index f562a8d515..8f723f8996 100644 --- a/roles/sys-svc-cln-domains/tasks/nginx_configs.yml +++ b/roles/sys-svc-cln-domains/tasks/nginx_configs.yml @@ -17,10 +17,16 @@ register: cln_domains_server_configs - name: đŸ—‘ī¸ Remove the nginx configs of domains the host no longer serves - ansible.builtin.file: - path: "{{ unserved_config }}" - state: absent - loop: "{{ cln_domains_server_configs.files | map(attribute='path') | list | unserved_nginx_configs(CURRENT_PLAY_DOMAINS_ALL) }}" - loop_control: - loop_var: unserved_config + ansible.builtin.command: + argv: "{{ ['rm', '-f', '-v', '--'] + cln_domains_unserved_configs }}" + vars: + cln_domains_unserved_configs: "{{ cln_domains_server_configs.files | map(attribute='path') | list | unserved_nginx_configs(CURRENT_PLAY_DOMAINS_ALL) }}" + register: cln_domains_removed + changed_when: cln_domains_removed.stdout_lines | length > 0 + when: cln_domains_unserved_configs | length > 0 notify: restart-openresty + +- name: 📋 List the removed nginx configs + ansible.builtin.debug: + msg: "{{ cln_domains_removed.stdout_lines }}" + when: cln_domains_removed is changed From 57eb64e0af9a150d22d729db9c2108c1d8cb0953 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 00:40:32 +0200 Subject: [PATCH 07/13] fix(cache): render a templated DOMAIN_PRIMARY against the play variables get_merged_domains rendered DOMAIN_PRIMARY with no templar and an empty variable scope. A primary declared as a template, such as echoserver's "{{ SOFTWARE_NAME | lower }}", therefore came out as 'none' inside the applications SPOT, while direct lookups saw the domain Ansible had already resolved. Every SPOT value built through it named file.none, and the dashboard waited for https://file.none/assets/img/logo.png until full-2 failed. The render now gets the templar and the play variables, which also covers services.tor.node. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/python/utils/cache/test_domains.py | 34 +++++++++++++++++++ utils/cache/domains.py | 4 +-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/unit/python/utils/cache/test_domains.py b/tests/unit/python/utils/cache/test_domains.py index c884e16db2..e9f653b371 100644 --- a/tests/unit/python/utils/cache/test_domains.py +++ b/tests/unit/python/utils/cache/test_domains.py @@ -72,6 +72,40 @@ class TestMissingPrimaryDomain(unittest.TestCase): self.assertIsInstance(result, dict) +class TestTemplatedPrimaryDomain(unittest.TestCase): + def setUp(self) -> None: + _reset_cache_for_tests() + + def test_a_templated_domain_primary_resolves_against_the_play_variables(self): + captured: list[str] = [] + + def _capture(_self, _apps, primary_domain): + captured.append(primary_domain) + return {} + + with tempfile.TemporaryDirectory() as tmp: + roles = _seed_minimal_role(Path(tmp)) + with ( + patch( + "utils.cache.applications.get_merged_applications", + return_value={}, + ), + patch( + "plugins.filter.canonical_domains_map.FilterModule.canonical_domains_map", + _capture, + ), + ): + cache_domains.get_merged_domains( + variables={ + "DOMAIN_PRIMARY": "{{ SOFTWARE_NAME | lower }}", + "SOFTWARE_NAME": "Infinito.Nexus", + }, + roles_dir=roles, + templar=None, + ) + self.assertEqual(captured, ["infinito.nexus"]) + + class TestCachingPerVariablesSignature(unittest.TestCase): def setUp(self) -> None: _reset_cache_for_tests() diff --git a/utils/cache/domains.py b/utils/cache/domains.py index ee4bd569a4..fa976ffbc7 100644 --- a/utils/cache/domains.py +++ b/utils/cache/domains.py @@ -245,11 +245,11 @@ def get_merged_domains( def _render_raw(raw: Any, name: str) -> Any: if isinstance(raw, str) and ("{{" in raw or "{%" in raw): return render_ansible_strict( - templar=None, + templar=templar, raw=raw, var_name=name, err_prefix="get_merged_domains", - variables={}, + variables=variables, ) return raw From f0701fb7b597d055701a7d46f0d4e2be7a77d26b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 00:43:39 +0200 Subject: [PATCH 08/13] perf(sys-svc-webserver-core): stop chmodding every nginx and www file The directory task applied 0755 recursively, so every file under /etc/nginx and /var/www became executable on each deploy and every later 0644 template flipped it back. full-2 reported all ten vhost configs changed on a run straight after full-1, which restarted OpenResty and sent each vhost through reload and verify every time. The task now sets the mode on the listed directories only. Without the recursive repair, the two writers that relied on it set their own mode: web-svc-asset copies the assets as u=rwX,go=rX with 0755 directories, and sys-svc-cdn creates public_cdn/roles/ explicitly instead of leaving it to the umask. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-svc-cdn/vars/main.yml | 1 + roles/sys-svc-webserver-core/tasks/03_directories.yml | 1 - roles/web-svc-asset/tasks/00_core.yml | 2 ++ 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/roles/sys-svc-cdn/vars/main.yml b/roles/sys-svc-cdn/vars/main.yml index 17d6516f25..dd3feb39b5 100644 --- a/roles/sys-svc-cdn/vars/main.yml +++ b/roles/sys-svc-cdn/vars/main.yml @@ -10,6 +10,7 @@ CDN_GLOBAL_DIRS: - "{{ _cdn_shared.shared.img }}" - "{{ _cdn_shared.shared.fonts }}" - "{{ _cdn_shared.vendor }}" + - "{{ _cdn_shared.root }}/roles" # Role-specific CDN dirs for the current application CDN_ROLE_DIRS: "{{ lookup('cdn', application_id).role | cdn_dirs }}" diff --git a/roles/sys-svc-webserver-core/tasks/03_directories.yml b/roles/sys-svc-webserver-core/tasks/03_directories.yml index d2932f4604..33fdc2eb47 100644 --- a/roles/sys-svc-webserver-core/tasks/03_directories.yml +++ b/roles/sys-svc-webserver-core/tasks/03_directories.yml @@ -5,7 +5,6 @@ path: "{{ item.path }}" state: directory mode: "{{ item.mode }}" - recurse: true loop: "{{ lookup('nginx','directories.ensure') }}" loop_control: label: "{{ item.path }}" diff --git a/roles/web-svc-asset/tasks/00_core.yml b/roles/web-svc-asset/tasks/00_core.yml index 74ba458194..6b3f04e85d 100644 --- a/roles/web-svc-asset/tasks/00_core.yml +++ b/roles/web-svc-asset/tasks/00_core.yml @@ -5,6 +5,8 @@ ansible.builtin.copy: src: "{{ source_directory }}" dest: "{{ target_directory }}" + mode: "u=rwX,go=rX" + directory_mode: "0755" - name: "🔍 Redirect {{ lookup('domain', 'web-svc-asset') }} to web-svc-file" include_role: From 5ffe51162e5cb2c6f1e0ae2ca4acf1537c08b5ba Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 00:47:55 +0200 Subject: [PATCH 09/13] perf(sys-stk-front-proxy): stop retrying a vhost whose upstream is not up yet The front proxy probes each vhost right after writing it, often before the application's compose up. nginx then answers 502, and the probe retried twice with 2 s pauses, about 7 s per vhost; in full-1 that hit collabora, onlyoffice and hub. A 5xx proves nginx serves the vhost and no outcome depends on the retries, so both probes now accept 502, 503 and 504 through front_proxy_probe_status_codes. The redeploy decisions still judge against front_proxy_status_codes, so a stale config still gets its restart. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-stk-front-proxy/defaults/main.yml | 1 + roles/sys-stk-front-proxy/tasks/main.yml | 4 ++-- roles/sys-stk-front-proxy/vars/main.yml | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 roles/sys-stk-front-proxy/vars/main.yml diff --git a/roles/sys-stk-front-proxy/defaults/main.yml b/roles/sys-stk-front-proxy/defaults/main.yml index 34d6e60c50..cd9fc1c6a9 100644 --- a/roles/sys-stk-front-proxy/defaults/main.yml +++ b/roles/sys-stk-front-proxy/defaults/main.yml @@ -2,3 +2,4 @@ # default vhost flavour webserver_vhost_flavour: "basic" # valid: basic, ws_generic front_proxy_stale_config_status_codes: [-1, 404] +front_proxy_upstream_down_status_codes: [502, 503, 504] diff --git a/roles/sys-stk-front-proxy/tasks/main.yml b/roles/sys-stk-front-proxy/tasks/main.yml index 2ee080d001..581710e5da 100644 --- a/roles/sys-stk-front-proxy/tasks/main.yml +++ b/roles/sys-stk-front-proxy/tasks/main.yml @@ -112,7 +112,7 @@ - name: "🔍 Check if '{{ front_proxy_domain }}' is reachable" uri_probe: url: "{{ front_proxy_probe_url }}" - status_code: "{{ front_proxy_status_codes }}" + status_code: "{{ front_proxy_probe_status_codes }}" register: site_check failed_when: false changed_when: false @@ -132,7 +132,7 @@ - name: "🔍 Verify '{{ front_proxy_domain }}' is served after the reload" uri_probe: url: "{{ front_proxy_probe_url }}" - status_code: "{{ front_proxy_status_codes }}" + status_code: "{{ front_proxy_probe_status_codes }}" register: front_proxy_reload_check failed_when: false changed_when: false diff --git a/roles/sys-stk-front-proxy/vars/main.yml b/roles/sys-stk-front-proxy/vars/main.yml new file mode 100644 index 0000000000..7bb3eb782f --- /dev/null +++ b/roles/sys-stk-front-proxy/vars/main.yml @@ -0,0 +1,2 @@ +--- +front_proxy_probe_status_codes: "{{ front_proxy_status_codes + front_proxy_upstream_down_status_codes }}" From f83a2ee5e0915deb701f59bf0513c1d9d90e04e5 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 00:58:12 +0200 Subject: [PATCH 10/13] perf(sys-service): prune the old-version units with one call each The prune stopped, disabled and deleted every old unit through its own systemd or file call: 44 legacy units on echoserver cost 118.8 s in full-2. One systemctl disable --now covers stop and disable of all of them, and one rm -f removes their unit files, under the block the shared condition already describes. A container probe confirmed that a masked unit and a template unit in the same call are ignored without failing it, and the step stays best-effort as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-service/tasks/01_prune.yml | 66 ++++++++-------------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/roles/sys-service/tasks/01_prune.yml b/roles/sys-service/tasks/01_prune.yml index 9f43c167b2..b06bb4554a 100644 --- a/roles/sys-service/tasks/01_prune.yml +++ b/roles/sys-service/tasks/01_prune.yml @@ -49,52 +49,24 @@ | list }} -- name: "âšī¸ sys-service | Stop old-version services (best-effort)" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - state: stopped - loop: "{{ sys_service_old_units_services }}" - when: sys_service_old_units_services | length > 0 - failed_when: false - -- name: "âšī¸ sys-service | Stop old-version timers (best-effort)" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - state: stopped - loop: "{{ sys_service_old_units_timers }}" - when: sys_service_old_units_timers | length > 0 - failed_when: false - -- name: "🔴 sys-service | Disable old-version services (best-effort)" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - enabled: false - loop: "{{ sys_service_old_units_services }}" - when: sys_service_old_units_services | length > 0 - failed_when: false - -- name: "🔴 sys-service | Disable old-version timers (best-effort)" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - enabled: false - loop: "{{ sys_service_old_units_timers }}" - when: sys_service_old_units_timers | length > 0 - failed_when: false - -- name: "đŸ—‘ī¸ sys-service | Delete old-version unit files" - become: true - ansible.builtin.file: - path: "{{ [ DIR_SYSTEM_SERVICE, item ] | path_join }}" - state: absent - loop: "{{ (sys_service_old_units_services + sys_service_old_units_timers) | unique }}" +- name: "đŸ—‘ī¸ sys-service | Remove the old-version units" when: (sys_service_old_units_services | length > 0) or (sys_service_old_units_timers | length > 0) + block: + - name: "âšī¸ sys-service | Stop and disable the old-version units (best-effort)" + become: true + ansible.builtin.command: + argv: "{{ ['systemctl', 'disable', '--now'] + sys_service_old_units_timers + sys_service_old_units_services }}" + changed_when: true + failed_when: false -- name: "🔄 sys-service | systemd daemon-reload after pruning" - become: true - ansible.builtin.systemd: - daemon_reload: true - when: (sys_service_old_units_services | length > 0) or (sys_service_old_units_timers | length > 0) + - name: "đŸ—‘ī¸ sys-service | Delete old-version unit files" + become: true + ansible.builtin.command: + argv: "{{ ['rm', '-f', '--'] + ((sys_service_old_units_services + sys_service_old_units_timers) | unique | map('regex_replace', '^', DIR_SYSTEM_SERVICE ~ + '/') | list) }}" + changed_when: true + + - name: "🔄 sys-service | systemd daemon-reload after pruning" + become: true + ansible.builtin.systemd: + daemon_reload: true From 66a2785084318efd6b39d3b6ae2f8ec3f8a7dc95 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 01:00:18 +0200 Subject: [PATCH 11/13] fix(sys-ctl-alm-compose): name the loop variable of the subservice include The include of the alarm subservices looped with the default item inside a role reached from an outer item loop, and full-2 warned 'The loop variable item is already in use'. It now loops as dependency_role, the name the plain dependency includes use; the same line as fe2156b05e on feature/store-i18n. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-ctl-alm-compose/tasks/00_core.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/roles/sys-ctl-alm-compose/tasks/00_core.yml b/roles/sys-ctl-alm-compose/tasks/00_core.yml index 693bf77fdb..0a24e24648 100644 --- a/roles/sys-ctl-alm-compose/tasks/00_core.yml +++ b/roles/sys-ctl-alm-compose/tasks/00_core.yml @@ -3,8 +3,10 @@ - name: "📂 Include dependent services for '{{ system_service_id }}'" include_role: - name: "{{ item }}" + name: "{{ dependency_role }}" loop: "{{ SYSTEMCTL_ALARM_COMPOSER_SUBSERVICES }}" + loop_control: + loop_var: dependency_role vars: system_service_timer_enabled: false system_service_copy_files: true From a948784231a0dc9962315f205cb24c89cdb34718 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 01:02:32 +0200 Subject: [PATCH 12/13] perf(web-app-matomo): find the consumer Lua files in one call The re-injection stat'ed each web-svc matomo consumer's body_filter Lua on its own and re-rendered the nginx lookup per item: 7.7 s for three consumers in full-2. One find over the lua directory with the consumer ids as patterns returns the deployed ones, and the re-render loops over their ids. Both tasks share the token condition on one block, and the find is skipped when no consumer exists, since an empty pattern list would match every file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tasks/08_reinject_consumers.yml | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/roles/web-app-matomo/tasks/08_reinject_consumers.yml b/roles/web-app-matomo/tasks/08_reinject_consumers.yml index 0a83a510ea..80916f3450 100644 --- a/roles/web-app-matomo/tasks/08_reinject_consumers.yml +++ b/roles/web-app-matomo/tasks/08_reinject_consumers.yml @@ -1,28 +1,25 @@ --- -- name: "🔎 Matomo | Stat rendered body_filter Lua of candidate web-svc matomo consumers" - ansible.builtin.stat: - path: "{{ lookup('nginx', 'directories.configuration.lua') }}{{ matomo_inj_consumer.id }}.lua" - loop: "{{ lookup('roles_with_service', 'matomo', scope='all') | selectattr('id', 'match', '^web-svc-') | list }}" - loop_control: - loop_var: matomo_inj_consumer - label: "{{ matomo_inj_consumer.id }}" - register: matomo_inj_candidates - changed_when: false +- name: "🔁 Matomo | Re-inject the tracker into the deployed web-svc consumers" when: - matomo_token_value is defined - matomo_token_value | length > 0 - -- name: "📄 Matomo | Re-render injection for deployed web-svc consumers" - ansible.builtin.include_role: - name: sys-front-inj-all vars: - application_id: "{{ matomo_inj_dir.matomo_inj_consumer.id }}" - domain: "{{ lookup('domain', matomo_inj_dir.matomo_inj_consumer.id) }}" - matomo_token_override: "{{ matomo_token_value }}" - loop: "{{ matomo_inj_candidates.results | default([]) | selectattr('stat.exists', 'defined') | selectattr('stat.exists') | list }}" - loop_control: - loop_var: matomo_inj_dir - label: "{{ matomo_inj_dir.matomo_inj_consumer.id }}" - when: - - matomo_token_value is defined - - matomo_token_value | length > 0 + matomo_inj_consumer_ids: "{{ lookup('roles_with_service', 'matomo', scope='all') | selectattr('id', 'match', '^web-svc-') | map(attribute='id') | list }}" + block: + - name: "🔎 Matomo | Find the rendered body_filter Lua of the web-svc matomo consumers" + ansible.builtin.find: + paths: "{{ lookup('nginx', 'directories.configuration.lua') }}" + patterns: "{{ matomo_inj_consumer_ids | map('regex_replace', '$', '.lua') | list }}" + register: matomo_inj_lua + when: matomo_inj_consumer_ids | length > 0 + + - name: "📄 Matomo | Re-render injection for deployed web-svc consumers" + ansible.builtin.include_role: + name: sys-front-inj-all + vars: + application_id: "{{ matomo_inj_consumer_id }}" + domain: "{{ lookup('domain', matomo_inj_consumer_id) }}" + matomo_token_override: "{{ matomo_token_value }}" + loop: "{{ matomo_inj_lua.files | default([]) | map(attribute='path') | map('basename') | map('splitext') | map('first') | list }}" + loop_control: + loop_var: matomo_inj_consumer_id From 5fb1f801c575f3cf82ca812286b8cf5bc7a2e6c8 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 24 Oct 2026 01:03:20 +0200 Subject: [PATCH 13/13] fix(sys-svc-container): keep the gateway fact's name static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task name rendered gw.gateway, which a skipped gateway lookup never sets, so every deploy outside a container warned 'Encountered 1 template error â€Ļ has no attribute gateway'. The name no longer reads the register. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-svc-container/tasks/00_core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/sys-svc-container/tasks/00_core.yml b/roles/sys-svc-container/tasks/00_core.yml index 949f6f2999..8dc194eae2 100644 --- a/roles/sys-svc-container/tasks/00_core.yml +++ b/roles/sys-svc-container/tasks/00_core.yml @@ -78,7 +78,7 @@ register: gw changed_when: false - - name: "🔧 Set docker bridge gateway fact to '{{ gw.gateway }}'" + - name: "🔧 Set the docker bridge gateway fact" set_fact: DOCKER_REACH_HOST: "{{ gw.gateway }}" changed_when: false