Skip to content

Commit 88e6f7c

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

File tree

2 files changed

+270
-1
lines changed

2 files changed

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

0 commit comments

Comments
 (0)