netx/netx_api/topology_classify_common.py
oliver 4ec890c407 Split topology classify and config sync services by domain.
Keep public facades stable while moving rules/apply/slices and policy/cycles/snapshots into focused modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:21:50 +08:00

184 lines
6.1 KiB
Python

"""Shared helpers for topology classify rules and apply."""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import TopoClassifyRule, TopoFabricNode, TopoFolder
from .timeutil import utcnow_naive
from .topology_membership import VIEW_ROLES, normalize_view_role
from .topology_schemas import (
ClassifyRuleOut,
TopologyFolderCreate,
)
_MAX_PATTERN_LEN = 512
_ROLE_VALUES = VIEW_ROLES | {"unknown"}
_MATCH_FIELDS = frozenset({"name", "ip", "name_ip"})
_SCOPES = frozenset({"role", "region"})
_SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"})
def _utcnow() -> datetime:
return utcnow_naive()
def _compile_pattern(pattern: str) -> re.Pattern[str]:
p = str(pattern or "").strip()
if not p:
raise HTTPException(status_code=400, detail="pattern_required")
if len(p) > _MAX_PATTERN_LEN:
raise HTTPException(status_code=400, detail="pattern_too_long")
try:
return re.compile(p, re.IGNORECASE)
except re.error as exc:
raise HTTPException(status_code=400, detail=f"invalid_pattern:{exc}") from exc
def _match_text(node: TopoFabricNode, match_field: str) -> str:
name = str(node.name or "")
ip = str(node.ip or "")
mf = str(match_field or "name").strip().lower()
if mf == "ip":
return ip
if mf == "name_ip":
return f"{name} {ip}".strip()
return name
def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
return ClassifyRuleOut(
id=row.id,
scope=str(row.scope or "role"),
name=str(row.name or ""),
pattern=str(row.pattern or ""),
match_field=str(row.match_field or "name"),
priority=int(row.priority or 100),
enabled=bool(row.enabled),
payload=dict(row.payload or {}),
remark=str(row.remark or ""),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload or {})
if scope == "role":
role = normalize_view_role(str(out.get("role") or ""))
if str(out.get("role") or "").strip().lower() not in VIEW_ROLES:
raise HTTPException(status_code=400, detail="role_payload_invalid")
return {"role": role}
if "folder_id" in out and str(out.get("folder_id") or "").strip():
return {"folder_id": str(out["folder_id"]).strip()}
if "region_name_from_group" in out:
try:
g = int(out.get("region_name_from_group"))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="region_group_invalid") from exc
if g < 1:
raise HTTPException(status_code=400, detail="region_group_invalid")
return {"region_name_from_group": g}
raise HTTPException(status_code=400, detail="region_payload_invalid")
def _enabled_rules(db: Session, scope: str) -> list[tuple[TopoClassifyRule, re.Pattern[str]]]:
rows = (
db.query(TopoClassifyRule)
.filter(TopoClassifyRule.scope == scope, TopoClassifyRule.enabled.is_(True))
.order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc())
.all()
)
out: list[tuple[TopoClassifyRule, re.Pattern[str]]] = []
for r in rows:
try:
out.append((r, _compile_pattern(r.pattern)))
except HTTPException:
continue
return out
def _ensure_region_by_name(db: Session, name: str) -> TopoFolder:
from .topology_service import bootstrap_topology_tree, create_folder
name = str(name or "").strip()[:256]
if not name:
raise HTTPException(status_code=400, detail="region_name_empty")
existing = (
db.query(TopoFolder)
.filter(TopoFolder.kind == "region", TopoFolder.name == name)
.first()
)
if existing is not None:
return existing
bootstrap_topology_tree(db)
created = create_folder(db, TopologyFolderCreate(name=name, kind="region"))
folder = db.get(TopoFolder, created.id)
assert folder is not None
return folder
def _resolve_role_hit(
node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
) -> tuple[str | None, str | None, bool]:
"""Return (role, rule_id, multi_hit)."""
hits: list[tuple[str, str]] = []
for rule, cre in rules:
text = _match_text(node, rule.match_field)
if not text:
continue
if cre.search(text):
role = normalize_view_role(str((rule.payload or {}).get("role") or ""))
hits.append((role, rule.id))
if not hits:
return None, None, False
return hits[0][0], hits[0][1], len(hits) > 1
def _resolve_region_hit(
db: Session,
node: TopoFabricNode,
rules: list[tuple[TopoClassifyRule, re.Pattern[str]]],
*,
create_missing: bool,
) -> tuple[str | None, str | None, bool]:
hits: list[tuple[str, str]] = []
for rule, cre in rules:
text = _match_text(node, rule.match_field)
if not text:
continue
m = cre.search(text)
if not m:
continue
payload = dict(rule.payload or {})
folder_id = str(payload.get("folder_id") or "").strip()
if folder_id:
hits.append((folder_id, rule.id))
continue
g = int(payload.get("region_name_from_group") or 0)
try:
region_name = m.group(g)
except IndexError:
continue
region_name = str(region_name or "").strip()
if not region_name:
continue
if create_missing:
folder = _ensure_region_by_name(db, region_name)
hits.append((folder.id, rule.id))
else:
existing = (
db.query(TopoFolder)
.filter(TopoFolder.kind == "region", TopoFolder.name == region_name)
.first()
)
hits.append((existing.id if existing else f"new:{region_name}", rule.id))
if not hits:
return None, None, False
return hits[0][0], hits[0][1], len(hits) > 1