Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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 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.
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 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.
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
23 changes: 14 additions & 9 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
5 changes: 5 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,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"

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 rather than an interface.",
)


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 rather than an interface.",
)


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