Skip to content

Commit c79b41b

Browse files
committed
Merge remote-tracking branch 'origin/pr/187'
* origin/pr/187: vmupdate: print info what's happening in dnf5 vmupdate: cleanup vmupdate: expire cache + correct option name vmupdate: print fetching info vmupdate: print info if progress reporting isn't supported vmupdate: do not flood stdout with messages in dnf5_api vmupdate: try dnf4 if dnf5 is not available vmupdate: support dnf5 python API
2 parents 6bb5a45 + 2705f61 commit c79b41b

File tree

6 files changed

+352
-15
lines changed

6 files changed

+352
-15
lines changed

vmupdate/agent/entrypoint.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,41 @@ def get_package_manager(os_data, log, log_handler, log_level, no_progress):
6666
try:
6767
from source.apt.apt_api import APT as PackageManager
6868
except ImportError:
69-
log.warning("Failed to load apt with progress bar. Use apt cli.")
69+
log.warning("Failed to load apt with progress bar. Using apt cli.")
7070
# no progress reporting
7171
no_progress = True
72+
print(f"Progress reporting not supported.", flush=True)
7273

7374
if no_progress:
7475
from source.apt.apt_cli import APTCLI as PackageManager
7576
elif os_data["os_family"] == "RedHat":
7677
try:
77-
from source.dnf.dnf_api import DNF as PackageManager
78-
except ImportError:
79-
log.warning("Failed to load dnf with progress bar. Use dnf cli.")
80-
# no progress reporting
81-
no_progress = True
82-
83-
if no_progress:
78+
version = int(os_data["release"].split(".")[0])
79+
except ValueError:
80+
version = 99 # fedora changed its version
81+
82+
loaded = False
83+
if version >= 41:
84+
try:
85+
from source.dnf.dnf5_api import DNF as PackageManager
86+
loaded = True
87+
except ImportError:
88+
log.warning("Failed to load dnf5.")
89+
90+
if not loaded:
91+
try:
92+
from source.dnf.dnf_api import DNF as PackageManager
93+
loaded = True
94+
except ImportError:
95+
log.warning(
96+
"Failed to load dnf with progress bar. Using dnf cli.")
97+
print(f"Progress reporting not supported.", flush=True)
98+
99+
if no_progress or not loaded:
84100
from source.dnf.dnf_cli import DNFCLI as PackageManager
85101
elif os_data["os_family"] == "ArchLinux":
86102
from source.pacman.pacman_cli import PACMANCLI as PackageManager
103+
print(f"Progress reporting not supported.", flush=True)
87104
else:
88105
raise NotImplementedError(
89106
"Only Debian, RedHat and ArchLinux based OS is supported.")

vmupdate/agent/source/apt/apt_api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class FetchProgress(apt.progress.base.AcquireProgress, Progress):
108108
def __init__(self, weight: int, log, refresh: bool = False):
109109
Progress.__init__(self, weight, log)
110110
self.action = "refresh" if refresh else "fetch"
111+
self.fetching_notified = False
111112

112113
def fail(self, item):
113114
"""
@@ -126,13 +127,19 @@ def pulse(self, _owner):
126127
This function returns a boolean value indicating whether the
127128
acquisition should be continued (True) or cancelled (False).
128129
"""
130+
if self.action == "fetch" and not self.fetching_notified:
131+
print(f"Fetching {self.total_items} packages "
132+
f"[{self._format_bytes(self.total_bytes)}]",
133+
flush=True)
134+
self.fetching_notified = True
129135
self.notify_callback(self.current_bytes / self.total_bytes * 100)
130136
return True
131137

132138
def start(self):
133139
"""Invoked when the Acquire process starts running."""
134140
self.log.info(f"{self.action.capitalize()} started.")
135-
print(f"{self.action.capitalize()}ing packages.", flush=True)
141+
if self.action == "refresh":
142+
print("Refreshing available packages.", flush=True)
136143
super().start()
137144
self.notify_callback(0)
138145

vmupdate/agent/source/common/progress_reporter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ def notify_callback(self, percent):
6464
self._callback(_percent)
6565
self._last_percent = _percent
6666

67+
@staticmethod
68+
def _format_bytes(size):
69+
units = ["B", "KB", "MB", "GB", "TB", "PB"]
70+
factor = 1000
71+
for unit in units:
72+
if size < factor:
73+
return f"{size:.2f} {unit}"
74+
size /= factor
75+
return f"{size:.2f} {units[-1]}"
76+
6777

6878
class ProgressReporter:
6979
"""
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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_if_unavailable = not hard_fail
61+
62+
result = ProcessResult()
63+
try:
64+
self.log.debug("Refreshing available packages...")
65+
66+
result += self.expire_cache()
67+
68+
repo_sack = self.base.get_repo_sack()
69+
repo_sack.create_repos_from_system_configuration()
70+
repo_sack.load_repos()
71+
self.log.debug("Cache refresh successful.")
72+
except Exception as exc:
73+
self.log.error(
74+
"An error occurred while refreshing packages: %s", str(exc))
75+
result += ProcessResult(EXIT.ERR_VM_REFRESH, out="", err=str(exc))
76+
77+
return result
78+
79+
def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult:
80+
"""
81+
Use `libdnf5` package to upgrade and track progress.
82+
"""
83+
self.config.obsoletes = remove_obsolete
84+
result = ProcessResult()
85+
try:
86+
self.log.debug("Performing package upgrade...")
87+
goal = Goal(self.base)
88+
goal.add_upgrade("*")
89+
transaction = goal.resolve()
90+
# fill empty `Command line` column in dnf history
91+
transaction.set_description("qubes-vm-update")
92+
93+
if transaction.get_transaction_packages_count() == 0:
94+
self.log.info("No packages to upgrade, quitting.")
95+
return ProcessResult(
96+
EXIT.OK, out="",
97+
err="\n".join(transaction.get_resolve_logs_as_strings()))
98+
99+
self.base.set_download_callbacks(
100+
libdnf5.repo.DownloadCallbacksUniquePtr(
101+
self.progress.fetch_progress))
102+
transaction.download()
103+
104+
if not transaction.check_gpg_signatures():
105+
problems = transaction.get_gpg_signature_problems()
106+
raise TransactionError(
107+
f"GPG signatures check failed: {problems}")
108+
109+
if result.code == EXIT.OK:
110+
print("Updating packages.", flush=True)
111+
self.log.debug("Committing upgrade...")
112+
transaction.set_callbacks(
113+
libdnf5.rpm.TransactionCallbacksUniquePtr(
114+
self.progress.upgrade_progress))
115+
tnx_result = transaction.run()
116+
if tnx_result != transaction.TransactionRunResult_SUCCESS:
117+
raise TransactionError(
118+
transaction.transaction_result_to_string(tnx_result))
119+
self.log.debug("Package upgrade successful.")
120+
self.log.info("Notifying dom0 about installed applications")
121+
subprocess.call(['/etc/qubes-rpc/qubes.PostInstall'])
122+
print("Updated", flush=True)
123+
except Exception as exc:
124+
self.log.error(
125+
"An error occurred while upgrading packages: %s", str(exc))
126+
result += ProcessResult(EXIT.ERR_VM_UPDATE, out="", err=str(exc))
127+
return result
128+
129+
130+
class FetchProgress(DownloadCallbacks, Progress):
131+
def __init__(self, weight: int, log):
132+
DownloadCallbacks.__init__(self)
133+
Progress.__init__(self, weight, log)
134+
self.bytes_to_fetch = 0
135+
self.bytes_fetched = 0
136+
self.package_bytes = {}
137+
self.package_names = {}
138+
self.count = 0
139+
self.fetching_notified = False
140+
141+
def add_new_download(
142+
self, _user_data, description: str, total_to_download: float
143+
) -> int:
144+
"""
145+
Notify the client that a new download has been created.
146+
147+
:param _user_data: User data entered together with url/package to download.
148+
:param description: The message describing new download (url/packagename).
149+
:param total_to_download: Total number of bytes to download.
150+
:return: Associated user data for new download.
151+
"""
152+
self.count += 1
153+
self.bytes_to_fetch += total_to_download
154+
self.package_bytes[self.count] = 0
155+
self.package_names[self.count] = description
156+
# downloading is not started yet
157+
self.notify_callback(0)
158+
return self.count
159+
160+
def progress(
161+
self, user_cb_data: int, total_to_download: float, downloaded: float
162+
) -> int:
163+
"""
164+
Download progress callback.
165+
166+
:param user_cb_data: Associated user data obtained from add_new_download.
167+
:param total_to_download: Total number of bytes to download.
168+
:param downloaded: Number of bytes downloaded.
169+
"""
170+
if not self.fetching_notified:
171+
print(f"Fetching {self.count} packages "
172+
f"[{self._format_bytes(self.bytes_to_fetch)}]",
173+
flush=True)
174+
self.fetching_notified = True
175+
self.bytes_fetched += downloaded - self.package_bytes[user_cb_data]
176+
if downloaded > self.package_bytes[user_cb_data]:
177+
if self.package_bytes[user_cb_data] == 0:
178+
print(f"Fetching {self.package_names[user_cb_data]} [{self._format_bytes(total_to_download)}]",
179+
flush=True)
180+
self.package_bytes[user_cb_data] = downloaded
181+
percent = self.bytes_fetched / self.bytes_to_fetch * 100
182+
self.notify_callback(percent)
183+
# Should return 0 on success,
184+
# in case anything in dnf5 changed we return their default value
185+
return DownloadCallbacks.progress(
186+
self, user_cb_data, total_to_download, downloaded)
187+
188+
def end(self, user_cb_data: int, status: int, msg: str) -> int:
189+
"""
190+
End of download callback.
191+
192+
:param user_cb_data: Associated user data obtained from add_new_download.
193+
:param status: The transfer status.
194+
:param msg: The error message in case of error.
195+
"""
196+
if status != 0:
197+
print(msg, flush=True, file=self._stdout)
198+
return DownloadCallbacks.end(self, user_cb_data, status, msg)
199+
200+
def mirror_failure(
201+
self, user_cb_data: int, msg: str, url: str, metadata: str
202+
) -> int:
203+
"""
204+
Mirror failure callback.
205+
206+
:param user_cb_data: Associated user data obtained from add_new_download.
207+
:param msg: Error message.
208+
:param url: Failed mirror URL.
209+
:param metadata: the type of metadata that is being downloaded
210+
"""
211+
print(f"Fetching {metadata} failure "
212+
f"({self.package_names[user_cb_data]}) {msg}",
213+
flush=True, file=self._stdout)
214+
return DownloadCallbacks.mirror_failure(
215+
self, user_cb_data, msg, url, metadata)
216+
217+
218+
class UpgradeProgress(TransactionCallbacks, Progress):
219+
def __init__(self, weight: int, log):
220+
TransactionCallbacks.__init__(self)
221+
Progress.__init__(self, weight, log)
222+
self.pgks = None
223+
self.pgks_done = None
224+
self.processed_packages = set()
225+
226+
def install_progress(
227+
self, item: libdnf5.base.TransactionPackage, amount: int, total: int
228+
):
229+
r"""
230+
Report the package installation progress periodically.
231+
232+
:param item: The TransactionPackage class instance for the package currently being installed
233+
:param amount: The portion of the package already installed
234+
:param total: The disk space used by the package after installation
235+
"""
236+
package = item.get_package().get_full_nevra()
237+
if package not in self.processed_packages:
238+
print(f"Installing {package}", flush=True)
239+
self.processed_packages.add(package)
240+
pkg_progress = amount / total
241+
percent = (self.pgks_done + pkg_progress) / self.pgks * 100
242+
self.notify_callback(percent)
243+
244+
def transaction_start(self, total: int):
245+
r"""
246+
Preparation phase has started.
247+
248+
:param total: The total number of packages in the transaction
249+
"""
250+
self.pgks_done = 0
251+
self.pgks = total
252+
253+
def uninstall_progress(
254+
self, item: libdnf5.base.TransactionPackage, amount: int, total: int
255+
):
256+
"""
257+
Report the package removal progress periodically.
258+
259+
:param item: The TransactionPackage class instance for the package currently being removed
260+
:param amount: The portion of the package already uninstalled
261+
:param total: The disk space freed by the package after removal
262+
"""
263+
package = item.get_package().get_full_nevra()
264+
if package not in self.processed_packages:
265+
print(f"Uninstalling {package}", flush=True)
266+
self.processed_packages.add(package)
267+
pkg_progress = amount / total
268+
percent = (self.pgks_done + pkg_progress) / self.pgks * 100
269+
self.notify_callback(percent)
270+
271+
def elem_progress(self, item, amount: int, total: int):
272+
r"""
273+
The installation/removal process for the item has started
274+
275+
:param item: The TransactionPackage class instance for the package currently being (un)installed
276+
:param amount: Index of the package currently being processed. Items are indexed starting from 0.
277+
:param total: The total number of packages in the transaction
278+
"""
279+
self.pgks_done = amount
280+
percent = amount / total * 100
281+
self.notify_callback(percent)
282+
283+
def script_start(self, item: libdnf5.base.TransactionPackage, nevra, type: int):
284+
r"""
285+
Execution of the rpm scriptlet has started
286+
287+
:param item: The TransactionPackage class instance for the package that owns the executed or triggered
288+
scriptlet. It can be `nullptr` if the scriptlet owner is not part of the transaction
289+
(e.g., a package installation triggered an update of the man database, owned by man-db package).
290+
:param nevra: Nevra of the package that owns the executed or triggered scriptlet.
291+
:param type: Type of the scriptlet
292+
"""
293+
print(f"Running rpm scriptlet for {nevra.get_name()}-{nevra.get_epoch()}:{nevra.get_version()}"
294+
f"-{nevra.get_release()}.{nevra.get_arch()}", flush=True)

0 commit comments

Comments
 (0)