Skip to content

Commit 18b7a4e

Browse files
authored
Merge pull request #3397 from Uninett/kea-dhcp-stats-cronjob
DHCP pool statistics from Kea DHCP API
2 parents 2a90992 + 639601f commit 18b7a4e

File tree

11 files changed

+1882
-0
lines changed

11 files changed

+1882
-0
lines changed

changelog.d/2931.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for fetching DHCP pool statistics from Kea DHCP API

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ start_arnold = "nav.bin.start_arnold:main"
135135
t1000 = "nav.bin.t1000:main"
136136
thresholdmon = "nav.bin.thresholdmon:main"
137137
navoui = "nav.bin.update_ouis:main"
138+
navdhcpstats = "nav.bin.dhcpstats:main"
138139

139140
[tool.setuptools]
140141
include-package-data = true

python/nav/bin/dhcpstats.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python
2+
#
3+
# Copyright (C) 2025 Sikt
4+
#
5+
# This file is part of Network Administration Visualized (NAV).
6+
#
7+
# NAV is free software: you can redistribute it and/or modify it under
8+
# the terms of the GNU General Public License version 3 as published by
9+
# the Free Software Foundation.
10+
#
11+
# This program is distributed in the hope that it will be useful, but WITHOUT
12+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
14+
# details. You should have received a copy of the GNU General Public License
15+
# along with NAV. If not, see <http://www.gnu.org/licenses/>.
16+
#
17+
"""
18+
Collects statistics from DHCP servers and sends them to the Carbon backend.
19+
"""
20+
21+
import argparse
22+
import logging
23+
from functools import partial
24+
import sys
25+
26+
from nav.config import getconfig
27+
from nav.dhcpstats import kea_dhcp
28+
from nav.dhcpstats.errors import CommunicationError
29+
from nav.errors import ConfigurationError
30+
from nav.logs import init_generic_logging
31+
from nav.metrics import carbon
32+
import nav.daemon
33+
34+
_logger = logging.getLogger("nav.dhcpstats")
35+
LOGFILE = "dhcpstats.log"
36+
CONFIGFILE = "dhcpstats.conf"
37+
PIDFILE = "dhcpstats.pid"
38+
39+
ENDPOINT_CLIENTS = {
40+
"kea-dhcp4": partial(kea_dhcp.Client, dhcp_version=4),
41+
}
42+
43+
44+
def main():
45+
"""Start collecting statistics."""
46+
parse_args()
47+
init_generic_logging(
48+
logfile=LOGFILE,
49+
stderr=True,
50+
stderr_level=logging.ERROR,
51+
read_config=True,
52+
)
53+
exit_if_already_running()
54+
try:
55+
config = getconfig(CONFIGFILE)
56+
except OSError as error:
57+
_logger.warning(error)
58+
config = {}
59+
collect_stats(config)
60+
61+
62+
def parse_args():
63+
"""
64+
Builds an ArgumentParser and returns parsed program arguments.
65+
(For now, this is called solely to support the --help option.)
66+
"""
67+
parser = argparse.ArgumentParser(
68+
description="Collects statistics from DHCP servers and sends them to the "
69+
"Carbon backend",
70+
epilog="Statistics are collected from each DHCP API endpoint configured in "
71+
"'CONFDIR/dhcpstats.conf', and then sent to the Carbon backend configured in "
72+
"'CONFDIR/graphite.conf'.",
73+
)
74+
return parser.parse_args()
75+
76+
77+
def collect_stats(config):
78+
"""
79+
Collects current stats from each configured endpoint.
80+
81+
:param config: dhcpstats.conf INI-parsed into a dict specifying
82+
endpoints to collect metrics from.
83+
"""
84+
85+
_logger.info("--> Starting stats collection <--")
86+
87+
all_stats = []
88+
89+
for client in get_endpoint_clients(config):
90+
_logger.info(
91+
"Collecting stats using %s...",
92+
client,
93+
)
94+
95+
try:
96+
fetched_stats = client.fetch_stats()
97+
except ConfigurationError as err:
98+
_logger.warning(
99+
"%s is badly configured: %s, skipping endpoint...",
100+
client,
101+
err,
102+
)
103+
except CommunicationError as err:
104+
_logger.warning(
105+
"Error while collecting stats using %s: %s, skipping endpoint...",
106+
client,
107+
err,
108+
)
109+
else:
110+
all_stats.extend(fetched_stats)
111+
_logger.info(
112+
"Successfully collected stats using %s",
113+
client,
114+
)
115+
116+
carbon.send_metrics(all_stats)
117+
118+
_logger.info("--> Stats collection done <--")
119+
120+
121+
def get_endpoint_clients(config):
122+
"""
123+
Yields one client per correctly configured endpoint in config. A section
124+
of the config correctly configures an endpoint if:
125+
126+
* Its name starts with 'endpoint_'.
127+
* It has the mandatory option 'type'.
128+
* The value of the 'type' option is mapped to a client initializer
129+
by ENDPOINT_CLIENTS, and the client doesn't raise a
130+
ConfigurationError when it is initialized with the rest of the
131+
options of the section as keyword arguments.
132+
133+
:param config: dhcpstats.conf INI-parsed into a dict specifying
134+
endpoints to collect metrics from.
135+
"""
136+
for section, options in config.items():
137+
if not section.startswith("endpoint_"):
138+
continue
139+
endpoint_name = section.removeprefix("endpoint_")
140+
endpoint_type = options.get("type")
141+
kwargs = {opt: val for opt, val in options.items() if opt != "type"}
142+
try:
143+
cls = ENDPOINT_CLIENTS[endpoint_type]
144+
except KeyError:
145+
_logger.warning(
146+
"Invalid endpoint type '%s' defined in config section [%s], skipping "
147+
"endpoint...",
148+
endpoint_type,
149+
section,
150+
)
151+
continue
152+
153+
try:
154+
client = cls(endpoint_name, **kwargs)
155+
except (ConfigurationError, TypeError) as err:
156+
_logger.warning(
157+
"Endpoint type '%s' defined in config section [%s] is badly "
158+
"configured: %s, skipping endpoint...",
159+
endpoint_type,
160+
section,
161+
err,
162+
)
163+
else:
164+
yield client
165+
166+
167+
def exit_if_already_running():
168+
try:
169+
nav.daemon.justme(PIDFILE)
170+
nav.daemon.writepidfile(PIDFILE)
171+
except nav.daemon.AlreadyRunningError:
172+
_logger.error(
173+
"Attempted to start a new dhcp stats collection process while another is "
174+
"running. This is likely due to stats collection taking longer than the "
175+
"cron interval"
176+
)
177+
sys.exit(1)
178+
except nav.daemon.DaemonError as error:
179+
_logger.error("%s", error)
180+
sys.exit(1)
181+
182+
183+
if __name__ == "__main__":
184+
main()

python/nav/dhcpstats/__init__.py

Whitespace-only changes.

python/nav/dhcpstats/errors.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#
2+
# Copyright (C) 2025 Sikt
3+
#
4+
# This file is part of Network Administration Visualized (NAV).
5+
#
6+
# NAV is free software: you can redistribute it and/or modify it under
7+
# the terms of the GNU General Public License version 3 as published by
8+
# the Free Software Foundation.
9+
#
10+
# This program is distributed in the hope that it will be useful, but WITHOUT
11+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
12+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
13+
# details. You should have received a copy of the GNU General Public License
14+
# along with NAV. If not, see <http://www.gnu.org/licenses/>.
15+
#
16+
"""Exceptions and errors related to dhcpstats."""
17+
18+
from nav.errors import GeneralException
19+
20+
21+
class CommunicationError(GeneralException):
22+
"""Communication error"""
23+
24+
25+
class KeaUnexpected(CommunicationError):
26+
"""An unexpected error occurred when communicating with Kea"""
27+
28+
29+
class KeaError(CommunicationError):
30+
"""Kea API failed during command processing"""
31+
32+
33+
class KeaUnsupported(CommunicationError):
34+
"""Command not supported by Kea API"""
35+
36+
37+
class KeaEmpty(CommunicationError):
38+
"""Requested resource not found by Kea API"""
39+
40+
41+
class KeaConflict(CommunicationError):
42+
"""
43+
Kea API failed to apply requested changes due to conflicts with
44+
its internal state
45+
"""

0 commit comments

Comments
 (0)