-
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
Sysctl library #99
Sysctl library #99
Conversation
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.
I think that we could improve some small thinks, but it's overall it looks good to me.
I will also suggest to add more logging, since we are not doing much of that.
|
||
CHARM_FILENAME_PREFIX = "90-juju-" | ||
SYSCTL_DIRECTORY = Path("/etc/sysctl.d") | ||
SYSCTL_FILENAME = Path(f"{SYSCTL_DIRECTORY}/95-juju-sysctl.conf") |
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-blocking:
SYSCTL_FILENAME = Path(f"{SYSCTL_DIRECTORY}/95-juju-sysctl.conf") | |
SYSCTL_FILENAME = SYSCTL_DIRECTORY / "95-juju-sysctl.conf" |
SYSCTL_FILENAME = Path(f"{SYSCTL_DIRECTORY}/95-juju-sysctl.conf") | ||
SYSCTL_HEADER = f"""# This config file was produced by sysctl lib v{LIBAPI}.{LIBPATCH} | ||
# | ||
# This file represents the output of the sysctl lib, which can combine multiple |
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.
Are we providing paths to original files here? I could not found it in the code.
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.
I added this as part of file creation, see this commit: 0cd9816
# NOTE: case where own charm calls update() more than once. | ||
if self.charm_filepath.exists(): | ||
self._merge(add_own_charm=False) |
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.
I think we need to provide better description in doc-string or change logic here, because this function is called update
and it's not following real update pattern. I'll try to explain it with example.
Calling update
twice as shown bellow, does not ensure that 95-juju-sysctl.conf
will have vm.max_map_count
configured. It will be applied, but there will be no information and after reboot it will be lost.
self.sysctl.update({"vm.max_map_count": 262144})
self.sysctl.update({"vm.swappiness": 50})
From this situation I would expect that 95-juju-sysctl.conf
to have both vm.max_map_count=262144
and vm.swappiness=50
.
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.
Yes, maybe update
is not the correct term here. It's more a configure
scenario, where we change everything, not adding.
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.
Yes, if the behaviour here is to write an entire config, I agree with @rgildein -- this name is confusing. It's kind of like dict.update
, but does something very different. What about renaming it configure
, as you suggest?
self._merge() | ||
|
||
def remove(self) -> None: | ||
"""Remove config for charm.""" |
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.
I would prefer to add note about not applying anything (since we are not doing any change in sysctl configuration, not reverting any value) with this remove, so charm developer will not expect that after calling sysctl.remove()
.
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.
Added as part of the description
|
||
def _create_snapshot(self) -> Dict[str, str]: | ||
"""Create a snaphot of config options that are going to be set.""" | ||
return {key: self._sysctl([key, "-n"])[0] for key in self._desired_config.keys()} |
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.
Like this we will call subprocess to many times and this can easily took 10+ seconds. Can we call it only once or load those values from 95-juju-sysctl.cong
?
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.
Simplified call into a single one. Thanks!
|
||
return config | ||
|
||
def _parse_line(self, line: str) -> Dict[str, str]: |
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.
To me it would be much cleaner if this function returns Tuple[str, str]
and not dictionary with single key and value.
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.
Yes, I updated the parsing
tests/unit/test_sysctl.py
Outdated
|
||
class TestSysctlConfig(unittest.TestCase): | ||
def setUp(self) -> None: | ||
self.desired_values = {"vm.swappiness": {"value": 0}} |
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.
this variable is never used, can we remove it
|
||
|
||
class TestSysctlConfig(unittest.TestCase): | ||
def setUp(self) -> 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.
I really think that we should mocked SYSCTL_DIRECTORY
(probably also SYSCTL_FILENAME
) to ensure that this tests will not create such file or change it. Right now I did not spotted any place where we are not mocking load or write config, but this can easily happen in future and we should avoid touching system configuration in unit tests.
except CalledProcessError as e: | ||
msg = f"Error executing '{cmd}': {e.stdout}" | ||
logger.error(msg) | ||
raise SysctlError(msg) |
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.
raise SysctlError(msg) | |
raise SysctlError(msg) from error |
"""Raised when there's an error running sysctl command.""" | ||
|
||
|
||
class SysctlPermissionError(Error): |
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-blocking: Can we stay on same page here and use ApplyError
as I did in grub lib? To me it's more obvious than some permission error.
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.
I haven't looked at the test thoroughly yet, but I added a few initial comments on the code.
|
||
def __repr__(self): | ||
"""Represent the Error.""" | ||
return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args) |
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.
__repr__
is usually supposed to be in the format that could be turned back into the object if sent through eval()
, in other words, the Python representation. Exception subclasses already have a nice __repr__
that does this:
>>> class FooError(Exception): pass
...
>>> repr(FooError(1, 'foo'))
"FooError(1, 'foo')"
So I don't think we need __repr__
here.
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.
I'm guilty of copying the exception format from the other modules in here :) I'll change it to remove unused parts. Thanks for the explanation!
return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args) | ||
|
||
@property | ||
def name(self): |
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.
Why is this needed?
return self.args[0] | ||
|
||
|
||
class SysctlError(Error): |
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.
I don't think this subclass adds any value. This package is already called sysctl
, so it seems like raising sysctl.Error
is already enough? Or at least the name is confusing given the overlap with the package name (sysctl.SysctlError
). If we need to distinguish sysctl command errors from other errors, maybe it can be sysctl.CommandError
or similar?
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.
I think CommandError
might be best here
cmd = [f"{key}={value}" for key, value in self._desired_config.items()] | ||
result = self._sysctl(cmd) | ||
expr = re.compile(r"^sysctl: permission denied on key \"([a-z_\.]+)\", ignoring$") | ||
failed_values = [expr.match(line) for line in result if expr.match(line)] |
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 you're compiling the regex every time you can just use re.match("pattern", line)
. But it's usually better to compile the regex just once, and save it as a class-level "constant". I usually do (at the class level):
class Config:
_apply_re = re.compile("...pattern...")
Also, re.match()
only matches at the start of the string, so no need for the ^
in the re.
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.
Good to know, I'll move this to the top
"""Apply values to machine.""" | ||
cmd = [f"{key}={value}" for key, value in self._desired_config.items()] | ||
result = self._sysctl(cmd) | ||
expr = re.compile(r"^sysctl: permission denied on key \"([a-z_\.]+)\", ignoring$") |
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.
Is there a way to make sysctl
fail with a non-zero exit code in this case and just use stderr in that case, instead of parsing error output?
|
||
def _parse_line(self, line: str) -> Dict[str, str]: | ||
"""Parse a line from juju-sysctl.conf file.""" | ||
if line.startswith("#") or line == "\n": |
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.
According to the man page, ;
is a valid command line too, so maybe line.startswith(("#", ";"))
. Also, maybe or not line.strip()
is better than the above, to ensure a blank line with spaces in it doesn't count either.
Also, we should probably check or '=' not in line
.
|
||
# TODO: add your code here! Happy coding! | ||
param, value = line.split("=") |
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.
This will cause a ValueError
unpacking if the value contains a =
, as it will be split into more than two parts. You want param, value = line.split("=", maxsplit=1)
or perhaps param, _, value = line.partition("=")
.
|
||
# TODO: add your code here! Happy coding! | ||
param, value = line.split("=") | ||
return {param.strip(): value.strip()} |
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.
It's odd and inefficient to return a one-value dictionary. A tuple (param, value)
would be better. Though then you have to return None
if there's no value.
Also, I don't think having this factored out into its own method, _parse_line
, really helps with clarity. I think it'd be clearer just inline in load function. That would simplify the error/None/{} case too, because you'd just continue
in the loop if the line was a comment or invalid.
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.
This was done initially on _load as you point out, but it looked something like:
{
param.strip(): value.strip()
for line in f.read().splitlines()
if line and not line.startswith('#')
for param, value in [line.split('=')]
}
which is quite a mouthful :/
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.
nvm, I merged _parse_line
into load function now.
tests/unit/test_sysctl.py
Outdated
@patch("builtins.open", new_callable=mock_open, read_data="vm.max_map_count=25500\n") | ||
@patch("charms.operator_libs_linux.v0.sysctl.check_output") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._create_charm_file") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") |
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.
Yeah, this is a lot of mocking... I think we'd be better to just change the directory and use real file operations. We're testing a lot more that way. We can have one test with mocked file operations if you want, to ensure that we're actually writing to the right path if the path isn't overridden.
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.
It is a lot, but I was trying to avoid creating files here. This happens more on the update
call which is the one that holds most of the logic. Would adding integration tests (and leaving unit tests like this) cover your concern here?
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.
It's more that mocking is messy and fragile. Messy, because no one wants to see six @patch
decorators :-), and fragile, because we're depending a lot on implementation details, so if our implementation changes slightly, the tests break. I normally don't mock local filesystem operations in unit tests, because local filesystem operations are fast and reliable.
That said, yes, some integration tests would be very valuable for this lib -- good idea!
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.
I'm not against so many @patch
, but I still think that we should change SYSCTL_DIRECTORY
to some tmp path to be sure that we will not change anything in system sysctl config and maybe also mocked all subprocess calls so unit tests will not call sysctl key=value
. I meant in setUp
function.
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.
Thanks for the updates. The main thing I'd want to see changed is the signature of update
to not use the inner {"value": x}
one-key dict (and probably change the name).
I'd also much prefer the tests to avoid so much mocking of internals (and filesystem operations): if we could just mock the subprocess call and use real filesystem operations for the file handling, the tests would be more robust in the face of implementation changes, and we'd need fewer integration tests.
Let's also get some end-to-end / integration tests here. I think we only need a few, especially if we update the unit tests to use real filesystem operations.
Args: | ||
config: dictionary with keys to update: | ||
``` | ||
{"vm.swappiness": {"value": 10}, ...} |
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.
Hmm, this is very strange. Why isn't the value just the value of the dictionary, so cfg.update({"vm.swappiness": 10})
? It's very strange to use a one-value dict with a fixed key like this.
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.
It's a good point. This was initially done differently. The key (here vm.swappiness
) could also have a rule associated with it, so we would have the subdict with value
and rule
keys. But that was not implemented in this version.
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.
I would suggest making a good API for what we have now then (that is, avoid the "value" dict), and then later, if we extend it, we could use something like {"vm.swappiness": Rule(foo, bar)}
.
# NOTE: case where own charm calls update() more than once. | ||
if self.charm_filepath.exists(): | ||
self._merge(add_own_charm=False) |
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.
Yes, if the behaviour here is to write an entire config, I agree with @rgildein -- this name is confusing. It's kind of like dict.update
, but does something very different. What about renaming it configure
, as you suggest?
"""Write the charm file.""" | ||
charm_params = [f"{key}={value}\n" for key, value in self._desired_config.items()] | ||
with open(self.charm_filepath, "w") as f: | ||
f.writelines(charm_params) |
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.
It's a minor point for small config files like this, but it's usually better to write a single line at a time, avoiding the whole file being instantiated in memory at once. It's also a bit simpler. Something like this:
with open(self.charm_filepath, "w") as f:
for key, value in self._desired_config.items():
f.write(f"{key}={value}\n")
result = {} | ||
for key, value in config.items(): | ||
result[key] = str(value["value"]) | ||
self._desired_config: Dict[str, str] = result |
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.
Nit: good use case for a dictionary comprehension:
self._desired_config = {k: str(v) for k, v in config.items()}
raise ApplyError(msg) | ||
|
||
def _create_snapshot(self) -> Dict[str, str]: | ||
"""Create a snaphot of config options that are going to be set.""" |
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.
Nit: snaphot -> snapshot
tests/unit/test_sysctl.py
Outdated
@patch("builtins.open", new_callable=mock_open, read_data="vm.max_map_count=25500\n") | ||
@patch("charms.operator_libs_linux.v0.sysctl.check_output") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._create_charm_file") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") |
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.
It's more that mocking is messy and fragile. Messy, because no one wants to see six @patch
decorators :-), and fragile, because we're depending a lot on implementation details, so if our implementation changes slightly, the tests break. I normally don't mock local filesystem operations in unit tests, because local filesystem operations are fast and reliable.
That said, yes, some integration tests would be very valuable for this lib -- good idea!
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.
Two not-blocking comment + one about unit tests.
for path in paths: | ||
with open(path, "r") as f: | ||
data += f.readlines() | ||
with open(SYSCTL_FILENAME, "w") as f: | ||
f.writelines(data) |
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-blocking: follow up from @benhoyt comment about creating charm file.
for path in paths: | |
with open(path, "r") as f: | |
data += f.readlines() | |
with open(SYSCTL_FILENAME, "w") as f: | |
f.writelines(data) | |
with open(SYSCTL_FILENAME, "w") as f: | |
f.write(SYSCTL_HEADER) | |
for path in paths: | |
with open(path, "r") as charm_file: | |
f.write(charm_file.readlines()) |
if line.startswith(("#", ";")) or not line.strip() or "=" not in line: | ||
continue | ||
|
||
key, _, value = line.partition("=") |
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-blocking:
key, _, value = line.partition("=") | |
key, value = line.split("=", 1) |
tests/unit/test_sysctl.py
Outdated
@patch("builtins.open", new_callable=mock_open, read_data="vm.max_map_count=25500\n") | ||
@patch("charms.operator_libs_linux.v0.sysctl.check_output") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._create_charm_file") | ||
@patch("charms.operator_libs_linux.v0.sysctl.Config._load_data") |
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.
I'm not against so many @patch
, but I still think that we should change SYSCTL_DIRECTORY
to some tmp path to be sure that we will not change anything in system sysctl config and maybe also mocked all subprocess calls so unit tests will not call sysctl key=value
. I meant in setUp
function.
Thanks for the guidance here! |
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.
This looks a lot better to me now, thanks!
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.
LGTM
All good to merge, or does anyone else need to review this? If not, I can merge. |
@benhoyt I think we are good to merge. Thanks! |
PoC integration with Kafka: canonical/kafka-operator#118
Addresses DPE-2111
This draft will be updated with feedback from #98 and Kafka PR before setting it to ready to review.