]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Support mkosi ssh for multiple running instances of the same image
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Wed, 27 Mar 2024 22:48:36 +0000 (23:48 +0100)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Thu, 28 Mar 2024 11:44:48 +0000 (12:44 +0100)
Let's add a stopgap solution until systemd-machined supports everything
we need. We maintain a super basic JSON state file in the runtime directory
that is used to map a machine name to the corresponding SSH proxy command.

We also store the path to the ssh key in there so that mkosi ssh can be
run from every directory.

The new Machine= option allows selecting the machine name to use. Unless
set explicitly, we also use the machine name as the hostname for the machine.

mkosi/__init__.py
mkosi/config.py
mkosi/qemu.py
mkosi/resources/mkosi.md
mkosi/user.py
mkosi/util.py
tests/test_json.py

index b747d4bcfcb6697bc4ad14f05fa099c6699e7322..f3ff5589cbc731446648d7fd33e240e16fc837c8 100644 (file)
@@ -3718,7 +3718,7 @@ def run_shell(args: Args, config: Config) -> None:
         ]
 
     # Underscores are not allowed in machine names so replace them with hyphens.
-    name = config.name().replace("_", "-")
+    name = config.machine_or_name().replace("_", "-")
     cmdline += ["--machine", name]
 
     for k, v in config.credentials.items():
index 8e1bda5d25330ee6fb1f6871742ae7e90bd91c4d..b24169c081f4d085c964bc462f3414b25aa3054d 100644 (file)
@@ -1414,6 +1414,7 @@ class Config:
     runtime_network: Network
     ssh_key: Optional[Path]
     ssh_certificate: Optional[Path]
+    machine: Optional[str]
     vmm: Vmm
 
     # QEMU-specific options
@@ -1436,6 +1437,9 @@ class Config:
     def name(self) -> str:
         return self.image_id or self.image or "default"
 
+    def machine_or_name(self) -> str:
+        return self.machine or self.name()
+
     def output_dir_or_cwd(self) -> Path:
         return self.output_dir or Path.cwd()
 
@@ -2747,6 +2751,12 @@ SETTINGS = (
         default=Vmm.qemu,
         help="Set the virtual machine monitor to use for mkosi qemu",
     ),
+    ConfigSetting(
+        dest="machine",
+        metavar="NAME",
+        section="Host",
+        help="Set the machine name to use when booting the image",
+    ),
     ConfigSetting(
         dest="qemu_gui",
         metavar="BOOL",
@@ -2794,7 +2804,7 @@ SETTINGS = (
         metavar="NUMBER|auto|hash",
         section="Host",
         parse=config_parse_vsock_cid,
-        default=QemuVsockCID.hash,
+        default=QemuVsockCID.auto,
         help="Specify the VSock connection ID to use",
     ),
     ConfigSetting(
@@ -3556,6 +3566,9 @@ def load_kernel_command_line_extra(args: argparse.Namespace) -> list[str]:
     if not any(s.startswith("SYSTEMD_SULOGIN_FORCE=") for s in args.kernel_command_line_extra):
         cmdline += ["SYSTEMD_SULOGIN_FORCE=1"]
 
+    if not any(s.startswith("systemd.hostname=") for s in args.kernel_command_line_extra) and args.machine:
+        cmdline += [f"systemd.hostname={args.machine}"]
+
     if args.qemu_cdrom:
         # CD-ROMs are read-only so tell systemd to boot in volatile mode.
         cmdline += ["systemd.volatile=yes"]
@@ -3866,6 +3879,7 @@ def summary(config: Config) -> str:
                     Runtime Network: {config.runtime_network}
                     SSH Signing Key: {none_to_none(config.ssh_key)}
                     SSH Certificate: {none_to_none(config.ssh_certificate)}
+                            Machine: {config.machine_or_name()}
 
             Virtual Machine Monitor: {config.vmm}
                            QEMU GUI: {yes_no(config.qemu_gui)}
index cac410ce69cd765137ffaed3c5c08c5862fa6135..c229ed34ad97c5e6c73dff723963b1518ff622f3 100644 (file)
@@ -553,6 +553,41 @@ def finalize_drive(drive: QemuDrive) -> Iterator[Path]:
         yield Path(file.name)
 
 
+@contextlib.contextmanager
+def finalize_state(config: Config, cid: int) -> Iterator[None]:
+    (INVOKING_USER.runtime_dir() / "machine").mkdir(parents=True, exist_ok=True)
+
+    if INVOKING_USER.is_regular_user():
+        os.chown(INVOKING_USER.runtime_dir(), INVOKING_USER.uid, INVOKING_USER.gid)
+        os.chown(INVOKING_USER.runtime_dir() / "machine", INVOKING_USER.uid, INVOKING_USER.gid)
+
+    with flock(INVOKING_USER.runtime_dir() / "machine"):
+        if (p := INVOKING_USER.runtime_dir() / "machine" / f"{config.machine_or_name()}.json").exists():
+            die(f"Another virtual machine named {config.machine_or_name()} is already running",
+                hint="Use --machine to specify a different virtual machine name")
+
+        p.write_text(
+            json.dumps(
+                {
+                    "Machine": config.machine_or_name(),
+                    "ProxyCommand": f"socat - VSOCK-CONNECT:{cid}:%p",
+                    "SshKey": os.fspath(config.ssh_key) if config.ssh_key else None,
+                },
+                sort_keys=True,
+                indent=4,
+            )
+        )
+
+        if INVOKING_USER.is_regular_user():
+            os.chown(p, INVOKING_USER.uid, INVOKING_USER.gid)
+
+    try:
+        yield
+    finally:
+        with flock(INVOKING_USER.runtime_dir() / "machine"):
+            p.unlink(missing_ok=True)
+
+
 def run_qemu(args: Args, config: Config) -> None:
     if config.output_format not in (
         OutputFormat.disk,
@@ -677,6 +712,7 @@ def run_qemu(args: Args, config: Config) -> None:
 
     cmdline += ["-accel", accel]
 
+    cid: Optional[int] = None
     if QemuDeviceNode.vhost_vsock in qemu_device_fds:
         if config.qemu_vsock_cid == QemuVsockCID.auto:
             cid = find_unused_vsock_cid(config, qemu_device_fds[QemuDeviceNode.vhost_vsock])
@@ -880,6 +916,9 @@ def run_qemu(args: Args, config: Config) -> None:
         cmdline += config.qemu_args
         cmdline += args.cmdline
 
+        if cid is not None:
+            stack.enter_context(finalize_state(config, cid))
+
         with spawn(
             cmdline,
             stdin=sys.stdin,
@@ -901,27 +940,26 @@ def run_qemu(args: Args, config: Config) -> None:
 
 
 def run_ssh(args: Args, config: Config) -> None:
-    if config.qemu_vsock_cid == QemuVsockCID.auto:
-        die("Can't use ssh verb with QemuVSockCID=auto")
+    with flock(INVOKING_USER.runtime_dir() / "machine"):
+        if not (p := INVOKING_USER.runtime_dir() / "machine" / f"{config.machine_or_name()}.json").exists():
+            die(f"{p} not found, cannot SSH into virtual machine {config.machine_or_name()}",
+                hint="Is the machine running and was it built with Ssh=yes and QemuVsock=yes?")
 
-    if not config.ssh_key:
-        die("SshKey= must be configured to use 'mkosi ssh'",
-            hint="Use 'mkosi genkey' to generate a new SSH key and certificate")
+        state = json.loads(p.read_text())
 
-    if config.qemu_vsock_cid == QemuVsockCID.hash:
-        cid = hash_to_vsock_cid(hash_output(config))
-    else:
-        cid = config.qemu_vsock_cid
+    if not state["SshKey"]:
+        die("An SSH key must be configured when booting the image to use 'mkosi ssh'",
+            hint="Use 'mkosi genkey' to generate a new SSH key and certificate")
 
     cmd: list[PathString] = [
         "ssh",
-        "-i", config.ssh_key,
+        "-i", state["SshKey"],
         "-F", "none",
         # Silence known hosts file errors/warnings.
         "-o", "UserKnownHostsFile=/dev/null",
         "-o", "StrictHostKeyChecking=no",
         "-o", "LogLevel=ERROR",
-        "-o", f"ProxyCommand=socat - VSOCK-CONNECT:{cid}:%p",
+        "-o", f"ProxyCommand={state['ProxyCommand']}",
         "root@mkosi",
     ]
 
index 554e9ecedba27bec3d23ee4e5cf1d08b8020acf6..c7aac8a5e4c3fa24571ab9250d5234a442a9add4 100644 (file)
@@ -101,6 +101,11 @@ The following command line verbs are known:
   arguments to the `ssh` invocation. To connect to a container, use
   `machinectl login` or `machinectl shell`.
 
+: The `Machine=` option can be used to give the machine a custom
+  hostname when booting it which can later be used to ssh into the image
+  (e.g. `mkosi --machine=mymachine qemu` followed by
+  `mkosi --machine=mymachine ssh`).
+
 `journalctl`
 
 : Uses `journalctl` to inspect the journal inside the image.
@@ -1533,15 +1538,11 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
 
 : When used with the `qemu` verb, this option specifies the vsock
   connection ID to use. Takes a number in the interval `[3, 0xFFFFFFFF)`
-  or `hash` or `auto`. Defaults to `hash`. When set to `hash`, the
+  or `hash` or `auto`. Defaults to `auto`. When set to `hash`, the
   connection ID will be derived from the full path to the image. When
   set to `auto`, `mkosi` will try to find a free connection ID
   automatically. Otherwise, the provided number will be used as is.
 
-: Note that when set to `auto`, `mkosi ssh` cannot be used as we cannot
-  figure out which free connection ID we found when booting the image
-  earlier.
-
 `QemuSwtpm=`, `--qemu-swtpm=`
 
 : When used with the `qemu` verb, this option specifies whether to start an instance of swtpm to be used as a
@@ -1827,6 +1828,15 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
   automatically be used for this purpose. Run `mkosi genkey` to
   automatically generate a certificate in `mkosi.crt`.
 
+`Machine=`, `--machine=`
+
+: Specify the machine name to use when booting the image. Can also be
+  used to refer to a specific image when SSH-ing into an image (e.g.
+  `mkosi --image=myimage ssh`).
+
+: Note that `Ephemeral=` has to be enabled to start multiple instances
+  of the same image.
+
 ## Specifiers
 
 The current value of various settings can be accessed when parsing
index 1ff1e021e04296cbad4b853ae082b312cba33c9d..2864eee25c13e4faa5845f74b230a7e27b59c968 100644 (file)
@@ -69,6 +69,17 @@ class INVOKING_USER:
 
         return cache / "mkosi"
 
+    @classmethod
+    def runtime_dir(cls) -> Path:
+        if (env := os.getenv("XDG_RUNTIME_DIR")) or (env := os.getenv("RUNTIME_DIRECTORY")):
+            d = Path(env)
+        elif cls.is_regular_user():
+            d = Path("/run/user") / str(cls.uid)
+        else:
+            d = Path("/run")
+
+        return d / "mkosi"
+
     @classmethod
     def rchown(cls, path: Path) -> None:
         if cls.is_regular_user() and any(p.stat().st_uid == cls.uid for p in path.parents) and path.exists():
index 05c6c91d54d629cf7e46846c8936b2b9c75f22d8..3da5b856079edcd5174d267200502aa523896fc0 100644 (file)
@@ -146,7 +146,8 @@ def flock_or_die(path: Path) -> Iterator[Path]:
             raise e
 
         die(f"Cannot lock {path} as it is locked by another process",
-            hint="Maybe another mkosi process is still using it?")
+            hint="Maybe another mkosi process is still using it? Use Ephemeral=yes to enable booting multiple "
+                 "instances of the same image")
 
 
 @contextlib.contextmanager
index 7ac5d3daa9b3f439af262487b910501b3c249fa2..768a37eb1d75d95c136175e41b635bc4c35e4989 100644 (file)
@@ -184,6 +184,7 @@ def test_config() -> None:
             "LocalMirror": null,
             "Locale": "en_C.UTF-8",
             "LocaleMessages": "",
+            "Machine": "machine",
             "MakeInitrd": false,
             "ManifestFormat": [
                 "json",
@@ -392,6 +393,7 @@ def test_config() -> None:
         local_mirror = None,
         locale = "en_C.UTF-8",
         locale_messages = "",
+        machine = "machine",
         make_initrd = False,
         manifest_format = [ManifestFormat.json, ManifestFormat.changelog],
         minimum_version = GenericVersion("123"),