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/plugins/lookup/cert.py b/plugins/lookup/cert.py index ce72c89dd2..ba91a52cbf 100644 --- a/plugins/lookup/cert.py +++ b/plugins/lookup/cert.py @@ -21,6 +21,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, @@ -28,6 +29,7 @@ from utils.tls_common import ( resolve_enabled, resolve_le_name, resolve_mode, + resolve_primary_domain_from_app, resolve_term, uniq_preserve, want_get, @@ -148,12 +150,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 @@ -202,8 +207,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/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/svc-db-openldap/tasks/00_core.yml b/roles/svc-db-openldap/tasks/00_core.yml index 4b8006ff5d..1bfa259093 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-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') }}" 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/roles/sys-service-terminator/tasks/02_unlock.yml b/roles/sys-service-terminator/tasks/02_unlock.yml index 60262d0382..f4390cc866 100644 --- a/roles/sys-service-terminator/tasks/02_unlock.yml +++ b/roles/sys-service-terminator/tasks/02_unlock.yml @@ -12,30 +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 | Activate the collected {{ SOFTWARE_DOMAIN }} timers" +- name: "🚀 DEPLOY | Enable and start the {{ SOFTWARE_DOMAIN }} timers of this version" + become: true + ansible.builtin.command: + argv: "{{ ['systemctl', 'enable', '--now'] + deploy_timers_unlock }}" + changed_when: true when: deploy_timers_unlock | length > 0 - block: - - name: "🚀 DEPLOY | Unmask all {{ SOFTWARE_DOMAIN }} timers" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - masked: false - loop: "{{ deploy_timers_unlock }}" - - - name: "🚀 DEPLOY | Enable and start all {{ SOFTWARE_DOMAIN }} timers" - become: true - ansible.builtin.systemd: - name: "{{ item }}" - enabled: true - state: started - loop: "{{ deploy_timers_unlock }}" diff --git a/roles/sys-service/tasks/01_prune.yml b/roles/sys-service/tasks/01_prune.yml index 30607864d7..b06bb4554a 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,56 +44,27 @@ (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 }} -- 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 | 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 | 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 }}" + 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 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/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 }}" 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-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 diff --git a/roles/sys-svc-container/tasks/00_core.yml b/roles/sys-svc-container/tasks/00_core.yml index 03c678ee2b..0bf93dafab 100644 --- a/roles/sys-svc-container/tasks/00_core.yml +++ b/roles/sys-svc-container/tasks/00_core.yml @@ -101,7 +101,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 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/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/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: diff --git a/tests/unit/python/plugins/lookup/test_cert.py b/tests/unit/python/plugins/lookup/test_cert.py index 647ad63983..1e9dd89bd4 100644 --- a/tests/unit/python/plugins/lookup/test_cert.py +++ b/tests/unit/python/plugins/lookup/test_cert.py @@ -201,6 +201,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" 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() diff --git a/tests/unit/python/utils/cache/test_domains.py b/tests/unit/python/utils/cache/test_domains.py index 2d8a98e9eb..cc550bb5a8 100644 --- a/tests/unit/python/utils/cache/test_domains.py +++ b/tests/unit/python/utils/cache/test_domains.py @@ -75,6 +75,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 fc48e97bfd..e4e0e95350 100644 --- a/utils/cache/domains.py +++ b/utils/cache/domains.py @@ -92,16 +92,18 @@ def _resolved_flag( return _as_bool(value) -def _render_plain(raw: Any, name: str) -> Any: +def _render_plain( + raw: Any, name: str, templar: Any = None, variables: Any = None +) -> Any: from utils.templating.ansible import render_ansible_strict 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 or {}, ) return raw @@ -283,7 +285,9 @@ def get_merged_domains( "must be set in variables." ) - primary_domain = _render_plain(primary_domain, "DOMAIN_PRIMARY") + primary_domain = _render_plain( + primary_domain, "DOMAIN_PRIMARY", templar=templar, variables=variables + ) apps = get_merged_applications( variables=variables,