Skip to content

Commit 8942608

Browse files
committed
vmupdate: support dnf5 python API
1 parent ea79a38 commit 8942608

File tree

2 files changed

+269
-1
lines changed

2 files changed

+269
-1
lines changed

vmupdate/agent/entrypoint.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ def get_package_manager(os_data, log, log_handler, log_level, no_progress):
7474
from source.apt.apt_cli import APTCLI as PackageManager
7575
elif os_data["os_family"] == "RedHat":
7676
try:
77-
from source.dnf.dnf_api import DNF as PackageManager
77+
version = int(os_data["release"].split(".")[0])
78+
except ValueError:
79+
version = 99 # fedora changed its version
80+
try:
81+
if version < 41:
82+
from source.dnf.dnf_api import DNF as PackageManager
83+
else:
84+
from source.dnf.dnf5_api import DNF as PackageManager
7885
except ImportError:
7986
log.warning("Failed to load dnf with progress bar. Use dnf cli.")
8087
# no progress reporting
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
# coding=utf-8
2+
#
3+
# The Qubes OS Project, http://www.qubes-os.org
4+
#
5+
# Copyright (C) 2025 Piotr Bartman <[email protected]>
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
20+
# USA.
21+
22+
import subprocess
23+
24+
import libdnf5
25+
from libdnf5.repo import DownloadCallbacks
26+
from libdnf5.rpm import TransactionCallbacks
27+
from libdnf5.base import Base, Goal
28+
29+
from source.common.process_result import ProcessResult
30+
from source.common.exit_codes import EXIT
31+
from source.common.progress_reporter import ProgressReporter, Progress
32+
33+
from .dnf_cli import DNFCLI
34+
35+
36+
class TransactionError(RuntimeError):
37+
pass
38+
39+
40+
class DNF(DNFCLI):
41+
def __init__(self, log_handler, log_level):
42+
super().__init__(log_handler, log_level)
43+
self.base = Base()
44+
self.base.load_config()
45+
self.base.setup()
46+
self.config = self.base.get_config()
47+
update = FetchProgress(weight=0, log=self.log) # % of total time
48+
fetch = FetchProgress(weight=50, log=self.log) # % of total time
49+
upgrade = UpgradeProgress(weight=50, log=self.log) # % of total time
50+
self.progress = ProgressReporter(update, fetch, upgrade)
51+
52+
def refresh(self, hard_fail: bool) -> ProcessResult:
53+
"""
54+
Use package manager to refresh available packages.
55+
56+
:param hard_fail: raise error if some repo is unavailable
57+
:return: (exit_code, stdout, stderr)
58+
"""
59+
self.config.skip_unavailable = not hard_fail
60+
61+
result = ProcessResult()
62+
try:
63+
self.log.debug("Refreshing available packages...")
64+
repo_sack = self.base.get_repo_sack()
65+
repo_sack.create_repos_from_system_configuration()
66+
repo_sack.load_repos()
67+
self.log.debug("Cache refresh successful.")
68+
except Exception as exc:
69+
self.log.error(
70+
"An error occurred while refreshing packages: %s", str(exc))
71+
result += ProcessResult(EXIT.ERR_VM_REFRESH, out="", err=str(exc))
72+
73+
return result
74+
75+
def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult:
76+
"""
77+
Use `libdnf5` package to upgrade and track progress.
78+
"""
79+
self.config.obsoletes = remove_obsolete
80+
result = ProcessResult()
81+
try:
82+
self.log.debug("Performing package upgrade...")
83+
goal = Goal(self.base)
84+
goal.add_upgrade("*")
85+
transaction = goal.resolve()
86+
# fill empty `Command line` column in dnf history
87+
transaction.set_description("qubes-vm-update")
88+
89+
if transaction.get_transaction_packages_count() == 0:
90+
self.log.info("No packages to upgrade, quitting.")
91+
return ProcessResult(
92+
EXIT.OK, out="",
93+
err="\n".join(transaction.get_resolve_logs_as_strings()))
94+
95+
self.base.set_download_callbacks(
96+
libdnf5.repo.DownloadCallbacksUniquePtr(
97+
self.progress.fetch_progress))
98+
transaction.download()
99+
100+
if not transaction.check_gpg_signatures():
101+
problems = transaction.get_gpg_signature_problems()
102+
raise TransactionError(
103+
f"GPG signatures check failed: {problems}")
104+
105+
if result.code == EXIT.OK:
106+
print("Updating packages.", flush=True)
107+
self.log.debug("Committing upgrade...")
108+
transaction.set_callbacks(
109+
libdnf5.rpm.TransactionCallbacksUniquePtr(
110+
self.progress.upgrade_progress))
111+
tnx_result = transaction.run()
112+
if tnx_result != transaction.TransactionRunResult_SUCCESS:
113+
raise TransactionError(
114+
transaction.transaction_result_to_string(tnx_result))
115+
self.log.debug("Package upgrade successful.")
116+
self.log.info("Notifying dom0 about installed applications")
117+
subprocess.call(['/etc/qubes-rpc/qubes.PostInstall'])
118+
print("Updated", flush=True)
119+
except Exception as exc:
120+
self.log.error(
121+
"An error occurred while upgrading packages: %s", str(exc))
122+
result += ProcessResult(EXIT.ERR_VM_UPDATE, out="", err=str(exc))
123+
return result
124+
125+
126+
class FetchProgress(DownloadCallbacks, Progress):
127+
def __init__(self, weight: int, log):
128+
DownloadCallbacks.__init__(self)
129+
Progress.__init__(self, weight, log)
130+
self.bytes_to_fetch = 0
131+
self.bytes_fetched = 0
132+
self.package_bytes = {}
133+
self.package_names = {}
134+
self.count = 0
135+
136+
def end(self, user_cb_data: int, status: int, msg: str) -> int:
137+
"""
138+
End of download callback.
139+
140+
:param user_cb_data: Associated user data obtained from add_new_download.
141+
:param status: The transfer status.
142+
:param msg: The error message in case of error.
143+
"""
144+
if status != 0:
145+
print(msg, flush=True, file=self._stdout)
146+
else:
147+
print(f"{self.package_names[user_cb_data]}: Fetched", flush=True)
148+
return DownloadCallbacks.end(self, user_cb_data, status, msg)
149+
150+
def mirror_failure(
151+
self, user_cb_data: int, msg: str, url: str, metadata: str
152+
) -> int:
153+
"""
154+
Mirror failure callback.
155+
156+
:param user_cb_data: Associated user data obtained from add_new_download.
157+
:param msg: Error message.
158+
:param url: Failed mirror URL.
159+
:param metadata: the type of metadata that is being downloaded
160+
"""
161+
print(f"Fetching {metadata} failure "
162+
f"({self.package_names[user_cb_data]}) {msg}",
163+
flush=True, file=self._stdout)
164+
return DownloadCallbacks.mirror_failure(
165+
self, user_cb_data, msg, url, metadata)
166+
167+
def progress(
168+
self, user_cb_data: int, total_to_download: float, downloaded: float
169+
) -> int:
170+
"""
171+
Download progress callback.
172+
173+
:param user_cb_data: Associated user data obtained from add_new_download.
174+
:param total_to_download: Total number of bytes to download.
175+
:param downloaded: Number of bytes downloaded.
176+
"""
177+
self.bytes_fetched += downloaded - self.package_bytes[user_cb_data]
178+
self.package_bytes[user_cb_data] = downloaded
179+
percent = self.bytes_fetched / self.bytes_to_fetch * 100
180+
self.notify_callback(percent)
181+
# Should return 0 on success,
182+
# in case anything in dnf5 changed we return their default value
183+
return DownloadCallbacks.progress(
184+
self, user_cb_data, total_to_download, downloaded)
185+
186+
def add_new_download(
187+
self, _user_data, description: str, total_to_download: float
188+
) -> int:
189+
"""
190+
Notify the client that a new download has been created.
191+
192+
:param _user_data: User data entered together with url/package to download.
193+
:param description: The message describing new download (url/packagename).
194+
:param total_to_download: Total number of bytes to download.
195+
:return: Associated user data for new download.
196+
"""
197+
print(f"Fetching package: {description}", flush=True)
198+
self.count += 1
199+
self.bytes_to_fetch += total_to_download
200+
self.package_bytes[self.count] = 0
201+
self.package_names[self.count] = description
202+
# downloading is not started yet
203+
self.notify_callback(0)
204+
return self.count
205+
206+
207+
class UpgradeProgress(TransactionCallbacks, Progress):
208+
def __init__(self, weight: int, log):
209+
TransactionCallbacks.__init__(self)
210+
Progress.__init__(self, weight, log)
211+
self.pgks = None
212+
self.pgks_done = None
213+
214+
def install_progress(
215+
self, item: libdnf5.base.TransactionPackage, amount: int, total: int
216+
):
217+
r"""
218+
Report the package installation progress periodically.
219+
220+
:param item: The TransactionPackage class instance for the package currently being installed
221+
:param amount: The portion of the package already installed
222+
:param total: The disk space used by the package after installation
223+
"""
224+
pkg_progress = amount / total
225+
percent = (self.pgks_done + pkg_progress) / self.pgks * 100
226+
self.notify_callback(percent)
227+
228+
def transaction_start(self, total: int):
229+
r"""
230+
Preparation phase has started.
231+
232+
:param total: The total number of packages in the transaction
233+
"""
234+
self.pgks_done = 0
235+
self.pgks = total
236+
237+
def uninstall_progress(
238+
self, item: libdnf5.base.TransactionPackage, amount: int, total: int
239+
):
240+
"""
241+
Report the package removal progress periodically.
242+
243+
:param item: The TransactionPackage class instance for the package currently being removed
244+
:param amount: The portion of the package already uninstalled
245+
:param total: The disk space freed by the package after removal
246+
"""
247+
pkg_progress = amount / total
248+
percent = (self.pgks_done + pkg_progress) / self.pgks * 100
249+
self.notify_callback(percent)
250+
251+
def elem_progress(self, item, amount: int, total: int):
252+
r"""
253+
The installation/removal process for the item has started
254+
255+
:param item: The TransactionPackage class instance for the package currently being (un)installed
256+
:param amount: Index of the package currently being processed. Items are indexed starting from 0.
257+
:param total: The total number of packages in the transaction
258+
"""
259+
self.pgks_done = amount
260+
percent = amount / total * 100
261+
self.notify_callback(percent)

0 commit comments

Comments
 (0)