From: Daan De Meyer Date: Wed, 27 Mar 2024 22:48:36 +0000 (+0100) Subject: Support mkosi ssh for multiple running instances of the same image X-Git-Tag: v23~50^2~3 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6852e1f0a7efdb155e07fa5d77bfb716e4644cbe;p=thirdparty%2Fmkosi.git Support mkosi ssh for multiple running instances of the same image 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. --- diff --git a/mkosi/__init__.py b/mkosi/__init__.py index b747d4bcf..f3ff5589c 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -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(): diff --git a/mkosi/config.py b/mkosi/config.py index 8e1bda5d2..b24169c08 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -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)} diff --git a/mkosi/qemu.py b/mkosi/qemu.py index cac410ce6..c229ed34a 100644 --- a/mkosi/qemu.py +++ b/mkosi/qemu.py @@ -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", ] diff --git a/mkosi/resources/mkosi.md b/mkosi/resources/mkosi.md index 554e9eced..c7aac8a5e 100644 --- a/mkosi/resources/mkosi.md +++ b/mkosi/resources/mkosi.md @@ -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 diff --git a/mkosi/user.py b/mkosi/user.py index 1ff1e021e..2864eee25 100644 --- a/mkosi/user.py +++ b/mkosi/user.py @@ -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(): diff --git a/mkosi/util.py b/mkosi/util.py index 05c6c91d5..3da5b8560 100644 --- a/mkosi/util.py +++ b/mkosi/util.py @@ -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 diff --git a/tests/test_json.py b/tests/test_json.py index 7ac5d3daa..768a37eb1 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -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"),