Skip to content

Commit 182199c

Browse files
Autonomous AI contributionuser
authored andcommitted
Add Guix vmupdate backend
Add a Guix source backend for qvm-template update handling. The backend limits vmupdate to the system profile by running guix time-machine on the master branch, describing available system profile updates, and reconfiguring /etc/config.scm through guix system reconfigure. Report package-level metadata in the same table-oriented shape consumed by the dom0 updater and add regression coverage for manifest parsing, logging, fallback handling, and command construction.
1 parent 32a14bb commit 182199c

5 files changed

Lines changed: 928 additions & 2 deletions

File tree

vmupdate/agent/entrypoint.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,16 @@ def get_package_manager(
9090
elif os_data["os_family"] == "ArchLinux":
9191
from source.pacman.pacman_cli import PACMANCLI as PackageManager
9292

93+
print("Progress reporting not supported.", flush=True)
94+
elif os_data["os_family"] == "Guix":
95+
from source.guix.guix_cli import GUIXCLI as PackageManager
96+
9397
print("Progress reporting not supported.", flush=True)
9498
elif os_data["os_family"] == "Qubes":
9599
PackageManager = import_dom0_package_manager(os_data, log, no_progress)
96100
else:
97101
raise NotImplementedError(
98-
"Only Debian, RedHat and ArchLinux based OS is supported."
102+
"Only Debian, RedHat, ArchLinux, Qubes and Guix based OS is supported."
99103
)
100104

101105
pkg_mng = PackageManager(log_handler, log_level, agent_type)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# coding=utf-8
2+
#
3+
# The Qubes OS Project, https://www.qubes-os.org
4+
#
5+
# Copyright (C) 2026 The Qubes OS Project
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.
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# coding=utf-8
2+
#
3+
# The Qubes OS Project, https://www.qubes-os.org
4+
#
5+
# Copyright (C) 2026 The Qubes OS Project
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 os
23+
import shlex
24+
import shutil
25+
import subprocess
26+
import sys
27+
from typing import Dict, List, Optional
28+
29+
from source.common.package_manager import AgentType, PackageManager
30+
from source.common.process_result import ProcessResult
31+
from source.common.exit_codes import EXIT
32+
33+
34+
class GUIXCLI(PackageManager):
35+
PROGRESS_REPORTING = False
36+
37+
TIME_MACHINE_BRANCH = "master"
38+
SYSTEM_CONFIG = "/etc/config.scm"
39+
SYSTEM_PROFILE = "/run/current-system/profile"
40+
SERVICE_DIR = "/run/qubes-service"
41+
TIME_MACHINE_ENVIRONMENT = (
42+
"HOME=/tmp",
43+
"XDG_CONFIG_HOME=/tmp/qubes-vm-update-guix-config",
44+
"XDG_CACHE_HOME=/tmp/qubes-vm-update-guix-cache",
45+
)
46+
MANIFEST_SEPARATOR = "|"
47+
STATE_PATHS = {
48+
"guix-system": "/run/current-system",
49+
}
50+
GUIX_CANDIDATES = (
51+
"/run/qubes/bin/guix",
52+
"/root/.config/guix/current/bin/guix",
53+
"/var/guix/profiles/per-user/root/current-guix/bin/guix",
54+
"/run/current-system/profile/bin/guix",
55+
)
56+
57+
def __init__(
58+
self, log_handler, log_level, agent_type: AgentType
59+
):
60+
super().__init__(log_handler, log_level, agent_type)
61+
self.package_manager = self._find_guix(self.GUIX_CANDIDATES)
62+
63+
def _find_guix(self, candidates) -> str:
64+
for path in candidates:
65+
if os.access(path, os.X_OK):
66+
return path
67+
68+
path = shutil.which("guix")
69+
if path is not None:
70+
return path
71+
72+
raise RuntimeError("Package manager not found!")
73+
74+
def _uses_qubes_update_proxy(self) -> bool:
75+
# updates-proxy-setup marks update clients. A VM with
76+
# qubes-updates-proxy provides the proxy and must not route its own
77+
# Guix traffic back through the local forwarder.
78+
return (
79+
os.path.exists(os.path.join(self.SERVICE_DIR,
80+
"updates-proxy-setup"))
81+
and not os.path.exists(os.path.join(self.SERVICE_DIR,
82+
"qubes-updates-proxy"))
83+
)
84+
85+
def _with_time_machine_environment(
86+
self, command: List[str]
87+
) -> List[str]:
88+
env = list(self.TIME_MACHINE_ENVIRONMENT)
89+
90+
if self._uses_qubes_update_proxy():
91+
proxy = "http://127.0.0.1:8082/"
92+
no_proxy = "127.0.0.1,localhost"
93+
env.extend([
94+
f"http_proxy={proxy}",
95+
f"https_proxy={proxy}",
96+
f"HTTP_PROXY={proxy}",
97+
f"HTTPS_PROXY={proxy}",
98+
f"all_proxy={proxy}",
99+
f"ALL_PROXY={proxy}",
100+
f"no_proxy={no_proxy}",
101+
f"NO_PROXY={no_proxy}",
102+
])
103+
104+
return ["env", *env, *command]
105+
106+
def _run_guix(self, command: List[str]) -> ProcessResult:
107+
result = self.run_cmd(self._with_time_machine_environment(command))
108+
if result and not (result.out.strip() or result.err.strip()):
109+
result.err = (
110+
f"Guix command failed with exit code {result.code}: "
111+
f"{shlex.join(command)}"
112+
)
113+
result.posted = False
114+
return result
115+
116+
def refresh(self, hard_fail: bool) -> ProcessResult:
117+
"""
118+
Refresh Guix channel metadata for system reconfiguration.
119+
120+
Use guix time-machine so Qubes vmupdate does not mutate root's Guix
121+
checkout as package-manager state. The update target is the Guix
122+
System generation produced by the later reconfigure step.
123+
"""
124+
cmd = [
125+
self.package_manager,
126+
"time-machine",
127+
f"--branch={self.TIME_MACHINE_BRANCH}",
128+
"--",
129+
"describe",
130+
]
131+
print(
132+
f"Refreshing Guix channel metadata from "
133+
f"{self.TIME_MACHINE_BRANCH}.",
134+
flush=True,
135+
)
136+
return self._run_guix(cmd)
137+
138+
def get_packages(self) -> Dict[str, List[str]]:
139+
"""
140+
Report Guix System profile entries as update state.
141+
142+
The shared updater summary compares package/version dictionaries.
143+
Guix profiles expose manifest entries as name, version, output, and
144+
store path, so report each system profile output plus the current
145+
system generation symlink.
146+
"""
147+
packages: Dict[str, List[str]] = {}
148+
for name, path in self.STATE_PATHS.items():
149+
if os.path.exists(path):
150+
packages[name] = [os.path.realpath(path)]
151+
152+
result = self._list_installed_packages()
153+
if result:
154+
self.log.warning(
155+
"Unable to list Guix system profile packages: %s",
156+
result.err or result.out,
157+
)
158+
return packages
159+
160+
for line in result.out.splitlines():
161+
if not line.strip():
162+
continue
163+
entry = self._parse_manifest_entry(line)
164+
if entry is None:
165+
self.log.warning(
166+
"Ignoring unexpected Guix package entry: %s", line
167+
)
168+
continue
169+
name, version, output, store_path = entry
170+
package = f"{name}:{output}"
171+
packages.setdefault(package, []).append(
172+
f"{version} {store_path}"
173+
)
174+
return packages
175+
176+
def _list_installed_packages(self) -> ProcessResult:
177+
command = [
178+
self.package_manager,
179+
"package",
180+
f"--profile={self.SYSTEM_PROFILE}",
181+
"--list-installed",
182+
]
183+
self.log.debug("run command: %s", " ".join(command))
184+
with subprocess.Popen(
185+
command,
186+
stdin=subprocess.PIPE,
187+
stdout=subprocess.PIPE,
188+
stderr=subprocess.PIPE,
189+
) as proc:
190+
out, err = proc.communicate()
191+
out = out.replace(b"\t", self.MANIFEST_SEPARATOR.encode())
192+
result = ProcessResult.from_untrusted_out_err(out, err)
193+
result.code = proc.returncode
194+
self.log.debug("command exit code: %i", result.code)
195+
return result
196+
197+
@staticmethod
198+
def _parse_manifest_entry(line):
199+
if GUIXCLI.MANIFEST_SEPARATOR in line:
200+
cols = [
201+
col.strip()
202+
for col in line.split(GUIXCLI.MANIFEST_SEPARATOR, 3)
203+
]
204+
if len(cols) == 4 and all(cols):
205+
return tuple(cols)
206+
207+
store_marker = "/gnu/store/"
208+
store_start = line.find(store_marker)
209+
if store_start != -1:
210+
store_path = line[store_start:].strip()
211+
fields = line[:store_start].strip().split()
212+
if len(fields) >= 3:
213+
name, version, output = fields[:3]
214+
return name, version, output, store_path
215+
entry = GUIXCLI._parse_sanitized_manifest_entry(
216+
fields, store_path
217+
)
218+
if entry is not None:
219+
return entry
220+
221+
cols = line.split(None, 3)
222+
if len(cols) == 4:
223+
return tuple(cols)
224+
225+
return None
226+
227+
@staticmethod
228+
def _parse_sanitized_manifest_entry(fields, store_path):
229+
"""
230+
Recover fields after ProcessResult stripped tabs from Guix output.
231+
232+
Guix separates name, version, output, and store path with tabs.
233+
ProcessResult removes tabs from untrusted output before callers parse
234+
it. When a column is wider than Guix's padding, adjacent fields can be
235+
glued together; the store item basename keeps the name-version boundary.
236+
"""
237+
store_item = os.path.basename(store_path)
238+
try:
239+
_store_hash, store_name_version = store_item.split("-", 1)
240+
except ValueError:
241+
return None
242+
243+
if len(fields) == 2:
244+
first, second = fields
245+
if store_name_version.startswith(f"{first}-"):
246+
version = store_name_version[len(first) + 1:]
247+
if second.startswith(version):
248+
output = second[len(version):]
249+
if output:
250+
return first, version, output, store_path
251+
252+
for index in range(1, len(first)):
253+
name = first[:index]
254+
version = first[index:]
255+
if f"{name}-{version}" == store_name_version:
256+
return name, version, second, store_path
257+
258+
return None
259+
260+
def get_action(self, remove_obsolete) -> List[str]:
261+
"""
262+
Kept for the PackageManager interface; upgrade_internal runs the
263+
reconfiguration through guix time-machine.
264+
"""
265+
return [
266+
"time-machine",
267+
f"--branch={self.TIME_MACHINE_BRANCH}",
268+
"--",
269+
"system",
270+
"reconfigure",
271+
"--no-bootloader",
272+
self.SYSTEM_CONFIG,
273+
]
274+
275+
def upgrade_internal(self, remove_obsolete: bool) -> ProcessResult:
276+
if not os.path.exists(self.SYSTEM_CONFIG):
277+
return ProcessResult(
278+
EXIT.ERR_VM_UPDATE,
279+
err=f"missing Guix system configuration: {self.SYSTEM_CONFIG}")
280+
281+
cmd = [self.package_manager, *self.get_action(remove_obsolete)]
282+
print(
283+
f"Reconfiguring Guix System from {self.SYSTEM_CONFIG} "
284+
f"using {self.TIME_MACHINE_BRANCH}.",
285+
flush=True,
286+
)
287+
result = self._run_guix(cmd)
288+
if not result:
289+
print("Reconfigured Guix System.", flush=True)
290+
else:
291+
print(
292+
"Guix System reconfiguration failed.",
293+
file=sys.stderr,
294+
flush=True,
295+
)
296+
return result
297+
298+
def install_requirements(
299+
self,
300+
requirements: Optional[Dict[str, str]],
301+
curr_pkg: Dict[str, List[str]]
302+
) -> ProcessResult:
303+
"""
304+
Qubes vmupdate plugins do not currently declare Guix requirements.
305+
Avoid installing ad hoc root profile packages as hidden update policy.
306+
"""
307+
if requirements:
308+
packages = ", ".join(sorted(requirements))
309+
return ProcessResult(
310+
EXIT.ERR_VM_PRE,
311+
err=f"Guix vmupdate requirements are unsupported: {packages}")
312+
return ProcessResult()
313+
314+
def clean(self) -> int:
315+
"""
316+
Keep Guix generations for rollback; do not collect garbage implicitly.
317+
"""
318+
return EXIT.OK

vmupdate/agent/source/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def get_os_data(logger: Optional = None) -> Dict[str, Any]:
3333
name: "Linux" or a string identifying the operating system,
3434
codename (optional): an operating system release code name,
3535
release (optional): version string,
36-
os_family: "Unknown", "RedHat", "Debian", "ArchLinux".
36+
os_family: "Unknown", "RedHat", "Debian", "ArchLinux", "Guix".
3737
"""
3838
data = {}
3939

@@ -69,6 +69,9 @@ def get_os_data(logger: Optional = None) -> Dict[str, Any]:
6969
if "arch" in family:
7070
data["os_family"] = "ArchLinux"
7171

72+
if "guix" in family:
73+
data["os_family"] = "Guix"
74+
7275
return data
7376

7477

0 commit comments

Comments
 (0)