Skip to content
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

Merged
merged 10 commits into from
Jul 24, 2023
Merged

Add base logic for grub lib #98

merged 10 commits into from
Jul 24, 2023

Conversation

rgildein
Copy link
Contributor

@rgildein rgildein commented Jul 7, 2023

  • add load function for grub config (assumption no duplicate keys)
  • add save function for grub config
  • add is_container function to determine if running machine is container
  • add GrubConfig object, which load config in lazy mode and provide function to make update (validate, merge and apply changes)
    • apply function with usage grub-mkconfig and update-grub
    • update function to wrap all functionality to update grub config
    • remove function if the charm is removed to remove charm configuration
  • add docstring with example

I tested this with simple ubuntu charm, where I tried to configure GRUB_CMDLINE_LINUX_DEFAULT. What I tested:

  • install method with ValidationError (by manually changing /etc/default/grub.d/95-juju-charm.cfg
  • update-status, where I check if current value is what charm expected
  • remove method, when I removed the charm
#!/usr/bin/env python3

import logging
from pathlib import Path
from subprocess import check_call, check_output
import charms.operator_libs_linux.v0.grub as grub

from ops.charm import CharmBase
from ops.main import main
from ops.model import ActiveStatus, BlockedStatus


log = logging.getLogger(__name__)


class UbuntuCharm(CharmBase):
    def __init__(self, *args):
        super().__init__(*args)
        ...
        ### testing of grub library
        self.framework.observe(self.on.install, self._on_install)
        self.framework.observe(self.on.update_status, self._on_update_status)
        self.framework.observe(self.on.remove, self._on_remove)
        self.grub = grub.GrubConfig(self.meta.name)
        log.info("found keys %s in grub config file", self.grub.keys())

   ...

    def _on_install(self, _):
        """Test configuring grub during install hook."""
        try:
            self.grub.update({"GRUB_CMDLINE_LINUX_DEFAULT": '"$GRUB_CMDLINE_LINUX_DEFAULT hugepagesz=1G"'})
        except grub.ValidationError as error:
            self.unit.status = BlockedStatus(f"[{error.key}] {error.message}")

    def _on_update_status(self, _):
        """Test checking grub configuration during update-status hook."""
        if self.grub["GRUB_CMDLINE_LINUX_DEFAULT"] != '"$GRUB_CMDLINE_LINUX_DEFAULT hugepagesz=1G"':
            self.unit.status = BlockedStatus("wrong grub configuration")

    def _on_remove(self, _):
        """Test cleaning grub configuration during remove hook."""
        self.grub.remove()


if __name__ == "__main__":
    main(UbuntuCharm)

- 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

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...

Copy link
Contributor Author

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.

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

Copy link
Contributor Author

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."""

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

Copy link

@esunar esunar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add:

  1. Note that this only handles simple configurations that starts with GRUB_
  2. add GRUB_.* check for configuration keys

@benhoyt benhoyt changed the title Add load and save logic for grub lirary Add load and save logic for grub library Jul 10, 2023
Copy link
Collaborator

@benhoyt benhoyt left a 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.
Copy link
Collaborator

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.

Copy link
Contributor Author

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?

Copy link
Collaborator

@benhoyt benhoyt Jul 11, 2023

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"
Copy link
Collaborator

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?

Copy link
Contributor Author

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
Copy link
Collaborator

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?

Copy link
Contributor Author

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
Copy link
Collaborator

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()]
Copy link
Collaborator

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)
Copy link
Collaborator

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.

Copy link
Contributor Author

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("/")
Copy link
Collaborator

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.

Copy link
Contributor Author

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.

Copy link
Collaborator

@benhoyt benhoyt Jul 11, 2023

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.

Copy link
Contributor Author

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
Copy link
Collaborator

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?

Copy link
Contributor Author

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:
Copy link
Collaborator

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?

Copy link
Collaborator

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)?

Copy link
Contributor Author

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]):
Copy link
Collaborator

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/

@rgildein rgildein changed the title Add load and save logic for grub library Add base logic for grub lib Jul 11, 2023
@rgildein rgildein marked this pull request as ready for review July 11, 2023 12:02
- implement the is_container funcion
- add docstring
- test the lib with custom charm
@rgildein
Copy link
Contributor Author

We should add:

1. Note that this only handles simple configurations that starts with GRUB_

2. add GRUB_.* check for configuration keys

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
Copy link
Collaborator

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.

Copy link
Contributor Author

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)
Copy link
Collaborator

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."""
Copy link
Collaborator

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Grub config object.
"""Manages GRUB configuration.

@rgildein rgildein requested review from benhoyt and esunar July 12, 2023 08:29
@zmraul zmraul mentioned this pull request Jul 14, 2023

Markdown is supported, following the CommonMark specification.
```python
Copy link
Collaborator

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)
Copy link
Collaborator

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?

"key %s is duplicated in config", "GRUB_CMDLINE_LINUX_DEFAULT"
)
self.assertEqual(
result["GRUB_CMDLINE_LINUX_DEFAULT"], "'\"$GRUB_CMDLINE_LINUX_DEFAULT pti=on\"'"
Copy link
Collaborator

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",
)

Copy link
Contributor Author

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.

tests/unit/test_grub.py Show resolved Hide resolved
@rgildein rgildein requested a review from benhoyt July 17, 2023 16:05
Copy link
Collaborator

@benhoyt benhoyt left a 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?

lib/charms/operator_libs_linux/v0/grub.py Outdated Show resolved Hide resolved
lib/charms/operator_libs_linux/v0/grub.py Outdated Show resolved Hide resolved
@rgildein rgildein requested a review from benhoyt July 19, 2023 07:45
Copy link
Collaborator

@benhoyt benhoyt left a 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?


class TestGrubUtils(BaseTestGrubLib):
def test_split_config_line(self):
"""Tets splitting single line."""
Copy link
Collaborator

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).

@rgildein
Copy link
Contributor Author

LGTM (one nit comment). Do you think you're ready to merge this?

I think it's ready. Is there anyone else who should do review?

@benhoyt benhoyt merged commit b8efcff into canonical:main Jul 24, 2023
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants