Repository: systemd/mkosi

Issue:
Allow running qemu as an unprivileged user
The man page suggests that `mkosi qemu` can be run as an unprivileged user under some circumstances:

> This command must be executed as root unless the image already exists and -f is not specified.

but in practice mkosi's [sudo check](https://github.com/systemd/mkosi/blob/e2681d2adbaabe1065f7cff307368d0ff0d69bb5/mkosi/__init__.py#L126) prevents this.

On my machine (Arch Linux with Wayland/Sway), qemu fails to start if invoked through sudo (with the message `Authorization required, but no authorization protocol specified`), but after removing `Verb.qemu` from the sudo check in a local checkout of the source code I was able to build an image (as root) and run it with `mkosi qemu` as my normal user.  There are clearly limitations in running qemu as an unprivileged user (setting up netdev requires root, for example), but it seems to be working well enough that I'd prefer if the qemu verb were removed from the sudo check.

I only discovered mkosi a few days ago and had never delved very deeply into qemu's workings beforehand, so it's quite possible there are potential dangers with this setup I'm not aware of.  If so, I can understand the desire to limit the tool to known-working configurations.  But given the indication from the docs, I wanted to see if this was something the maintainers would be willing to enable.


Discussion:
I've sorted out the `xhost` workaround to run qemu through sudo.  I would still prefer to have the option of running without sudo when not testing features that require root privileges.
It's a bit tricky, because `qemu` implies `build` if the image has not been built yet and we need root for a lot of things we do. We'll happily review patches!

Resolving patch:
diff --git a/mkosi/__init__.py b/mkosi/__init__.py
--- a/mkosi/__init__.py
+++ b/mkosi/__init__.py
@@ -125,7 +125,7 @@
 PathString = Union[Path, str]
 
 MKOSI_COMMANDS_NEED_BUILD = (Verb.shell, Verb.boot, Verb.qemu, Verb.serve)
-MKOSI_COMMANDS_SUDO = (Verb.build, Verb.clean, Verb.shell, Verb.boot, Verb.qemu, Verb.serve)
+MKOSI_COMMANDS_SUDO = (Verb.build, Verb.clean, Verb.shell, Verb.boot, Verb.serve)
 MKOSI_COMMANDS_CMDLINE = (Verb.build, Verb.shell, Verb.boot, Verb.qemu, Verb.ssh)
 
 DRACUT_SYSTEMD_EXTRAS = [
@@ -4719,6 +4719,7 @@ def remove_duplicates(items: List[T]) -> List[T]:
 
 class ListAction(argparse.Action):
     delimiter: str
+    deduplicate: bool = True
 
     def __init__(self, *args: Any, choices: Optional[Iterable[Any]] = None, **kwargs: Any) -> None:
         self.list_choices = choices
@@ -4762,7 +4763,8 @@ def __call__(
         else:
             ary.append(values)
 
-        ary = remove_duplicates(ary)
+        if self.deduplicate:
+            ary = remove_duplicates(ary)
         setattr(namespace, self.dest, ary)
 
 
@@ -4778,6 +4780,10 @@ class SpaceDelimitedListAction(ListAction):
     delimiter = " "
 
 
+class RepeatableSpaceDelimitedListAction(SpaceDelimitedListAction):
+    deduplicate = False
+
+
 class BooleanAction(argparse.Action):
     """Parse boolean command line arguments
 
@@ -4999,7 +5005,7 @@ def _read_args_from_files(self, arg_strings: List[str]) -> List[str]:
                             if cli_arg in action.option_strings:
                                 if isinstance(action, ListAction):
                                     value = value.replace(os.linesep, action.delimiter)
-                        new_arg_strings.extend([cli_arg, value])
+                        new_arg_strings.append(f"{cli_arg}={value}")
             except OSError as e:
                 self.error(str(e))
         # return the modified argument list
@@ -5688,7 +5694,7 @@ def create_parser() -> ArgumentParserMkosi:
     )
     group.add_argument(
         "--qemu-args",
-        action=SpaceDelimitedListAction,
+        action=RepeatableSpaceDelimitedListAction,
         default=[],
         # Suppress the command line option because it's already possible to pass qemu args as normal
         # arguments.
@@ -6041,9 +6047,6 @@ def empty_directory(path: Path) -> None:
 
 
 def unlink_output(args: MkosiArgs) -> None:
-    if not args.force and args.verb != Verb.clean:
-        return
-
     if not args.skip_final_phase:
         with complete_step("Removing output files…"):
             unlink_try_hard(args.output)
@@ -7809,8 +7812,9 @@ def run_qemu_cmdline(args: MkosiArgs) -> Iterator[List[str]]:
         cmdline += ["-vga", "virtio"]
 
     if args.netdev:
-        if not ensure_networkd(args):
-            # Fall back to usermode networking if the host doesn't have networkd (eg: Debian)
+        if not ensure_networkd(args) or os.getuid() != 0:
+            # Fall back to usermode networking if the host doesn't have networkd (eg: Debian).
+            # Also fall back if running as an unprivileged user, which likely can't set up the tap interface.
             fwd = f",hostfwd=tcp::{args.ssh_port}-:{args.ssh_port}" if args.ssh_port != 22 else ""
             cmdline += ["-nic", f"user,model=virtio-net-pci{fwd}"]
         else:
@@ -8115,16 +8119,18 @@ def run_verb(raw: argparse.Namespace) -> None:
 
     if args.verb in MKOSI_COMMANDS_SUDO:
         check_root()
-        unlink_output(args)
 
-    if args.verb == Verb.build:
+    if args.verb == Verb.build and not args.force:
         check_output(args)
 
+    if needs_build(args) or args.verb == Verb.clean:
+        check_root()
+        unlink_output(args)
+
     if args.verb == Verb.summary:
         print_summary(args)
 
     if needs_build(args):
-        check_root()
         check_native(args)
         init_namespace(args)
         manifest = build_stuff(args)
diff --git a/mkosi/machine.py b/mkosi/machine.py
--- a/mkosi/machine.py
+++ b/mkosi/machine.py
@@ -15,7 +15,7 @@
 import pexpect  # type: ignore
 
 from . import (
-    MKOSI_COMMANDS_SUDO,
+    MKOSI_COMMANDS_NEED_BUILD,
     CompletedProcess,
     build_stuff,
     check_native,
@@ -106,7 +106,7 @@ def _ensure_booted(self) -> None:
         assert self._serial is not None or self.args.verb == Verb.shell
 
     def build(self) -> None:
-        if self.args.verb in MKOSI_COMMANDS_SUDO:
+        if self.args.verb in MKOSI_COMMANDS_NEED_BUILD + (Verb.build, Verb.clean):
             check_root()
             unlink_output(self.args)
 


Test patch:
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -174,7 +174,7 @@ def __eq__(self, other: Mapping[str, Any]) -> bool: # type: ignore
                 is_eq = False
         return is_eq
 
-    def _append_list(self, ref_entry: str, new_args: Any, job_name: str = DEFAULT_JOB_NAME, separator: str = ",") -> None:
+    def _append_list(self, ref_entry: str, new_args: Any, job_name: str = DEFAULT_JOB_NAME, separator: str = ",", with_duplicates: bool = False) -> None:
         """Helper function handling comma separated list as supported by mkosi"""
         args_list = []
         if isinstance(new_args, str):
@@ -189,7 +189,7 @@ def _append_list(self, ref_entry: str, new_args: Any, job_name: str = DEFAULT_JO
             if isinstance(arg, str) and arg.startswith("!"):
                 if arg[1:] in self.reference_config[job_name][ref_entry]:
                     self.reference_config[job_name][ref_entry].remove(arg[1:])
-            elif arg not in self.reference_config[job_name][ref_entry]:
+            elif with_duplicates or arg not in self.reference_config[job_name][ref_entry]:
                 self.reference_config[job_name][ref_entry].append(arg)
 
     @staticmethod
@@ -378,6 +378,8 @@ def _update_ref_from_file(self, mk_config: Dict[str, Any], job_name: str = DEFAU
                 self._append_list("extra_search_paths", mk_config_host["ExtraSearchPaths"], job_name, ":")
             if "QemuHeadless" in mk_config_host:
                 self.reference_config[job_name]["qemu_headless"] = mk_config_host["QemuHeadless"]
+            if "QemuArgs" in mk_config_host:
+                self._append_list("qemu_args", mk_config_host["QemuArgs"], job_name, " ", with_duplicates=True)
             if "Netdev" in mk_config_host:
                 self.reference_config[job_name]["netdev"] = mk_config_host["Netdev"]
             if "Ephemeral" in mk_config_host:
@@ -598,6 +600,7 @@ def prepare_mkosi_default(self, directory: str) -> None:
             "Host": {
                 "ExtraSearchPaths": "search/here:search/there",
                 "QemuHeadless": True,
+                "QemuArgs": "-device virtio-vga-gl -vga none",
                 "Netdev": True,
             },
         }
@@ -664,6 +667,7 @@ def prepare_mkosi_default_d_1(self, directory: str) -> None:
             "Host": {
                 "ExtraSearchPaths": "search/ubu",
                 "QemuHeadless": True,
+                "QemuArgs": "-vga virtio -device usb-kbd -device usb-mouse",
                 "Netdev": True,
             },
         }
@@ -730,6 +734,7 @@ def prepare_mkosi_default_d_2(self, directory: str) -> None:
             "Host": {
                 "ExtraSearchPaths": "search/debi",
                 "QemuHeadless": True,
+                "QemuArgs": "-device virtio-vga-gl,xres=1920,yres=1080 -display sdl,gl=on",
                 "Netdev": True,
             },
         }