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

feat: add dnf charm library #75

Merged
merged 23 commits into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e44a31e
enhance: Add initial dnf charm library
NucciTheBoss Feb 28, 2023
eb39972
bugfix: There is not actually a `dnf purge` command
NucciTheBoss Mar 1, 2023
0865332
bugfix: Install `dnf-plugins-core` when dnf is first defined.
NucciTheBoss Mar 1, 2023
a4ac907
enhance: Add tox env for running tests with cleantest
NucciTheBoss Mar 1, 2023
4dfccb7
enhance: Add integration tests for dnf charm library
NucciTheBoss Mar 1, 2023
f931eb2
enhance: Pin version of setup-lxd action to v0.1.0 and specify specif…
NucciTheBoss Mar 1, 2023
465375a
enhance: Add pipeline section for running integration tests with clea…
NucciTheBoss Mar 1, 2023
bc5a24d
refactor: Switch rpm env to Alma Linux 9
NucciTheBoss Mar 1, 2023
1ae289d
bugfix: Add ignore for test_dnf
NucciTheBoss Mar 1, 2023
5fea119
refactor: Simplify tox env name
NucciTheBoss Mar 3, 2023
661114b
refactor: Reduce complexity of dnf library
NucciTheBoss Mar 3, 2023
6a2b799
refactor: Update tests to reflect new dnf API
NucciTheBoss Mar 3, 2023
6518bcf
refactor: Further refinement
NucciTheBoss Mar 6, 2023
b55e859
refactor: Change PackageInfo to dataclass
NucciTheBoss Mar 7, 2023
d19923e
refactor: Further refinement
NucciTheBoss Mar 7, 2023
8d1ccc5
refactor: Make integration test similar to other tests
NucciTheBoss Mar 7, 2023
75204be
refactor: Update CI and tox to use CentOS 9 Stream image for testing dnf
NucciTheBoss Mar 7, 2023
0f14712
refactor: Fix spelling error in install docstring
NucciTheBoss Mar 7, 2023
5c47c80
enhance: Add unit tests
NucciTheBoss Mar 7, 2023
9f137e0
refactor: Drop type annotations in docstrings
NucciTheBoss Mar 8, 2023
f75e25f
refactor: Return package info rather than recapture error
NucciTheBoss Mar 8, 2023
07dbc8f
feat: register the dnf lib with charmhub
jnsgruk Mar 8, 2023
50a9aaf
docs: Add module-level docstring for Charmhub documentation
NucciTheBoss Mar 8, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: canonical/setup-lxd@90d76101915da56a42a562ba766b1a77019242fd
- uses: canonical/[email protected]
with:
channel: 5.9/stable
- name: Install dependencies
run: python -m pip install tox
- name: Run integration tests
Expand Down
299 changes: 299 additions & 0 deletions lib/charms/operator_libs_linux/v0/dnf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Copyright 2023 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Abstractions for the system's DNF package information and repositories.

This charm library contains abstractions and wrappers around Enterprise Linux
(CentOS Stream, AlmaLinux, Rocky Linux, Cloud Linux, etc.) repositories and packages
to provide an idiomatic and Pythonic mechanism for managing packages and repositories
within machine charms deployed to an Enterprise Linux base.

To install packages using the library:

```python
import charms.operator_libs_linux.v0.dnf as dnf

try:
dnf.install("epel-release")
dnf.install("slurm-slurmd", "slurm-slurmctld", "slurm-slurmdbd", "slurm-slurmrestd")
except dnf.Error:
logger.error("Failed to install requested packages.")
```

To upgrade specific packages and/or entire system using the library:

```python
import charms.operator_libs_linux.v0.dnf as dnf

try:
# To upgrade a specific package.
dnf.upgrade("slurm-slurmd")
# To upgrade the whole system.
dnf.upgrade()
except dnf.Error:
logger.error("Failed to perform requested package upgrades.")
```

__Important:__ If no packages are passed to `dnf.upgrade(...)`, all packages with newer
versions available in the system's known repositories will be upgraded.

To remove packages using the library:

```python
import charms.operator_libs_linux.v0.dnf as dnf

try:
dnf.remove("slurm-slurmd", "slurm-slurmctld", "slurm-slurmdbd", "slurm-slurmrestd")
dnf.remove("epel-release")
except dnf.Error:
logger.error("Failed to remove requested packages.")
```

Packages can have three possible states: installed, available, and absent. "installed"
means that the package is currently installed on the system. "available" means that the
package is available within the system's known package repositories, but is not currently
installed. "absent" means that the package was not found either or the system on within
the system's known package repositories.

To find details of a specific package using the library:

```python
import charms.operator_libs_linux.v0.dnf as dnf

try:
package = dnf.fetch("slurm-slurmd")
assert package.installed
assert package.version == "22.05.6"
except AssertionError:
logger.error("Package slurm-slurmd is not installed or is not expected version.")
```

__Important:__ `dnf.fetch(...)` will only match exact package names, not aliases. Ensure
that you are using the exact package name, otherwise the fetched package will be marked
as "absent". e.g. use the exact package name "python2.7" and not the alias "python2".

To add a new package repository using the library:

```python
import charms.operator_libs_linux.v0.dnf as dnf

try:
dnf.add_repo("http://mirror.stream.centos.org/9-stream/HighAvailability/x86_64/os")
dnf.install("pacemaker")
except dnf.Error:
logger.error("Failed to install pacemaker package from HighAvailability repository.")
```
"""

import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Union

# The unique Charmhub library identifier, never change it
LIBID = "1e93f444444d4a4a8df06c1c16b33aaf"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 1


class Error(Exception):
"""Raise when dnf encounters an execution error."""


class _PackageState(Enum):
INSTALLED = "installed"
AVAILABLE = "available"
ABSENT = "absent"


@dataclass(frozen=True)
class PackageInfo:
"""Dataclass representing DNF package information."""

name: str
_state: _PackageState
arch: str = None
epoch: str = None
version: str = None
release: str = None
repo: str = None

@property
def installed(self) -> bool:
"""Determine if package is marked 'installed'."""
return self._state == _PackageState.INSTALLED

@property
def available(self) -> bool:
"""Determine if package is marked 'available'."""
return self._state == _PackageState.AVAILABLE

@property
def absent(self) -> bool:
"""Determine if package is marked 'absent'."""
return self._state == _PackageState.ABSENT

@property
def full_version(self) -> Optional[str]:
"""Get full version of package."""
if self.absent:
return None

full_version = [self.version, f"-{self.release}"]
if self.epoch:
full_version.insert(0, f"{self.epoch}:")

return "".join(full_version)


def version() -> str:
"""Get version of `dnf` executable."""
return _dnf("--version").splitlines()[0]


def installed() -> bool:
"""Determine if the `dnf` executable is available on PATH."""
return shutil.which("dnf") is not None


def upgrade(*packages: Optional[str]) -> None:
"""Upgrade one or more packages.

Args:
*packages:
Packages to upgrade on system. If packages is omitted,
upgrade all packages on the system.
"""
_dnf("upgrade", *packages)


def install(*packages: Union[str, os.PathLike]) -> None:
"""Install one or more packages.

Args:
*packages: Packages to install on the system.
"""
if not packages:
raise TypeError("No packages specified.")
_dnf("install", *packages)


def remove(*packages: str) -> None:
"""Remove one or more packages from the system.

Args:
*packages: Packages to remove from system.
"""
if not packages:
raise TypeError("No packages specified.")
_dnf("remove", *packages)


def fetch(package: str) -> PackageInfo:
"""Fetch information about a package.

Args:
package: Package to get information about.

Returns:
PackageInfo: Information about package.

Notes:
`package` needs to exactly match the name of the package that you are fetching.
For example, if working with the `python2` package on select EL distributions,
`dnf.install("python2")` will succeed, but `dnf.fetch("python2")` will return
the package in ABSENT state. This is because the name of the python2 package is
python2.7, not python2. To get info about the python2 package, you need to use
its exact name: `dnf.fetch("python2.7")`.
"""
try:
stdout = _dnf("list", "-q", package)
# Only take top two lines of output.
status, info = stdout.splitlines()[:2]

# Check if package is in states INSTALLED or AVAILABLE. If not, mark absent.
if "Installed" in status:
state = _PackageState.INSTALLED
elif "Available" in status:
state = _PackageState.AVAILABLE
else:
return PackageInfo(name=package, _state=_PackageState.ABSENT)

pkg_name, pkg_version, pkg_repo = info.split()
name, arch = pkg_name.rsplit(".", 1)

# Version should be good, but if not mark absent since package
# is probably in a bad state then.
version_match = re.match(r"(?:(.*):)?(.*)-(.*)", pkg_version)
if not version_match:
return PackageInfo(name=package, _state=_PackageState.ABSENT)
else:
epoch, version, release = version_match.groups()

return PackageInfo(
name=name,
arch=arch,
epoch=epoch,
version=version,
release=release,
repo=pkg_repo[1:] if pkg_repo.startswith("@") else pkg_repo,
_state=state,
)
except Error:
return PackageInfo(name=package, _state=_PackageState.ABSENT)


def add_repo(repo: str) -> None: # pragma: no cover
"""Add a new repository to DNF.

Args:
repo: URL of new repository to add.
"""
if not fetch("dnf-plugins-core").installed:
install("dnf-plugins-core")
_dnf("config-manager", "--add-repo", repo)


def _dnf(*args: str) -> str:
"""Execute a DNF command.

Args:
*args: Arguments to pass to `dnf` executable.

Raises:
Error: Raised if DNF command execution fails.

Returns:
str: Captured stdout of executed DNF command.
"""
try:
return subprocess.run(
["dnf", "-y", *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
).stdout.strip("\n")
except FileNotFoundError:
raise Error(f"dnf not found on PATH {os.getenv('PATH')}")
except subprocess.CalledProcessError as e:
raise Error(f"{e} Reason:\n{e.stderr}")
61 changes: 61 additions & 0 deletions tests/integration/test_dnf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.

"""Integration tests for dnf charm library."""

import charms.operator_libs_linux.v0.dnf as dnf


def test_install_package() -> None:
dnf.install("epel-release")
dnf.install("slurm-slurmd", "slurm-slurmctld", "slurm-slurmdbd", "slurm-slurmrestd")
assert dnf.fetch("epel-release").installed
assert dnf.fetch("slurm-slurmd").installed
assert dnf.fetch("slurm-slurmctld").installed
assert dnf.fetch("slurm-slurmdbd").installed
assert dnf.fetch("slurm-slurmrestd").installed


def test_query_dnf_and_package() -> None:
assert dnf.version() == "4.14.0"
package = dnf.fetch("slurm-slurmd")
assert package.installed
assert not package.available
assert not package.absent
assert package.name == "slurm-slurmd"
assert package.arch == "x86_64"
assert package.epoch is None
assert package.version == "22.05.6"
assert package.release == "3.el9"
assert package.full_version == "22.05.6-3.el9"
assert package.repo == "epel"


def test_remove_package() -> None:
dnf.remove("slurm-slurmdbd")
dnf.remove("slurm-slurmd", "slurm-slurmrestd", "slurm-slurmctld")
assert dnf.fetch("slurm-slurmdbd").available
assert dnf.fetch("slurm-slurmd").available
assert dnf.fetch("slurm-slurmrestd").available
assert dnf.fetch("slurm-slurmctld").available


def test_check_absent() -> None:
bogus = dnf.fetch("nuccitheboss")
assert not bogus.installed
assert not bogus.available
assert bogus.absent
assert bogus.name == "nuccitheboss"
assert bogus.arch is None
assert bogus.epoch is None
assert bogus.version is None
assert bogus.release is None
assert bogus.full_version is None
assert bogus.repo is None


def test_add_repo() -> None:
dnf.add_repo("http://mirror.stream.centos.org/9-stream/HighAvailability/x86_64/os")
dnf.install("pacemaker")
assert dnf.fetch("pacemaker").installed
Loading