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 12 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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ 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
run: tox -e integration
- name: Run integration tests - cleantest
run: tox -e integration-cleantest
239 changes: 239 additions & 0 deletions lib/charms/operator_libs_linux/v0/dnf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
# 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 system's DNF package information and repositories."""

import os
import re
import shutil
import subprocess
from enum import Enum
from io import StringIO
from typing import Dict, Optional, Union


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


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


class PackageInfo:
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
"""Dataclass representing DNF package information."""
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
jnsgruk marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, data: Dict[str, Union[str, _PackageState]]) -> None:
self._data = data

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

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

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

@property
def unknown(self) -> bool:
"""Determine if package is marked 'unknown'."""
return self._data["state"] == _PackageState.UNKNOWN

@property
def name(self) -> str:
"""Get name of package."""
return self._data["name"]

@property
def arch(self) -> Optional[str]:
"""Get architecture of package."""
return self._data.get("arch", None)

@property
def epoch(self) -> Optional[str]:
"""Get epoch of package."""
return self._data.get("epoch", None)

@property
def version(self) -> Optional[str]:
"""Get version of package."""
return self._data.get("version", None)

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

@property
def release(self) -> Optional[str]:
"""Get release of package."""
return self._data.get("release", None)

@property
def repo(self) -> Optional[str]:
"""Get repository package is from."""
return self._data.get("repo", None)


def version() -> str:
"""Get version of `dnf` executable."""
return StringIO(_dnf("--version")).readline().strip("\n")


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


def update() -> None:
"""Update all packages on the system."""
_dnf("update")
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved


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

Args:
*packages (str): Packages to upgrade on system.
"""
if len(packages) == 0:
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
raise ExecutionError("No packages specified.")
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
_dnf("upgrade", *packages)


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

Args:
*packages (Union[str, os.PathLine]): Packages to install on the system.
"""
if len(packages) == 0:
raise TypeError("No packages specified.")
_dnf("install", *packages)


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

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


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

Args:
*packages (str): Packages to purge from system.
"""
if len(packages) == 0:
raise TypeError("No packages specified.")
_dnf("remove", *packages)
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved


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

Args:
package (str): Package to get information about.

Returns:
PackageInfo: Information about package.
"""
try:
status, info = _dnf("list", "-q", package).split("\n")[:2] # Only take top two lines.
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved
pkg_name, pkg_version, pkg_repo = info.split()
name, arch = pkg_name.rsplit(".", 1)
epoch, version, release = re.match(r"(?:(.*):)?(.*)-(.*)", pkg_version).groups()
if "Installed" in status:
state = _PackageState.INSTALLED
elif "Available" in status:
state = _PackageState.AVAILABLE
else:
state = _PackageState.UNKNOWN
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved

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 ExecutionError:
return PackageInfo({"name": package, "state": _PackageState.ABSENT})


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

Args:
repo (str): 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 (str): Arguments to pass to `dnf` executable.

Raises:
ExecutionError: Raised if DNF command execution fails.

Returns:
str: Captured stdout of executed DNF command.
"""
if not installed():
raise ExecutionError(f"dnf not found on PATH {os.getenv('PATH')}")
NucciTheBoss marked this conversation as resolved.
Show resolved Hide resolved

cmd = ["dnf", "-y", *args]
try:
return subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True,
).stdout.strip("\n")
except subprocess.CalledProcessError as e:
raise ExecutionError(f"{e} Reason:\n{e.stderr}")
Loading