diff --git a/compose.yml b/compose.yml index e902b35427..acb0393246 100644 --- a/compose.yml +++ b/compose.yml @@ -24,7 +24,6 @@ services: build: context: . dockerfile: Dockerfile - network: host args: INFINITO_PARENT_IMAGE: ${INFINITO_PARENT_IMAGE:?Run 'make dotenv' to generate the .env single source of truth} INFINITO_SRC_DIR: ${INFINITO_SRC_DIR:?Run 'make dotenv' to generate the .env single source of truth} diff --git a/plugins/lookup/engine.py b/plugins/lookup/engine.py index 22113aaaff..68b239af78 100644 --- a/plugins/lookup/engine.py +++ b/plugins/lookup/engine.py @@ -2,6 +2,7 @@ {{ lookup('engine', 'redis', application_id) }} # full dict {{ lookup('engine', 'redis', application_id, 'url') }} # one field + {{ lookup('engine', 'redis', application_id, 'session_path') }} # phpredis session.save_path Mirrors the ``database`` lookup but for the non-RDBMS engines registered in :data:`utils.roles.applications.services.engines.ENGINES` (redis, memcached, @@ -16,6 +17,7 @@ See ``docs/architecture/central-engines.md``. from __future__ import annotations from typing import Any +from urllib.parse import quote from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader @@ -44,9 +46,31 @@ _FIELDS = ( "prefix", "url", "container", + "session_path", ) +def redis_session_path( + host: str, port: int, prefix: str, username: str, password: str +) -> str: + """Return the phpredis ``session.save_path`` of one consumer. + + Args: + host: redis host the consumer reaches. + port: redis port. + prefix: consumer prefix; session keys become ``:session:``. + username: ACL user, sent only together with a password. + password: redis password; empty for an embedded sidecar. + """ + path = f"tcp://{host}:{port}?prefix={quote(prefix, safe='')}:session:" + if password: + path += ( + f"&auth[user]={quote(username, safe='')}" + f"&auth[pass]={quote(password, safe='')}" + ) + return path + + def qdrant_consumer_key(applications: dict[str, Any], svc_id: str) -> str: """Return the engine-wide key a data-owning qdrant consumer presents. @@ -194,6 +218,17 @@ class LookupModule(LookupBase): "prefix": prefix, "url": url, "container": container, + "session_path": ( + redis_session_path( + host, + port, + prefix, + username if shared else "", + password if shared else "", + ) + if engine == "redis" + else "" + ), } if want == "all": return [resolved] diff --git a/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 b/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 index e3046cb739..13335d6e6c 100644 --- a/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 +++ b/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 @@ -173,11 +173,9 @@ matrix_bot_baibot_config_initial_global_config_handler_catch_all: "static/{{ MAT {% endif %} matrix_synapse_usage_exporter_enabled: {{ MATRIX_ADDON_SYNAPSE_USAGE_EXPORTER_ENABLED | string | lower }} -{% if MATRIX_ADDON_SYNAPSE_USAGE_EXPORTER_ENABLED %} matrix_synapse_metrics_enabled: true matrix_synapse_container_metrics_api_host_bind_port: "0.0.0.0:{{ MATRIX_SYNAPSE_METRICS_PORT }}" matrix_synapse_metrics_port: {{ MATRIX_SYNAPSE_METRICS_PORT }} -{% endif %} jitsi_enabled: {{ MATRIX_JITSI_SELF_HOSTED | string | lower }} {% if MATRIX_ADDON_JITSI_ENABLED and not MATRIX_JITSI_SELF_HOSTED | bool %} diff --git a/roles/web-app-matrix/vars/main.yml b/roles/web-app-matrix/vars/main.yml index 31db1210d0..4c9ca11a22 100644 --- a/roles/web-app-matrix/vars/main.yml +++ b/roles/web-app-matrix/vars/main.yml @@ -90,6 +90,7 @@ MATRIX_BRIDGE: "{{ lookup('config', application_id, 'services.matrix.plugins') | '^', 'mautrix_') | zip(MATRIX_BRIDGE_ADDONS | map(attribute='value.enabled')))) }}" MATRIX_CONFERENCING_ELEMENT_CALL: "{{ lookup('config', application_id, 'services.matrix.conferencing.element_call') | bool }}" +MATRIX_SYNAPSE_METRICS_PORT: "{{ lookup('config', application_id, 'services.prometheus.native_metrics.port') }}" MATRIX_ADDON_BAIBOT_ENABLED: "{{ lookup('config', application_id, 'addons.baibot.enabled') | bool }}" MATRIX_ADDON_BAIBOT_LOCALPART: "{{ lookup('config', application_id, 'addons.baibot.config.user_localpart') }}" diff --git a/roles/web-app-prometheus/files/playwright/playwright.spec.js b/roles/web-app-prometheus/files/playwright/playwright.spec.js index 54689eca2a..1069bb48ce 100644 --- a/roles/web-app-prometheus/files/playwright/playwright.spec.js +++ b/roles/web-app-prometheus/files/playwright/playwright.spec.js @@ -216,7 +216,7 @@ const prometheusTargetRoles = decodeDotenvJsonList( "PROMETHEUS_TARGET_ROLES_JSON" ); -test("prometheus scrape: every consumer role reports up=1", async ({ page }) => { +test("prometheus scrape: every consumer role reports up=1 and passes its probe", async ({ page }) => { test.skip(prometheusTargetRoles.length === 0, "no prometheus consumer roles in inventory"); const expectedPrometheusBaseUrl = prometheusBaseUrl.replace(/\/$/, ""); @@ -275,6 +275,26 @@ test("prometheus scrape: every consumer role reports up=1", async ({ page }) => failures, `prometheus scrape failures:\n - ${failures.join("\n - ")}` ).toEqual([]); + + const probeUrl = `${expectedPrometheusBaseUrl}/api/v1/query?query=${encodeURIComponent('probe_success{job="blackbox-healthz"}')}`; + await expect + .poll( + async () => { + const response = await page.request.get(probeUrl, { ignoreHTTPSErrors: true }); + const probes = response.ok() ? (await response.json())?.data?.result || [] : []; + return prometheusTargetRoles + .filter((target) => { + const own = probes.filter((entry) => entry?.metric?.app === target.id); + return own.length === 0 || own.some((entry) => entry?.value?.[1] !== "1"); + }) + .map((target) => target.id); + }, + { + timeout: resolveTimeout(120_000), + message: "roles whose /healthz/ready probe is missing or failing (probe_success != 1)", + }, + ) + .toEqual([]); }); // Persona scenarios. diff --git a/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 b/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 index b8c78938dc..48147594e0 100644 --- a/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 +++ b/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 @@ -24,7 +24,7 @@ modules: method: GET follow_redirects: false fail_if_ssl: false - fail_if_not_ssl: {{ lookup('tls', application_id, 'enabled') | bool | lower }} + fail_if_not_ssl: false tls_config: - insecure_skip_verify: {{ not (lookup('tls', application_id, 'enabled') | bool) }} + insecure_skip_verify: {{ ((TLS_MODE | lower) == 'self_signed' and not lookup('ca_injected', application_id)) | lower }} preferred_ip_protocol: "ip4" diff --git a/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 b/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 index 35cd730a20..86ea8e71aa 100644 --- a/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 +++ b/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 @@ -41,20 +41,21 @@ scrape_configs: {% endfor %} -{% set canonical_map = lookup('applications') | canonical_domains_map(DOMAIN_PRIMARY) %} +{% set canonical_map = lookup('domains') %} +{% set probed_roles = lookup('roles_with_service', 'prometheus') | map(attribute='id') | list %} - job_name: "blackbox-healthz" metrics_path: /probe params: module: [http_ready] static_configs: {% for _application_id in CURRENT_PLAY_APPLICATIONS | list | sort %} -{% if _application_id != application_id %} +{% if _application_id in probed_roles %} {% set domains_raw = canonical_map.get(_application_id, []) %} {% set canonical_probe_domains = (domains_raw.values() | list) if (domains_raw is mapping) else domains_raw %} {% if canonical_probe_domains | length > 0 %} - targets: {% for domain in canonical_probe_domains %} - - "{{ lookup('tls', application_id, 'protocols.web') }}://{{ domain }}/healthz/ready" + - "{{ lookup('tls', domain, 'url.base') }}healthz/ready" {% endfor %} labels: app: "{{ _application_id }}" diff --git a/roles/web-app-seaweedfs/meta/services.yml b/roles/web-app-seaweedfs/meta/services.yml index 199bfffaa4..cbb4e41918 100644 --- a/roles/web-app-seaweedfs/meta/services.yml +++ b/roles/web-app-seaweedfs/meta/services.yml @@ -22,18 +22,24 @@ sso: ldap: enabled: false shared: false +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. logout: bond: 1 - enabled: "{{ 'web-svc-logout' in group_names }}" - shared: "{{ 'web-svc-logout' in group_names }}" + enabled: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. dashboard: bond: 1 - enabled: "{{ 'web-app-dashboard' in group_names }}" - shared: "{{ 'web-app-dashboard' in group_names }}" + enabled: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. prometheus: bond: 1 - enabled: "{{ 'web-app-prometheus' in group_names }}" - shared: "{{ 'web-app-prometheus' in group_names }}" + enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" # nocheck: playwright-service-flag — design consumer, no Playwright surface (provider: web-svc-design) design: bond: 1 diff --git a/roles/web-app-seaweedfs/meta/variants.yml b/roles/web-app-seaweedfs/meta/variants.yml index 65402965ea..241a0252a3 100644 --- a/roles/web-app-seaweedfs/meta/variants.yml +++ b/roles/web-app-seaweedfs/meta/variants.yml @@ -7,14 +7,14 @@ enabled: true shared: true logout: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" dashboard: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" prometheus: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" design: enabled: true shared: true diff --git a/roles/web-app-suitecrm/files/Dockerfile b/roles/web-app-suitecrm/files/Dockerfile index 681672774b..08c8f95e71 100644 --- a/roles/web-app-suitecrm/files/Dockerfile +++ b/roles/web-app-suitecrm/files/Dockerfile @@ -5,6 +5,7 @@ FROM ${SUITECRM_BASE_IMAGE}:${SUITECRM_BASE_VERSION} AS base ARG SUITECRM_APP_DIR ARG SUITECRM_PHP_MEMORY_LIMIT +ARG SUITECRM_PHPREDIS_VERSION RUN rm -rf /var/lib/apt/lists/* \ && apt-get -o Acquire::Retries=3 update && apt-get -o Acquire::Retries=3 install -y \ @@ -32,6 +33,10 @@ RUN rm -rf /var/lib/apt/lists/* \ ldap \ && rm -rf /var/lib/apt/lists/* +RUN : "${SUITECRM_PHPREDIS_VERSION:?}" \ + && pecl install "redis-${SUITECRM_PHPREDIS_VERSION}" \ + && docker-php-ext-enable redis + RUN : "${SUITECRM_PHP_MEMORY_LIMIT:?}" && { \ echo 'file_uploads = On'; \ echo 'upload_max_filesize = 32M'; \ diff --git a/roles/web-app-suitecrm/meta/services.yml b/roles/web-app-suitecrm/meta/services.yml index a30f61cacf..c8c28cd350 100644 --- a/roles/web-app-suitecrm/meta/services.yml +++ b/roles/web-app-suitecrm/meta/services.yml @@ -29,6 +29,16 @@ mariadb: bond: 1 enabled: true # nocheck: dynamic-flag shared: "{{ 'svc-db-mariadb' in group_names }}" +redis: + bond: 1 + backup: + disabled: true + enabled: true # nocheck: dynamic-flag + shared: "{{ 'svc-db-redis' in group_names }}" + cpus: "0.25" + mem_reservation: 128m + mem_limit: 256m + pids_limit: 256 # nocheck: playwright-service-flag — self-provider entry; test_env_services_match.py:28-32 grants the forbidden-self-gate exemption suitecrm: healthcheck: @@ -49,12 +59,16 @@ suitecrm: version: 8.2-apache custom: true app_version: 8.10.2 + phpredis_version: 6.3.0 update: - key: app_version - type: git_tags - repository: https://github.com/salesagility/SuiteCRM-Core.git - match: "v" - strip: true + - key: app_version + type: git_tags + repository: https://github.com/salesagility/SuiteCRM-Core.git + match: "v" + strip: true + - key: phpredis_version + type: git_tags + repository: https://github.com/phpredis/phpredis.git name: suitecrm min_storage: 376MB cpus: 1.5 diff --git a/roles/web-app-suitecrm/meta/variants.yml b/roles/web-app-suitecrm/meta/variants.yml index 919b80916b..95f8ca06ae 100644 --- a/roles/web-app-suitecrm/meta/variants.yml +++ b/roles/web-app-suitecrm/meta/variants.yml @@ -24,6 +24,8 @@ shared: true mariadb: shared: true + redis: + shared: true matomo: enabled: true shared: true @@ -56,6 +58,8 @@ shared: false # nocheck: variants-static-override backup shared stays literally true mariadb: shared: false # nocheck: variants-static-override backup shared stays literally true + redis: + shared: false matomo: enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true diff --git a/roles/web-app-suitecrm/templates/compose.yml.j2 b/roles/web-app-suitecrm/templates/compose.yml.j2 index 62057023e5..a4a9f433e8 100644 --- a/roles/web-app-suitecrm/templates/compose.yml.j2 +++ b/roles/web-app-suitecrm/templates/compose.yml.j2 @@ -6,6 +6,7 @@ SUITECRM_BASE_IMAGE: "{{ SUITECRM_BASE_IMAGE }}" SUITECRM_BASE_VERSION: "{{ SUITECRM_BASE_VERSION }}" SUITECRM_APP_VERSION: "{{ SUITECRM_APP_VERSION }}" + SUITECRM_PHPREDIS_VERSION: "{{ SUITECRM_PHPREDIS_VERSION }}" SUITECRM_ENTRYPOINT_SCRIPT_HOST_REL: "{{ SUITECRM_ENTRYPOINT_SCRIPT_HOST_REL }}" SUITECRM_ENTRYPOINT_SCRIPT_DOCKER: "{{ SUITECRM_ENTRYPOINT_SCRIPT_DOCKER }}" SUITECRM_LDAP_EXTENSION_DIR: "{{ SUITECRM_LDAP_EXTENSION_DIR }}" diff --git a/roles/web-app-suitecrm/templates/env.j2 b/roles/web-app-suitecrm/templates/env.j2 index b0b2e61f6b..1bcd038098 100644 --- a/roles/web-app-suitecrm/templates/env.j2 +++ b/roles/web-app-suitecrm/templates/env.j2 @@ -21,6 +21,8 @@ SUITECRM_ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email }} SUITECRM_URL={{ SUITECRM_URL }} +SUITECRM_SESSION_SAVE_PATH={{ lookup('engine', 'redis', application_id, 'session_path') }} + {% if SUITECRM_SSO_ENABLED | bool %} AUTH_TYPE=saml diff --git a/roles/web-app-suitecrm/templates/php.ini.j2 b/roles/web-app-suitecrm/templates/php.ini.j2 index e818dbc83e..1ff9b031e4 100644 --- a/roles/web-app-suitecrm/templates/php.ini.j2 +++ b/roles/web-app-suitecrm/templates/php.ini.j2 @@ -3,5 +3,7 @@ display_startup_errors = Off log_errors = On error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED -session.save_handler = files -session.save_path = "{{ SUITECRM_APP_DIR }}/tmp" +session.save_handler = redis +session.save_path = ${SUITECRM_SESSION_SAVE_PATH} +redis.session.locking_enabled = 1 +redis.session.lock_retries = -1 diff --git a/roles/web-app-suitecrm/vars/main.yml b/roles/web-app-suitecrm/vars/main.yml index c4eebd66a3..1a3aadc303 100644 --- a/roles/web-app-suitecrm/vars/main.yml +++ b/roles/web-app-suitecrm/vars/main.yml @@ -14,6 +14,7 @@ SUITECRM_PHP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories SUITECRM_BASE_IMAGE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.image') }}" SUITECRM_BASE_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.version') }}" SUITECRM_APP_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.app_version') }}" +SUITECRM_PHPREDIS_VERSION: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.phpredis_version') }}" SUITECRM_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.name') }}" SUITECRM_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}" diff --git a/tests/lint/repository/test_compose_build_needs_no_host_network.py b/tests/lint/repository/test_compose_build_needs_no_host_network.py new file mode 100644 index 0000000000..bb951b001a --- /dev/null +++ b/tests/lint/repository/test_compose_build_needs_no_host_network.py @@ -0,0 +1,60 @@ +"""Compose hands a build to ``docker buildx bake`` and grants it only the +``fs.read`` entitlement of the build context. Since Buildx 0.38.0 a block with +``network: host`` stops there with ``additional privileges requested``, and +declaring ``build.entitlements`` or setting ``COMPOSE_BAKE=false`` does not +change that, so such a service cannot be built through ``make compose-up``. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from utils.cache.files import iter_project_files +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +STACK_FILE = str(PROJECT_ROOT / "compose.yml") +OVERRIDES = f"{PROJECT_ROOT / 'compose'}/" +STACK_BUILD = "compose.yml: infinito" + + +def _builds(path): + """Yield ``(label, build)`` for every service of a compose file that builds. + + Args: + path: compose file to read; a YAML file without ``services`` yields nothing. + """ + document = load_yaml_any(path) + if not isinstance(document, dict): + return + for name, service in (document.get("services") or {}).items(): + build = service.get("build") + if isinstance(build, dict): + yield f"{Path(path).relative_to(PROJECT_ROOT)}: {name}", build + + +class TestComposeBuildNeedsNoHostNetwork(unittest.TestCase): + def test_no_build_block_asks_for_host_networking(self) -> None: + compose_files = [ + path + for path in sorted(iter_project_files(extensions=(".yml",))) + if path == STACK_FILE or path.startswith(OVERRIDES) + ] + builds = dict(hit for path in compose_files for hit in _builds(path)) + self.assertIn(STACK_BUILD, builds, "the stack build block was not scanned") + offenders = [ + label for label, build in builds.items() if build.get("network") == "host" + ] + self.assertEqual( + [], + offenders, + "Compose cannot grant bake the network.host entitlement " + "(Buildx >= 0.38.0 refuses the build); drop 'network: host' from: " + f"{offenders}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_engine_redis_session_path.py b/tests/unit/python/plugins/lookup/test_engine_redis_session_path.py new file mode 100644 index 0000000000..0b8dba098e --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_engine_redis_session_path.py @@ -0,0 +1,49 @@ +"""phpredis reads ``auth[user]``/``auth[pass]`` and ``prefix`` from the query of +``session.save_path``; a password with a reserved character that is not +percent-encoded splits the query and the handler authenticates with a fragment. +""" + +from __future__ import annotations + +import importlib +import unittest +from urllib.parse import parse_qs, urlsplit + +plugin_module = importlib.import_module("plugins.lookup.engine") +redis_session_path = plugin_module.redis_session_path + + +class TestRedisSessionPath(unittest.TestCase): + def test_an_embedded_sidecar_gets_no_credentials(self) -> None: + self.assertEqual( + "tcp://redis:6379?prefix=suitecrm:session:", + redis_session_path("redis", 6379, "suitecrm", "", ""), + ) + + def test_a_shared_redis_gets_user_and_password(self) -> None: + query = parse_qs( + urlsplit( + redis_session_path("redis-central", 6379, "suitecrm", "default", "pw") + ).query + ) + self.assertEqual(["default"], query["auth[user]"]) + self.assertEqual(["pw"], query["auth[pass]"]) + + def test_a_password_with_reserved_characters_survives_the_query(self) -> None: + password = "a&b=c?d#e/f+g h" + query = parse_qs( + urlsplit( + redis_session_path("redis-central", 6379, "suitecrm", "default", password) + ).query + ) + self.assertEqual([password], query["auth[pass]"]) + self.assertEqual(["suitecrm:session:"], query["prefix"]) + + def test_a_user_without_a_password_is_not_sent(self) -> None: + self.assertNotIn( + "auth", redis_session_path("redis", 6379, "suitecrm", "default", "") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-prometheus/__init__.py b/tests/unit/python/roles/web-app-prometheus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py b/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py new file mode 100644 index 0000000000..c49856cbc6 --- /dev/null +++ b/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from utils.cache.files import PROJECT_ROOT +from utils.cache.yaml import load_yaml_str + +TEMPLATE_DIR = PROJECT_ROOT / "roles/web-app-prometheus/templates/configuration" + + +def ready_module(*, tls_mode: str, prometheus_tls: bool, ca_injected: bool) -> dict: + def lookup(name, *terms): + if name == "tls" and terms[1] == "enabled": + return prometheus_tls + if name == "ca_injected": + return ca_injected + raise AssertionError(f"the template calls an unexpected lookup: {name!r}") + + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters["bool"] = bool + rendered = env.get_template("blackbox.yml.j2").render( + lookup=lookup, + application_id="web-app-prometheus", + TLS_MODE=tls_mode, + ) + return load_yaml_str(rendered)["modules"]["http_ready"]["http"] + + +class TestBlackboxReadyModule(unittest.TestCase): + def test_an_http_target_passes_while_prometheus_itself_uses_tls(self): + module = ready_module( + tls_mode="letsencrypt", prometheus_tls=True, ca_injected=False + ) + self.assertIs(module["fail_if_not_ssl"], False) + + def test_certificates_are_verified_while_prometheus_itself_has_no_tls(self): + module = ready_module( + tls_mode="letsencrypt", prometheus_tls=False, ca_injected=False + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], False) + + def test_certificates_are_verified_once_the_private_ca_is_mounted(self): + module = ready_module( + tls_mode="self_signed", prometheus_tls=True, ca_injected=True + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], False) + + def test_verification_is_skipped_only_without_the_private_ca(self): + module = ready_module( + tls_mode="self_signed", prometheus_tls=False, ca_injected=False + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py b/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py new file mode 100644 index 0000000000..abc914988d --- /dev/null +++ b/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from utils.cache.files import PROJECT_ROOT +from utils.cache.yaml import load_yaml_str + +TEMPLATE_DIR = PROJECT_ROOT / "roles/web-app-prometheus/templates/configuration" +ONION = "a" * 56 + ".onion" + +DOMAINS = { + "web-app-prometheus": [f"prometheus.{ONION}"], + "web-app-nextcloud": {"nextcloud": f"next.cloud.{ONION}"}, + "web-app-gitea": ["git.infinito.test"], + "web-svc-cdn": ["cdn.infinito.test"], + "svc-db-postgres": ["postgres.infinito.test"], +} +ROLES_WITH_THE_PROMETHEUS_SERVICE = ["web-app-nextcloud", "web-app-gitea"] + + +def lookup(name, *terms, **_kwargs): + if name == "domains": + return DOMAINS + if name == "roles_with_service": + return [{"id": role} for role in ROLES_WITH_THE_PROMETHEUS_SERVICE] + if name == "tls": + term, want = terms + scheme = "http" if term.endswith(".onion") else "https" + return { + "url.base": f"{scheme}://{term}/", + "protocols.web": "http", + "enabled": False, + }[want] + if name == "domain": + return DOMAINS[terms[0]][0] + if name == "scrape_target": + return f"{terms[1]}:9000" + if name == "native_metrics_apps": + return [] + raise AssertionError(f"the template calls an unexpected lookup: {name!r}") + + +def probed_targets() -> dict[str, list[str]]: + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters["bool"] = bool + rendered = env.get_template("prometheus.yml.j2").render( + lookup=lookup, + application_id="web-app-prometheus", + CURRENT_PLAY_APPLICATIONS=list(DOMAINS), + DOMAIN_PRIMARY="infinito.test", + ALERT_RULES_CONFIG_DOCKER="/etc/prometheus/alert_rules.yml", + ) + jobs = {job["job_name"]: job for job in load_yaml_str(rendered)["scrape_configs"]} + return { + group["labels"]["app"]: group["targets"] + for group in jobs["blackbox-healthz"]["static_configs"] + } + + +class TestBlackboxTargets(unittest.TestCase): + def test_an_onion_app_is_probed_under_its_onion_name_over_http(self): + self.assertEqual( + probed_targets()["web-app-nextcloud"], + [f"http://next.cloud.{ONION}/healthz/ready"], + ) + + def test_a_clearnet_app_is_probed_under_its_own_scheme(self): + self.assertEqual( + probed_targets()["web-app-gitea"], + ["https://git.infinito.test/healthz/ready"], + ) + + def test_only_the_roles_the_service_lookup_names_are_probed(self): + self.assertEqual(set(probed_targets()), set(ROLES_WITH_THE_PROMETHEUS_SERVICE)) + + +if __name__ == "__main__": + unittest.main()