Merge pull request #2951 from markparonyan/api-issue-11-gqdty1

feat(api): type link filters and available_filters response
This commit is contained in:
Jeremy Grossmann 2026-10-07 18:25:30 +02:00 committed by GitHub
commit bcf38d74f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 186 additions and 6 deletions

View File

@ -123,8 +123,13 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
return link.asdict() return link.asdict()
@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))]) @router.get(
async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: "/{link_id}/available_filters",
response_model=List[schemas.LinkFilterDefinition],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Audit"))],
)
async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]:
""" """
Return all filters available for a given link. Return all filters available for a given link.

View File

@ -37,6 +37,10 @@ from .controller.links import (
Link, Link,
LinkCapture, LinkCapture,
LinkCreate, LinkCreate,
LinkFilterDefinition,
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkUpdate, LinkUpdate,
MarkerCreate, MarkerCreate,
MarkerDefinitionCreate, MarkerDefinitionCreate,
@ -244,6 +248,10 @@ __all__ = [
"Link", "Link",
"LinkCapture", "LinkCapture",
"LinkCreate", "LinkCreate",
"LinkFilterDefinition",
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkUpdate", "LinkUpdate",
"LoggedInUserUpdate", "LoggedInUserUpdate",
"MarkerCreate", "MarkerCreate",

View File

@ -15,10 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum from enum import Enum
from typing import List, Optional, Tuple from typing import Annotated, List, Literal, Optional, Tuple
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..update import PartialUpdateModel from ..update import PartialUpdateModel
from .labels import Label from .labels import Label
@ -54,6 +54,77 @@ class LinkStyle(BaseModel):
control_offset: Optional[Tuple[float, float]] = None control_offset: Optional[Tuple[float, float]] = None
class LinkFilterType(str, Enum):
"""
Packet filter type.
"""
frequency_drop = "frequency_drop"
packet_loss = "packet_loss"
delay = "delay"
corrupt = "corrupt"
bpf = "bpf"
class LinkFilters(BaseModel):
"""
Packet filters applied on a link. Each filter is an array of positional values.
A filter set to an empty array or to its disabled value (0, 0 for delay, empty text for bpf) is not applied.
"""
model_config = ConfigDict(extra="forbid")
frequency_drop: Optional[List[Annotated[int, Field(ge=-1, le=32767)]]] = Field(
None,
max_length=1,
description="[frequency]: -1 drops every packet, N > 0 drops every Nth packet, 0 disables the filter",
)
packet_loss: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field(
None,
max_length=1,
description="[chance]: percentage chance for a packet to be lost, 0 disables the filter",
)
delay: Optional[List[Annotated[int, Field(ge=0, le=32767)]]] = Field(
None,
max_length=2,
description="[latency, jitter] in milliseconds: latency must be 1 to 32767 unless both values are 0, "
"which disables the filter; jitter is 0 to 32767",
)
corrupt: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field(
None,
max_length=1,
description="[chance]: percentage chance for a packet to be corrupted, 0 disables the filter",
)
bpf: Optional[List[str]] = Field(
None,
max_length=1,
description="[expressions]: BPF expressions, one per line, matching packets are dropped",
)
class LinkFilterParameter(BaseModel):
"""
Parameter of a packet filter.
"""
name: str
type: Literal["int", "text"]
minimum: Optional[int] = None
maximum: Optional[int] = None
unit: Optional[str] = None
class LinkFilterDefinition(BaseModel):
"""
Packet filter available on a link.
"""
type: LinkFilterType
name: str
description: str
parameters: List[LinkFilterParameter]
class LinkBase(BaseModel): class LinkBase(BaseModel):
""" """
Link data. Link data.
@ -62,7 +133,7 @@ class LinkBase(BaseModel):
nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2) nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2)
suspend: Optional[bool] = None suspend: Optional[bool] = None
link_style: Optional[LinkStyle] = None link_style: Optional[LinkStyle] = None
filters: Optional[dict] = None filters: Optional[LinkFilters] = None
markers: Optional[dict] = Field( markers: Optional[dict] = Field(
None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}" None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
) )

View File

@ -32,6 +32,11 @@ from .nodes import Node
from .projects import Supplier, Variable from .projects import Supplier, Variable
class TopologyLink(Link):
# Legacy topology files store filters as untyped dicts
filters: Optional[dict] = None # type: ignore[assignment]
class TopologyType(str, Enum): class TopologyType(str, Enum):
topology = "topology" topology = "topology"
@ -39,7 +44,7 @@ class TopologyType(str, Enum):
class TopologyContent(BaseModel): class TopologyContent(BaseModel):
computes: List[Compute] = Field(..., description="List of computes") computes: List[Compute] = Field(..., description="List of computes")
drawings: List[Drawing] = Field(..., description="List of drawings") drawings: List[Drawing] = Field(..., description="List of drawings")
links: List[Link] = Field(..., description="List of links") links: List[TopologyLink] = Field(..., description="List of links")
nodes: List[Node] = Field(..., description="List of nodes") nodes: List[Node] = Field(..., description="List of nodes")

View File

@ -335,6 +335,97 @@ class TestLinkRoutes:
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.json() == FILTERS assert response.json() == FILTERS
async def test_update_link_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
filters = {
"frequency_drop": [50],
"packet_loss": [10],
"delay": [10, 5],
"corrupt": [3],
"bpf": ["icmp"],
}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == filters
assert link.filters == filters
async def test_update_link_disabled_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"delay": [0, 0], "packet_loss": [0], "corrupt": [], "bpf": [""]}},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["filters"] == {}
@pytest.mark.parametrize(
"filters",
[
{"packet_loss": [101]},
{"packet_loss": [-1]},
{"corrupt": [101]},
{"frequency_drop": [-2]},
{"frequency_drop": [32768]},
{"delay": [32768, 0]},
{"delay": [10, -1]},
{"packet_loss": [1, 2]},
{"bpf": [1]},
{"packet_loss": ["abc"]},
],
)
async def test_update_link_invalid_filter_values(
self, app: FastAPI, client: AsyncClient, project: Project, filters: dict
) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_unknown_filter_type(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
json={"filters": {"unknown": [1]}},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
assert "message" in response.json()
assert link.filters == {}
async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": {"delay": [0, 5]}}
)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_openapi_link_filters(self, app: FastAPI) -> None:
schemas = app.openapi()["components"]["schemas"]
link_filters = schemas["LinkFilters"]
assert link_filters["additionalProperties"] is False
assert set(link_filters["properties"]) == {"frequency_drop", "packet_loss", "delay", "corrupt", "bpf"}
assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
path = "/v3/projects/{project_id}/links/{link_id}/available_filters"
responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
""" """
Test getting UDP tunnel interface information from a link. Test getting UDP tunnel interface information from a link.