Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
url="https://www.qubes-os.org/",
packages=setuptools.find_packages(include=("vmupdate", "vmupdate*")),
entry_points={
"console_scripts": "qubes-vm-update = vmupdate.vmupdate:main",
"console_scripts": [
"qubes-vm-update = vmupdate.vmupdate:main",
"qvm-template-upgrade = "
"vmupdate.template_upgrade:main",
],
},
)
36 changes: 28 additions & 8 deletions vmupdate/agent/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,35 @@ def main(args=None):
os_data, log, log_handler, log_level, agent_type, args.no_progress
)

log.debug("Running upgrades.")
return_code = pkg_mng.upgrade(
refresh=not args.no_refresh,
hard_fail=not args.force_upgrade,
remove_obsolete=not args.leave_obsolete,
print_streams=args.show_output,
)
if args.version_upgrade:
log.debug(
"Running distribution version upgrade to %s.",
args.version_upgrade,
)
try:
return_code = pkg_mng.version_upgrade(
args.version_upgrade, print_streams=args.show_output
)
except NotImplementedError as err:
log.error("Distribution version upgrade failed: %s", err)
print(str(err), file=sys.stderr)
return_code = EXIT.ERR_VM_UPDATE
else:
log.debug("Running upgrades.")
return_code = pkg_mng.upgrade(
refresh=not args.no_refresh,
hard_fail=not args.force_upgrade,
remove_obsolete=not args.leave_obsolete,
print_streams=args.show_output,
)

if not pkg_mng.PROGRESS_REPORTING and not args.no_progress:
# A version upgrade emits its own 0/100 milestones, so only the normal
# update path needs the fallback "finished" tick for CLI managers.
if (
not args.version_upgrade
and not pkg_mng.PROGRESS_REPORTING
and not args.no_progress
):
# even if progress reporting is unavailable we want info that update finished
if agent_type is AgentType.UPDATE_VM:
print(f"{55:.2f}", flush=True, file=sys.stderr)
Expand Down
14 changes: 13 additions & 1 deletion vmupdate/agent/source/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ class AgentArgs:
"action": "store_true",
"help": "Only download packages",
},
("--version-upgrade",): {
"action": "store",
"default": None,
"help": "Upgrade the distribution to the given next major "
"release, e.g. --version-upgrade 42. Without this flag a "
"normal same-release package update is performed.",
},
}
EXCLUSIVE_OPTIONS_1 = {
("--show-output", "--verbose", "-v"): {
Expand Down Expand Up @@ -104,5 +111,10 @@ def to_cli_args(args):
if args_dict[param_name]:
cli_args.append(keys[0])
else:
cli_args.extend((keys[0], args_dict[param_name]))
# Value-bearing options default to None when unset (e.g.
# --version-upgrade on a normal update). Skip those so we
# never inject a bare "None" into the agent command line.
arg_value = args_dict[param_name]
if arg_value is not None:
cli_args.extend((keys[0], str(arg_value)))
return cli_args
44 changes: 44 additions & 0 deletions vmupdate/agent/source/common/package_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,33 @@ def upgrade(
print(result.err, file=sys.stderr, flush=True)
return result.code

def version_upgrade(
self,
target_version: str,
print_streams: bool = False,
) -> int:
"""
Upgrade the distribution to the next major release.

Separate from `upgrade`, which only refreshes packages within the
current release. The family-specific work lives in
`_release_upgrade`.

:param target_version: the major release to move to, e.g. "42"
:param print_streams: dump captured output to std streams
:return: return code (0 on success)
"""
result = self._release_upgrade(target_version)
self._log_output("version-upgrade", result)
# Logs are for the agent log; print_streams is only CLI passthrough.
# Avoid printing again when the subprocess already streamed live.
if print_streams and not result.posted:
if result.out:
print(result.out, flush=True)
Comment thread
nihalxkumar marked this conversation as resolved.
if result.err:
print(result.err, file=sys.stderr, flush=True)
return result.code

def _upgrade(
self,
refresh: bool,
Expand Down Expand Up @@ -312,6 +339,23 @@ def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult:

return self.run_cmd(cmd)

def _release_upgrade(self, target_version: str) -> ProcessResult:
"""
Perform a distribution release upgrade.

Overridden per package-manager family. The default raises
NotImplementedError; callers (e.g. entrypoint.main) catch it and
decide how to report the unsupported path.
"""
raise self._missing_release_upgrade_error()

def _missing_release_upgrade_error(self) -> NotImplementedError:
"""Build the error used when a family has no release upgrade."""
return NotImplementedError(
"Distribution version upgrade is not implemented for this "
f"package manager ({self.package_manager})."
)

def clean(self) -> int:
"""
Clean cache files of package manager.
Expand Down
113 changes: 113 additions & 0 deletions vmupdate/agent/source/dnf/dnf_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.

import sys
import shutil
from typing import List

from source.utils import get_os_data
from source.common.package_manager import PackageManager, AgentType
from source.common.process_result import ProcessResult
from source.common.exit_codes import EXIT
Expand Down Expand Up @@ -142,6 +144,117 @@ def get_action(self, remove_obsolete) -> List[str]:
result.append("update")
return result

def _release_upgrade(self, target_version: str) -> ProcessResult:
"""
Move the qube to a new Fedora/RHEL release with `distro-sync`.

Crossing a release boundary is not the same as a normal update:
we point dnf/yum at the *target* `--releasever` and let
`distro-sync` converge the whole package set onto that release
(installing, upgrading, and erasing as needed). A plain `upgrade`
would leave the system on a mix of old and new release packages.

dom0 derives `target_version` from `os-*` qvm-features that this
agent wrote on an earlier boot, and those can drift, so we re-read
the distribution from inside the qube and refuse on any mismatch
before doing anything irreversible.

Progress is reported as bare floats on stderr (one per line),
which dom0's QubeConnection parses to drive the progress bar.
0 at the start and 100 once distro-sync succeeds. A whole-release
distro-sync has no usable fine-grained progress, so the streamed
package output is what gives the user liveliness.
"""
target = str(target_version).strip()

# Re-verify from in-qube data before we touch anything.
guard = self._verify_release_upgrade(target)
if guard.code:
return guard

self._report_progress(0.0)

# Wipe metadata and packages cached for the *old* release; otherwise
# dnf may resolve the transaction against the previous releasever
# and land on an inconsistent set.
result = self.run_cmd(
[self.package_manager, "clean", "all"], realtime=False
)
if result.code:
result.code = EXIT.ERR_VM_UPDATE
return result

# The actual release bump.
upgrade = self.run_cmd(
[
self.package_manager,
f"--releasever={target}",
"distro-sync",
"--best",
"--allowerasing",
"--assumeyes",
]
)
if upgrade.code:
upgrade.code = EXIT.ERR_VM_UPDATE
result += upgrade
return result

result += upgrade
self._report_progress(100.0)
return result

def _verify_release_upgrade(self, target: str) -> ProcessResult:
"""
Confirm, from inside the qube, that a release upgrade to `target`
is sane. Returns an errored ProcessResult to abort, or an empty
(code 0) result to proceed.

Enforces a single-step jump: the target must be a plain release
number, the qube must be a RedHat-family system, and the target must
be exactly one greater than the current in-qube major release. This
mirrors dom0's `compute_target_version`, ruling out downgrades,
no-ops, and multi-step jumps.
"""
if not target.isdigit():
return self._refuse(f"invalid target release {target!r}.")

os_data = get_os_data(self.log)
family = os_data.get("os_family")
if family != "RedHat":
return self._refuse(f"in-qube os-family is {family!r}, not RedHat.")

current_major = os_data.get("release", "").split(".")[0]
if not current_major.isdigit():
return self._refuse(
f"cannot read a numeric in-qube release from "
f"{os_data.get('release')!r}."
)
if int(target) != int(current_major) + 1:
return self._refuse(
f"in-qube release {os_data.get('release')!r} can only move "
f"to {int(current_major) + 1} (single step), not {target!r}."
)

return ProcessResult()

def _refuse(self, reason: str) -> ProcessResult:
"""Log and build the standard "refusing version upgrade" error."""
msg = f"Refusing version upgrade: {reason}"
self.log.error(msg)
return ProcessResult(EXIT.ERR_VM_UPDATE, out="", err=msg)

@staticmethod
def _report_progress(percent: float) -> None:
"""
Emit a progress milestone for dom0's progress bar.

The agent-to-dom0 progress protocol writes a bare float value
(one per line) to stderr; dom0's QubeConnection reads these
to drive the progress bar. 100.0 signals completion.
"""
print(f"{percent:.2f}", flush=True, file=sys.stderr)

def clean(self) -> int:
"""
Performs cleanup of temporary files kept for repositories.
Expand Down
27 changes: 17 additions & 10 deletions vmupdate/qube_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,23 @@ def __exit__(self, exc_type, exc_val, exc_tb):
str(err),
)

if self.qube.is_running() and not self._initially_running:
if self._has_assigned_pci_devices(self.qube):
self.logger.info(
"Waiting for full shutdown %s (PCI devices assigned)",
self.qube.name,
)
shutdown_domains([self.qube], self.logger)
else:
self.logger.info("Shutdown %s", self.qube.name)
self.qube.shutdown()
try:
if self.qube.is_running() and not self._initially_running:
if self._has_assigned_pci_devices(self.qube):
self.logger.info(
"Waiting for full shutdown %s (PCI devices assigned)",
self.qube.name,
)
shutdown_domains([self.qube], self.logger)
else:
self.logger.info("Shutdown %s", self.qube.name)
self.qube.shutdown()
except Exception as err: # pylint: disable=broad-except
self.logger.error(
"Cannot shutdown %s, because of error: %s",
self.qube.name,
str(err),
)

self.__connected = False

Expand Down
Loading