Skip to content

Commit d6a28e9

Browse files
committed
thatmattlove#357 - fix(workers): cgroup-aware cpu_count and HYPERGLASS_WORKERS override thatmattlove#354 - ssh_netmiko: add read_timeout to send_command() to fix traceroute timeouts thatmattlove#347 - fix(arista): include large_community_list in BGP communities thatmattlove#344 - fix(frr): handle empty results and missing optional attributes
1 parent fd34bda commit d6a28e9

8 files changed

Lines changed: 111 additions & 25 deletions

File tree

hyperglass/execution/drivers/ssh_netmiko.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ async def collect(self, host: str = None, port: int = None) -> Iterable:
5252

5353
global_args = netmiko_device_globals.get(self.device.platform, {})
5454

55-
send_args = netmiko_device_send_args.get(self.device.platform, {})
55+
send_args = {
56+
"read_timeout": params.request_timeout,
57+
**netmiko_device_send_args.get(self.device.platform, {}),
58+
}
5659

5760
driver_kwargs = {
5861
"host": host or self.device._target,

hyperglass/main.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828

2929

3030
# Local
31-
from .util import cpu_count
3231
from .state import use_state
3332
from .settings import Settings
3433

@@ -151,13 +150,7 @@ def run(workers: int = None):
151150
host=state.params.logging.syslog.host,
152151
port=state.params.logging.syslog.port,
153152
)
154-
_workers = workers
155-
156-
if workers is None:
157-
if Settings.debug:
158-
_workers = 1
159-
else:
160-
_workers = cpu_count(2)
153+
_workers = workers if workers is not None else Settings.workers
161154

162155
log.bind(
163156
version=__version__,

hyperglass/models/parsing/arista_eos.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def bgp_table(self: "AristaBGPTable") -> "BGPRouteTable":
130130
# block. Therefore, we must verify it exists before including its data.
131131
communities = []
132132
if route.route_detail is not None:
133-
communities = route.route_detail.community_list
133+
communities = route.route_detail.community_list + route.route_detail.large_community_list
134134

135135
# iBGP paths contain an empty AS_PATH array. If the AS_PATH is empty, we
136136
# set the source_as to the router's local-as.

hyperglass/models/parsing/frr.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ class FRRNextHop(_FRRBase):
3131

3232
ip: str
3333
afi: str
34-
metric: int
34+
metric: int = 0
3535
accessible: bool
36-
used: bool
36+
used: bool = False
3737

3838

3939
class FRRPeer(_FRRBase):
@@ -65,7 +65,12 @@ class FRRPath(_FRRBase):
6565
def validate_path(cls, values):
6666
"""Extract meaningful data from FRR response."""
6767
new = values.copy()
68-
new["aspath"] = values["aspath"]["segments"][0]["list"]
68+
# Local prefixes (i.e. those in the same ASN) usually have
69+
# no AS_PATH. Set AS_PATH to AS0 for now.
70+
if values["aspath"]["length"] != 0:
71+
new["aspath"] = values["aspath"]["segments"][0]["list"]
72+
else:
73+
new["aspath"] = [0]
6974
community = values.get("community", {"list": []})
7075
new["community"] = community["list"]
7176
new["lastUpdate"] = values["lastUpdate"]["epoch"]

hyperglass/models/system.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class HyperglassSettings(BaseSettings):
5050
port: int = 8001
5151
ca_cert: t.Optional[FilePath] = None
5252
container: bool = False
53+
workers: t.Optional[int] = None
5354

5455
def __init__(self, **kwargs) -> None:
5556
"""Create hyperglass Settings instance."""
@@ -78,6 +79,7 @@ def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "Re
7879
"redis_dsn",
7980
"host",
8081
"port",
82+
"workers",
8183
)
8284
)
8385
for attr in params:
@@ -135,12 +137,22 @@ def log_level(self: "HyperglassSettings") -> str:
135137
return "DEBUG"
136138
return "WARNING"
137139

138-
@property
139-
def workers(self: "HyperglassSettings") -> int:
140-
"""Get worker count, inferred from debug mode."""
141-
if self.debug:
140+
@field_validator("workers", mode="after")
141+
def validate_workers(
142+
cls: "HyperglassSettings", value: t.Optional[int], info: ValidationInfo
143+
) -> int:
144+
"""Resolve worker count.
145+
146+
Precedence: explicit override (``HYPERGLASS_WORKERS`` / kwarg) > debug
147+
mode (1) > auto-detected ``cpu_count(2)`` capped at 8.
148+
"""
149+
if value is not None:
150+
if value < 1:
151+
raise ValueError("workers must be at least 1")
152+
return value
153+
if info.data.get("debug") is True:
142154
return 1
143-
return cpu_count(2)
155+
return min(cpu_count(2), 8)
144156

145157
@property
146158
def redis(self: "HyperglassSettings") -> t.Dict[str, t.Union[None, int, str]]:
@@ -158,7 +170,7 @@ def redis(self: "HyperglassSettings") -> t.Dict[str, t.Union[None, int, str]]:
158170
@property
159171
def redis_connection_pool(self: "HyperglassSettings") -> t.Dict[str, t.Any]:
160172
"""Get Redis ConnectionPool keyword arguments."""
161-
return {"url": str(self.redis_dsn), "max_connections": at_least(8, cpu_count(2))}
173+
return {"url": str(self.redis_dsn), "max_connections": at_least(8, self.workers)}
162174

163175
@property
164176
def dev_url(self: "HyperglassSettings") -> str:

hyperglass/plugins/_builtin/bgp_route_frr.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def parse_frr(output: t.Sequence[str]) -> "OutputDataModel":
3636

3737
_log.debug("Pre-parsed data", data=parsed)
3838

39+
# If empty (i.e. no route found), skip
40+
if not parsed:
41+
continue
42+
3943
validated = FRRBGPTable(**parsed)
4044
bgp_table = validated.bgp_table()
4145

hyperglass/util/system_info.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,35 @@ def get_node_version() -> t.Tuple[int, int, int]:
6060
return tuple((int(v) for v in version.split(".")))
6161

6262

63-
def cpu_count(multiplier: int = 0) -> int:
64-
"""Get server's CPU core count.
63+
def cpu_count(multiplier: int = 1) -> int:
64+
"""Get the process-allowed CPU core count.
6565
66-
Used to determine the number of web server workers.
66+
Prefers cgroup- and affinity-aware APIs so deployments inside
67+
containers or under cpuset constraints see the real allowed CPU
68+
count rather than the host's logical core count.
6769
"""
68-
# Standard Library
69-
import multiprocessing
70+
n: t.Optional[int] = None
71+
72+
# Python 3.13+: respects cgroup cpu.max and sched_getaffinity.
73+
process_cpu_count = getattr(os, "process_cpu_count", None)
74+
if callable(process_cpu_count):
75+
n = process_cpu_count()
76+
77+
# Fallback for Python 3.12 and earlier on Linux.
78+
if n is None and hasattr(os, "sched_getaffinity"):
79+
try:
80+
n = len(os.sched_getaffinity(0))
81+
except OSError:
82+
pass
83+
84+
# Final fallback for non-Linux platforms.
85+
if n is None:
86+
# Standard Library
87+
import multiprocessing
88+
89+
n = multiprocessing.cpu_count()
7090

71-
return multiprocessing.cpu_count() * multiplier
91+
return max(n * multiplier, 1)
7292

7393

7494
def check_python() -> str:
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Tests for cgroup/affinity-aware system info utilities."""
2+
3+
# Standard Library
4+
import os
5+
6+
# Third Party
7+
import pytest
8+
9+
# Local
10+
from ..system_info import cpu_count
11+
12+
13+
def test_cpu_count_default_multiplier_returns_at_least_one():
14+
"""cpu_count() with no args returns >=1 on any platform."""
15+
assert cpu_count() >= 1
16+
17+
18+
def test_cpu_count_multiplier_scales():
19+
"""cpu_count(N) scales the detected count."""
20+
base = cpu_count()
21+
assert cpu_count(2) == base * 2
22+
23+
24+
def test_cpu_count_floor_is_one():
25+
"""cpu_count(0) returns at least 1, never 0."""
26+
assert cpu_count(0) == 1
27+
28+
29+
@pytest.mark.skipif(
30+
not hasattr(os, "sched_getaffinity"),
31+
reason="sched_getaffinity is Linux-only",
32+
)
33+
def test_cpu_count_respects_sched_getaffinity(monkeypatch):
34+
"""When process_cpu_count is unavailable, falls back to sched_getaffinity."""
35+
monkeypatch.delattr(os, "process_cpu_count", raising=False)
36+
monkeypatch.setattr(os, "sched_getaffinity", lambda _pid: {0, 1, 2})
37+
assert cpu_count() == 3
38+
39+
40+
def test_cpu_count_falls_back_to_multiprocessing(monkeypatch):
41+
"""When neither cgroup-aware API works, falls back to multiprocessing."""
42+
monkeypatch.delattr(os, "process_cpu_count", raising=False)
43+
monkeypatch.delattr(os, "sched_getaffinity", raising=False)
44+
45+
# Standard Library
46+
import multiprocessing
47+
48+
monkeypatch.setattr(multiprocessing, "cpu_count", lambda: 7)
49+
assert cpu_count() == 7

0 commit comments

Comments
 (0)