From edd650f9d71e83af125456f5fa6e6a8bfe4437fc Mon Sep 17 00:00:00 2001 From: Marta Vicente Navarro Date: Thu, 13 Aug 2026 14:11:41 +0200 Subject: [PATCH 1/5] Add Config Discovery support for Vault Adds a `discovery:` strategy block to Vault's spec.yaml targeting port 8200, with candidates covering the legacy/OpenMetrics x token/no-token dispatch matrix in priority order (rich OpenMetrics, safe OpenMetrics, rich legacy, safe legacy). Wires `get_e2e_discovery_metadata()` into the E2E docker environment and adds unit and E2E coverage for the generated candidates. Known gap: `test_e2e_discovery`'s `no_token` candidate never authenticates against Vault, so 24 metrics that only appear once a request passes through Vault's authenticated logical-request pipeline (audit logging, ACL/token checks, policy lookups, lease issuance) are excluded from that test's assertions. These metrics remain fully covered by the existing `test_e2e` suite. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 --- vault/assets/configuration/spec.yaml | 21 ++++++ .../vault/config_models/discovery.py | 67 +++++++++++++++++++ .../config_models/discovery_overrides.py | 12 ++++ .../config_models/discovery_strategies.py | 18 +++++ .../datadog_checks/vault/data/auto_conf.yaml | 19 ++++++ vault/pyproject.toml | 2 +- vault/tests/conftest.py | 12 +++- vault/tests/test_discovery.py | 45 +++++++++++++ vault/tests/test_e2e.py | 59 +++++++++++++++- vault/tests/utils.py | 4 +- 10 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 vault/datadog_checks/vault/config_models/discovery.py create mode 100644 vault/datadog_checks/vault/config_models/discovery_overrides.py create mode 100644 vault/datadog_checks/vault/config_models/discovery_strategies.py create mode 100644 vault/datadog_checks/vault/data/auto_conf.yaml create mode 100644 vault/tests/test_discovery.py diff --git a/vault/assets/configuration/spec.yaml b/vault/assets/configuration/spec.yaml index 7f16135bf700d..57f334483932a 100644 --- a/vault/assets/configuration/spec.yaml +++ b/vault/assets/configuration/spec.yaml @@ -2,6 +2,20 @@ name: Vault fleet_configurable: true files: - name: vault.yaml + discovery: + strategies: + - strategy: from_ports + port_hints: + - 8200 + candidates: + - use_openmetrics: "true" + api_url: "http://{service.host}:{port.number}/v1" + no_token: "true" + - use_openmetrics: "true" + api_url: "http://{service.host}:{port.number}/v1" + - api_url: "http://{service.host}:{port.number}/v1" + no_token: "true" + - api_url: "http://{service.host}:{port.number}/v1" options: - template: init_config options: @@ -86,3 +100,10 @@ files: path: /var/log/vault.log source: vault service: +- name: auto_conf.yaml + options: + - template: ad_identifiers + overrides: + value.example: + - vault + - template: auto_conf/discovery diff --git a/vault/datadog_checks/vault/config_models/discovery.py b/vault/datadog_checks/vault/config_models/discovery.py new file mode 100644 index 0000000000000..ac725baffcde6 --- /dev/null +++ b/vault/datadog_checks/vault/config_models/discovery.py @@ -0,0 +1,67 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# This file is autogenerated. +# To change this file you should edit assets/configuration/spec.yaml and then run the following commands: +# ddev -x validate config -s +# ddev -x validate models -s + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from datadog_checks.base.utils.discovery import Service, candidate_ports +from datadog_checks.vault.config_models import discovery_overrides +from datadog_checks.vault.config_models.instance import InstanceConfig +from datadog_checks.vault.config_models.shared import SharedConfig + + +def _generated_candidates(service: Service) -> Iterator[dict[str, Any]]: + shared = SharedConfig.model_validate({}, context={'configured_fields': frozenset()}).model_dump( + by_alias=True, mode='json', exclude_none=True + ) + # discovery[0]: from_ports + for port in candidate_ports(service, [8200]): + ctx = {'port': port} + instance_data = { + 'use_openmetrics': 'true', + 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), + 'no_token': 'true', + } + instance = InstanceConfig.model_validate( + instance_data, context={'configured_fields': frozenset(instance_data)} + ).model_dump(by_alias=True, mode='json', exclude_none=True) + yield {'init_config': shared, 'instances': [instance]} + instance_data = { + 'use_openmetrics': 'true', + 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), + } + instance = InstanceConfig.model_validate( + instance_data, context={'configured_fields': frozenset(instance_data)} + ).model_dump(by_alias=True, mode='json', exclude_none=True) + yield {'init_config': shared, 'instances': [instance]} + instance_data = { + 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), + 'no_token': 'true', + } + instance = InstanceConfig.model_validate( + instance_data, context={'configured_fields': frozenset(instance_data)} + ).model_dump(by_alias=True, mode='json', exclude_none=True) + yield {'init_config': shared, 'instances': [instance]} + instance_data = { + 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), + } + instance = InstanceConfig.model_validate( + instance_data, context={'configured_fields': frozenset(instance_data)} + ).model_dump(by_alias=True, mode='json', exclude_none=True) + yield {'init_config': shared, 'instances': [instance]} + + +def candidates(service: Service) -> Iterator[dict[str, Any]]: + override = getattr(discovery_overrides, 'candidates', None) + if override is None: + yield from _generated_candidates(service) + else: + yield from override(service, default=_generated_candidates) diff --git a/vault/datadog_checks/vault/config_models/discovery_overrides.py b/vault/datadog_checks/vault/config_models/discovery_overrides.py new file mode 100644 index 0000000000000..66af68809dd4c --- /dev/null +++ b/vault/datadog_checks/vault/config_models/discovery_overrides.py @@ -0,0 +1,12 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# Override the generated discovery candidates() for this integration. +# +# Define a candidates(service, default) function to wrap or replace the generated +# candidate generation. `default` is the generated generator; call it to reuse +# the spec-driven candidates, or ignore it to replace them entirely. +# +# def candidates(service, default): +# yield from default(service) diff --git a/vault/datadog_checks/vault/config_models/discovery_strategies.py b/vault/datadog_checks/vault/config_models/discovery_strategies.py new file mode 100644 index 0000000000000..5ac036ddb4684 --- /dev/null +++ b/vault/datadog_checks/vault/config_models/discovery_strategies.py @@ -0,0 +1,18 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) + +# Here you can define custom (local:) discovery strategies for this integration. +# +# Decorate a generator with @discovery_strategy (imported from +# datadog_checks.base.utils.discovery) and reference it from the spec discovery +# stanza as `strategy: local:`. The function receives the +# discovered Service plus the inputs declared in the spec and yields one context +# (ctx) mapping per candidate, exposing the keys listed in `provides`. +# +# from datadog_checks.base.utils.discovery import discovery_strategy +# +# @discovery_strategy(provides=('svc',)) +# def from_some_config(service, config_path): +# ... +# yield {'svc': ...} diff --git a/vault/datadog_checks/vault/data/auto_conf.yaml b/vault/datadog_checks/vault/data/auto_conf.yaml new file mode 100644 index 0000000000000..760f83cede32d --- /dev/null +++ b/vault/datadog_checks/vault/data/auto_conf.yaml @@ -0,0 +1,19 @@ +## @param ad_identifiers - list of strings - required +## A list of container identifiers that are used by Autodiscovery to identify +## which container the check should be run against. For more information, see: +## https://docs.datadoghq.com/agent/guide/ad_identifiers/ +# +ad_identifiers: + - vault + +## Enables configuration discovery +# +discovery: {} + +## Unused init configuration +# +init_config: + +## Unused instance configuration +# +instances: [] diff --git a/vault/pyproject.toml b/vault/pyproject.toml index 26b89bcc8d473..ec26feae8427f 100644 --- a/vault/pyproject.toml +++ b/vault/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Private :: Do Not Upload", ] dependencies = [ - "datadog-checks-base>=37.33.0", + "datadog-checks-base>=37.41.0", ] dynamic = [ "version", diff --git a/vault/tests/conftest.py b/vault/tests/conftest.py index c43a33e8d2cca..51d9049a72e19 100644 --- a/vault/tests/conftest.py +++ b/vault/tests/conftest.py @@ -8,7 +8,7 @@ import pytest import requests -from datadog_checks.dev import LazyFunction, TempDir, docker_run, run_command +from datadog_checks.dev import LazyFunction, TempDir, docker_run, get_e2e_discovery_metadata, run_command from datadog_checks.dev.ci import running_on_ci from datadog_checks.dev.conditions import WaitFor from datadog_checks.dev.fs import create_file @@ -97,7 +97,15 @@ def dd_environment(e2e_instance, dd_save_state): ): dd_save_state('client_token_path', token_file) - yield e2e_instance(), {'docker_volumes': ['{}:/home/vault-sink'.format(sink_dir)]} + yield ( + e2e_instance(), + { + 'docker_volumes': [ + '{}:/home/vault-sink'.format(sink_dir), + *get_e2e_discovery_metadata()['docker_volumes'], + ] + }, + ) class ApplyPermissions(LazyFunction): diff --git a/vault/tests/test_discovery.py b/vault/tests/test_discovery.py new file mode 100644 index 0000000000000..b63abbfcb951a --- /dev/null +++ b/vault/tests/test_discovery.py @@ -0,0 +1,45 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import pytest + +from datadog_checks.base.utils.discovery import Port, Service +from datadog_checks.vault import Vault + +pytestmark = [pytest.mark.unit] + + +def generated_instances(service: Service) -> list[dict]: + return [config['instances'][0] for config in Vault.generate_configs(service)] + + +def test_generates_one_candidate_per_mode_and_token_strategy() -> None: + # Order matters: discovery accepts the first candidate whose real check run collects a + # metric, so the richest (full OpenMetrics scrape) candidates must be tried before the + # safe fallbacks that only ever hit the always-unauthenticated leader/health endpoints. + service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),)) + + instances = generated_instances(service) + + assert [(instance.get('use_openmetrics'), instance.get('no_token')) for instance in instances] == [ + (True, True), + (True, False), + (False, True), + (False, False), + ] + + +def test_all_candidates_target_the_same_api_url() -> None: + service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),)) + + instances = generated_instances(service) + + assert all(instance['api_url'] == 'http://127.0.0.1:8200/v1' for instance in instances) + + +def test_ipv6_host_is_bracketed_in_generated_api_url() -> None: + service = Service(id='vault', host='fd00::1', ports=(Port(number=8200),)) + + instances = generated_instances(service) + + assert all(instance['api_url'] == 'http://[fd00::1]:8200/v1' for instance in instances) diff --git a/vault/tests/test_e2e.py b/vault/tests/test_e2e.py index 7808699caf17f..e66541e6cc7fd 100644 --- a/vault/tests/test_e2e.py +++ b/vault/tests/test_e2e.py @@ -4,7 +4,10 @@ import pytest -from .common import auth_required +from datadog_checks.dev.docker import assert_all_discovery_candidates_stable +from datadog_checks.vault import Vault + +from .common import auth_required, noauth_required from .utils import assert_collection @@ -18,3 +21,57 @@ def test_e2e(dd_agent_check, e2e_instance, global_tags, use_openmetrics, use_aut aggregator = dd_agent_check(instance, rate=True) assert_collection(aggregator, global_tags, use_openmetrics, runs=2) + + +@noauth_required +@pytest.mark.e2e +def test_e2e_discovery(dd_agent_check_discovery): + aggregator = dd_agent_check_discovery(rate=True) + + # `assert_collection` needs at least one tag every metric carries to drive its per-metric + # coverage loop. Discovery resolves `api_url` from the container at runtime, so read back + # the tag the check itself attached instead of hardcoding a value. + api_url_tag = next(tag for tag in aggregator.metrics('vault.is_leader')[0].tags if tag.startswith('api_url:')) + assert_collection( + aggregator, + [api_url_tag], + use_openmetrics=True, + runs=2, + # These metrics only appear once a request has gone through Vault's authenticated + # logical-request pipeline (ACL/token checks, audit logging, policy lookups, lease + # issuance). The main `test_e2e` suite gets that incidentally, from auth-related tests + # that run earlier in the same long-lived container; this test's `no_token` discovery + # candidate only ever hits unauthenticated status endpoints (health/leader/metrics), + # which bypass that pipeline entirely, so these metrics can't be guaranteed present here. + exclude=( + 'vault.vault.audit.log.request.count', + 'vault.vault.audit.log.request.quantile', + 'vault.vault.audit.log.request.sum', + 'vault.vault.audit.log.request.failure.count', + 'vault.vault.audit.log.response.count', + 'vault.vault.audit.log.response.quantile', + 'vault.vault.audit.log.response.sum', + 'vault.vault.audit.log.response.failure.count', + 'vault.vault.core.check.token.count', + 'vault.vault.core.check.token.quantile', + 'vault.vault.core.check.token.sum', + 'vault.vault.core.fetch.acl_and_token.count', + 'vault.vault.core.fetch.acl_and_token.quantile', + 'vault.vault.core.fetch.acl_and_token.sum', + 'vault.vault.core.handle.request.count', + 'vault.vault.core.handle.request.quantile', + 'vault.vault.core.handle.request.sum', + 'vault.vault.expire.num_leases', + 'vault.vault.policy.get_policy.count', + 'vault.vault.policy.get_policy.quantile', + 'vault.vault.policy.get_policy.sum', + 'vault.vault.token.lookup.count', + 'vault.vault.token.lookup.quantile', + 'vault.vault.token.lookup.sum', + ), + ) + + +@pytest.mark.e2e +def test_e2e_discovery_all_candidates(dd_agent_check): + assert_all_discovery_candidates_stable(dd_agent_check, Vault, compose_service='vault-leader') diff --git a/vault/tests/utils.py b/vault/tests/utils.py index f9cf2c22b4f40..49f4da7c0c7f7 100644 --- a/vault/tests/utils.py +++ b/vault/tests/utils.py @@ -22,7 +22,7 @@ def assert_all_metrics(aggregator): aggregator.assert_no_duplicate_metrics() -def assert_collection(aggregator, tags, use_openmetrics, runs=1): +def assert_collection(aggregator, tags, use_openmetrics, runs=1, exclude=()): metrics = set(METRICS) metrics.update(METRICS_OPTIONAL) metrics.add('is_leader') @@ -73,6 +73,8 @@ def assert_collection(aggregator, tags, use_openmetrics, runs=1): if metric.startswith(tuple(METRICS_OPTIONAL)): at_least = 0 metric = 'vault.{}'.format(metric) + if metric in exclude: + at_least = 0 for tag in tags: try: From 4ef4aae0b70d19598f3e5bf73d9310bb75bef765 Mon Sep 17 00:00:00 2001 From: Marta Vicente Navarro Date: Thu, 13 Aug 2026 14:13:08 +0200 Subject: [PATCH 2/5] Add changelog entry for vault discovery support Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 --- vault/changelog.d/24851.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 vault/changelog.d/24851.added diff --git a/vault/changelog.d/24851.added b/vault/changelog.d/24851.added new file mode 100644 index 0000000000000..c38e59dbbfdbc --- /dev/null +++ b/vault/changelog.d/24851.added @@ -0,0 +1 @@ +Add Config Discovery support. From 0f58c221cfe653d6bc11e5683c16f54fc7f0c1a1 Mon Sep 17 00:00:00 2001 From: Marta Vicente Navarro Date: Thu, 13 Aug 2026 14:26:45 +0200 Subject: [PATCH 3/5] Try both metric-scraping candidates before either health-only fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault's check only skips its metrics scrape when neither a token nor `no_token` is configured (see `VaultCheckV2.metric_collection_enabled` and the equivalent legacy-check gating), so the two `no_token: false` candidates never attempt to collect metrics at all — they trivially "succeed" on the always-unauthenticated `is_leader`/health output alone. With the previous ordering (openmetrics+no_token, openmetrics, legacy+no_token, legacy), a failed openmetrics scrape on the first candidate would let the second, health-only candidate win immediately, permanently starving out the third candidate even though it could still collect the full metric set. Reorder so both `no_token: true` candidates are tried, in either mode, before either health-only fallback. Flagged by Codex review on PR #24851. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 --- vault/assets/configuration/spec.yaml | 4 ++-- vault/datadog_checks/vault/config_models/discovery.py | 4 ++-- vault/tests/test_discovery.py | 10 +++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/vault/assets/configuration/spec.yaml b/vault/assets/configuration/spec.yaml index 57f334483932a..294334daee3ab 100644 --- a/vault/assets/configuration/spec.yaml +++ b/vault/assets/configuration/spec.yaml @@ -11,10 +11,10 @@ files: - use_openmetrics: "true" api_url: "http://{service.host}:{port.number}/v1" no_token: "true" - - use_openmetrics: "true" - api_url: "http://{service.host}:{port.number}/v1" - api_url: "http://{service.host}:{port.number}/v1" no_token: "true" + - use_openmetrics: "true" + api_url: "http://{service.host}:{port.number}/v1" - api_url: "http://{service.host}:{port.number}/v1" options: - template: init_config diff --git a/vault/datadog_checks/vault/config_models/discovery.py b/vault/datadog_checks/vault/config_models/discovery.py index ac725baffcde6..d822d36593788 100644 --- a/vault/datadog_checks/vault/config_models/discovery.py +++ b/vault/datadog_checks/vault/config_models/discovery.py @@ -35,16 +35,16 @@ def _generated_candidates(service: Service) -> Iterator[dict[str, Any]]: ).model_dump(by_alias=True, mode='json', exclude_none=True) yield {'init_config': shared, 'instances': [instance]} instance_data = { - 'use_openmetrics': 'true', 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), + 'no_token': 'true', } instance = InstanceConfig.model_validate( instance_data, context={'configured_fields': frozenset(instance_data)} ).model_dump(by_alias=True, mode='json', exclude_none=True) yield {'init_config': shared, 'instances': [instance]} instance_data = { + 'use_openmetrics': 'true', 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), - 'no_token': 'true', } instance = InstanceConfig.model_validate( instance_data, context={'configured_fields': frozenset(instance_data)} diff --git a/vault/tests/test_discovery.py b/vault/tests/test_discovery.py index b63abbfcb951a..c85a5ae3c1355 100644 --- a/vault/tests/test_discovery.py +++ b/vault/tests/test_discovery.py @@ -15,16 +15,20 @@ def generated_instances(service: Service) -> list[dict]: def test_generates_one_candidate_per_mode_and_token_strategy() -> None: # Order matters: discovery accepts the first candidate whose real check run collects a - # metric, so the richest (full OpenMetrics scrape) candidates must be tried before the - # safe fallbacks that only ever hit the always-unauthenticated leader/health endpoints. + # metric. Both `no_token=True` candidates actually attempt a metrics scrape (the check only + # skips scraping when neither a token nor `no_token` is configured, see + # `VaultCheckV2.metric_collection_enabled`), so they must be tried, in either mode, before + # either `no_token=False` candidate — those never scrape at all and would trivially "succeed" + # on the always-unauthenticated leader/health metrics alone, permanently starving out a + # `no_token` candidate that could have collected the full metric set. service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),)) instances = generated_instances(service) assert [(instance.get('use_openmetrics'), instance.get('no_token')) for instance in instances] == [ (True, True), - (True, False), (False, True), + (True, False), (False, False), ] From c3377fa99748162977672b1492e6862afaafaaf5 Mon Sep 17 00:00:00 2001 From: Marta Vicente Navarro Date: Thu, 13 Aug 2026 14:46:10 +0200 Subject: [PATCH 4/5] Bump datadog-checks-base floor to 38.0.0 for vault The discovery templates rely on datadog-checks-base bracketing IPv6-literal hosts when interpolating `{service.host}` into a candidate URL. That fix landed in datadog-checks-base 38.0.0 (37.41.0 predates it), so the minimum-base-package CI job fails test_ipv6_host_is_bracketed_in_generated_api_url with the previously declared 37.41.0 floor. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 --- vault/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vault/pyproject.toml b/vault/pyproject.toml index ec26feae8427f..c5fd5d9cbaca6 100644 --- a/vault/pyproject.toml +++ b/vault/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Private :: Do Not Upload", ] dependencies = [ - "datadog-checks-base>=37.41.0", + "datadog-checks-base>=38.0.0", ] dynamic = [ "version", From 68a3d5b9039a474bbfb874fd9b243a2df9ac0d90 Mon Sep 17 00:00:00 2001 From: Marta Vicente Navarro Date: Thu, 13 Aug 2026 16:36:28 +0200 Subject: [PATCH 5/5] Remove credential-less discovery candidates for vault The two "with token" candidates omitted `no_token` without configuring `client_token`/`client_token_path`. `VaultCheckV2.metric_collection_enabled` (and the equivalent legacy-check gating) skips the metrics scrape entirely in that case, so those candidates could only ever emit the always-unauthenticated leader/health metrics. Discovery accepts the first candidate whose check run collects at least one metric with no error, so a health-only candidate would trivially "succeed" and get locked in permanently -- a degraded config masquerading as working, with no core metrics. Discovery has no way to synthesize a token on its own, so `no_token: true` is the only signal it can produce that guarantees a real metrics scrape. Remove the two credential-less candidates, keeping only the OpenMetrics and legacy `no_token: true` candidates. Updates tests/test_discovery.py for the two-candidate expectation and adds an explicit assertion that every generated candidate enables metric collection (`no_token` true, since no credential is ever synthesized). Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 --- vault/assets/configuration/spec.yaml | 3 -- .../vault/config_models/discovery.py | 15 --------- vault/tests/test_discovery.py | 33 +++++++++++++------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/vault/assets/configuration/spec.yaml b/vault/assets/configuration/spec.yaml index 294334daee3ab..4e335c10fcf33 100644 --- a/vault/assets/configuration/spec.yaml +++ b/vault/assets/configuration/spec.yaml @@ -13,9 +13,6 @@ files: no_token: "true" - api_url: "http://{service.host}:{port.number}/v1" no_token: "true" - - use_openmetrics: "true" - api_url: "http://{service.host}:{port.number}/v1" - - api_url: "http://{service.host}:{port.number}/v1" options: - template: init_config options: diff --git a/vault/datadog_checks/vault/config_models/discovery.py b/vault/datadog_checks/vault/config_models/discovery.py index d822d36593788..df473260ea521 100644 --- a/vault/datadog_checks/vault/config_models/discovery.py +++ b/vault/datadog_checks/vault/config_models/discovery.py @@ -42,21 +42,6 @@ def _generated_candidates(service: Service) -> Iterator[dict[str, Any]]: instance_data, context={'configured_fields': frozenset(instance_data)} ).model_dump(by_alias=True, mode='json', exclude_none=True) yield {'init_config': shared, 'instances': [instance]} - instance_data = { - 'use_openmetrics': 'true', - 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), - } - instance = InstanceConfig.model_validate( - instance_data, context={'configured_fields': frozenset(instance_data)} - ).model_dump(by_alias=True, mode='json', exclude_none=True) - yield {'init_config': shared, 'instances': [instance]} - instance_data = { - 'api_url': 'http://{service.host}:{port.number}/v1'.format(service=service, **ctx), - } - instance = InstanceConfig.model_validate( - instance_data, context={'configured_fields': frozenset(instance_data)} - ).model_dump(by_alias=True, mode='json', exclude_none=True) - yield {'init_config': shared, 'instances': [instance]} def candidates(service: Service) -> Iterator[dict[str, Any]]: diff --git a/vault/tests/test_discovery.py b/vault/tests/test_discovery.py index c85a5ae3c1355..16e30ee8c6629 100644 --- a/vault/tests/test_discovery.py +++ b/vault/tests/test_discovery.py @@ -13,14 +13,15 @@ def generated_instances(service: Service) -> list[dict]: return [config['instances'][0] for config in Vault.generate_configs(service)] -def test_generates_one_candidate_per_mode_and_token_strategy() -> None: - # Order matters: discovery accepts the first candidate whose real check run collects a - # metric. Both `no_token=True` candidates actually attempt a metrics scrape (the check only - # skips scraping when neither a token nor `no_token` is configured, see - # `VaultCheckV2.metric_collection_enabled`), so they must be tried, in either mode, before - # either `no_token=False` candidate — those never scrape at all and would trivially "succeed" - # on the always-unauthenticated leader/health metrics alone, permanently starving out a - # `no_token` candidate that could have collected the full metric set. +def test_generates_one_candidate_per_mode() -> None: + # Only `no_token=True` candidates are generated. A candidate without `no_token` and without a + # configured `client_token`/`client_token_path` never attempts a metrics scrape at all (see + # `VaultCheckV2.metric_collection_enabled` and the equivalent legacy-check gating), so it would + # only ever emit the always-unauthenticated leader/health metrics and never the real metric + # set. Discovery accepts the first candidate whose check run collects at least one metric with + # no error, so such a health-only candidate would trivially "succeed" and get locked in + # permanently — a degraded config masquerading as a working one. We never synthesize a token, + # so the only way to guarantee a real metrics scrape is `no_token=True`. service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),)) instances = generated_instances(service) @@ -28,11 +29,23 @@ def test_generates_one_candidate_per_mode_and_token_strategy() -> None: assert [(instance.get('use_openmetrics'), instance.get('no_token')) for instance in instances] == [ (True, True), (False, True), - (True, False), - (False, False), ] +def test_all_candidates_enable_metric_collection() -> None: + # Every generated candidate must be able to reach Vault's real metrics scrape, not just the + # always-unauthenticated leader/health endpoints. `no_token=True` is the only signal discovery + # can produce on its own; a `client_token`/`client_token_path` can only come from the user. + service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),)) + + instances = generated_instances(service) + + assert all( + instance.get('no_token') is True or instance.get('client_token') or instance.get('client_token_path') + for instance in instances + ) + + def test_all_candidates_target_the_same_api_url() -> None: service = Service(id='vault', host='127.0.0.1', ports=(Port(number=8200),))