-
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 base logic for grub lib #98
Conversation
- add load function for grub config (assumption no duplicate keys) - add save function for grub config - add GrubConfig object, which load config in lazy mode and provide function to make update (validate, merge and apply changes)
- add update function - add remove function
|
||
Complete documentation about creating and documenting libraries can be found | ||
in the SDK docs at https://juju.is/docs/sdk/libraries. | ||
NOTE: This library is not capable to read grub config file containes |
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 something missing at the end of the note?
NOTE: This library is not capable of reading grub config files containing...
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 part of not finished docstring. I update it, so you can check it now. Thanks
def _parse_config(data: str) -> Dict[str, str]: | ||
"""Parse config file lines. | ||
|
||
This function is capable to update single key value if it's used with $ symbol. |
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.
Second line of docstring might sound better like this:
This function is capable of updating single key values using the "$" symbol
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 you check it again, I add full docstring with example.
raise ApplyError("New config check failed.") from error | ||
|
||
def _save_grub_configuration(self) -> None: | ||
"""Save current gru configuration.""" |
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.
typo: gru
should be grub
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.
We should add:
- Note that this only handles simple configurations that starts with GRUB_
- add GRUB_.* check for configuration 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.
Looks like good code. Added some comments to consider.
This is a placeholder docstring for this charm library. Docstrings are | ||
presented on Charmhub and updated whenever you push a new version of the | ||
library. | ||
"""Simple library for managing Linux kernel configuration via grub. |
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: it looks like the official spelling of the project is "GRUB". See https://www.gnu.org/software/grub/ -- so we should probably use that in docstrings where applicable.
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 mean that I should use GRUB
instead of grub
?
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, GRUB, but only when we're referring to the project in prose (per that doc, it's an acronym for GRand Unified Bootloader). But of course the module name will still be grub
. And I guess a class name like GrubConfig
would be GRUBConfig
, like HTTPServer
.
@@ -29,4 +37,259 @@ | |||
# to 0 if you are raising the major API version | |||
LIBPATCH = 1 | |||
|
|||
# TODO: add your code here! Happy coding! | |||
LIB_CONFIG_DIRECTORY = Path("/var/lib/charm-grub/") | |||
GRUB_CONFIG = Path("/etc/default/grub.d/") / "95-juju-charm.cfg" |
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: Why use the /
operator here instead of just specifying the full path inside the Path()
call?
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 was using directory / path, than I removed the directory and now it's back as GRUB_DIRECTORY
.
|
||
def __init__(self, key: str, message: str) -> None: | ||
self._key = key | ||
self._message = message |
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.
Normally you'd simplify and remove the property boilerplate just by having these as self.key = key
and so on. It doesn't really matter that they're writeable.
Also, you might want to customize the __str__
so it's a bit more readable, maybe just def __str__(self): return self.message
?
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 that is good point, I'll do it.
if key in config: | ||
logger.warning("key %s is duplicated in config", key) | ||
|
||
config[key] = 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.
It seems like we're not handling quoting/escaping here. Won't that break things? Perhaps we should use shlex
?
if path.exists(): | ||
logger.debug("grub config %s already exist and it will overwritten", path) | ||
|
||
context = [f"{key}={value}" for key, value in config.items()] |
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.
Similar here. Maybe we need to use shlex.quote
?
|
||
with open(path, "r", encoding="UTF-8") as file: | ||
data = file.read() | ||
config = _parse_config(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.
Seeing we've got the file object anyway, why not avoid reading the whole thing into memory, and use the fact that for line in file
iterates over lines? (make _parse_config
take a file / iterable-of-lines). I realize GRUB configs shouldn't be large, but it's good practice and no harder that way. Similar for writing it below.
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 point I will do it. It happens if you start with _parse_config
function and than add _load_config
🔢
def charm_name(self) -> str: | ||
"""Get charm name or use value obtained from JUJU_UNIT_NAME env.""" | ||
if self._charm_name is None: | ||
self._charm_name, *_ = os.getenv("JUJU_UNIT_NAME", "unknown").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.
To me it seems odd that this is so closely tied to the charm name, particularly the use of environment variables. What's the need for having the charm name in this class? And if needed, I think it'd be preferable to require it to be passed in, rather than parsing environment variables in a library.
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 be able to store only charm config file at /etc/default/grub.d/90-juju-<charm-name>
. Using the env variable is only there so the charm developer does not have to think about it, we do not want to use ops
here. Do you think that it's not needed? I can remove it.
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, I would prefer charm libs don't read environment variables directly, but that the charm name is passed in explicitly to the initialiser.
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.
Ok, I'm going to remove it.
This object will load current configuration option for grub and provide option | ||
to update it with simple validation. | ||
|
||
There is only one public function `update`, which should handle everything. It will |
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.
There's also remove
, by the looks. Might be better not to mention the public methods here to avoid this getting out of date. Perhaps define the public methods at the top (after __init__
) so they're more discoverable?
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 are ring, I forgot that I add remove
and apply
. I'll update docstring.
I'm used to define all public on bottom (my order magic objects (init, eq, ...), private properties, private functions, public properties and public method), but I'm ok to move it to top if that required.
"""Initialize the grub config.""" | ||
self._charm_name = charm_name | ||
|
||
def __contains__(self, key: str) -> bool: |
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.
Would it be simpler to just have GrubConfig
subclass dict
directly?
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.
That said, I often find these "smart objects" cause more confusion than they bring value. It's kind of a dict, but not quite. Worth considering a dumber/simpler API that deals in plain dicts, perhaps top-level functions like read_config() -> dict
and update_config(dict)
?
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 not subclass of dict, only due to usage of lazy loading. If you check example in lib docstring on in this PR.
Basically the idea is to define self.grub = GrubConfig()
in charm __init__
, so it can be access in any function. Some charm may want to use apply
in action instead of install, some maybe will do some configuration via config_changed.
return False | ||
|
||
|
||
class GrubConfig(Mapping[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.
I think we should avoid repeating the name of the module/lib in the class name. Especially for short import names, it's usually better to encourage module.Name
use. So this would be just Config
, and code would use it as import grub ... grub.Config()
. See also https://benhoyt.com/writings/python-api-file-structure/
- implement the is_container funcion - add docstring - test the lib with custom charm
I do not think that this is necessary, since this lib will be used by charm developer and not end user. If charm developer want, he/she can open the config file and do anything, that's why I do not see any benefits to add such validation. Also without such validation we can do {"TEST" : "1", "GRUB_A": "$TEST", "GRUB_B": "$TEST"} |
# info -f grub -n 'Simple configuration' | ||
""" | ||
|
||
# TODO: use shlex |
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.
IMO this should block merge, otherwise it'll be very easy to get config files with invalid format.
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 are right, I forgot to implement and I add only TODO for it. Thanks
def __init__(self, key: str, message: str) -> None: | ||
self.key = key | ||
self.message = message | ||
super().__init__(message) |
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.
Normally you'd call super first (eg: in case it sets .message
and you want to overwrite it). Doesn't matter in this specific case, but it's not good practice.
|
||
|
||
def check_update_grub() -> bool: | ||
"""Check if an update to /boot/grub/grub.cfg is available.""" |
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.
For boolean-returning functions, I like the word "Report whether an update..." as that indicates what the return value is. Similar for is_container
below, "Report whether the local machine is a container."""
|
||
|
||
class Config(Mapping[str, str]): | ||
"""Grub config object. |
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.
"""Grub config object. | |
"""Manages GRUB configuration. |
|
||
Markdown is supported, following the CommonMark specification. | ||
```python |
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.
Should this have an ending ``` before the end of the docstring?
if key in config: | ||
logger.warning("key %s is duplicated in config", key) | ||
|
||
config[key] = shlex.quote(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.
Should _parse_config
be unquoting the value? Perhaps using shlex.split
?
tests/unit/test_grub.py
Outdated
"key %s is duplicated in config", "GRUB_CMDLINE_LINUX_DEFAULT" | ||
) | ||
self.assertEqual( | ||
result["GRUB_CMDLINE_LINUX_DEFAULT"], "'\"$GRUB_CMDLINE_LINUX_DEFAULT pti=on\"'" |
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, per the comment above about quoting -- it's double-quoting things, when it should be unquoting the value. I would expect this:
self.assertEqual(
result["GRUB_CMDLINE_LINUX_DEFAULT"], "$GRUB_CMDLINE_LINUX_DEFAULT pti=on",
)
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 are right, I did not though about it at all. Thanks for pointing this out.
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.
Left a couple of minor comments, but it looks reasonably good to me. I'm not an expert in GRUB, so might pay to get someone to review that is, but maybe one of the other reviewers that's already looked at it has done that?
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 (one nit comment). Do you think you're ready to merge this?
tests/unit/test_grub.py
Outdated
|
||
class TestGrubUtils(BaseTestGrubLib): | ||
def test_split_config_line(self): | ||
"""Tets splitting single 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.
Nit: Tets -> Test (there's also similar instance below).
I think it's ready. Is there anyone else who should do review? |
grub-mkconfig
andupdate-grub
I tested this with simple ubuntu charm, where I tried to configure
GRUB_CMDLINE_LINUX_DEFAULT
. What I tested:/etc/default/grub.d/95-juju-charm.cfg