Skip to content

Commit 493bed2

Browse files
logan-connollyJenkins
authored andcommitted
monitor(hosts): add site filtering backend support
Site is modeled as a normal condition inside the existing filter grammar rather than a separate top-level scope field, since the product direction is toward composable filter conditions in general and site_id shouldn't stay a permanent special case. This also means the frontend gets component reuse for free, instead of needing to handle site differently. However, in the backend the site condition node _does_ need to be handled differently. Only a single site_id condition is supported, combined with the rest of the tree via 'and'. It can also be negated with 'not'. Here are some details regarding why that decision was made: - Livestatus broadcasts one filter string to every queried site, so a site restriction can't be pushed down once it's combined with an unrelated condition via `or`, or once a subtree mixing site and non-site conditions is negated (De Morgan's turns the safe `and` into an unsupported `or`). Both are rejected with a 400. - Combining multiple site_id conditions via and/or is technically reducible to one restriction, but nothing in the UI ever produces two to combine - one multi-select already covers that, so this narrower rule costs nothing today. Negation is the one exception worth supporting on its own: excluding a few sites out of many is a real capability a plain inclusion list can't express. CMK-37572 Change-Id: Ia9a3cec325c97a674d2d07cf7eb32cf30be6f321
1 parent 1abaab4 commit 493bed2

8 files changed

Lines changed: 575 additions & 35 deletions

File tree

cmk/gui/monitor/hosts/_api/_filters.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from annotated_types import MinLen
99
from pydantic import AfterValidator
1010

11+
from cmk.ccc.site import SiteId
1112
from cmk.gui.openapi.framework.model import api_field, api_model
13+
from cmk.gui.openapi.framework.model.converter import SiteIdConverter, TypedPlainValidator
1214
from cmk.livestatus_client.expressions import LqSafe
1315

1416
from .._models import HostFilter, HostState, HostStateLabel
@@ -63,6 +65,20 @@ class StateChoiceCondition:
6365
)
6466

6567

68+
@api_model
69+
class SiteChoiceCondition:
70+
type: Literal["condition"] = api_field(
71+
description="Node type discriminator", example="condition"
72+
)
73+
field: Literal["site_id"] = api_field(description="Site field", example="site_id")
74+
op: Literal["one_of"] = api_field(description="Set membership operation", example="one_of")
75+
value: Annotated[
76+
list[Annotated[SiteId, TypedPlainValidator(str, SiteIdConverter.should_exist)]],
77+
MinLen(1),
78+
AfterValidator(validate_uniqueness),
79+
] = api_field(description="Site IDs to match", example=["local"])
80+
81+
6682
@api_model
6783
class NumericCondition:
6884
type: Literal["condition"] = api_field(
@@ -87,7 +103,13 @@ class BooleanCondition:
87103
value: bool = api_field(description="Boolean value to compare against", example=False)
88104

89105

90-
type ConditionNode = StringCondition | StateChoiceCondition | NumericCondition | BooleanCondition
106+
type ConditionNode = (
107+
StringCondition
108+
| StateChoiceCondition
109+
| SiteChoiceCondition
110+
| NumericCondition
111+
| BooleanCondition
112+
)
91113

92114

93115
@api_model(slots=False)
@@ -129,6 +151,89 @@ class NotNode:
129151
type FilterNode = AndNode | OrNode | NotNode | ConditionNode
130152

131153

154+
def extract_site_scope(
155+
node: FilterNode, all_site_ids: frozenset[SiteId]
156+
) -> tuple[FilterNode | None, list[SiteId] | None]:
157+
"""Split off the tree's 'site_id' condition, if any, as a site scope restriction.
158+
159+
Site ID isn't a real Livestatus column, so it can't contribute a filter line; it's pushed
160+
down into which sites get queried instead. Only a single 'site_id' condition is supported,
161+
optionally wrapped with a 'not' condition. If a site condition is combined with the rest of
162+
the tree with anything but an 'and' condition, a `ValueError` is raised.
163+
164+
Additional details:
165+
166+
- Combining it with an unrelated condition via 'or' would need hosts from every other site
167+
too (e.g. "site A's hosts OR any DOWN host"), which can't be reduced to a site restriction.
168+
- Negating a mixed (site + non-site) subtree hits the same problem:
169+
NOT(site_id=A AND cond) = (site_id!=A OR NOT cond), which turns the 'and' into an 'or'.
170+
- The only one site condition is done to constrain the current parsing logic. This constraint
171+
can be lifted when the need arises, but will come with added complexity.
172+
"""
173+
found: list[tuple[SiteChoiceCondition, bool]] = []
174+
175+
def record(condition: SiteChoiceCondition, *, negated: bool, and_only: bool) -> None:
176+
if not and_only:
177+
raise ValueError(
178+
"'site_id' conditions may only be combined via 'and'; they cannot appear "
179+
"inside 'or', or inside 'not' together with other conditions."
180+
)
181+
if found:
182+
raise ValueError("Only one 'site_id' condition is allowed per filter.")
183+
found.append((condition, negated))
184+
185+
def walk(current: FilterNode, and_only: bool) -> FilterNode | None:
186+
match current:
187+
case SiteChoiceCondition():
188+
record(current, negated=False, and_only=and_only)
189+
return None
190+
191+
case NotNode(child=SiteChoiceCondition() as site_condition):
192+
record(site_condition, negated=True, and_only=and_only)
193+
return None
194+
195+
case NotNode(child=child):
196+
# Passes through unchanged unless a 'site_id' condition turns up further inside,
197+
# which `and_only=False` rejects (see docstring: mixed-subtree negation).
198+
walk(child, and_only=False)
199+
return current
200+
201+
case AndNode(children=children):
202+
residual_children = [
203+
residual
204+
for residual in (walk(child, and_only) for child in children)
205+
if residual is not None
206+
]
207+
match residual_children:
208+
case []:
209+
return None
210+
case [single]:
211+
return single
212+
case _:
213+
return AndNode(type="and", children=residual_children)
214+
215+
case OrNode(children=children):
216+
# 'or' can never be reduced to a site restriction, so any 'site_id' condition
217+
# anywhere inside is rejected by `record`; nothing is ever extracted here.
218+
for child in children:
219+
walk(child, and_only=False)
220+
return current
221+
222+
case _:
223+
return current
224+
225+
residual = walk(node, and_only=True)
226+
227+
if not found:
228+
return residual, None
229+
230+
condition, negated = found[0]
231+
extracted_site_ids = {SiteId(site_id) for site_id in condition.value}
232+
site_ids = list(all_site_ids - extracted_site_ids if negated else extracted_site_ids)
233+
234+
return residual, site_ids
235+
236+
132237
def parse_as_livestatus_filter(node: FilterNode) -> HostFilter:
133238
filters: list[str] = []
134239
_accumulate_filters(node, filters)
@@ -161,6 +266,9 @@ def _accumulate_filters(node: FilterNode, filters: list[str]) -> None:
161266
case "one_of" if len(node.value) > 1:
162267
filters.append(f"Or: {len(node.value)}")
163268

269+
case SiteChoiceCondition():
270+
raise AssertionError("Site conditions are not fully supported as filter nodes.")
271+
164272
case AndNode() | OrNode():
165273
for child in node.children:
166274
_accumulate_filters(child, filters)

cmk/gui/monitor/hosts/_api/_list_hosts.py

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from annotated_types import Interval
1010
from pydantic import PlainValidator
1111

12+
from cmk.ccc.site import SiteId
1213
from cmk.gui import sites
1314
from cmk.gui.openapi.framework import (
1415
ApiContext,
@@ -21,6 +22,7 @@
2122
VersionedEndpoint,
2223
)
2324
from cmk.gui.openapi.framework.model import api_field, api_model, ApiOmitted
25+
from cmk.gui.openapi.utils import RestAPIRequestGeneralException
2426
from cmk.gui.utils import permission_verification as permissions
2527

2628
from .._impl import LiveStatusHostRepository
@@ -35,7 +37,7 @@
3537
)
3638
from .._repositories import HostRepository
3739
from ._family import MONITOR_HOSTS_FAMILY
38-
from ._filters import FilterNode, parse_as_livestatus_filter
40+
from ._filters import extract_site_scope, FilterNode, parse_as_livestatus_filter
3941
from ._modes import build_host_modes, ModeInfo
4042
from ._urls import host_view_link
4143
from ._validators import parse_host_search_query, parse_host_sort_options
@@ -244,8 +246,6 @@ def list_hosts(
244246
body: HostsRequestBody = HostsRequestBody(),
245247
) -> HostsResponse:
246248
"""List hosts to be consumed by the all host monitoring page."""
247-
host_repo = LiveStatusHostRepository(connection=sites.live())
248-
249249
# A `None` request means "remove the limit". We only honor that for users allowed to ignore
250250
# the hard limit; everyone else is clamped to the safety ceiling. Numeric requests are already
251251
# bounded to the ceiling by the request schema, so they pass through unchanged.
@@ -257,41 +257,84 @@ def list_hosts(
257257
case _:
258258
limit = body.limit
259259

260-
parsed_filters = (
261-
HostFilter("")
262-
if isinstance(body.filter, ApiOmitted)
263-
else parse_as_livestatus_filter(body.filter)
264-
)
260+
# Validated before opening a Livestatus connection below, so a bad filter is rejected without
261+
# ever needing one.
262+
if isinstance(body.filter, ApiOmitted):
263+
filters, site_ids = None, None
264+
else:
265+
try:
266+
filters, site_ids = extract_site_scope(
267+
node=body.filter,
268+
all_site_ids=frozenset(api_context.config.sites),
269+
)
270+
except ValueError as exc:
271+
raise RestAPIRequestGeneralException(
272+
status=400, title="Invalid filter", detail=str(exc)
273+
) from exc
265274

266-
return _handle_list_hosts(
267-
host_repo,
268-
limit=limit,
269-
query="" if isinstance(body.q, ApiOmitted) else body.q,
270-
sorters=_DEFAULT_SORT if isinstance(body.sort, ApiOmitted) else body.sort,
271-
filters=parsed_filters,
272-
fields=_DEFAULT_FIELDS if isinstance(body.fields, ApiOmitted) else body.fields,
273-
)
275+
host_repo = LiveStatusHostRepository(connection=sites.live())
276+
277+
# NOTE: we never want this value scoped by the selected sites. It should always get full count.
278+
# As a temporary solution, we are querying count here and passing the result to the handler.
279+
# This is done to make the handler more testable without the need to be tested within a request
280+
# context as `sites` triggers that side-effect.
281+
total_host_count = host_repo.count_total()
282+
283+
fields = _DEFAULT_FIELDS if isinstance(body.fields, ApiOmitted) else body.fields
284+
285+
# `sites.only_sites([])` can't express "query zero sites"; an empty list is falsy to it and
286+
# gets treated as "no restriction" instead, i.e. every site. This happens when the filter
287+
# negates every currently configured site, so it's handled here before ever calling into
288+
# Livestatus, rather than being passed through.
289+
if site_ids == []:
290+
return HostsResponse(
291+
hosts=[],
292+
meta=HostsPageMeta(limit=limit, matched=0, total=total_host_count, fields=fields),
293+
)
294+
295+
with sites.only_sites(site_ids):
296+
return _handle_list_hosts(
297+
host_repo,
298+
total_host_count,
299+
limit=limit,
300+
query="" if isinstance(body.q, ApiOmitted) else body.q,
301+
sorters=_DEFAULT_SORT if isinstance(body.sort, ApiOmitted) else body.sort,
302+
filters=HostFilter("") if filters is None else parse_as_livestatus_filter(filters),
303+
fields=fields,
304+
site_ids=site_ids,
305+
)
274306

275307

276308
def _handle_list_hosts(
277309
host_repo: HostRepository,
310+
total_host_count: int,
278311
*,
279312
limit: int | None = _DEFAULT_LIMIT,
280313
query: str = "",
281314
sorters: Sequence[HostSort] = _DEFAULT_SORT,
282315
filters: HostFilter = HostFilter(""),
283316
fields: Set[HostOptionalField] = _DEFAULT_FIELDS,
317+
site_ids: Sequence[SiteId] | None = None,
284318
) -> HostsResponse:
319+
# Derived from the same `site_ids` the caller scoped the connection with via `only_sites`,
320+
# rather than taken as a separately-passed flag, so the two can't drift apart.
321+
has_site_filter = site_ids is not None
322+
285323
hosts = host_repo.fetch(
286324
limit=limit,
287325
query=query,
288326
sorters=sorters,
289327
filters=filters,
290328
)
291-
total_host_count = host_repo.count_total()
329+
# `limit` reaches Livestatus as a per-site cap (queried in parallel across sites, then merged
330+
# and sorted here), so a multi-site fetch can come back with up to `limit * len(sites)` rows.
331+
# Re-applying it client-side after the merge is what makes the *global* top-`limit` hold.
332+
if limit is not None:
333+
hosts = hosts[:limit]
334+
292335
if limit is None:
293336
matched_host_count = len(hosts)
294-
elif query or filters:
337+
elif query or filters or has_site_filter:
295338
matched_host_count = host_repo.count_matched(query=query, filters=filters)
296339
else:
297340
matched_host_count = total_host_count

cmk/gui/monitor/hosts/_pages/_monitor_all_hosts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from cmk.gui.pagetypes import PagetypeTopics
2929
from cmk.gui.permissions import permission_registry
3030
from cmk.gui.type_defs import DynamicIconName, IconNames, StaticIcon, Visual
31+
from cmk.gui.user_sites import sorted_sites
3132
from cmk.gui.utils.roles import UserPermissions
3233
from cmk.gui.utils.urls import makeuri_contextless
3334
from cmk.shared_typing.monitoring.all_hosts import (
@@ -36,6 +37,7 @@
3637
MonitoringAllHostsApp,
3738
MonitoringPageLinkButton,
3839
RowAction,
40+
Site,
3941
)
4042
from cmk.utils import paths
4143

@@ -133,6 +135,10 @@ def page(self, ctx: PageContext) -> None:
133135
poll_interval_ms=ctx.config.view_option_refreshes[0] * 1000,
134136
user_id=str(user.id),
135137
site=str(omd_site()),
138+
sites=[
139+
Site(id=str(site_id), alias=alias)
140+
for site_id, alias in sorted_sites(ctx.config.sites)
141+
],
136142
edition=Edition(edition(paths.omd_root).short),
137143
actions=[
138144
MonitoringAction(

packages/cmk-shared-typing/source/monitoring/all_hosts.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,25 @@
1111
"site": {
1212
"type": "string"
1313
},
14+
"sites": {
15+
"type": "array",
16+
"items": { "$ref": "#/$defs/site" }
17+
},
1418
"edition": { "$ref": "#/$defs/edition" }
1519
},
16-
"required": ["user_id", "site", "edition"],
20+
"required": ["user_id", "site", "sites", "edition"],
1721
"$defs": {
1822
"edition": {
1923
"type": "string",
2024
"enum": ["community", "pro", "ultimate", "ultimatemt", "cloud"]
25+
},
26+
"site": {
27+
"type": "object",
28+
"properties": {
29+
"id": { "type": "string" },
30+
"alias": { "type": "string" }
31+
},
32+
"required": ["id", "alias"]
2133
}
2234
}
2335
}

0 commit comments

Comments
 (0)