diff --git a/changelog/+bare-iphost-attribute.added.md b/changelog/+bare-iphost-attribute.added.md new file mode 100644 index 000000000..f984938c5 --- /dev/null +++ b/changelog/+bare-iphost-attribute.added.md @@ -0,0 +1,8 @@ +Added support for `IPHost` attributes declaring `allow_prefix: false`. Their values are exposed as bare +`ipaddress.IPv4Address`/`IPv6Address` objects (no prefix), serialized back as a bare-address string, and +typed as `IPAddress` in generated protocols. `IPHost` attributes that do not declare the parameter keep +returning `ipaddress.IPv4Interface`/`IPv6Interface`, as does any attribute read from a server that does +not publish the parameter. + +This replaces the `IPAddress` attribute kind announced earlier in this release cycle: no such attribute +kind exists on the Infrahub server, so the code paths handling it were unreachable and have been removed. diff --git a/changelog/+ipaddress-attribute-kind.added.md b/changelog/+ipaddress-attribute-kind.added.md deleted file mode 100644 index e7e9b2192..000000000 --- a/changelog/+ipaddress-attribute-kind.added.md +++ /dev/null @@ -1 +0,0 @@ -Added support for the new `IPAddress` attribute kind. Values are exposed as bare `ipaddress.IPv4Address`/`IPv6Address` objects (no prefix) and serialized to a bare-address string when writing, alongside the existing `IPHost` and `IPNetwork` kinds. diff --git a/docs/_templates/sdk_compatibility.j2 b/docs/_templates/sdk_compatibility.j2 index 360d08b6e..79fdd0029 100644 --- a/docs/_templates/sdk_compatibility.j2 +++ b/docs/_templates/sdk_compatibility.j2 @@ -55,6 +55,16 @@ Some SDK features require a minimum Infrahub version: {% endfor %} +### Bare IP address attributes + +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 or later, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. + +:::warning +Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. +::: + +A newer SDK against an older Infrahub is safe: when the server does not publish `allow_prefix`, the SDK keeps returning interface objects. + ## General guidance - **Use the SDK version that matches your Infrahub release.** The version mapping table above shows which SDK version was tested and shipped with each Infrahub release. diff --git a/docs/docs/python-sdk/reference/compatibility.mdx b/docs/docs/python-sdk/reference/compatibility.mdx index 586e5f9ec..88f5e7069 100644 --- a/docs/docs/python-sdk/reference/compatibility.mdx +++ b/docs/docs/python-sdk/reference/compatibility.mdx @@ -153,12 +153,23 @@ Some SDK features require a minimum Infrahub version: | Feature | Minimum SDK | Minimum Infrahub | | --- | --- | --- | +| Bare IP address attributes | 1.23.0 | 1.11 | | infrahubctl branch report | 1.19.0 | 1.7 | | FileObject support | 1.19.0 | 1.8 | | NumberPool support | 1.13.0 | 1.3 | +### Bare IP address attributes + +An `IPHost` attribute whose schema sets `allow_prefix: false` holds a bare address such as `10.0.0.1` rather than `10.0.0.1/32`. From SDK 1.23.0 or later, reading such an attribute returns an `ipaddress.IPv4Address` or `ipaddress.IPv6Address`, and generated protocols type it as `IPAddress` instead of `IPHost`. An `IPHost` attribute that does not set `allow_prefix` is unaffected and still returns an `ipaddress.IPv4Interface` or `ipaddress.IPv6Interface`. + +:::warning +Reading a bare IP address attribute requires SDK 1.23.0 or later. Earlier versions parse every `IPHost` value as an interface, so a bare `10.0.0.1` becomes `IPv4Interface('10.0.0.1/32')` and the host mask reappears in your code. No error is raised. +::: + +A newer SDK against an older Infrahub is safe: when the server does not publish `allow_prefix`, the SDK keeps returning interface objects. + ## General guidance - **Use the SDK version that matches your Infrahub release.** The version mapping table above shows which SDK version was tested and shipped with each Infrahub release. diff --git a/docs/docs_generation/compatibility.py b/docs/docs_generation/compatibility.py index 0f9c6f253..76ae42b8c 100644 --- a/docs/docs_generation/compatibility.py +++ b/docs/docs_generation/compatibility.py @@ -192,6 +192,7 @@ class FeatureRequirement: # Features that require specific minimum versions of both SDK and Infrahub. FEATURE_REQUIREMENTS: list[FeatureRequirement] = [ + FeatureRequirement(feature="Bare IP address attributes", min_sdk="1.23.0", min_infrahub="1.11"), FeatureRequirement(feature="infrahubctl branch report", min_sdk="1.19.0", min_infrahub="1.7"), FeatureRequirement(feature="FileObject support", min_sdk="1.19.0", min_infrahub="1.8"), FeatureRequirement(feature="NumberPool support", min_sdk="1.13.0", min_infrahub="1.3"), diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 5dbb82085..324cccedb 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -1,7 +1,6 @@ from __future__ import annotations import ipaddress -from collections.abc import Callable from typing import TYPE_CHECKING, Any, NamedTuple, get_args from ..uuidt import UUIDT @@ -75,7 +74,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N """Build an ``Attribute`` from raw GraphQL data. IP-typed attributes (``IPHost``, ``IPNetwork``) are parsed via the standard - ``ipaddress`` module so the in-memory value is a network/interface object. + ``ipaddress`` module so the in-memory value is an address/interface/network object. Args: name (str): The name of the attribute. @@ -109,13 +108,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N self.is_from_profile: bool | None = data.get("is_from_profile") if self._value: - value_mapper: dict[str, Callable] = { - "IPHost": ipaddress.ip_interface, - "IPNetwork": ipaddress.ip_network, - "IPAddress": ipaddress.ip_address, - } - mapper = value_mapper.get(schema.kind, lambda value: value) - self._value = mapper(data.get("value")) + self._value = self._coerce_value(data.get("value")) self.is_inherited: bool | None = data.get("is_inherited") self.updated_at: str | None = data.get("updated_at") @@ -134,6 +127,18 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N if data.get(prop_name): setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type] + def _coerce_value(self, value: Any) -> Any: + if self._schema.kind == "IPHost": + # An attribute declaring ``allow_prefix: false`` holds a bare address, so parsing it as an + # interface would re-attach the host mask. Absent parameters mean an older server that does + # not publish the flag, which keeps the historical prefixed behaviour. + if (self._schema.parameters or {}).get("allow_prefix", True): + return ipaddress.ip_interface(value) + return ipaddress.ip_address(value) + if self._schema.kind == "IPNetwork": + return ipaddress.ip_network(value) + return value + @property def value(self) -> Any: return self._value diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index e0a4c61cc..e855db1c5 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -18,6 +18,7 @@ RelationshipSchemaAPI, TemplateSchemaAPI, ) +from ..schema.main import AttributeKind from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, TEMPLATE_FILE_NAME @@ -118,6 +119,10 @@ def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: def _jinja2_filter_render_attribute(value: AttributeSchemaAPI) -> str: attribute_kind: str = ATTRIBUTE_KIND_MAP[value.kind] + # An attribute declaring ``allow_prefix: false`` holds a bare address rather than an interface + if value.kind == AttributeKind.IPHOST and not (value.parameters or {}).get("allow_prefix", True): + attribute_kind = "IPAddress" + if value.optional and value.default_value is None: attribute_kind += "Optional" diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index 1b0eee5c8..84c4979a3 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -81,6 +81,14 @@ class NumberPoolParametersRead(AttributeParametersRead): ) +class IPHostAttributeParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + allow_prefix: bool = Field( + default=True, + description="When false, this attribute holds a bare IP address rather than an interface.", + ) + + class DropdownChoiceRead(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) name: str = Field( @@ -284,6 +292,18 @@ class NumberPoolAttributeRead(AttributeSchemaBaseRead): ) +class IPHostAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.IPHOST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: IPHostAttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + class GenericAttributeRead(AttributeSchemaBaseRead): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ @@ -298,7 +318,6 @@ class GenericAttributeRead(AttributeSchemaBaseRead): AttributeKind.MAC_ADDRESS, AttributeKind.COLOR, AttributeKind.BANDWIDTH, - AttributeKind.IPHOST, AttributeKind.IPNETWORK, AttributeKind.BOOLEAN, AttributeKind.CHECKBOX, @@ -320,7 +339,12 @@ class GenericAttributeRead(AttributeSchemaBaseRead): ] AttributeSchemaRead = Annotated[ - TextAttributeRead | NumberAttributeRead | ListAttributeRead | NumberPoolAttributeRead | GenericAttributeRead, + TextAttributeRead + | NumberAttributeRead + | ListAttributeRead + | NumberPoolAttributeRead + | IPHostAttributeRead + | GenericAttributeRead, Field(discriminator="kind"), ] diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py index 8d10710ab..66fe92dca 100644 --- a/infrahub_sdk/schema/generated/write.py +++ b/infrahub_sdk/schema/generated/write.py @@ -81,6 +81,14 @@ class NumberPoolParametersWrite(AttributeParametersWrite): ) +class IPHostAttributeParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + allow_prefix: bool = Field( + default=True, + description="When false, this attribute holds a bare IP address rather than an interface.", + ) + + class DropdownChoiceWrite(BaseModel): model_config = ConfigDict(extra="ignore", use_enum_values=True) name: str = Field( @@ -280,6 +288,18 @@ class NumberPoolAttributeWrite(AttributeSchemaBaseWrite): ) +class IPHostAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.IPHOST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: IPHostAttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + class GenericAttributeWrite(AttributeSchemaBaseWrite): model_config = ConfigDict(extra="ignore", use_enum_values=True) kind: Literal[ @@ -294,7 +314,6 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite): AttributeKind.MAC_ADDRESS, AttributeKind.COLOR, AttributeKind.BANDWIDTH, - AttributeKind.IPHOST, AttributeKind.IPNETWORK, AttributeKind.BOOLEAN, AttributeKind.CHECKBOX, @@ -316,7 +335,12 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite): ] AttributeSchemaWrite = Annotated[ - TextAttributeWrite | NumberAttributeWrite | ListAttributeWrite | NumberPoolAttributeWrite | GenericAttributeWrite, + TextAttributeWrite + | NumberAttributeWrite + | ListAttributeWrite + | NumberPoolAttributeWrite + | IPHostAttributeWrite + | GenericAttributeWrite, Field(discriminator="kind"), ] diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 896a45fb1..c4acf866c 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -895,7 +895,7 @@ async def bare_ipaddress_schema() -> NodeSchemaAPI: "display_labels": ["address_value"], "order_by": ["address_value"], "attributes": [ - {"name": "address", "kind": "IPAddress"}, + {"name": "address", "kind": "IPHost", "parameters": {"allow_prefix": False}}, ], } return NodeSchema(**data).convert_api() diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index f6b0ec67b..9259f7a71 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -4,6 +4,7 @@ import ipaddress import json import tempfile +from dataclasses import dataclass from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING @@ -24,6 +25,7 @@ from infrahub_sdk.node.metadata import NodeMetadata, RelationshipMetadata from infrahub_sdk.node.property import NodeProperty from infrahub_sdk.node.related_node import RelatedNode, RelatedNodeSync +from infrahub_sdk.schema import AttributeSchema, NodeSchema if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -1787,13 +1789,8 @@ async def test_create_input_data_with_IPHost_attribute( } -@pytest.mark.skip( - reason="The IPAddress attribute kind is not yet defined in the Infrahub backend, so the generated " - "AttributeKind enum omits it and the schema fixture cannot be built. Re-enable once the backend " - "adds the IPAddress attribute type." -) @pytest.mark.parametrize("client_type", client_types) -async def test_create_input_data_with_IPAddress_attribute( +async def test_create_input_data_with_bare_IPHost_attribute( client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str ) -> None: data = {"address": {"value": ipaddress.ip_address("1.1.1.1"), "is_protected": True}} @@ -2202,13 +2199,85 @@ async def test_node_IPHost_deserialization( assert ip_address.address.value == ipaddress.ip_interface("1.1.1.1/24") -@pytest.mark.skip( - reason="The IPAddress attribute kind is not yet defined in the Infrahub backend, so the generated " - "AttributeKind enum omits it and the schema fixture cannot be built. Re-enable once the backend " - "adds the IPAddress attribute type." -) +@dataclass +class IPHostCoercionCase: + name: str + parameters: dict[str, Any] | None + stored_value: str + expected: Any + + +IPHOST_COERCION_CASES = [ + IPHostCoercionCase( + name="bare-ipv4", + parameters={"allow_prefix": False}, + stored_value="1.1.1.1", + expected=ipaddress.ip_address("1.1.1.1"), + ), + IPHostCoercionCase( + name="bare-ipv6", + parameters={"allow_prefix": False}, + stored_value="2001:db8::1", + expected=ipaddress.ip_address("2001:db8::1"), + ), + IPHostCoercionCase( + name="prefixed-ipv4", + parameters={"allow_prefix": True}, + stored_value="1.1.1.1/24", + expected=ipaddress.ip_interface("1.1.1.1/24"), + ), + IPHostCoercionCase( + name="prefixed-ipv6", + parameters={"allow_prefix": True}, + stored_value="2001:db8::1/64", + expected=ipaddress.ip_interface("2001:db8::1/64"), + ), + # A server that does not publish the parameter must keep the historical prefixed behaviour, + # including re-attaching the host mask to a value that arrives bare. + IPHostCoercionCase( + name="parameters-absent-prefixed-value", + parameters=None, + stored_value="1.1.1.1/24", + expected=ipaddress.ip_interface("1.1.1.1/24"), + ), + IPHostCoercionCase( + name="parameters-absent-bare-value", + parameters=None, + stored_value="1.1.1.1", + expected=ipaddress.ip_interface("1.1.1.1/32"), + ), + IPHostCoercionCase( + name="parameters-empty-bare-value", + parameters={}, + stored_value="1.1.1.1", + expected=ipaddress.ip_interface("1.1.1.1/32"), + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in IPHOST_COERCION_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_node_IPHost_deserialization_honours_allow_prefix( + client: InfrahubClient, case: IPHostCoercionCase, client_type: str +) -> None: + schema = NodeSchema( + name="DnsRecord", + namespace="Infra", + attributes=[AttributeSchema(name="address", kind="IPHost", parameters=case.parameters)], + ).convert_api() + data = {"id": "aaaaaaaaaaaaaa", "address": {"value": case.stored_value, "is_protected": True}} + + if client_type == "standard": + dns_record = InfrahubNode(client=client, schema=schema, data=data) + else: + dns_record = InfrahubNodeSync(client=client, schema=schema, data=data) + + assert dns_record.address.value == case.expected + assert type(dns_record.address.value) is type(case.expected) + + @pytest.mark.parametrize("client_type", client_types) -async def test_node_IPAddress_deserialization( +async def test_node_bare_IPHost_deserialization( client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str ) -> None: data = { diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 531556bcd..c7a0dde3b 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -73,6 +73,76 @@ class RenderAttributeTestCase: ] +@dataclass +class RenderIPHostAttributeTestCase: + name: str + parameters: dict[str, Any] | None + optional: bool + default_value: Any + expected: str + + +RENDER_IPHOST_ATTRIBUTE_TEST_CASES = [ + RenderIPHostAttributeTestCase( + name="bare-required", + parameters={"allow_prefix": False}, + optional=False, + default_value=None, + expected="address: IPAddress", + ), + RenderIPHostAttributeTestCase( + name="bare-optional-no-default", + parameters={"allow_prefix": False}, + optional=True, + default_value=None, + expected="address: IPAddressOptional", + ), + RenderIPHostAttributeTestCase( + name="bare-optional-with-default", + parameters={"allow_prefix": False}, + optional=True, + default_value="10.0.0.1", + expected="address: IPAddress", + ), + RenderIPHostAttributeTestCase( + name="prefixed-required", + parameters={"allow_prefix": True}, + optional=False, + default_value=None, + expected="address: IPHost", + ), + RenderIPHostAttributeTestCase( + name="prefixed-optional-no-default", + parameters={"allow_prefix": True}, + optional=True, + default_value=None, + expected="address: IPHostOptional", + ), + # A server that does not publish the parameter keeps the historical prefixed annotation. + RenderIPHostAttributeTestCase( + name="parameters-absent-required", + parameters=None, + optional=False, + default_value=None, + expected="address: IPHost", + ), + RenderIPHostAttributeTestCase( + name="parameters-absent-optional-no-default", + parameters=None, + optional=True, + default_value=None, + expected="address: IPHostOptional", + ), + RenderIPHostAttributeTestCase( + name="parameters-empty-required", + parameters={}, + optional=False, + default_value=None, + expected="address: IPHost", + ), +] + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in RENDER_ATTRIBUTE_TEST_CASES], @@ -87,6 +157,21 @@ async def test_filter_render_attribute(test_case: RenderAttributeTestCase) -> No assert CodeGenerator._jinja2_filter_render_attribute(attr) == test_case.expected +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in RENDER_IPHOST_ATTRIBUTE_TEST_CASES], +) +async def test_filter_render_iphost_attribute(test_case: RenderIPHostAttributeTestCase) -> None: + attr = AttributeSchemaAPI( + name="address", + kind="IPHost", + optional=test_case.optional, + default_value=test_case.default_value, + parameters=test_case.parameters, + ) + assert CodeGenerator._jinja2_filter_render_attribute(attr) == test_case.expected + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in SYNCIFY_TEST_CASES],