-
Notifications
You must be signed in to change notification settings - Fork 39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for collecting DHCP metrics from Kea Control Agent #2937
base: master
Are you sure you want to change the base?
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## master #2937 +/- ##
==========================================
+ Coverage 56.58% 56.65% +0.07%
==========================================
Files 602 604 +2
Lines 43729 43890 +161
Branches 48 48
==========================================
+ Hits 24744 24868 +124
- Misses 18973 19010 +37
Partials 12 12 ☔ View full report in Codecov by Sentry. |
d157f0b
to
0640c0c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you, @jorund1 !
I have not read and understood the code in full detail, but I have some generic feedback.
Since this is your first contribution, I like to mention that I am a proponent of the "step-down rule". Full quote:
We want the code to read like a top-down narrative. We want every function to be followed by those at the next level of abstraction so that we can read the program, descending one level of abstraction at a time as we read down the list of functions. I call this The Stepdown Rule.
To say this differently, we want to be able to read the program as though it were a set of TO paragraphs, each of which is describing the current level of abstraction and referencing subsequent TO paragraphs at the next level down.
To include the setups and teardowns, we include setups, then we include the test page content, and then we include the teardowns.
To include the setups, we include the suite setup if this is a suite, then we include the regular setup.
To include the suite setup, we search the parent hierarchy for the "SuiteSetUp" page and add an include statement with the path of that page.
To search the parent...
It turns out to be very difficult for programmers to learn to follow this rule and write functions that stay at a single level of abstraction. But learning this trick is also very important. It is the key to keeping functions short and making sure they do "one thing." Making the code read like a top-down set of TO paragraphs is an effective technique for keeping the abstraction level consistent.
In general, in kea_metrics_test.py
, I would like to see the actual tests first in the code file, fixtures and helpers further towards the bottom. The only exception is when the programming language makes this ordering impossible, which can happend in Python, since it's not a pre-compiled language.
Other than that, I see you have written some code in order to validate JSON config data from Kea. At this point, we might consider pulling in Pydantic into NAV also, I think a lot of this code would disappear with it. We use Pydantic > 2 for validation of JSON data in several of the other projects our team maintains. It's not core to what you're really working on, but I at least urge you to have look at the library :)
python/nav/dhcp/generic_metrics.py
Outdated
fmt = str.maketrans({".": "_", "/": "_"}) # 192.0.2.0/24 --> 192_0_0_0_24 | ||
graphite_metrics = [] | ||
for metric in self.fetch_metrics(): | ||
graphite_path = f"{self.graphite_prefix}.{str(metric.subnet_prefix).translate(fmt)}.{metric.key}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is already being based on the NAV codebase, you may be better off just using nav.metrics.names.escape_metric_name()
rather than building your own translation table here.
################################################################################ | ||
|
||
|
||
def test_success_responses_does_succeed(success_response, enqueue_post_response): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def test_success_responses_does_succeed(success_response, enqueue_post_response): | |
def test_success_responses_should_succeed(success_response, enqueue_post_response): |
assert isinstance(response.service, str) | ||
|
||
|
||
def test_error_responses_does_not_succeed(error_response, enqueue_post_response): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def test_error_responses_does_not_succeed(error_response, enqueue_post_response): | |
def test_error_responses_should_not_succeed(error_response, enqueue_post_response): |
assert isinstance(response.service, str) | ||
|
||
|
||
def test_invalid_json_responses_raises_jsonerror( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def test_invalid_json_responses_raises_jsonerror( | |
def test_invalid_json_responses_should_raise_jsonerror( |
################################################################################ | ||
# Testing KeaDhcpSubnet and KeaDhcpConfig instantiation from json # | ||
################################################################################ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should potentially consider grouping tests for individual classes by using classes, e.g.
class KeaDhcpSubnetTest:
def test_dhcp4_subnet4_config_should_be_parsed_correctly(self, dhcp4_config):
...
def test_correct_subnet_from_dhcp4_config_json(dhcp4_config): | ||
j = json.loads(dhcp4_config) | ||
subnet = KeaDhcpSubnet.from_json(j["Dhcp4"]["subnet4"][0]) | ||
assert subnet.id == 1 | ||
assert subnet.prefix == IP("192.0.0.0/8") | ||
assert len(subnet.pools) == 2 | ||
assert subnet.pools[0] == (IP("192.1.0.1"), IP("192.1.0.200")) | ||
assert subnet.pools[1] == (IP("192.3.0.1"), IP("192.3.0.200")) | ||
|
||
|
||
def test_correct_config_from_dhcp4_config_json(dhcp4_config): | ||
j = json.loads(dhcp4_config) | ||
config = KeaDhcpConfig.from_json(j) | ||
assert len(config.subnets) == 1 | ||
subnet = config.subnets[0] | ||
assert subnet.id == 1 | ||
assert subnet.prefix == IP("192.0.0.0/8") | ||
assert len(subnet.pools) == 2 | ||
assert subnet.pools[0] == (IP("192.1.0.1"), IP("192.1.0.200")) | ||
assert subnet.pools[1] == (IP("192.3.0.1"), IP("192.3.0.200")) | ||
assert config.dhcp_version == 4 | ||
assert config.config_hash is None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not entirely sure what these tests are really testing for, so I'm having a hard time suggesting alternate names here:
I generally prefer that tests are named very explicitly so that when I'm looking at a test report and see a failing test, I won't have to dig deeply into the test code itself to figure out what requirement it was that actually failed.
Names along the lines of "when x happens y should do z" (or, if grouping tests in classes to test individual functions or classes in multiple ways, "when x happens it should do z")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for helpful input. Make sure to continue pointing out any conventions, guidelines or modules I've missed out on!
Regarding naming of test functions. I agree that the function names are unclear. I've not followed any convention, but for those places where I'm testing a single function, the test function's name usually has been some combination of fail|correct|invalid
, function name
, and function input
. I'll begin to name my test functions in such a way that the name becomes a summary of the test's intention.
Regarding Pydantic. There's definetively a use-case for Pydantic here. Validating takes up big portions of the code and is not fun to read.. Some manual processing must alas be done so long as we use config-get
instead of the more well tailored but not universally available subnet4-list
command of the API to fetch subnet information.
python/nav/dhcp/generic_metrics.py
Outdated
def fetch_metrics_to_graphite(self, host, port): | ||
graphite_metrics = [] | ||
for metric in self.fetch_metrics(): | ||
graphite_path = f"{self.graphite_prefix}.{escape_metric_name(metric.subnet_prefix.strNormal())}.{metric.key}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the frontend is supposed to use this path to generate a graph then I imagine the path format should be defined somewhere else, probably in python/nav/metrics/templates like #2405 does
python/nav/dhcp/kea_metrics.py
Outdated
netprefix, | ||
kea_name, | ||
) | ||
for val, t in timeseries: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if t
stands for time then might as well call it time
or timestamp
or something more explicit, and just call val value
to be even more explicit
python/nav/dhcp/kea_metrics.py
Outdated
|
||
def _send_query(self, session: requests.Session, command: str, **kwargs) -> dict: | ||
""" | ||
Send `command` to the Kea Control Agent. An exception is raised iff |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Send `command` to the Kea Control Agent. An exception is raised iff | |
Send `command` to the Kea Control Agent. An exception is raised if |
python/nav/dhcp/kea_metrics.py
Outdated
f"a query (responded with: {responses!r})" | ||
) | ||
if not (len(responses) == 1 and "result" in responses[0]): | ||
# "We've only sent the command to *one* service. Thus responses should contain *one* response." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
quotes?
def test_all_responses_is_empty_but_valid_should_yield_no_metrics( | ||
valid_dhcp4, responsequeue | ||
): | ||
""" | ||
If the Kea DHCP server we query does not have any subnets configured, the | ||
correct thing to do is to return an empty iterable, (as opposed to failing). | ||
|
||
Likewise, if it returns no statistics for its configured subnets, the | ||
correct thing to do is to return an empty iterable. | ||
""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be split into 2 tests? One for "no subnets configured" and one "no statistics for configured subnets"
4c8f756
to
78607bf
Compare
Why: The main metrics one wishes to obtain from a dhcp server of a particular type (Kea DHCP, ISC DHCP, udhcpd, etc.) are the same accross the board, and thus the methods that process these metrics (e.g. sending them to a graphite server, creating a canonical graphite path for a specific type of metric, etc) are better off being defined once in a superclass.
KeaDhcpMetricSource is an implementation of DhcpMetricSource which collects the four metrics defined in the DhcpMetricKeys enum (number of total, used, free and touched addresses) for each vlan of a Kea DHCP server.
Why: I'll need to check more carefully how to obtain the amount of free addresses in a subnet, it proboably must be calculated since Kea doesn't seem to supply it.
Need to see how to deal with shared networks that might also be defined as well. Should be exactly the same as for subnets, since a shared network really is just a uniquely named list of subnets.
why: This is build-up for an up-coming commit that implements caching of self.kea_dhcp_config in KeaDhcpMetricSource; everytime we fetch a new kea_dhcp_config with fetch_dhcp_config(), we would like to store it as well - hence the name change of the function. The up-coming commit will then make use of Kea Control Agent's `config-hash-get` command (included in Kea versions >= 2.4.0) to check if we need to update the cached config or not whenever set_and_fetch_dhcp_config() is called.
What: In addition to updating function names etc., I've also updated the mocking of the requests.post requests in the test script so that we can give different respsonses based on the Kea Control Agent command the kea_dhcp_data script sends to the Kea Control Agent server with its request.post calls
For now, I'll just call this new function for unwrap(), since all of the uses of send_query() expects a list of one response and the first thing that is done is always to unwrap the singleton response list. unwrap() could be made to have generic typing, but for simplicity it uses KeaResponse instead of a generic TypeVar('T') for now.
Also fixes a typo on line 342 where an extra comma had sneaked itself into the code
What: Before, we only included subnets defined in the subnet[4,6] section of the Kea DHCP config obtained through the "config-get" query in the KeaDhcpConfig.subnets list. Now we also include the subnets defined in the shared-networks section of the Kea DHCP config, and thus we include all subnets than could possibly be configured for a Kea DHCP server, which means that we can now fetch metrics from all defined subnets.
Why: Datetime instances are the most precise, and can easily be converted to unix timestamps is need be. Datetime instances also makes it easy to work with timezone differences, which we sadly seem to have to care about since the Kea Control Agent doesn't provide timezone data along with its timestamps.
Why: If we don't obtain the config, we do not have enough information to start fetching subnet metrics; in this case there is no way to move forward and there's no hope of obtaining some useful data by continuing the call.
what: prior to this commit, the key for a specific metric (i.e. the name of that metric used by NAV) had the same naming convention as dhcpd-pools(1), e.g. "cur" was the name used for the "amount of addresses currently assigned to dhcp clients on this subnet" and "max" was the name used for the "total amount of addresses controlled by this subnet". DhcpMetricKey.CUR and DhcpMetricKey.MAX, however, is not very descriptive, so I changed the key names to be DhcpMetricKey.TOTAL and DhcpMetricKey.ASSIGNED. DhcpMetricKey.TOUCH was removed all together because it seems to me like this is not a common metric to be reported by dhcp servers (dhcpd-pools(1) uses "touch" to mean the number of assigned addresses that has timed out but that is not yet marked as re-assignable by the dhcp-server).
what: Modify the docstrings so that they all follow the same pattern; i.e. they all begin by describing what they return.
why: In the future, one might want to include sensitive information, such as passwords or tokens in requests, and a response from Kea might contain secrets, especially with regards to "config-get" responses, where a config might contain passwords.
what: before this change, our mocking functionality only allowed for setting the string of the response object that is returned by requests.post() and requests.Session().post(), by using responsequeue.add("<command-name>", "<returned-string>"). responsequeue.autofill("dhcp<4 or 6>", config_to_return, statistics_to_return) This commit modifies adds an extra parameter to both of these functions, the `attrs` parameter: responsequeue.add("<command-name>", "<returned-string>", attrs={}). responsequeue.autofill("dhcp<4 or 6>", config_to_return, statistics_to_return, attrs={}) if `attrs` = {"myattr": "myval"}, then the requests.Response() object returned by any call to requests.post() or requests.Session().post() will have the attribute "myattr": attrs = {"myattr", "myval"} responsequeue.add("<command-name>", "<returned-string>", attrs) response = requests.post(...) assert response.myattr == "myval"
(this is what the graphite exporting function expects)
78607bf
to
76da903
Compare
Implements #2931
Uses python 3.9 typehints
Defines the
KeaDhcpMetricSource
class and its superclassDhcpMetricSource
, having methodsfetch_metrics
andfetch_metrics_to_graphite
that can be used to fetch metrics from a Kea DHCP server controlled by a Kea Control Agent and send these metrics to a graphite server. Example usage:The other defined classes and functions are helpers and is not really meant to be used by other parts of NAV.
Todo:
KeaDhcpConfig.from_json
for storing the configuration hash supplied in the json obtained fromconfig-get
queries.config-hash-get
queries inKeaDhcpMetricSource.fetch_and_set_dhcp_config
andKeaDhcpMetricSource.fetch_dhcp_config_hash
statistic-get
queries inKeaDhcpMetricSource.fetch_metrics