Skip to content
Merged
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
138 changes: 138 additions & 0 deletions backend/infrahub/core/query/ipam.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ def from_db(cls, result: QueryResult) -> IPv6AddressFreeData:
)


@dataclass(frozen=True)
class IPParentPrefixResult:
prefix_id: str
prefix_kind: str


def _get_namespace_id(
namespace: Node | str | None = None,
) -> str:
Expand All @@ -96,6 +102,138 @@ def _get_namespace_id(
return registry.default_ipnamespace


class IPParentPrefixLookupQuery(Query):
name = "ip_parent_prefix_lookup"
type = QueryType.READ
insert_return = False

def __init__(
self,
ip_value: ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Network | ipaddress.IPv6Network,
**kwargs,
) -> None:
self.ip_value = ip_value
super().__init__(**kwargs)

def _build_possible_parent_prefixes(self) -> None:
"""Build the list of possible parent prefix binary addresses and their prefix lengths."""
if isinstance(self.ip_value, ipaddress.IPv4Address | ipaddress.IPv6Address):
is_address = True
ip_as_network = ipaddress.ip_network(self.ip_value)
prefixlen = ip_as_network.prefixlen
else:
is_address = False
ip_as_network = self.ip_value
prefixlen = ip_as_network.prefixlen

prefix_bin = convert_ip_to_binary_str(ip_as_network)

if is_address:
start_prefixlen = ip_as_network.max_prefixlen - 1
else:
start_prefixlen = prefixlen - 1

possible_prefix_map: dict[str, int] = {}
for candidate_len in range(start_prefixlen, -1, -1):
candidate_bin = prefix_bin[:candidate_len].ljust(ip_as_network.max_prefixlen, "0")
if candidate_bin not in possible_prefix_map:
possible_prefix_map[candidate_bin] = candidate_len

self.params["possible_prefix_and_length_list"] = [
[binary, length] for binary, length in possible_prefix_map.items()
]
self.params["possible_prefix_list"] = list(possible_prefix_map.keys())
self.params["ip_version"] = ip_as_network.version

async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
self._build_possible_parent_prefixes()

branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at.to_string())
self.params.update(branch_params)
self.params["ip_prefix_kind"] = InfrahubKind.IPPREFIX
self.params["ip_prefix_attribute_kind"] = PREFIX_ATTRIBUTE_LABEL

query = """
// ------------------
// Shortlist candidate AttributeIPNetwork nodes using the binary_address index
// ------------------
OPTIONAL MATCH (av:%(ip_prefix_attribute_kind)s)
WHERE av.version = $ip_version
AND av.binary_address IN $possible_prefix_list
AND any(
prefix_and_length IN $possible_prefix_and_length_list
WHERE av.binary_address = prefix_and_length[0] AND av.prefixlen <= prefix_and_length[1]
)
// ------------------
// Walk back to BuiltinIPPrefix candidates (unbound from specific av)
// ------------------
WITH av
WHERE av IS NOT NULL
OPTIONAL MATCH (maybe_parent:%(ip_prefix_kind)s)
-[:HAS_ATTRIBUTE]->(:Attribute {name: "prefix"})
-[:HAS_VALUE]->(av)
WITH DISTINCT maybe_parent
WHERE maybe_parent IS NOT NULL
// ------------------
// Verify the prefix node itself is active on this branch
// ------------------
CALL (maybe_parent) {
OPTIONAL MATCH (maybe_parent)-[r:IS_PART_OF]->(:Root)
WHERE %(branch_filter)s
ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
LIMIT 1
RETURN r IS NOT NULL AND r.status = "active" AS node_is_active
}
WITH maybe_parent
WHERE node_is_active = TRUE
Comment thread
gmazoyer marked this conversation as resolved.
// ------------------
// Resolve the branch-effective attribute value for each candidate
// ------------------
CALL (maybe_parent) {
OPTIONAL MATCH (maybe_parent)-[r1:HAS_ATTRIBUTE]->(:Attribute {name: "prefix"})-[r2:HAS_VALUE]->(av:AttributeValue)
WHERE all(r IN [r1, r2] WHERE (%(branch_filter)s))
ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC,
r2.branch_level DESC, r2.from DESC, r2.status ASC
LIMIT 1
WITH av, r1, r2
WHERE r1.status = "active" AND r2.status = "active"
// ------------------
// Re-check containment against the resolved branch-effective value
// ------------------
WITH av, (
av.version = $ip_version
AND av.binary_address IN $possible_prefix_list
AND any(
prefix_and_length IN $possible_prefix_and_length_list
WHERE av.binary_address = prefix_and_length[0] AND av.prefixlen <= prefix_and_length[1]
)
) AS is_allowed_value
RETURN CASE WHEN is_allowed_value = TRUE THEN av ELSE NULL END AS allowed_av
}
WITH maybe_parent, allowed_av
WHERE allowed_av IS NOT NULL
RETURN maybe_parent.uuid AS parent_prefix_uuid,
maybe_parent.kind AS parent_prefix_kind,
allowed_av.prefixlen AS prefixlen
ORDER BY prefixlen DESC
Comment thread
coderabbitai[bot] marked this conversation as resolved.
""" % {
"branch_filter": branch_filter,
"ip_prefix_kind": self.params["ip_prefix_kind"],
"ip_prefix_attribute_kind": self.params["ip_prefix_attribute_kind"],
}
self.add_to_query(query)
self.return_labels = ["parent_prefix_uuid", "parent_prefix_kind", "prefixlen"]

def get_data(self) -> list[IPParentPrefixResult]:
return [
IPParentPrefixResult(
prefix_id=result.get_as_type("parent_prefix_uuid", return_type=str),
prefix_kind=result.get_as_type("parent_prefix_kind", return_type=str),
)
for result in self.get_results()
]
Comment thread
gmazoyer marked this conversation as resolved.


class IPPrefixSubnetFetch(Query):
name = "ipprefix_subnet_fetch"
type = QueryType.READ
Expand Down
28 changes: 28 additions & 0 deletions backend/infrahub/graphql/queries/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from infrahub.core.constants import InfrahubKind
from infrahub.core.manager import NodeManager
from infrahub.core.query.ipam import IPParentPrefixLookupQuery
from infrahub.core.query.node import NodeGetListByAttributeValueQuery
from infrahub.graphql.field_extractor import extract_graphql_fields

Expand All @@ -30,6 +31,7 @@ class NodeEdge(ObjectType):
class NodeEdges(ObjectType):
count = Field(Int, required=True)
edges = Field(List(of_type=NonNull(NodeEdge)), required=True)
parent_prefixes = Field(List(of_type=NonNull(NodeEdge)), required=False)


def _collapse_ipv6(s: str) -> str:
Expand Down Expand Up @@ -98,6 +100,20 @@ def _collapse_ipv6(s: str) -> str:
return compressed_address


def _try_parse_ip_or_prefix(
q: str,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Network | ipaddress.IPv6Network | None:
"""Try to parse a query string as an IP address or CIDR prefix.

Returns the parsed object or None if the string is not a valid IP/CIDR.
"""
with contextlib.suppress(ValueError):
return ipaddress.ip_address(q)
with contextlib.suppress(ValueError):
return ipaddress.ip_network(q, strict=False)
return None


async def search_resolver(
root: dict, # noqa: ARG001
info: GraphQLResolveInfo,
Expand All @@ -123,6 +139,18 @@ async def search_resolver(
# Convert any IPv6 address, network or partial address to collapsed format as it might be stored in db.
q = _collapse_ipv6(q)

# Detect if the query is a valid IP address or CIDR prefix for parent prefix lookup
parsed_ip = _try_parse_ip_or_prefix(q)
Comment thread
gmazoyer marked this conversation as resolved.
if parsed_ip is not None and "parent_prefixes" in fields:
prefix_query = await IPParentPrefixLookupQuery.init(
db=graphql_context.db, branch=graphql_context.branch, at=graphql_context.at, ip_value=parsed_ip
)
await prefix_query.execute(db=graphql_context.db)
parent_prefix_results = [
{"node": {"id": result.prefix_id, "kind": result.prefix_kind}} for result in prefix_query.get_data()
]
response["parent_prefixes"] = parent_prefix_results

if case_sensitive:
# Case-sensitive search using the dedicated query
query = await NodeGetListByAttributeValueQuery.init(
Expand Down
Loading
Loading