Skip to content

Commit f1accf0

Browse files
committed
rest_api: Unify static and dynamic attributes handling
Replace the Multinested-dependent schemes in host config with the new static + dynamic schemes. CMK-22453 Change-Id: If84a80b08a76cc3c8322ea95c9fff9389dec8eac
1 parent d8cd198 commit f1accf0

File tree

9 files changed

+290
-89
lines changed

9 files changed

+290
-89
lines changed

cmk/gui/fields/definitions.py

+91-6
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
import typing
1313
import uuid
1414
import warnings
15-
from collections.abc import Callable, Collection, Mapping
15+
from collections.abc import Callable, Collection, Mapping, MutableMapping
1616
from datetime import datetime, timezone
1717
from typing import Any, Literal
1818

19+
import marshmallow
1920
from cryptography.x509 import CertificateSigningRequest, load_pem_x509_csr
2021
from cryptography.x509.oid import NameOID
2122
from marshmallow import fields as _fields
@@ -32,11 +33,11 @@
3233
from cmk.utils.livestatus_helpers.tables import Hostgroups, Hosts, Servicegroups
3334
from cmk.utils.livestatus_helpers.types import Column, Table
3435
from cmk.utils.regex import regex, REGEX_ID
35-
from cmk.utils.tags import TagGroupID, TagID
36+
from cmk.utils.tags import TagConfig, TagGroup, TagGroupID, TagID
3637
from cmk.utils.user import UserId
3738

3839
from cmk.gui import sites
39-
from cmk.gui.config import builtin_role_ids
40+
from cmk.gui.config import active_config, builtin_role_ids
4041
from cmk.gui.customer import customer_api, SCOPE_GLOBAL
4142
from cmk.gui.exceptions import MKUserError
4243
from cmk.gui.fields.base import BaseSchema, MultiNested, ValueTypedDictSchema
@@ -48,11 +49,11 @@
4849
from cmk.gui.userdb import load_users
4950
from cmk.gui.watolib import userroles
5051
from cmk.gui.watolib.groups_io import load_group_information
51-
from cmk.gui.watolib.host_attributes import host_attribute
52+
from cmk.gui.watolib.host_attributes import ABCHostAttribute, all_host_attributes, host_attribute
5253
from cmk.gui.watolib.hosts_and_folders import Folder, folder_tree, Host
5354
from cmk.gui.watolib.passwords import contact_group_choices, password_exists
5455
from cmk.gui.watolib.sites import site_management_registry
55-
from cmk.gui.watolib.tags import load_tag_group
56+
from cmk.gui.watolib.tags import load_tag_config_read_only, load_tag_group
5657

5758
from cmk.fields import base, Boolean, DateTime, validators
5859

@@ -734,6 +735,90 @@ def validate_custom_host_attributes(
734735
return host_attributes
735736

736737

738+
class CustomHostAttributesAndTagGroups(BaseSchema):
739+
class Meta:
740+
unknown = marshmallow.INCLUDE
741+
742+
# Set it to true on create and update schemas to raise an error if a readonly attribute is passed
743+
_raise_error_if_attribute_is_readonly = False
744+
745+
@marshmallow.post_load(pass_original=True)
746+
def _validate_extra_attributes(
747+
self,
748+
result_data: dict[str, Any],
749+
original_data: MutableMapping[str, Any],
750+
**_unused_args: Any,
751+
) -> dict[str, Any]:
752+
for field in self.fields:
753+
original_data.pop(field, None)
754+
755+
if not original_data:
756+
return result_data
757+
758+
host_attributes = all_host_attributes(active_config)
759+
tag_group_config = load_tag_config_read_only()
760+
761+
for name, value in original_data.items():
762+
if tag_group := self._get_custom_tag_group(name, tag_group_config):
763+
self._validate_tag_group(tag_group, value)
764+
765+
elif host_attribute := self._get_custom_host_attribute(name, host_attributes):
766+
self._validate_attribute(host_attribute, value)
767+
768+
else:
769+
self._raise_error(f"Unknown Attribute: {name!r}: {value!r}")
770+
771+
result_data[name] = value
772+
return result_data
773+
774+
@marshmallow.post_dump(pass_original=True)
775+
def _add_tags_and_custom_attributes_back(
776+
self, dump_data: dict[str, Any], original_data: dict[str, Any], **_kwargs: Any
777+
) -> dict[str, Any]:
778+
# Custom attributes and tags are thrown away during validation as they have no field in the schema.
779+
# So we dump them back in here.
780+
# TODO: This code complies with the behavior enforced by the test_openapi_host_has_deleted_custom_attributes
781+
# test. However more research is needed to determine if it should change.
782+
original_data.update(dump_data)
783+
return original_data
784+
785+
def _get_custom_tag_group(self, tag_name: str, tag_config: TagConfig) -> TagGroup | None:
786+
return tag_config.get_tag_group(TagGroupID(tag_name[4:]))
787+
788+
def _get_custom_host_attribute(
789+
self, attribute_name: str, attributes: dict[str, ABCHostAttribute]
790+
) -> ABCHostAttribute | None:
791+
try:
792+
attribute = attributes[attribute_name]
793+
if not attribute.from_config():
794+
return None
795+
796+
return attribute
797+
798+
except KeyError:
799+
return None
800+
801+
def _validate_attribute(self, host_attribute: ABCHostAttribute, value: object) -> None:
802+
if self._raise_error_if_attribute_is_readonly and not host_attribute.editable():
803+
self._raise_error(f"Attribute {host_attribute.name()!r} is readonly.")
804+
805+
if not isinstance(value, str):
806+
self._raise_error(f"Attribute {host_attribute.name()!r} must be a string.")
807+
808+
try:
809+
host_attribute.validate_input(value, "")
810+
811+
except MKUserError as exc:
812+
self._raise_error(f"{host_attribute.name()}: {str(exc)}")
813+
814+
def _validate_tag_group(self, tag_group: TagGroup, value: object) -> None:
815+
if value not in tag_group.get_tag_ids():
816+
self._raise_error(f"Invalid value for tag-group {tag_group.title!r}: {value!r}")
817+
818+
def _raise_error(self, message: str) -> None:
819+
raise ValidationError(message)
820+
821+
737822
def ensure_string(value):
738823
if not isinstance(value, str):
739824
raise ValidationError(f"Not a string, but a {type(value).__name__}")
@@ -1067,7 +1152,7 @@ def _validate(self, value):
10671152
if (
10681153
self.presence == "might_not_exist_on_view"
10691154
and self.context is not None
1070-
and self.context["object_context"] == "view"
1155+
and self.context.get("object_context") == "view"
10711156
):
10721157
return
10731158

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2025 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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# !/usr/bin/env python3
2+
# Copyright (C) 2025 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+
7+
from cmk.gui import fields as gui_fields
8+
from cmk.gui.fields.attributes import HostContactGroup
9+
from cmk.gui.fields.base import BaseSchema
10+
from cmk.gui.fields.definitions import CustomHostAttributesAndTagGroups
11+
from cmk.gui.watolib.builtin_attributes import (
12+
HostAttributeLabels,
13+
HostAttributeManagementIPMICredentials,
14+
HostAttributeManagementProtocol,
15+
HostAttributeManagementSNMPCommunity,
16+
HostAttributeMetaData,
17+
HostAttributeNetworkScan,
18+
HostAttributeNetworkScanResult,
19+
HostAttributeParents,
20+
HostAttributeSite,
21+
HostAttributeSNMPCommunity,
22+
)
23+
from cmk.gui.watolib.groups import HostAttributeContactGroups
24+
from cmk.gui.watolib.host_attributes import ABCHostAttribute
25+
26+
from cmk import fields
27+
28+
from .host_attribute_schemas import BaseHostTagGroup
29+
30+
31+
class BaseFolderTagGroup(BaseHostTagGroup):
32+
pass
33+
34+
35+
class BaseFolderAttribute(BaseSchema):
36+
"""Base class for all folder attribute schemas."""
37+
38+
site = HostAttributeSite().openapi_field()
39+
parents = HostAttributeParents().openapi_field()
40+
41+
contactgroups = fields.Nested(
42+
HostContactGroup,
43+
description=HostAttributeContactGroups().help(),
44+
)
45+
46+
bake_agent_package = gui_fields.bake_agent_field()
47+
snmp_community = HostAttributeSNMPCommunity().openapi_field()
48+
49+
labels = HostAttributeLabels().openapi_field()
50+
51+
network_scan = HostAttributeNetworkScan().openapi_field()
52+
53+
management_protocol = HostAttributeManagementProtocol().openapi_field()
54+
management_snmp_community = HostAttributeManagementSNMPCommunity().openapi_field()
55+
management_ipmi_credentials = HostAttributeManagementIPMICredentials().openapi_field()
56+
57+
58+
class FolderCustomHostAttributesAndTagGroups(CustomHostAttributesAndTagGroups):
59+
def _get_custom_host_attribute(
60+
self, attribute_name: str, attributes: dict[str, ABCHostAttribute]
61+
) -> ABCHostAttribute | None:
62+
attribute = super()._get_custom_host_attribute(attribute_name, attributes)
63+
64+
if attribute and not attribute.show_in_folder():
65+
attribute = None
66+
67+
return attribute
68+
69+
70+
class FolderCreateAttribute(
71+
BaseFolderAttribute, BaseFolderTagGroup, FolderCustomHostAttributesAndTagGroups
72+
):
73+
_raise_error_if_attribute_is_readonly = True
74+
75+
76+
class FolderUpdateAttribute(
77+
BaseFolderAttribute, BaseFolderTagGroup, FolderCustomHostAttributesAndTagGroups
78+
):
79+
_raise_error_if_attribute_is_readonly = True
80+
81+
82+
class FolderViewAttribute(
83+
BaseFolderAttribute, BaseFolderTagGroup, FolderCustomHostAttributesAndTagGroups
84+
):
85+
network_scan_result = HostAttributeNetworkScanResult().openapi_field()
86+
meta_data = HostAttributeMetaData().openapi_field()

cmk/gui/openapi/endpoints/host_config/attribute_schemas.py cmk/gui/openapi/endpoints/_common/host_attribute_schemas.py

+49-8
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# Copyright (C) 2025 Checkmk GmbH - License: GNU General Public License v2
33
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
44
# conditions defined in the file COPYING, which is part of this source code package.
5+
from cmk.utils.tags import BuiltinTagConfig, TagGroupID
6+
57
from cmk.gui import fields as gui_fields
68
from cmk.gui.fields.attributes import HostContactGroup
7-
from cmk.gui.fields.utils import BaseSchema
9+
from cmk.gui.fields.base import BaseSchema
10+
from cmk.gui.fields.definitions import CustomHostAttributesAndTagGroups
811
from cmk.gui.watolib.builtin_attributes import (
912
HostAttributeAdditionalIPv4Addresses,
1013
HostAttributeAdditionalIPv6Addresses,
@@ -31,6 +34,41 @@
3134

3235
from cmk import fields
3336

37+
built_in_tag_group_config = BuiltinTagConfig()
38+
39+
40+
def _get_valid_tag_ids(built_in_tag_group_id: TagGroupID) -> list[str | None]:
41+
tag_group = built_in_tag_group_config.get_tag_group(built_in_tag_group_id)
42+
assert tag_group is not None
43+
44+
return [None if tag_id is None else str(tag_id) for tag_id in tag_group.get_tag_ids()]
45+
46+
47+
class BaseHostTagGroup(BaseSchema):
48+
tag_address_family = fields.String(
49+
description="The IP address family of the host.",
50+
example="ip-v4-only",
51+
enum=_get_valid_tag_ids(TagGroupID("address_family")),
52+
)
53+
54+
tag_agent = fields.String(
55+
description="Agent and API integrations",
56+
example="cmk-agent",
57+
enum=_get_valid_tag_ids(TagGroupID("agent")),
58+
)
59+
60+
tag_snmp_ds = fields.String(
61+
description="The SNMP data source of the host.",
62+
example="snmp-v2",
63+
enum=_get_valid_tag_ids(TagGroupID("snmp_ds")),
64+
)
65+
66+
tag_piggyback = fields.String(
67+
description="Use piggyback data for this host.",
68+
example="piggyback",
69+
enum=_get_valid_tag_ids(TagGroupID("piggyback")),
70+
)
71+
3472

3573
class BaseHostAttribute(BaseSchema):
3674
"""Base class for all host attribute schemas."""
@@ -67,18 +105,21 @@ class BaseHostAttribute(BaseSchema):
67105
inventory_failed = HostAttributeDiscoveryFailed().openapi_field()
68106

69107

70-
class HostCreateAttribute(BaseHostAttribute):
71-
pass
108+
class HostCreateAttribute(BaseHostAttribute, BaseHostTagGroup, CustomHostAttributesAndTagGroups):
109+
_raise_error_if_attribute_is_readonly = True
110+
72111

112+
class HostViewAttribute(BaseHostAttribute, BaseHostTagGroup, CustomHostAttributesAndTagGroups):
113+
class Meta:
114+
dateformat = "iso8601"
73115

74-
class HostViewAttribute(BaseHostAttribute):
75116
network_scan_result = HostAttributeNetworkScanResult().openapi_field()
76117
meta_data = HostAttributeMetaData().openapi_field()
77118

78119

79-
class HostUpdateAttribute(BaseHostAttribute):
80-
pass
120+
class HostUpdateAttribute(BaseHostAttribute, BaseHostTagGroup, CustomHostAttributesAndTagGroups):
121+
_raise_error_if_attribute_is_readonly = True
81122

82123

83-
class ClusterCreateAttribute(BaseHostAttribute):
84-
pass
124+
class ClusterCreateAttribute(BaseHostAttribute, BaseHostTagGroup, CustomHostAttributesAndTagGroups):
125+
_raise_error_if_attribute_is_readonly = True

cmk/gui/openapi/endpoints/folder_config/request_schemas.py

+16-15
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
from cmk.gui import fields as gui_fields
1313
from cmk.gui.fields.utils import BaseSchema
14+
from cmk.gui.openapi.endpoints._common.folder_attribute_schemas import (
15+
FolderCreateAttribute,
16+
FolderUpdateAttribute,
17+
)
1418
from cmk.gui.openapi.endpoints.common_fields import EXISTING_FOLDER, EXISTING_FOLDER_PATTERN
1519

1620
from cmk import fields
@@ -56,15 +60,15 @@ class CreateFolder(BaseSchema):
5660
example="/",
5761
pattern=EXISTING_FOLDER_PATTERN,
5862
)
59-
attributes = gui_fields.host_attributes_field(
60-
"folder",
61-
"create",
62-
"inbound",
63+
64+
attributes = fields.Nested(
65+
FolderCreateAttribute,
6366
required=False,
6467
description=(
65-
"Specific attributes to apply for all hosts in this folder (among other things)."
68+
"Specific attributes to apply for all hosts in this folder (among other things). Built-in and custom attributes and tag groups can be set here."
6669
),
6770
example={"tag_criticality": "prod"},
71+
load_default=dict(),
6872
)
6973

7074

@@ -92,32 +96,29 @@ class UpdateFolder(BaseSchema):
9296
required=False,
9397
description="The title of the folder. Used in the GUI.",
9498
)
95-
attributes = gui_fields.host_attributes_field(
96-
"folder",
97-
"update",
98-
"inbound",
99+
100+
attributes = fields.Nested(
101+
FolderUpdateAttribute,
99102
description=(
100103
"Replace all attributes with the ones given in this field. Already set"
101104
"attributes, not given here, will be removed. Can't be used together with "
102105
"update_attributes or remove_attributes fields."
103106
),
104107
example={"tag_networking": "wan"},
105108
required=False,
106-
load_default=None,
107109
)
108-
update_attributes = gui_fields.host_attributes_field(
109-
"folder",
110-
"update",
111-
"inbound",
110+
111+
update_attributes = fields.Nested(
112+
FolderUpdateAttribute,
112113
description=(
113114
"Just update the folder attributes with these attributes. The previously set "
114115
"attributes will be overwritten. Can't be used together with attributes or "
115116
"remove_attributes fields."
116117
),
117118
example={"tag_criticality": "prod"},
118119
required=False,
119-
load_default=None,
120120
)
121+
121122
remove_attributes = fields.List(
122123
fields.String(),
123124
description=(

0 commit comments

Comments
 (0)