diff --git a/vault/assets/configuration/spec.yaml b/vault/assets/configuration/spec.yaml index 7f16135bf700d..4e335c10fcf33 100644 --- a/vault/assets/configuration/spec.yaml +++ b/vault/assets/configuration/spec.yaml @@ -2,6 +2,17 @@ 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" + - api_url: "http://{service.host}:{port.number}/v1" + no_token: "true" options: - template: init_config options: @@ -86,3 +97,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/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. 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..df473260ea521 --- /dev/null +++ b/vault/datadog_checks/vault/config_models/discovery.py @@ -0,0 +1,52 @@ +# (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 = { + '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]} + + +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..c5fd5d9cbaca6 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>=38.0.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..16e30ee8c6629 --- /dev/null +++ b/vault/tests/test_discovery.py @@ -0,0 +1,62 @@ +# (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() -> 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) + + assert [(instance.get('use_openmetrics'), instance.get('no_token')) for instance in instances] == [ + (True, True), + (False, True), + ] + + +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),)) + + 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: