mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
Keep public facades stable while moving rules/apply/slices and policy/cycles/snapshots into focused modules. Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
6.1 KiB
Python
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
|
|
|
|
|