Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog/+bare-iphost-attribute.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion changelog/+ipaddress-attribute-kind.added.md

This file was deleted.

10 changes: 10 additions & 0 deletions docs/_templates/sdk_compatibility.j2
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ Some SDK features require a minimum Infrahub version:
{% endfor %}
<!-- vale on -->

### 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 onwards, 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.
Expand Down
11 changes: 11 additions & 0 deletions docs/docs/python-sdk/reference/compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<!-- vale on -->

### 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 onwards, 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.
Expand Down
1 change: 1 addition & 0 deletions docs/docs_generation/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
22 changes: 14 additions & 8 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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.
Expand Down Expand Up @@ -109,13 +109,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._value_coercer()(data.get("value"))

self.is_inherited: bool | None = data.get("is_inherited")
self.updated_at: str | None = data.get("updated_at")
Expand All @@ -134,6 +128,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 _value_coercer(self) -> Callable[[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
return ipaddress.ip_address
if self._schema.kind == "IPNetwork":
return ipaddress.ip_network
return lambda value: value

@property
def value(self) -> Any:
return self._value
Expand Down
7 changes: 7 additions & 0 deletions infrahub_sdk/protocols_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -118,6 +119,12 @@ 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.
# Absent parameters mean an older server that does not publish the flag, which keeps the
# historical prefixed annotation.
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"

Expand Down
28 changes: 26 additions & 2 deletions infrahub_sdk/schema/generated/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: a value with a subnet prefix is rejected and a host prefix is dropped.",
)


class DropdownChoiceRead(BaseModel):
model_config = ConfigDict(extra="ignore", use_enum_values=True)
name: str = Field(
Expand Down Expand Up @@ -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[
Expand All @@ -298,7 +318,6 @@ class GenericAttributeRead(AttributeSchemaBaseRead):
AttributeKind.MAC_ADDRESS,
AttributeKind.COLOR,
AttributeKind.BANDWIDTH,
AttributeKind.IPHOST,
AttributeKind.IPNETWORK,
AttributeKind.BOOLEAN,
AttributeKind.CHECKBOX,
Expand All @@ -320,7 +339,12 @@ class GenericAttributeRead(AttributeSchemaBaseRead):
]

AttributeSchemaRead = Annotated[
TextAttributeRead | NumberAttributeRead | ListAttributeRead | NumberPoolAttributeRead | GenericAttributeRead,
TextAttributeRead
| NumberAttributeRead
| ListAttributeRead
| NumberPoolAttributeRead
| IPHostAttributeRead
| GenericAttributeRead,
Field(discriminator="kind"),
]

Expand Down
28 changes: 26 additions & 2 deletions infrahub_sdk/schema/generated/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: a value with a subnet prefix is rejected and a host prefix is dropped.",
)


class DropdownChoiceWrite(BaseModel):
model_config = ConfigDict(extra="ignore", use_enum_values=True)
name: str = Field(
Expand Down Expand Up @@ -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[
Expand All @@ -294,7 +314,6 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite):
AttributeKind.MAC_ADDRESS,
AttributeKind.COLOR,
AttributeKind.BANDWIDTH,
AttributeKind.IPHOST,
AttributeKind.IPNETWORK,
AttributeKind.BOOLEAN,
AttributeKind.CHECKBOX,
Expand All @@ -316,7 +335,12 @@ class GenericAttributeWrite(AttributeSchemaBaseWrite):
]

AttributeSchemaWrite = Annotated[
TextAttributeWrite | NumberAttributeWrite | ListAttributeWrite | NumberPoolAttributeWrite | GenericAttributeWrite,
TextAttributeWrite
| NumberAttributeWrite
| ListAttributeWrite
| NumberPoolAttributeWrite
| IPHostAttributeWrite
| GenericAttributeWrite,
Field(discriminator="kind"),
]

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/sdk/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
93 changes: 81 additions & 12 deletions tests/unit/sdk/test_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading