Skip to content

Commit 747d956

Browse files
apepojkenclaude
andcommitted
FIX REST API: PagerDuty notification rule integration key format
The REST API was writing the PagerDuty integration key to notification_parameter.mk as ("routing_key", key) — the legacy 2-tuple form. The notification dispatcher's get_password_from_env_or_context() builds a list of all PARAMETER_ROUTING_KEY* env vars and only recognises the 3-tuple form ("cmk_postprocessed", "explicit_password", (uuid, key)) by the literal string "explicit_password" in that list. The 2-tuple form falls through to the password-store branch which tries to look up "routing_key" as a store ID, fails, and the plugin exits with "Unable to retrieve password from passwordstore". The GUI's FormSpec migrate (_migrate_to_password in cmk/gui/wato/_notification_parameter/_pagerduty.py) already produces the 3-tuple form for UI-touched rules; the REST API path bypassed it. Replace APIPagerDutyKeyOption with APICheckmkPassword_FromKey (which already sits directly above APIPagerDutyKeyOption in the same file and is used by all other key-bearing notification plugins). PagerDutyPluginModel.routing_key is re-typed from RoutingKeyType to CheckmkPassword. A normalizer in PagerDutyPlugin.from_mk_file_format() handles existing on-disk rules in the legacy form so they keep working through the API and are rewritten in the new format on next save. Werk: 20007 Tested: - ruff format and lint pass on all changed files - pre-commit lightweight hooks pass (whitespace, license, secrets, namespace) - Bazel-backed pre-commit hooks (format/lint/mypy/unittest) not run locally; CI will validate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f5e829c commit 747d956

6 files changed

Lines changed: 150 additions & 48 deletions

File tree

.werks/20007.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[//]: # (werk v3)
2+
# REST API: Fix PagerDuty notification rule integration key format
3+
4+
key | value
5+
---------- | ---
6+
date | 2026-05-19T12:00:00+00:00
7+
version | 2.6.0b1
8+
class | fix
9+
edition | community
10+
component | rest-api
11+
level | 1
12+
compatible | yes
13+
14+
PagerDuty notification rules created via the REST API silently produced broken
15+
on-disk configuration. When such a rule was triggered the notification
16+
dispatcher exited with `Unable to retrieve password from passwordstore` and the
17+
alert was lost.
18+
19+
The REST API was writing the integration key to `notification_parameter.mk`
20+
as the legacy two-element tuple `("routing_key", key)`. The notification
21+
dispatcher expects the modern three-element form
22+
`("cmk_postprocessed", "explicit_password", (uuid, key))`, which the GUI's
23+
FormSpec migration produces but which the REST API path bypassed.
24+
25+
The REST API now serialises the integration key in the same form as the GUI.
26+
Rules created or updated via the API are dispatched correctly.
27+
28+
Rules created in earlier patch levels whose on-disk values are still in the
29+
legacy form are normalised on read, so they keep working through the API and
30+
are rewritten in the new format the next time they are saved.

.werks/first_free

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20007
1+
20008

cmk/gui/rest_api_types/notifications_rule_types.py

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
PushOverPriorityStringType,
6868
PushOverPriorityType,
6969
RegexModes,
70-
RoutingKeyType,
7170
ServiceEventType,
7271
ServiceNowPluginName,
7372
Signl4PluginName,
@@ -2494,47 +2493,6 @@ def to_mk_file_format(self) -> CheckmkPassword | None:
24942493
return self.checkmk_password
24952494

24962495

2497-
@dataclass
2498-
class APIPagerDutyKeyOption:
2499-
option: Literal["explicit", "store"] | None = None
2500-
store_id: str = ""
2501-
key: str = ""
2502-
2503-
@classmethod
2504-
def from_api_request(cls, incoming: APIKey) -> APIPagerDutyKeyOption:
2505-
if "key" in incoming:
2506-
return cls(option="explicit", key=incoming["key"])
2507-
return cls(option="store", store_id=incoming["store_id"])
2508-
2509-
def api_response(self) -> APIKey:
2510-
if self.option is None:
2511-
return {}
2512-
2513-
r: APIKey = {"option": self.option}
2514-
if self.option == "explicit":
2515-
r["key"] = self.key
2516-
return r
2517-
r["store_id"] = self.store_id
2518-
return r
2519-
2520-
@classmethod
2521-
def from_mk_file_format(cls, data: RoutingKeyType | None) -> APIPagerDutyKeyOption:
2522-
if data is None:
2523-
return cls()
2524-
2525-
if "routing_key" in data:
2526-
return cls(option="explicit", key=data[1])
2527-
return cls(option="store", key=data[1])
2528-
2529-
def to_mk_file_format(self) -> RoutingKeyType | None:
2530-
if self.option is None:
2531-
return None
2532-
2533-
if self.option == "explicit":
2534-
return "routing_key", self.key
2535-
return "store", self.store_id
2536-
2537-
25382496
# ----------------------------------------------------------------
25392497
class API_WebhookURL(API_ExplicitOrStore, total=False):
25402498
url: str

cmk/gui/rest_api_types/notifications_types.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
APICheckmkPassword_FromPassword,
3838
APICheckmkPassword_FromSecret,
3939
APINotifyPlugin,
40-
APIPagerDutyKeyOption,
4140
APIPluginDict,
4241
BasicOrTokenAuth,
4342
CheckboxEmailBodyInfo,
@@ -1176,11 +1175,34 @@ def to_mk_file_format(self) -> PluginNameWithParameters:
11761175
)
11771176

11781177

1178+
def _normalize_pagerduty_routing_key(data: object) -> CheckmkPassword:
1179+
"""Accept both the legacy 2-tuple and the current CheckmkPassword 3-tuple.
1180+
1181+
Rules created via the REST API in 2.4.0 wrote the legacy `("routing_key", key)`
1182+
or `("store", store_id)` form. The notification dispatcher expects the
1183+
`("cmk_postprocessed", "explicit_password", (uuid, key))` form. The GUI's
1184+
FormSpec migrate covers UI-touched rules; this normalizer covers rules read
1185+
via the REST API that have not yet been rewritten through the UI.
1186+
"""
1187+
if isinstance(data, tuple):
1188+
if len(data) == 2 and data[0] == "routing_key":
1189+
return (
1190+
"cmk_postprocessed",
1191+
"explicit_password",
1192+
(password_store.ad_hoc_password_id(), data[1]),
1193+
)
1194+
if len(data) == 2 and data[0] == "store":
1195+
return ("cmk_postprocessed", "stored_password", (data[1], ""))
1196+
if len(data) == 3 and data[0] == "cmk_postprocessed":
1197+
return cast(CheckmkPassword, data)
1198+
raise ValueError(f"Invalid PagerDuty routing_key format: {data!r}")
1199+
1200+
11791201
@dataclass
11801202
class PagerDutyPlugin:
11811203
plugin_name: ClassVar[PagerdutyPluginName] = "pagerduty"
11821204
option: PluginOptions = PluginOptions.CANCEL
1183-
integration_key: APIPagerDutyKeyOption = field(default_factory=APIPagerDutyKeyOption)
1205+
integration_key: APICheckmkPassword_FromKey = field(default_factory=APICheckmkPassword_FromKey)
11841206
disable_ssl_cert_verification: CheckboxTrueOrNone = field(default_factory=CheckboxTrueOrNone)
11851207
http_proxy: CheckboxHttpProxy = field(default_factory=CheckboxHttpProxy)
11861208
url_prefix_for_links_to_checkmk: CheckboxURLPrefix = field(default_factory=CheckboxURLPrefix)
@@ -1195,7 +1217,9 @@ def from_mk_file_format(cls, pluginparams: PagerDutyPluginModel | None) -> Pager
11951217

11961218
return cls(
11971219
option=PluginOptions.WITH_PARAMS,
1198-
integration_key=APIPagerDutyKeyOption.from_mk_file_format(pluginparams["routing_key"]),
1220+
integration_key=APICheckmkPassword_FromKey.from_mk_file_format(
1221+
_normalize_pagerduty_routing_key(pluginparams["routing_key"]),
1222+
),
11991223
disable_ssl_cert_verification=CheckboxTrueOrNone.from_mk_file_format(
12001224
pluginparams.get("ignore_ssl"),
12011225
),
@@ -1216,7 +1240,7 @@ def from_api_request(cls, incoming: APINotifyPlugin) -> PagerDutyPlugin:
12161240

12171241
return cls(
12181242
option=PluginOptions.WITH_PARAMS,
1219-
integration_key=APIPagerDutyKeyOption.from_api_request(params["integration_key"]),
1243+
integration_key=APICheckmkPassword_FromKey.from_api_request(params["integration_key"]),
12201244
disable_ssl_cert_verification=CheckboxTrueOrNone.from_api_request(
12211245
params["disable_ssl_cert_verification"]
12221246
),

cmk/utils/notify_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ class JsmOperationsPluginModel(TypedDict, total=False):
592592

593593

594594
class PagerDutyPluginModel(TypedDict):
595-
routing_key: tuple[Literal["routing_key", "store"], str]
595+
routing_key: CheckmkPassword
596596
webhook_url: Literal["https://events.pagerduty.com/v2/enqueue"]
597597
ignore_ssl: NotRequired[Literal[True]]
598598
proxy_url: NotRequired[ProxyUrl]

tests/unit/cmk/gui/watolib/test_notifications.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,93 @@ def test_notification_rule_roundtrip_preserves_explicit_email_addresses_handling
265265

266266
assert mk_format_cloud["contact_users"] == ["user1"]
267267
assert mk_format_cloud["contact_emails"] == ["cloud@example.com"]
268+
269+
270+
def test_pagerduty_from_api_request_produces_cmk_postprocessed_tuple() -> None:
271+
"""A PagerDuty rule created via the REST API must serialize to the
272+
`("cmk_postprocessed", "explicit_password", (uuid, key))` form so the
273+
notification dispatcher recognises the `"explicit_password"` marker.
274+
"""
275+
from cmk.gui.rest_api_types.notifications_rule_types import (
276+
API_PagerDutyData,
277+
APINotifyPlugin,
278+
)
279+
from cmk.gui.rest_api_types.notifications_types import PagerDutyPlugin
280+
from cmk.utils.notify_types import PluginOptions
281+
282+
incoming: APINotifyPlugin = {
283+
"option": PluginOptions.WITH_PARAMS,
284+
"plugin_params": API_PagerDutyData(
285+
plugin_name="pagerduty",
286+
integration_key={"option": "explicit", "key": "abcdef0123456789"},
287+
disable_ssl_cert_verification={"state": "disabled"},
288+
http_proxy={"state": "disabled"},
289+
url_prefix_for_links_to_checkmk={"state": "disabled"},
290+
),
291+
}
292+
293+
plugin = PagerDutyPlugin.from_api_request(incoming)
294+
_, params = plugin.to_mk_file_format()
295+
assert params is not None
296+
routing_key = params["routing_key"]
297+
assert routing_key[0] == "cmk_postprocessed"
298+
assert routing_key[1] == "explicit_password"
299+
assert routing_key[2][1] == "abcdef0123456789"
300+
301+
302+
def test_pagerduty_from_mk_file_format_normalizes_legacy_explicit_tuple() -> None:
303+
"""Legacy on-disk values written by 2.4.0 REST API as
304+
`("routing_key", key)` must be normalised on read so the API can return
305+
them without raising and so they get rewritten in the new format.
306+
"""
307+
from cmk.gui.rest_api_types.notifications_types import PagerDutyPlugin
308+
309+
legacy_params = {
310+
"routing_key": ("routing_key", "abcdef0123456789"),
311+
"webhook_url": "https://events.pagerduty.com/v2/enqueue",
312+
}
313+
314+
plugin = PagerDutyPlugin.from_mk_file_format(legacy_params)
315+
_, params = plugin.to_mk_file_format()
316+
assert params is not None
317+
routing_key = params["routing_key"]
318+
assert routing_key[0] == "cmk_postprocessed"
319+
assert routing_key[1] == "explicit_password"
320+
assert routing_key[2][1] == "abcdef0123456789"
321+
322+
323+
def test_pagerduty_from_mk_file_format_normalizes_legacy_store_tuple() -> None:
324+
"""Legacy on-disk values for password-store-backed keys must be normalised."""
325+
from cmk.gui.rest_api_types.notifications_types import PagerDutyPlugin
326+
327+
legacy_params = {
328+
"routing_key": ("store", "my_pd_key"),
329+
"webhook_url": "https://events.pagerduty.com/v2/enqueue",
330+
}
331+
332+
plugin = PagerDutyPlugin.from_mk_file_format(legacy_params)
333+
_, params = plugin.to_mk_file_format()
334+
assert params is not None
335+
routing_key = params["routing_key"]
336+
assert routing_key[0] == "cmk_postprocessed"
337+
assert routing_key[1] == "stored_password"
338+
assert routing_key[2][0] == "my_pd_key"
339+
340+
341+
def test_pagerduty_from_mk_file_format_preserves_current_format() -> None:
342+
"""Values already in the current 3-tuple form must round-trip unchanged."""
343+
from cmk.gui.rest_api_types.notifications_types import PagerDutyPlugin
344+
345+
current_params = {
346+
"routing_key": (
347+
"cmk_postprocessed",
348+
"explicit_password",
349+
("uuid-1234", "abcdef0123456789"),
350+
),
351+
"webhook_url": "https://events.pagerduty.com/v2/enqueue",
352+
}
353+
354+
plugin = PagerDutyPlugin.from_mk_file_format(current_params)
355+
_, params = plugin.to_mk_file_format()
356+
assert params is not None
357+
assert params["routing_key"] == current_params["routing_key"]

0 commit comments

Comments
 (0)