Skip to content

Commit 2fd779b

Browse files
TsadoqJenkins
authored andcommitted
rest api: Add the internal translate_metric_names action
A check plug-in emits perf-data under names of its own choosing, and the graphing layer renames them into the metric namespace that graph templates and RRD lookups are keyed by - "wait" becomes "io_wait". Livestatus reports the raw names, the metric endpoint only answers to the canonical ones, and no REST client can get from one to the other. Add POST graph/actions/translate_metric_names/invoke, returning the whole raw to canonical mapping of one host and service. A mapping rather than a single name, because a caller that has to report an unknown name needs the list of names that do exist, and one round-trip then serves both. The action is registered under APIVersion.INTERNAL only, in the graph family that is already declared "Checkmk Internal": this resolves an identifier the GUI itself only shows once "Show internal IDs" is switched on, so it is not a stability commitment. The handler runs no livestatus query of its own; it calls the graphing engine's fetcher, so the column list and the site scoping stay in one place. An unmonitored host or service yields an empty mapping and a 200, not a 404: the caller is the one that knows how to phrase that. A host and service resolving on several sites has their names folded into one mapping, which is safe because the translation follows from the check command, not the site. A site the caller may not see is not in that category - it is a wrong scope, not an answer - and is refused before the query rather than silently scoping it to nothing. The check is the new SiteIdConverter.should_be_authorized, which reads the sites the livestatus connection is built from, not the whole site configuration as should_exist does, so a site that exists but is not authorized and a site that does not exist give the same error and the unauthorized ones stay unenumerable. Jira: CMK-37696 Change-Id: Id21a5eec88632d57637b57f5b36d107286560eb5
1 parent 9908420 commit 2fd779b

7 files changed

Lines changed: 284 additions & 0 deletions

File tree

cmk/gui/graphing/openapi/_registration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .fetch_graph_data import ENDPOINT_FETCH_GRAPH_DATA
1515
from .get_graph_pin import ENDPOINT_GET_GRAPH_PIN
1616
from .set_graph_pin import ENDPOINT_SET_GRAPH_PIN
17+
from .translate_metric_names import ENDPOINT_TRANSLATE_METRIC_NAMES
1718

1819

1920
def register(
@@ -29,3 +30,4 @@ def register(
2930
versioned_endpoint_registry.register(ENDPOINT_ADD_TO_CONTAINER)
3031
versioned_endpoint_registry.register(ENDPOINT_ADD_TO_VISUAL)
3132
versioned_endpoint_registry.register(ENDPOINT_EXPORT)
33+
versioned_endpoint_registry.register(ENDPOINT_TRANSLATE_METRIC_NAMES)

cmk/gui/graphing/openapi/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,19 @@ def from_discovered(cls, discovered: DiscoveredGraphs) -> Self:
186186
)
187187

188188

189+
@api_model
190+
class MetricNameMappingResponse:
191+
metric_names: dict[str, str] = api_field(
192+
description=(
193+
"The canonical metric name each of the service's raw perf-data names is known by, "
194+
"keyed by the raw name. A name no plug-in renames maps to itself, so every name the "
195+
"service reports has an entry. Empty when the host or service is not monitored, or "
196+
"when it reports no perf data at all."
197+
),
198+
example={"wait": "io_wait", "user": "user"},
199+
)
200+
201+
189202
@api_model
190203
class GraphFetchRequest:
191204
internal: Annotated[Mapping[str, object], Json] = api_field(
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
# conditions defined in the file COPYING, which is part of this source code package.
5+
6+
from typing import Annotated
7+
8+
from cmk.ccc.site import SiteId
9+
from cmk.graphing_engine import HostName, ServiceName
10+
from cmk.gui.config import active_config
11+
from cmk.gui.openapi.framework import (
12+
APIVersion,
13+
EndpointDoc,
14+
EndpointHandler,
15+
EndpointMetadata,
16+
EndpointPermissions,
17+
VersionedEndpoint,
18+
)
19+
from cmk.gui.openapi.framework.model import api_field, api_model
20+
from cmk.gui.openapi.framework.model.common_fields import AnnotatedHostName
21+
from cmk.gui.openapi.framework.model.converter import SiteIdConverter, TypedPlainValidator
22+
from cmk.gui.openapi.restful_objects.constructors import domain_type_action_href
23+
from cmk.gui.openapi.utils import ProblemException
24+
from cmk.gui.utils import permission_verification as permissions
25+
from cmk.livestatus_client import MKLivestatusException
26+
27+
from .._engine_plugins import registered_translations
28+
from .._engine_source import RRDFetchMetricNameMapping
29+
from ._family import GRAPH_FAMILY
30+
from .models import MetricNameMappingResponse
31+
32+
33+
@api_model
34+
class TranslateMetricNamesRequest:
35+
hostname: AnnotatedHostName = api_field(description="The host name.", example="my-host")
36+
service_description: str = api_field(
37+
description="The service description.", example="CPU utilization"
38+
)
39+
site: (
40+
Annotated[SiteId, TypedPlainValidator(str, SiteIdConverter.should_be_authorized)] | None
41+
) = api_field(
42+
description=(
43+
"Resolve the service on this site only. None searches every site the user may see, "
44+
"and folds the names of a host/service monitored on several of them into one mapping."
45+
),
46+
example="mysite",
47+
default=None,
48+
)
49+
50+
51+
def translate_metric_names_v1(body: TranslateMetricNamesRequest) -> MetricNameMappingResponse:
52+
"""Map the raw perf-data names of a service to their canonical metric names"""
53+
try:
54+
per_service = RRDFetchMetricNameMapping(
55+
host_name=HostName(body.hostname),
56+
service_name=ServiceName(body.service_description),
57+
debug=active_config.debug,
58+
site_id=body.site,
59+
registered_translations=registered_translations(),
60+
)()
61+
except MKLivestatusException as exc:
62+
raise ProblemException(
63+
status=503,
64+
title="Monitoring data source unavailable",
65+
detail=str(exc),
66+
) from exc
67+
except Exception as exc:
68+
raise ProblemException(
69+
status=500,
70+
title="Metric name translation failed",
71+
detail=f"Failed to translate the metric names: {exc}",
72+
) from exc
73+
74+
return MetricNameMappingResponse(
75+
metric_names={
76+
str(raw_name): str(canonical_name)
77+
for mapping in per_service.values()
78+
for raw_name, canonical_name in mapping.items()
79+
}
80+
)
81+
82+
83+
ENDPOINT_TRANSLATE_METRIC_NAMES = VersionedEndpoint(
84+
metadata=EndpointMetadata(
85+
path=domain_type_action_href("graph", "translate_metric_names"),
86+
link_relation="cmk/translate_metric_names",
87+
method="post",
88+
),
89+
# The endpoint itself needs no permissions. Opening a livestatus connection checks
90+
# these three (_set_livestatus_auth in cmk/gui/sites.py) to work out which objects the
91+
# user may see, and whatever gets checked has to be declared here.
92+
permissions=EndpointPermissions(
93+
required=permissions.Optional(
94+
permissions.AllPerm(
95+
[
96+
permissions.Perm("general.see_all"),
97+
permissions.OkayToIgnorePerm("bi.see_all"),
98+
permissions.OkayToIgnorePerm("mkeventd.seeall"),
99+
]
100+
)
101+
)
102+
),
103+
doc=EndpointDoc(family=GRAPH_FAMILY.name),
104+
versions={APIVersion.INTERNAL: EndpointHandler(handler=translate_metric_names_v1)},
105+
)

cmk/gui/openapi/framework/model/converter.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,20 @@ def should_be_configurable(value: str) -> SiteId:
415415
return site_id
416416
raise ValueError(f"Site {site_id!r} is not configurable.")
417417

418+
@staticmethod
419+
def should_be_authorized(value: str) -> SiteId:
420+
"""Validates that the given site ID exists and the user is authorized to see it.
421+
422+
Unlike should_exist, this checks the user's authorized sites, and a site outside them
423+
raises the same error as a site that does not exist at all. Use it in monitoring
424+
endpoints, where should_exist would let a restricted user enumerate the sites they may
425+
not see (cf. werks #18993, #18994).
426+
"""
427+
site_id = SiteId(value)
428+
if site_id not in user.authorized_sites(unfiltered_sites=active_config.sites):
429+
raise ValueError(f"Site {site_id!r} does not exist.")
430+
return site_id
431+
418432

419433
class PasswordConverter:
420434
@staticmethod

cmk/gui/openapi/restful_objects/type_defs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@
184184
"cmk/show_dashboard_responsive_grid",
185185
"cmk/sign",
186186
"cmk/start",
187+
"cmk/translate_metric_names",
187188
"cmk/host_config",
188189
"cmk/folder_config",
189190
"cmk/global_config",

tests/testlib/rest_api_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,24 @@ def discover_single_timeseries_graphs(
13261326
expect_ok=expect_ok,
13271327
)
13281328

1329+
def translate_metric_names(
1330+
self,
1331+
hostname: str,
1332+
service_description: str,
1333+
site: str | None = None,
1334+
expect_ok: bool = True,
1335+
) -> Response:
1336+
return self.request(
1337+
"post",
1338+
url=f"/domain-types/{self.domain}/actions/translate_metric_names/invoke",
1339+
body={
1340+
"hostname": hostname,
1341+
"service_description": service_description,
1342+
"site": site,
1343+
},
1344+
expect_ok=expect_ok,
1345+
)
1346+
13291347
def fetch_context_menu(self, add_type: str, expect_ok: bool = True) -> Response:
13301348
return self.request(
13311349
"get",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
# conditions defined in the file COPYING, which is part of this source code package.
5+
6+
# The fetcher stand-ins are constructed with the endpoint's keyword arguments, which only
7+
# Callable[..., ...] can spell; the sibling endpoint test does the same.
8+
# mypy: disable-error-code="explicit-any"
9+
10+
from collections.abc import Callable, Mapping
11+
12+
import pytest
13+
14+
from cmk.graphing_engine import HostName, MetricName, Service, ServiceName
15+
from cmk.gui.graphing.openapi import translate_metric_names as endpoint_module
16+
from cmk.livestatus_client import MKLivestatusSocketError
17+
from tests.testlib.rest_api_client import ClientRegistry
18+
19+
# The CPU utilization check reports the raw perf-data name "wait", which the collection
20+
# plug-in renames to the metric "io_wait".
21+
_RAW_NAME = MetricName("wait")
22+
_CANONICAL_NAME = MetricName("io_wait")
23+
24+
25+
def _fetcher_returning(
26+
mapping: Mapping[Service, Mapping[MetricName, MetricName]],
27+
asked_about: dict[str, object],
28+
) -> Callable[..., Callable[[], Mapping[Service, Mapping[MetricName, MetricName]]]]:
29+
"""Stand in for RRDFetchMetricNameMapping, recording the service it was asked about."""
30+
31+
def _make(**kwargs: object) -> Callable[[], Mapping[Service, Mapping[MetricName, MetricName]]]:
32+
asked_about.update(kwargs)
33+
return lambda: mapping
34+
35+
return _make
36+
37+
38+
def test_translate_metric_names_returns_the_fetched_mapping(
39+
clients: ClientRegistry, monkeypatch: pytest.MonkeyPatch
40+
) -> None:
41+
asked_about: dict[str, object] = {}
42+
monkeypatch.setattr(
43+
endpoint_module,
44+
"RRDFetchMetricNameMapping",
45+
_fetcher_returning(
46+
{
47+
Service(
48+
host_name=HostName("my-host"),
49+
service_name=ServiceName("CPU utilization"),
50+
): {_RAW_NAME: _CANONICAL_NAME}
51+
},
52+
asked_about,
53+
),
54+
)
55+
56+
resp = clients.Graph.translate_metric_names(
57+
hostname="my-host", service_description="CPU utilization"
58+
)
59+
60+
assert resp.json["metric_names"] == {"wait": "io_wait"}
61+
# The mapping has to be the one for the posted service, not for whatever the fetcher was
62+
# handed: without this the endpoint could ignore the request body and still pass.
63+
assert asked_about["host_name"] == "my-host"
64+
assert asked_about["service_name"] == "CPU utilization"
65+
66+
67+
def test_translate_metric_names_of_an_unknown_service_is_an_empty_mapping(
68+
clients: ClientRegistry, monkeypatch: pytest.MonkeyPatch
69+
) -> None:
70+
# An unmonitored host or service resolves to no rows. That is an empty answer, not an error:
71+
# the caller is the one that turns it into a message.
72+
monkeypatch.setattr(endpoint_module, "RRDFetchMetricNameMapping", _fetcher_returning({}, {}))
73+
74+
resp = clients.Graph.translate_metric_names(
75+
hostname="my-host", service_description="No such service"
76+
)
77+
78+
assert resp.json["metric_names"] == {}
79+
80+
81+
def test_translate_metric_names_scopes_the_fetch_to_a_site_the_user_may_see(
82+
clients: ClientRegistry, monkeypatch: pytest.MonkeyPatch
83+
) -> None:
84+
asked_about: dict[str, object] = {}
85+
monkeypatch.setattr(
86+
endpoint_module, "RRDFetchMetricNameMapping", _fetcher_returning({}, asked_about)
87+
)
88+
89+
clients.Graph.translate_metric_names(
90+
hostname="my-host", service_description="CPU utilization", site="NO_SITE"
91+
)
92+
93+
assert asked_about["site_id"] == "NO_SITE"
94+
95+
96+
def test_translate_metric_names_of_a_site_the_user_may_not_see_is_rejected(
97+
clients: ClientRegistry, monkeypatch: pytest.MonkeyPatch
98+
) -> None:
99+
# A site outside the user's list would otherwise scope the query to nothing and answer 200 with
100+
# an empty mapping, which reads exactly like a service that has no perf data.
101+
monkeypatch.setattr(endpoint_module, "RRDFetchMetricNameMapping", _fetcher_returning({}, {}))
102+
103+
resp = clients.Graph.translate_metric_names(
104+
hostname="my-host",
105+
service_description="CPU utilization",
106+
site="no-such-site",
107+
expect_ok=False,
108+
)
109+
110+
assert resp.status_code == 400
111+
112+
113+
def test_translate_metric_names_livestatus_failure_is_503(
114+
clients: ClientRegistry, monkeypatch: pytest.MonkeyPatch
115+
) -> None:
116+
def _raise(
117+
**_kwargs: object,
118+
) -> Callable[[], Mapping[Service, Mapping[MetricName, MetricName]]]:
119+
def _fetch() -> Mapping[Service, Mapping[MetricName, MetricName]]:
120+
raise MKLivestatusSocketError("connection refused")
121+
122+
return _fetch
123+
124+
monkeypatch.setattr(endpoint_module, "RRDFetchMetricNameMapping", _raise)
125+
126+
resp = clients.Graph.translate_metric_names(
127+
hostname="my-host", service_description="CPU utilization", expect_ok=False
128+
)
129+
130+
assert resp.status_code == 503
131+
assert "connection refused" in resp.json["detail"]

0 commit comments

Comments
 (0)