diff --git a/TODO.md b/TODO.md index 04ab597167..d641e15551 100644 --- a/TODO.md +++ b/TODO.md @@ -2779,6 +2779,88 @@ SPDX-License-Identifier: LGPL-2.1-or-later - translate SIGTERM to clean ACPI shutdown event - implement hotkeys ^]^]r and ^]^]p like nspawn +- **vmspawn disk hotplug:** + - virtio-blk-pci — the simplest path. Each disk is an independent PCI + device. QMP sequence (two steps): + + blockdev-add {driver: "raw", node-name: "disk1", + file: {driver: "file", filename: "/path/to/img"}} + device_add {driver: "virtio-blk-pci", id: "disk1", drive: "disk1"} + + Removal (three steps): + + device_del {id: "disk1"} + ... wait for DEVICE_DELETED event (guest acknowledges unplug) ... + blockdev-del {node-name: "disk1"} + + Works on both i440fx (legacy PCI) and q35 (PCIe) machine types. PCI + address auto-assigned by QEMU — no topology pre-configuration needed. + Each disk independently hotpluggable. Guest sees a virtio block + device (/dev/vdX). Well-tested path — used by libvirt, Incus, and all + major VM managers. No special boot-time setup required. + + - NVMe — two-level model: controller + namespace(s). The controller is + a PCIe device; namespaces live on an internal NVMe bus attached to + the controller. Key limitation: namespaces are NOT hotpluggable — + TYPE_NVME_BUS has no HotplugHandler, so device_add of nvme-ns at + runtime fails with "Bus does not support hotplugging". The only + option is hotplugging the entire controller, which embeds one + namespace via its "drive" property: + + blockdev-add {driver: "raw", node-name: "disk1", + file: {driver: "file", filename: "/path/to/img"}} + device_add {driver: "nvme", id: "disk1", drive: "disk1", serial: "disk1"} + + Same two-step pattern as virtio-blk, with these limitations: + + 1. PCIe-only (implements INTERFACE_PCIE_DEVICE). Does not work on + i440fx. Requires q35 or virt (aarch64). + 2. Requires pre-configured PCIe root ports that exist at boot. + Without them, device_add fails with "no slot/function available". + vmspawn would need to create empty root ports at QEMU startup to + reserve hotplug slots. + 3. No namespace-level granularity. Each hotplugged disk burns a full + PCIe slot (controller + one namespace). Cannot add multiple + namespaces to a single controller at runtime. + 4. Serial property required (up to 20 chars). virtio-blk does not + require it. + + - virtio-scsi — shared virtio-scsi-pci controller with individual + scsi-hd devices attached. Incus uses this as its default bus. The + controller must exist at boot, but individual disks (LUNs) can be + hotplugged onto it without burning PCI slots. Scales better than + virtio-blk when many disks are needed, but adds complexity + (controller management, LUN assignment). + +- **vmspawn AcquireQMP():** implement as id-rewriting proxy with FD + passing. vmspawn acts as a QMP multiplexer. When a client calls + AcquireQMP(): + + 1. Create socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0). + 2. Return one end to the client via sd_varlink_push_fd() + + sd_varlink_reply(). + 3. Add an sd-event I/O source on vmspawn's end for the client socket. + 4. Send a synthetic QMP greeting on the client socket, handle + qmp_capabilities locally. Maybe we don't even need that and just + document that it's a fully initialized connection. + 5. For client commands: read from the client socket, rewrite id to + vmspawn's internal counter (store mapping internal_id -> + (client_fd, original_id_json_variant)), forward to QEMU. + 6. For QEMU responses: match internal id, look up original client id, + rewrite back, send to the correct client socket. + 7. Broadcast QMP events (no id) to all AcquireQMP clients AND to + SubscribeEvents subscribers. + 8. On client EOF: remove the I/O source, clean up id mappings. + + This keeps vmspawn in full control of the QMP connection — VMControl + handlers and multiple AcquireQMP clients can coexist without id + collisions. The server needs SD_VARLINK_SERVER_ALLOW_FD_PASSING_OUTPUT + (already set in machined's pattern). + + AcquireQMP() also requires server-side Varlink protocol upgrades. + mvo's WIP branch: + + - we probably needs .pcrpkeyrd or so as additional PE section in UKIs, which contains a separate public key for PCR values that only apply in the initrd, i.e. in the boot phase "enter-initrd". Then, consumers in userspace diff --git a/man/machinectl.xml b/man/machinectl.xml index e64a20bb1d..b4fb15b4f9 100644 --- a/man/machinectl.xml +++ b/man/machinectl.xml @@ -262,16 +262,17 @@ poweroff NAME… - Power off one or more containers. This will - trigger a shutdown by sending SIGRTMIN+4 to the container's init - process, which causes systemd-compatible init systems to shut - down cleanly. Use stop as alias for poweroff. - This operation does not work on containers that do not run a + Power off one or more machines. For VMs managed by + systemd-vmspawn1, + this sends an ACPI powerdown request via QMP. For containers, this + sends SIGRTMIN+4 to the container's init process, which causes + systemd-compatible init systems to shut down cleanly. This + operation does not work on containers that do not run a systemd1-compatible init system, such as sysvinit. Use - terminate (see below) to immediately - terminate a container or VM, without cleanly shutting it - down. + stop as alias for poweroff. + Use terminate (see below) to immediately + terminate a machine without cleanly shutting it down. @@ -279,16 +280,40 @@ reboot NAME… - Reboot one or more containers. This will - trigger a reboot by sending SIGINT to the container's init - process, which is roughly equivalent to pressing Ctrl+Alt+Del - on a non-containerized system, and is compatible with - containers running any system manager. Use restart as alias - for reboot. + Reboot one or more machines. For VMs managed by + systemd-vmspawn1, + this sends a system reset request via QMP. For containers, this + sends SIGINT to the container's init process, which is roughly + equivalent to pressing Ctrl+Alt+Del on a non-containerized + system, and is compatible with containers running any system + manager. Use restart as alias for + reboot. + + pause NAME… + + Pause one or more machines. For VMs managed by + systemd-vmspawn1, + this freezes vCPU execution at the hypervisor level — the guest + operating system is not notified and does not observe an ACPI suspend. + From the guest's perspective time simply stops until the machine is + resumed with resume. + + + + + + resume NAME… + + Resume one or more previously paused machines. + This restarts execution after a pause. + + + + terminate NAME… diff --git a/mkosi/mkosi.conf.d/arch/mkosi.conf b/mkosi/mkosi.conf.d/arch/mkosi.conf index 73001894c2..229cc6394b 100644 --- a/mkosi/mkosi.conf.d/arch/mkosi.conf +++ b/mkosi/mkosi.conf.d/arch/mkosi.conf @@ -55,3 +55,4 @@ Packages= tpm2-tools # kernel-bootcfg --add-uri= is just too useful virt-firmware + virtiofsd diff --git a/mkosi/mkosi.conf.d/centos-fedora/mkosi.conf b/mkosi/mkosi.conf.d/centos-fedora/mkosi.conf index 739b2d94eb..dd00fa737c 100644 --- a/mkosi/mkosi.conf.d/centos-fedora/mkosi.conf +++ b/mkosi/mkosi.conf.d/centos-fedora/mkosi.conf @@ -70,3 +70,4 @@ Packages= vim-common # kernel-bootcfg --add-uri= is just too useful virt-firmware + virtiofsd diff --git a/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf index 80dc87213a..8a4e534dda 100644 --- a/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf +++ b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf @@ -75,3 +75,4 @@ Packages= tgt tpm2-tools tzdata + virtiofsd diff --git a/mkosi/mkosi.conf.d/opensuse/mkosi.conf b/mkosi/mkosi.conf.d/opensuse/mkosi.conf index 295ed53c58..b0593e3f1a 100644 --- a/mkosi/mkosi.conf.d/opensuse/mkosi.conf +++ b/mkosi/mkosi.conf.d/opensuse/mkosi.conf @@ -91,5 +91,6 @@ Packages= veritysetup # kernel-bootcfg --add-uri= is just too useful virt-firmware + virtiofsd xz zypper diff --git a/shell-completion/bash/machinectl b/shell-completion/bash/machinectl index 78319d91a4..50b46fb279 100644 --- a/shell-completion/bash/machinectl +++ b/shell-completion/bash/machinectl @@ -45,7 +45,7 @@ _machinectl() { local -A VERBS=( [STANDALONE]='list list-images clean pull-tar pull-raw list-transfers cancel-transfer import-fs' - [MACHINES]='status show start stop login shell enable disable poweroff reboot terminate kill + [MACHINES]='status show start stop login shell enable disable poweroff reboot pause resume terminate kill image-status show-image remove export-tar export-raw' [MACHINES_OR_FILES]='edit cat' [MACHINE_ONLY]='clone rename set-limit' diff --git a/shell-completion/zsh/_machinectl b/shell-completion/zsh/_machinectl index d5f7aa9680..31ddf4fca5 100644 --- a/shell-completion/zsh/_machinectl +++ b/shell-completion/zsh/_machinectl @@ -38,6 +38,8 @@ "disable:Disable automatic container start at boot" "poweroff:Power off one or more VMs/containers" "reboot:Reboot one or more VMs/containers" + "pause:Pause one or more machines" + "resume:Resume one or more previously paused machines" "terminate:Terminate one or more VMs/containers" "kill:Send signal to process or a VM/container" "copy-to:Copy files from the host to a container" @@ -77,7 +79,7 @@ start|enable|disable) _machinectl_images ;; - status|show|poweroff|reboot|terminate|kill) + status|show|poweroff|reboot|pause|resume|terminate|kill) _sd_machines ;; login|shell) diff --git a/src/libsystemd/sd-json/json-stream.c b/src/libsystemd/sd-json/json-stream.c index e8cd2c55dd..d8475ae873 100644 --- a/src/libsystemd/sd-json/json-stream.c +++ b/src/libsystemd/sd-json/json-stream.c @@ -35,10 +35,6 @@ struct JsonStreamQueueItem { int fds[]; }; -static const char* json_stream_description(const JsonStream *s) { - return (s ? s->description : NULL) ?: "json-stream"; -} - /* Returns the size of the framing delimiter in bytes: strlen(delimiter) for multi-char * delimiters (e.g. "\r\n"), or 1 for the default NUL-byte delimiter (delimiter == NULL). */ static size_t json_stream_delimiter_size(const JsonStream *s) { @@ -54,12 +50,6 @@ static usec_t json_stream_now(const JsonStream *s) { return now(CLOCK_MONOTONIC); } -#define json_stream_log(s, fmt, ...) \ - log_debug("%s: " fmt, json_stream_description(s), ##__VA_ARGS__) - -#define json_stream_log_errno(s, error, fmt, ...) \ - log_debug_errno((error), "%s: " fmt, json_stream_description(s), ##__VA_ARGS__) - sd_json_variant** json_stream_queue_item_get_data(JsonStreamQueueItem *q) { assert(q); return &q->data; diff --git a/src/libsystemd/sd-json/json-stream.h b/src/libsystemd/sd-json/json-stream.h index 92a09d4941..671b0f8985 100644 --- a/src/libsystemd/sd-json/json-stream.h +++ b/src/libsystemd/sd-json/json-stream.h @@ -6,6 +6,7 @@ #include "sd-forward.h" #include "list.h" +#include "log.h" /* JsonStream provides the transport layer used by sd-varlink (and other consumers like * the QMP client) for exchanging length-delimited JSON messages over a pair of file @@ -132,6 +133,16 @@ void json_stream_done(JsonStream *s); int json_stream_set_description(JsonStream *s, const char *description); const char* json_stream_get_description(const JsonStream *s); +static inline const char* json_stream_description(const JsonStream *s) { + return (s ? s->description : NULL) ?: "json-stream"; +} + +#define json_stream_log(s, fmt, ...) \ + log_debug("%s: " fmt, json_stream_description(s), ##__VA_ARGS__) + +#define json_stream_log_errno(s, error, fmt, ...) \ + log_debug_errno((error), "%s: " fmt, json_stream_description(s), ##__VA_ARGS__) + /* fd ownership */ int json_stream_attach_fds(JsonStream *s, int input_fd, int output_fd); diff --git a/src/machine/machine-varlink.c b/src/machine/machine-varlink.c index a3d3cfcc7e..fcdeeb7ae8 100644 --- a/src/machine/machine-varlink.c +++ b/src/machine/machine-varlink.c @@ -142,6 +142,7 @@ int vl_method_register(sd_varlink *link, sd_json_variant *parameters, sd_varlink { "vSockCid", _SD_JSON_VARIANT_TYPE_INVALID, machine_cid, offsetof(Machine, vsock_cid), 0 }, { "sshAddress", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(Machine, ssh_address), SD_JSON_STRICT }, { "sshPrivateKeyPath", SD_JSON_VARIANT_STRING, json_dispatch_path, offsetof(Machine, ssh_private_key_path), 0 }, + { "controlAddress", SD_JSON_VARIANT_STRING, json_dispatch_path, offsetof(Machine, control_address), SD_JSON_STRICT }, { "allocateUnit", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(Machine, allocate_unit), 0 }, VARLINK_DISPATCH_POLKIT_FIELD, {} diff --git a/src/machine/machine.c b/src/machine/machine.c index 535128692e..63fed79687 100644 --- a/src/machine/machine.c +++ b/src/machine/machine.c @@ -154,6 +154,7 @@ Machine* machine_free(Machine *m) { free(m->netif); free(m->ssh_address); free(m->ssh_private_key_path); + free(m->control_address); return mfree(m); } @@ -245,6 +246,7 @@ int machine_save(Machine *m) { env_file_fputs_assignment(f, "SSH_ADDRESS=", m->ssh_address); env_file_fputs_assignment(f, "SSH_PRIVATE_KEY_PATH=", m->ssh_private_key_path); + env_file_fputs_assignment(f, "CONTROL_ADDRESS=", m->control_address); r = flink_tmpfile(f, temp_path, m->state_file, LINK_TMPFILE_REPLACE); if (r < 0) @@ -338,6 +340,7 @@ int machine_load(Machine *m) { "VSOCK_CID", &vsock_cid, "SSH_ADDRESS", &m->ssh_address, "SSH_PRIVATE_KEY_PATH", &m->ssh_private_key_path, + "CONTROL_ADDRESS", &m->control_address, "UID", &uid); if (r == -ENOENT) return 0; diff --git a/src/machine/machine.h b/src/machine/machine.h index 899218f48d..6f6183b712 100644 --- a/src/machine/machine.h +++ b/src/machine/machine.h @@ -96,6 +96,7 @@ typedef struct Machine { unsigned vsock_cid; char *ssh_address; char *ssh_private_key_path; + char *control_address; LIST_HEAD(Operation, operations); diff --git a/src/machine/machinectl.c b/src/machine/machinectl.c index d31cfcbbe5..e8a30a8959 100644 --- a/src/machine/machinectl.c +++ b/src/machine/machinectl.c @@ -9,6 +9,7 @@ #include "sd-bus.h" #include "sd-event.h" #include "sd-journal.h" +#include "sd-varlink.h" #include "alloc-util.h" #include "ask-password-agent.h" @@ -45,6 +46,7 @@ #include "pager.h" #include "parse-argument.h" #include "parse-util.h" +#include "path-lookup.h" #include "path-util.h" #include "pidref.h" #include "polkit-agent.h" @@ -1106,34 +1108,73 @@ static int verb_kill_machine(int argc, char *argv[], uintptr_t _data, void *user return 0; } +static int verb_machine_control_one(const char *machine_name, const char *method); + static int verb_reboot_machine(int argc, char *argv[], uintptr_t data, void *userdata) { - if (arg_runner == RUNNER_VMSPAWN) - return log_error_errno( - SYNTHETIC_ERRNO(EOPNOTSUPP), - "%s only support supported for --runner=nspawn", - streq(argv[0], "reboot") ? "Reboot" : "Restart"); - - arg_kill_whom = "leader"; - arg_signal = SIGINT; /* sysvinit + systemd */ - - return verb_kill_machine(argc, argv, data, userdata); -} - -static int verb_poweroff_machine(int argc, char *argv[], uintptr_t data, void *userdata) { - arg_kill_whom = "leader"; - arg_signal = SIGRTMIN+4; /* only systemd */ - - return verb_kill_machine(argc, argv, data, userdata); -} - -static int verb_terminate_machine(int argc, char *argv[], uintptr_t _data, void *userdata) { - _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; sd_bus *bus = ASSERT_PTR(userdata); int r; (void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password); for (int i = 1; i < argc; i++) { + r = verb_machine_control_one(argv[i], "io.systemd.MachineInstance.Reboot"); + if (r >= 0) + continue; + if (r != -EOPNOTSUPP) + return r; + + /* Container fallback: SIGINT to init (sysvinit + systemd compatible) */ + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + r = bus_call_method(bus, bus_machine_mgr, "KillMachine", &error, NULL, + "ssi", argv[i], "leader", (int32_t) SIGINT); + if (r < 0) + return log_error_errno(r, "Could not reboot machine '%s': %s", argv[i], bus_error_message(&error, r)); + } + + return 0; +} + +static int verb_poweroff_machine(int argc, char *argv[], uintptr_t data, void *userdata) { + sd_bus *bus = ASSERT_PTR(userdata); + int r; + + (void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password); + + for (int i = 1; i < argc; i++) { + /* VM with varlink control socket: QMP graceful powerdown */ + r = verb_machine_control_one(argv[i], "io.systemd.MachineInstance.PowerOff"); + if (r >= 0) + continue; + if (r != -EOPNOTSUPP) + return r; + + /* Not a VM: signal-based poweroff */ + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + r = bus_call_method(bus, bus_machine_mgr, "KillMachine", &error, NULL, + "ssi", argv[i], "leader", (int32_t) (SIGRTMIN+4)); + if (r < 0) + return log_error_errno(r, "Could not kill machine: %s", bus_error_message(&error, r)); + } + + return 0; +} + +static int verb_terminate_machine(int argc, char *argv[], uintptr_t _data, void *userdata) { + sd_bus *bus = ASSERT_PTR(userdata); + int r; + + (void) polkit_agent_open_if_enabled(arg_transport, arg_ask_password); + + for (int i = 1; i < argc; i++) { + /* VM with varlink control socket: QMP quit (immediate termination) */ + r = verb_machine_control_one(argv[i], "io.systemd.MachineInstance.Terminate"); + if (r >= 0) + continue; + if (r != -EOPNOTSUPP) + return r; + + /* Not a VM or no varlink socket: fall back to machined */ + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; r = bus_call_method(bus, bus_machine_mgr, "TerminateMachine", &error, NULL, "s", argv[i]); if (r < 0) return log_error_errno(r, "Could not terminate machine: %s", bus_error_message(&error, r)); @@ -1142,6 +1183,99 @@ static int verb_terminate_machine(int argc, char *argv[], uintptr_t _data, void return 0; } +/* Look up the controlAddress of a machine by calling machined's Machine.List varlink interface. */ +static int machine_get_control_address(const char *machine_name, char **ret) { + _cleanup_(sd_varlink_unrefp) sd_varlink *vl = NULL; + sd_json_variant *reply = NULL; + const char *error_id = NULL; + int r; + + assert(machine_name); + assert(ret); + + if (arg_transport != BUS_TRANSPORT_LOCAL) + return -EOPNOTSUPP; + + _cleanup_free_ char *p = NULL; + r = runtime_directory_generic(arg_runtime_scope, "systemd/machine/io.systemd.Machine", &p); + if (r < 0) + return log_error_errno(r, "Failed to determine Machine varlink socket path: %m"); + + r = sd_varlink_connect_address(&vl, p); + if (r < 0) + return log_error_errno(r, "Failed to connect to machined varlink: %m"); + + r = sd_varlink_callbo( + vl, + "io.systemd.Machine.List", + &reply, + &error_id, + SD_JSON_BUILD_PAIR_STRING("name", machine_name)); + if (r < 0) + return log_error_errno(r, "Failed to list machine: %m"); + if (error_id) + return log_error_errno(sd_varlink_error_to_errno(error_id, reply), + "Failed to look up machine '%s': %s", machine_name, error_id); + + sd_json_variant *addr = sd_json_variant_by_key(reply, "controlAddress"); + if (!addr || !sd_json_variant_is_string(addr)) + return -EOPNOTSUPP; /* No varlink control socket, caller decides whether to log */ + + char *a = strdup(sd_json_variant_string(addr)); + if (!a) + return log_oom(); + + *ret = a; + return 0; +} + +static int verb_machine_control_one(const char *machine_name, const char *method) { + _cleanup_free_ char *address = NULL; + int r; + + r = machine_get_control_address(machine_name, &address); + if (r < 0) + return r; + + _cleanup_(sd_varlink_unrefp) sd_varlink *vl = NULL; + r = sd_varlink_connect_address(&vl, address); + if (r < 0) + return log_error_errno(r, "Failed to connect to machine control socket: %m"); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *reply = NULL; + const char *error_id = NULL; + r = sd_varlink_call(vl, method, /* parameters= */ NULL, &reply, &error_id); + if (r < 0) + return log_error_errno(r, "Failed to call %s: %m", method); + if (error_id) + return log_error_errno(sd_varlink_error_to_errno(error_id, reply), + "Machine control call failed: %s", error_id); + + return 0; +} + +static int verb_vm_control(int argc, char *argv[], const char *method) { + int r; + + for (int i = 1; i < argc; i++) { + r = verb_machine_control_one(argv[i], method); + if (r == -EOPNOTSUPP) + return log_error_errno(r, "Machine '%s' does not expose a varlink control socket.", argv[i]); + if (r < 0) + return r; + } + + return 0; +} + +static int verb_pause(int argc, char *argv[], uintptr_t _data, void *userdata) { + return verb_vm_control(argc, argv, "io.systemd.MachineInstance.Pause"); +} + +static int verb_resume(int argc, char *argv[], uintptr_t _data, void *userdata) { + return verb_vm_control(argc, argv, "io.systemd.MachineInstance.Resume"); +} + static const char *select_copy_method(bool copy_from, bool force) { if (force) return copy_from ? "CopyFromMachineWithFlags" : "CopyToMachineWithFlags"; @@ -2077,10 +2211,12 @@ static int help(void) { " or on the local host\n" " enable NAME... Enable automatic container start at boot\n" " disable NAME... Disable automatic container start at boot\n" - " poweroff NAME... Power off one or more containers\n" - " reboot NAME... Reboot one or more containers\n" - " terminate NAME... Terminate one or more VMs/containers\n" - " kill NAME... Send signal to processes of a VM/container\n" + " poweroff NAME... Power off one or more machines\n" + " reboot NAME... Reboot one or more machines\n" + " pause NAME... Pause one or more machines\n" + " resume NAME... Resume one or more paused machines\n" + " terminate NAME... Terminate one or more machines\n" + " kill NAME... Send signal to processes of a machine\n" " copy-to NAME PATH [PATH] Copy files from the host to a container\n" " copy-from NAME PATH [PATH] Copy files from a container to the host\n" " bind NAME PATH [PATH] Bind mount a path from the host into a container\n" @@ -2465,6 +2601,8 @@ static int machinectl_main(int argc, char *argv[], sd_bus *bus) { { "poweroff", 2, VERB_ANY, 0, verb_poweroff_machine }, { "stop", 2, VERB_ANY, 0, verb_poweroff_machine }, /* Convenience alias */ { "kill", 2, VERB_ANY, 0, verb_kill_machine }, + { "pause", 2, VERB_ANY, 0, verb_pause }, + { "resume", 2, VERB_ANY, 0, verb_resume }, { "login", VERB_ANY, 2, 0, verb_login_machine }, { "shell", VERB_ANY, VERB_ANY, 0, verb_shell_machine }, { "bind", 3, 4, 0, verb_bind_mount }, diff --git a/src/machine/machined-varlink.c b/src/machine/machined-varlink.c index ac506ad87f..4ab68a77f9 100644 --- a/src/machine/machined-varlink.c +++ b/src/machine/machined-varlink.c @@ -489,6 +489,7 @@ static int list_machine_one_and_maybe_read_metadata(sd_varlink *link, Machine *m JSON_BUILD_PAIR_UNSIGNED_NOT_EQUAL("vSockCid", m->vsock_cid, VMADDR_CID_ANY), JSON_BUILD_PAIR_STRING_NON_EMPTY("sshAddress", m->ssh_address), JSON_BUILD_PAIR_STRING_NON_EMPTY("sshPrivateKeyPath", m->ssh_private_key_path), + JSON_BUILD_PAIR_STRING_NON_EMPTY("controlAddress", m->control_address), JSON_BUILD_PAIR_VARIANT_NON_NULL("addresses", addr_array), JSON_BUILD_PAIR_STRV_ENV_PAIR_NON_EMPTY("OSRelease", os_release), JSON_BUILD_PAIR_UNSIGNED_NOT_EQUAL("UIDShift", shift, UID_INVALID), diff --git a/src/shared/machine-register.c b/src/shared/machine-register.c index 924d768877..a161d1b050 100644 --- a/src/shared/machine-register.c +++ b/src/shared/machine-register.c @@ -187,6 +187,7 @@ int register_machine( SD_JSON_BUILD_PAIR_CONDITION(!!reg->root_directory, "rootDirectory", SD_JSON_BUILD_STRING(reg->root_directory)), SD_JSON_BUILD_PAIR_CONDITION(!!reg->ssh_address, "sshAddress", SD_JSON_BUILD_STRING(reg->ssh_address)), SD_JSON_BUILD_PAIR_CONDITION(!!reg->ssh_private_key_path, "sshPrivateKeyPath", SD_JSON_BUILD_STRING(reg->ssh_private_key_path)), + SD_JSON_BUILD_PAIR_CONDITION(!!reg->control_address, "controlAddress", SD_JSON_BUILD_STRING(reg->control_address)), SD_JSON_BUILD_PAIR_CONDITION(isatty_safe(STDIN_FILENO), "allowInteractiveAuthentication", SD_JSON_BUILD_BOOLEAN(true)), SD_JSON_BUILD_PAIR_CONDITION(reg->allocate_unit, "allocateUnit", SD_JSON_BUILD_BOOLEAN(true)), SD_JSON_BUILD_PAIR_CONDITION(pidref_is_set(reg->pidref), "leaderProcessId", JSON_BUILD_PIDREF(reg->pidref))); diff --git a/src/shared/machine-register.h b/src/shared/machine-register.h index 996bcfe7a2..ffec15a881 100644 --- a/src/shared/machine-register.h +++ b/src/shared/machine-register.h @@ -17,6 +17,7 @@ typedef struct MachineRegistration { int local_ifindex; const char *ssh_address; const char *ssh_private_key_path; + const char *control_address; bool allocate_unit; } MachineRegistration; diff --git a/src/shared/meson.build b/src/shared/meson.build index 22dccf0e2a..705cb955bd 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -165,6 +165,7 @@ shared_sources = files( 'printk-util.c', 'prompt-util.c', 'ptyfwd.c', + 'qmp-client.c', 'qrcode-util.c', 'quota-util.c', 'reboot-util.c', @@ -216,6 +217,7 @@ shared_sources = files( 'varlink-io.systemd.Login.c', 'varlink-io.systemd.Machine.c', 'varlink-io.systemd.MachineImage.c', + 'varlink-io.systemd.MachineInstance.c', 'varlink-io.systemd.ManagedOOM.c', 'varlink-io.systemd.Manager.c', 'varlink-io.systemd.Metrics.c', @@ -226,6 +228,7 @@ shared_sources = files( 'varlink-io.systemd.Network.Link.c', 'varlink-io.systemd.PCRExtend.c', 'varlink-io.systemd.PCRLock.c', + 'varlink-io.systemd.QemuMachineInstance.c', 'varlink-io.systemd.Repart.c', 'varlink-io.systemd.Resolve.c', 'varlink-io.systemd.Resolve.Hook.c', @@ -234,6 +237,7 @@ shared_sources = files( 'varlink-io.systemd.Udev.c', 'varlink-io.systemd.Unit.c', 'varlink-io.systemd.UserDatabase.c', + 'varlink-io.systemd.VirtualMachineInstance.c', 'varlink-io.systemd.oom.c', 'varlink-io.systemd.oom.Prekill.c', 'varlink-io.systemd.service.c', diff --git a/src/shared/qmp-client.c b/src/shared/qmp-client.c new file mode 100644 index 0000000000..6a92550e72 --- /dev/null +++ b/src/shared/qmp-client.c @@ -0,0 +1,845 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-event.h" +#include "sd-json.h" + +#include "alloc-util.h" +#include "fd-util.h" +#include "hash-funcs.h" +#include "json-stream.h" +#include "json-util.h" +#include "qmp-client.h" +#include "set.h" +#include "siphash24.h" +#include "string-util.h" + +typedef enum QmpClientState { + QMP_CLIENT_HANDSHAKE_INITIAL, /* waiting for QMP greeting */ + QMP_CLIENT_HANDSHAKE_GREETING_RECEIVED, /* greeting received, sending qmp_capabilities */ + QMP_CLIENT_HANDSHAKE_CAPABILITIES_SENT, /* waiting for qmp_capabilities response */ + QMP_CLIENT_RUNNING, /* connected, ready for commands */ + QMP_CLIENT_DISCONNECTED, /* connection closed */ + _QMP_CLIENT_STATE_MAX, + _QMP_CLIENT_STATE_INVALID = -EINVAL, +} QmpClientState; + +/* States routed to dispatch_handshake. */ +#define QMP_CLIENT_STATE_IS_HANDSHAKE(s) \ + IN_SET(s, \ + QMP_CLIENT_HANDSHAKE_INITIAL, \ + QMP_CLIENT_HANDSHAKE_GREETING_RECEIVED, \ + QMP_CLIENT_HANDSHAKE_CAPABILITIES_SENT) + +typedef struct QmpSlot { + uint64_t id; + qmp_command_callback_t callback; + void *userdata; +} QmpSlot; + +struct QmpClient { + unsigned n_ref; + + JsonStream stream; + + sd_event_source *quit_event_source; + sd_event_source *defer_event_source; + + uint64_t next_id; + Set *slots; /* QmpSlot* entries indexed by id, for async dispatch */ + + qmp_event_callback_t event_callback; + void *event_userdata; + qmp_disconnect_callback_t disconnect_callback; + void *disconnect_userdata; + + unsigned next_fdset_id; /* monotonic fdset-id allocator for add-fd */ + + QmpClientState state; + sd_json_variant *current; /* most recently parsed message, pending dispatch */ +}; + +static void qmp_slot_hash_func(const QmpSlot *p, struct siphash *state) { + siphash24_compress_typesafe(p->id, state); +} + +static int qmp_slot_compare_func(const QmpSlot *a, const QmpSlot *b) { + return CMP(a->id, b->id); +} + +DEFINE_PRIVATE_HASH_OPS(qmp_slot_hash_ops, + QmpSlot, qmp_slot_hash_func, qmp_slot_compare_func); + +static void qmp_client_clear(QmpClient *c); + +static QmpClient* qmp_client_destroy(QmpClient *c) { + if (!c) + return NULL; + + qmp_client_clear(c); + + return mfree(c); +} + +DEFINE_PRIVATE_TRIVIAL_REF_FUNC(QmpClient, qmp_client); +DEFINE_TRIVIAL_UNREF_FUNC(QmpClient, qmp_client, qmp_client_destroy); + +static void qmp_client_clear_current(QmpClient *c) { + assert(c); + + c->current = sd_json_variant_unref(c->current); +} + +static void qmp_client_dispatch_event(QmpClient *c, sd_json_variant *v) { + int r; + + assert(c); + assert(v); + + if (!c->event_callback) + return; + + struct { + const char *event; + sd_json_variant *data; + } p = {}; + + static const sd_json_dispatch_field table[] = { + { "event", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, event), SD_JSON_MANDATORY }, + { "data", SD_JSON_VARIANT_OBJECT, sd_json_dispatch_variant_noref, voffsetof(p, data), 0 }, + {}, + }; + + r = sd_json_dispatch(v, table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG|SD_JSON_DEBUG, &p); + if (r < 0) + return; + + r = c->event_callback(c, p.event, p.data, c->event_userdata); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Event callback returned error, ignoring: %m"); +} + +/* QEMU's error "class" is effectively always "GenericError"; only "desc" carries useful info. */ +static const char* qmp_extract_error_description(sd_json_variant *v) { + sd_json_variant *error = sd_json_variant_by_key(v, "error"); + if (!error) + return NULL; + sd_json_variant *desc = sd_json_variant_by_key(error, "desc"); + if (desc) + return sd_json_variant_string(desc); + return "unspecified error"; +} + +/* Returns 1 with id set; 0 if absent (e.g. pre-parse error responses); -EBADMSG on wrong type. */ +static int qmp_extract_response_id(sd_json_variant *v, uint64_t *ret) { + sd_json_variant *id_variant; + + assert(v); + assert(ret); + + id_variant = sd_json_variant_by_key(v, "id"); + if (!id_variant) { + *ret = 0; + return 0; + } + if (!sd_json_variant_is_unsigned(id_variant)) + return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), + "QMP response 'id' field is not an unsigned integer."); + + *ret = sd_json_variant_unsigned(id_variant); + return 1; +} + +/* Returns 0 on success (ret_result = "return" value), -EIO on QMP error (reterr_desc set). */ +static int qmp_parse_response(sd_json_variant *v, sd_json_variant **ret_result, const char **reterr_desc) { + const char *desc; + + desc = qmp_extract_error_description(v); + if (desc) { + if (reterr_desc) + *reterr_desc = desc; + return -EIO; + } + + if (ret_result) + *ret_result = sd_json_variant_by_key(v, "return"); + return 0; +} + +static int qmp_client_build_command( + QmpClient *c, + const char *command, + sd_json_variant *arguments, + sd_json_variant **ret, + uint64_t *ret_id) { + + uint64_t id; + int r; + + assert(c); + assert(command); + assert(ret); + assert(ret_id); + + id = c->next_id++; + + r = sd_json_buildo( + ret, + SD_JSON_BUILD_PAIR_STRING("execute", command), + SD_JSON_BUILD_PAIR_CONDITION(!!arguments, "arguments", SD_JSON_BUILD_VARIANT(arguments)), + SD_JSON_BUILD_PAIR_UNSIGNED("id", id)); + if (r < 0) + return r; + + *ret_id = id; + return 0; +} + +/* Route c->current to event callback or matching async slot. Returns 1 on dispatch. */ +static int qmp_client_dispatch_reply(QmpClient *c) { + sd_json_variant *result = NULL; + const char *desc = NULL; + uint64_t id; + int error, r; + + assert(c); + + if (!c->current) + return 0; + + /* Events have an "event" key */ + if (sd_json_variant_by_key(c->current, "event")) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = TAKE_PTR(c->current); + qmp_client_dispatch_event(c, v); + return 1; + } + + /* Command responses carry an "id" matching a request we sent */ + r = qmp_extract_response_id(c->current, &id); + if (r < 0) { + qmp_client_clear_current(c); + return json_stream_log_errno(&c->stream, r, "Discarding QMP response with malformed id: %m"); + } + if (r == 0) { + qmp_client_clear_current(c); + json_stream_log(&c->stream, "Discarding unrecognized QMP message"); + return 1; + } + + _cleanup_free_ QmpSlot *pending = set_remove(c->slots, &(QmpSlot) { .id = id }); + if (!pending) { + qmp_client_clear_current(c); + json_stream_log(&c->stream, "Discarding QMP response with unknown id %" PRIu64, id); + return 1; + } + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = TAKE_PTR(c->current); + error = qmp_parse_response(v, &result, &desc); + + r = pending->callback(c, result, desc, error, pending->userdata); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Command callback returned error, ignoring: %m"); + + return 1; +} + +/* Fail all pending async commands with the given error. Called on disconnect. */ +static void qmp_client_fail_pending(QmpClient *c, int error) { + QmpSlot *p; + int r; + + assert(c); + + while ((p = set_steal_first(c->slots))) { + r = p->callback(c, /* result= */ NULL, /* error_desc= */ NULL, error, p->userdata); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Command callback returned error, ignoring: %m"); + free(p); + } +} + +/* Synthetic SHUTDOWN on unexpected disconnect so subscribers learn the VM is gone. */ +static void qmp_client_emit_synthetic_shutdown(QmpClient *c) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *data = NULL; + int r; + + assert(c); + + if (!c->event_callback) + return; + + r = sd_json_buildo( + &data, + SD_JSON_BUILD_PAIR_BOOLEAN("guest", false), + SD_JSON_BUILD_PAIR_STRING("reason", "disconnected")); + if (r < 0) { + json_stream_log_errno(&c->stream, r, "Failed to build synthetic SHUTDOWN event data, skipping: %m"); + return; + } + + r = c->event_callback(c, "SHUTDOWN", data, c->event_userdata); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Event callback returned error, ignoring: %m"); +} + +static bool qmp_client_handle_disconnect(QmpClient *c) { + assert(c); + + if (c->state == QMP_CLIENT_DISCONNECTED) + return false; + + c->state = QMP_CLIENT_DISCONNECTED; + + /* Disable defer event source so we don't busy-loop on the EOF condition. */ + if (c->defer_event_source) + (void) sd_event_source_set_enabled(c->defer_event_source, SD_EVENT_OFF); + + qmp_client_fail_pending(c, -ECONNRESET); + qmp_client_emit_synthetic_shutdown(c); + if (c->disconnect_callback) + c->disconnect_callback(c, c->disconnect_userdata); + + return true; +} + +static bool qmp_client_test_disconnect(QmpClient *c) { + assert(c); + + /* Already disconnected? */ + if (c->state == QMP_CLIENT_DISCONNECTED) + return false; + + if (!json_stream_should_disconnect(&c->stream)) + return false; + + return qmp_client_handle_disconnect(c); +} + +/* INITIAL → greeting → GREETING_RECEIVED → qmp_capabilities → CAPABILITIES_SENT → response → RUNNING. */ +static int qmp_client_dispatch_handshake(QmpClient *c) { + int r; + + assert(c); + assert(QMP_CLIENT_STATE_IS_HANDSHAKE(c->state)); + + if (!c->current) + return 0; + + /* Defensive: QEMU shouldn't emit events during capability negotiation, but if one + * arrives, dispatch it as an event rather than mis-parsing it as a handshake reply. */ + if (sd_json_variant_by_key(c->current, "event")) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = TAKE_PTR(c->current); + qmp_client_dispatch_event(c, v); + return 1; + } + + switch (c->state) { + + case QMP_CLIENT_HANDSHAKE_INITIAL: { + /* Waiting for QMP greeting. Take ownership so by_key()'s borrowed pointer + * stays valid through the case scope. */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = TAKE_PTR(c->current); + if (!sd_json_variant_by_key(v, "QMP")) + return json_stream_log_errno(&c->stream, SYNTHETIC_ERRNO(EPROTO), + "Expected QMP greeting, got something else"); + + c->state = QMP_CLIENT_HANDSHAKE_GREETING_RECEIVED; + + /* Fall through to immediately send capabilities */ + _fallthrough_; + } + + case QMP_CLIENT_HANDSHAKE_GREETING_RECEIVED: { + /* Send qmp_capabilities command */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd = NULL; + r = sd_json_buildo( + &cmd, + SD_JSON_BUILD_PAIR_STRING("execute", "qmp_capabilities"), + SD_JSON_BUILD_PAIR_UNSIGNED("id", c->next_id++)); + if (r < 0) + return r; + + r = json_stream_enqueue(&c->stream, cmd); + if (r < 0) + return r; + + c->state = QMP_CLIENT_HANDSHAKE_CAPABILITIES_SENT; + return 1; + } + + case QMP_CLIENT_HANDSHAKE_CAPABILITIES_SENT: { + /* Take ownership so desc (borrowed from v's "error.desc") survives the format string. */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = TAKE_PTR(c->current); + const char *desc = NULL; + r = qmp_parse_response(v, /* ret_result= */ NULL, &desc); + if (r < 0) + return json_stream_log_errno(&c->stream, SYNTHETIC_ERRNO(EPROTO), + "qmp_capabilities failed: %s", desc); + + c->state = QMP_CLIENT_RUNNING; + return 1; + } + + default: + assert_not_reached(); + } +} + +static int qmp_client_dispatch(QmpClient *c) { + assert(c); + + if (!c->current) + return 0; + + if (QMP_CLIENT_STATE_IS_HANDSHAKE(c->state)) + return qmp_client_dispatch_handshake(c); + + return qmp_client_dispatch_reply(c); +} + +/* Single step: write → dispatch → parse → read → disconnect. Matches sd_varlink_process(). */ +int qmp_client_process(QmpClient *c) { + int r; + + assert(c); + + if (c->state < 0 || c->state == QMP_CLIENT_DISCONNECTED) + return -ENOTCONN; + + /* Pin against a callback dropping the last ref mid-dispatch. Matches sd_varlink_process(). */ + qmp_client_ref(c); + + /* 1. Write — drain output buffer */ + r = json_stream_write(&c->stream); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Failed to write to QMP socket: %m"); + if (r != 0) + goto finish; + + /* 2. Dispatch — route based on state */ + r = qmp_client_dispatch(c); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Failed to dispatch QMP message: %m"); + if (r != 0) + goto finish; + + /* 3. Parse — extract one complete message into c->current */ + if (!c->current) { + r = json_stream_parse(&c->stream, &c->current); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Failed to parse QMP message: %m"); + if (r != 0) + goto finish; + } + + /* 4. Read — fill input buffer from fd */ + if (!c->current) { + r = json_stream_read(&c->stream); + if (r < 0) + json_stream_log_errno(&c->stream, r, "Failed to read from QMP socket: %m"); + if (r != 0) + goto finish; + } + + /* 5. Test disconnect */ + if (qmp_client_test_disconnect(c)) { + r = 1; + goto finish; + } + +finish: + /* Re-arm defer source on progress so we get called again next iteration. */ + if (r >= 0 && c->defer_event_source) { + int q; + + q = sd_event_source_set_enabled(c->defer_event_source, r > 0 ? SD_EVENT_ON : SD_EVENT_OFF); + if (q < 0) + r = json_stream_log_errno(&c->stream, q, "Failed to enable deferred event source: %m"); + } + + /* -ENOBUFS is the buffered stream's 16 MiB cap, not a transport error — propagate without disconnecting. */ + if (r < 0 && r != -ENOBUFS && c->state != QMP_CLIENT_DISCONNECTED) + qmp_client_handle_disconnect(c); + + qmp_client_unref(c); + return r; +} + +int qmp_client_wait(QmpClient *c, uint64_t timeout_usec) { + assert(c); + + if (c->state == QMP_CLIENT_DISCONNECTED) + return -ENOTCONN; + + return json_stream_wait(&c->stream, timeout_usec); +} + +bool qmp_client_is_idle(QmpClient *c) { + assert(c); + return set_isempty(c->slots); +} + +bool qmp_client_is_disconnected(QmpClient *c) { + assert(c); + return c->state == QMP_CLIENT_DISCONNECTED; +} + +/* Map our state to the transport phase used for POLLIN / salvage / timeout decisions. */ +static JsonStreamPhase qmp_client_phase(void *userdata) { + QmpClient *c = ASSERT_PTR(userdata); + + /* A parsed-but-undispatched message is mid-processing, not waiting on the wire. */ + if (c->current) + return JSON_STREAM_PHASE_OTHER; + + /* During handshake we're waiting for the greeting or qmp_capabilities response. */ + if (QMP_CLIENT_STATE_IS_HANDSHAKE(c->state)) + return JSON_STREAM_PHASE_AWAITING_REPLY; + + /* Running with pending async commands — waiting for their responses. */ + if (c->state == QMP_CLIENT_RUNNING && !set_isempty(c->slots)) + return JSON_STREAM_PHASE_AWAITING_REPLY; + + /* Running with no pending commands — waiting for unsolicited events. */ + if (c->state == QMP_CLIENT_RUNNING) + return JSON_STREAM_PHASE_READING; + + return JSON_STREAM_PHASE_OTHER; +} + +static int qmp_client_dispatch_cb(void *userdata) { + QmpClient *c = ASSERT_PTR(userdata); + return qmp_client_process(c); +} + +static int qmp_client_defer_callback(sd_event_source *source, void *userdata) { + QmpClient *c = ASSERT_PTR(userdata); + + assert(source); + + (void) qmp_client_process(c); + + return 1; +} + +/* Drive handshake to completion. Matches sd-bus's bus_ensure_running(). */ +static int qmp_client_ensure_running(QmpClient *c) { + int r; + + assert(c); + + if (c->state == QMP_CLIENT_RUNNING) + return 1; + + for (;;) { + if (c->state < 0 || c->state == QMP_CLIENT_DISCONNECTED) + return -ENOTCONN; + + r = qmp_client_process(c); + if (r < 0) + return r; + if (c->state == QMP_CLIENT_RUNNING) + return 1; + if (r > 0) + continue; + + r = qmp_client_wait(c, USEC_INFINITY); + if (r < 0) + return r; + } +} + +static void qmp_client_detach_event(QmpClient *c) { + if (!c) + return; + + c->defer_event_source = sd_event_source_disable_unref(c->defer_event_source); + c->quit_event_source = sd_event_source_disable_unref(c->quit_event_source); + json_stream_detach_event(&c->stream); +} + +static void qmp_client_clear(QmpClient *c) { + assert(c); + + qmp_client_handle_disconnect(c); + qmp_client_detach_event(c); + qmp_client_clear_current(c); + json_stream_done(&c->stream); + c->slots = set_free(c->slots); +} + +/* Blocks until output buffer is empty. Matches sd_varlink_flush(). */ +static int qmp_client_flush(QmpClient *c) { + if (!c) + return 0; + + if (c->state == QMP_CLIENT_DISCONNECTED) + return -ENOTCONN; + + return json_stream_flush(&c->stream); +} + +/* Notify callbacks, fire disconnect, detach sources, close fd. Matches sd_varlink_close(). */ +static int qmp_client_close(QmpClient *c) { + if (!c) + return 0; + + /* Take a temporary ref to prevent destruction mid-callback, + * matching sd_varlink_close()'s pattern. */ + qmp_client_ref(c); + qmp_client_clear(c); + qmp_client_unref(c); + + return 1; +} + +static int qmp_client_quit_callback(sd_event_source *source, void *userdata) { + QmpClient *c = ASSERT_PTR(userdata); + + assert(source); + + qmp_client_flush(c); + qmp_client_close(c); + + return 1; +} + +int qmp_client_connect_fd(QmpClient **ret, int fd) { + _cleanup_(qmp_client_unrefp) QmpClient *c = NULL; + int r; + + assert(ret); + assert(fd >= 0); + + c = new(QmpClient, 1); + if (!c) + return -ENOMEM; + + *c = (QmpClient) { + .n_ref = 1, + .state = QMP_CLIENT_HANDSHAKE_INITIAL, + .next_id = 1, + }; + + const JsonStreamParams params = { + .delimiter = "\r\n", + .phase = qmp_client_phase, + .dispatch = qmp_client_dispatch_cb, + .userdata = c, + }; + + r = json_stream_init(&c->stream, ¶ms); + if (r < 0) + return r; + + r = json_stream_connect_fd_pair(&c->stream, fd, fd); + if (r < 0) + return r; + + *ret = TAKE_PTR(c); + return 0; +} + +int qmp_client_attach_event(QmpClient *c, sd_event *event, int64_t priority) { + int r; + + assert(c); + assert(event); + assert(!json_stream_get_event(&c->stream)); + + r = json_stream_attach_event(&c->stream, event, priority); + if (r < 0) + return r; + + sd_event *ev = json_stream_get_event(&c->stream); + + r = sd_event_add_exit(ev, &c->quit_event_source, qmp_client_quit_callback, c); + if (r < 0) + goto fail; + + r = sd_event_source_set_priority(c->quit_event_source, priority); + if (r < 0) + goto fail; + + (void) sd_event_source_set_description(c->quit_event_source, "qmp-client-quit"); + + r = sd_event_add_defer(ev, &c->defer_event_source, qmp_client_defer_callback, c); + if (r < 0) + goto fail; + + r = sd_event_source_set_priority(c->defer_event_source, priority); + if (r < 0) + goto fail; + + r = sd_event_source_set_enabled(c->defer_event_source, SD_EVENT_OFF); + if (r < 0) + goto fail; + + (void) sd_event_source_set_description(c->defer_event_source, "qmp-client-defer"); + + return 0; + +fail: + qmp_client_detach_event(c); + return r; +} + +/* Cleanup hook: closes any fds in *args not yet transferred to the stream. */ +static QmpClientArgs* qmp_client_args_close_fds(QmpClientArgs *p) { + assert(p); + close_many_unset(p->fds_consume, p->n_fds); + return NULL; +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(QmpClientArgs*, qmp_client_args_close_fds); + +/* Transfer fds to the stream. On partial failure narrow args to the unstaged tail so + * the caller's cleanup closes only the untransferred fds. */ +static int qmp_client_stage_fds(QmpClient *c, QmpClientArgs *args) { + int r; + + assert(c); + + if (!args || args->n_fds == 0) + return 0; + + assert(args->fds_consume); + + for (size_t i = 0; i < args->n_fds; i++) { + r = json_stream_push_fd(&c->stream, args->fds_consume[i]); + if (r < 0) { + /* Already-staged are owned by the stream; narrow args to the rest. */ + json_stream_reset_pushed_fds(&c->stream); + args->fds_consume = &args->fds_consume[i]; + args->n_fds -= i; + return r; + } + } + + args->n_fds = 0; + return 0; +} + +int qmp_client_invoke( + QmpClient *c, + const char *command, + QmpClientArgs *args, + qmp_command_callback_t callback, + void *userdata) { + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd = NULL; + _cleanup_free_ QmpSlot *pending = NULL; + /* Closes any fds in args not yet handed to the stream on every early-return path; + * TAKE_PTR()'d on the success path below once stage_fds has consumed them. */ + _cleanup_(qmp_client_args_close_fdsp) QmpClientArgs *fds_owner = args; + uint64_t id; + int r; + + assert(c); + assert(command); + assert(callback); + + r = qmp_client_ensure_running(c); + if (r < 0) + return r; + + r = qmp_client_build_command(c, command, args ? args->arguments : NULL, &cmd, &id); + if (r < 0) + return r; + + pending = new(QmpSlot, 1); + if (!pending) + return -ENOMEM; + + *pending = (QmpSlot) { + .id = id, + .callback = callback, + .userdata = userdata, + }; + + r = set_ensure_put(&c->slots, &qmp_slot_hash_ops, pending); + if (r < 0) + return r; + assert(r > 0); + + /* Stage AFTER ensure_running() drained internal enqueues so the next enqueue is ours. */ + r = qmp_client_stage_fds(c, args); + if (r < 0) { + set_remove(c->slots, pending); + return r; + } + + r = json_stream_enqueue(&c->stream, cmd); + if (r < 0) { + json_stream_reset_pushed_fds(&c->stream); + set_remove(c->slots, pending); + return r; + } + + /* Arm defer so process() drains the output on the next iteration. */ + if (c->defer_event_source) + (void) sd_event_source_set_enabled(c->defer_event_source, SD_EVENT_ON); + + TAKE_PTR(pending); + TAKE_PTR(fds_owner); + return 0; +} + +void qmp_client_bind_event(QmpClient *c, qmp_event_callback_t callback, void *userdata) { + assert(c); + c->event_callback = callback; + c->event_userdata = userdata; +} + +void qmp_client_bind_disconnect(QmpClient *c, qmp_disconnect_callback_t callback, void *userdata) { + assert(c); + c->disconnect_callback = callback; + c->disconnect_userdata = userdata; +} + +int qmp_client_set_description(QmpClient *c, const char *description) { + assert(c); + return json_stream_set_description(&c->stream, description); +} + +sd_event* qmp_client_get_event(QmpClient *c) { + assert(c); + return json_stream_get_event(&c->stream); +} + +unsigned qmp_client_next_fdset_id(QmpClient *c) { + assert(c); + return c->next_fdset_id++; +} + +bool qmp_schema_has_member(sd_json_variant *schema, const char *member_name) { + sd_json_variant *entry; + + assert(member_name); + + if (!sd_json_variant_is_array(schema)) + return false; + + JSON_VARIANT_ARRAY_FOREACH(entry, schema) { + if (!sd_json_variant_is_object(entry)) + continue; + + sd_json_variant *meta = sd_json_variant_by_key(entry, "meta-type"); + if (!meta || !streq_ptr(sd_json_variant_string(meta), "object")) + continue; + + sd_json_variant *members = sd_json_variant_by_key(entry, "members"); + if (!sd_json_variant_is_array(members)) + continue; + + sd_json_variant *m; + JSON_VARIANT_ARRAY_FOREACH(m, members) { + if (!sd_json_variant_is_object(m)) + continue; + sd_json_variant *mn = sd_json_variant_by_key(m, "name"); + if (mn && streq_ptr(sd_json_variant_string(mn), member_name)) + return true; + } + } + + return false; +} diff --git a/src/shared/qmp-client.h b/src/shared/qmp-client.h new file mode 100644 index 0000000000..dbe65162e9 --- /dev/null +++ b/src/shared/qmp-client.h @@ -0,0 +1,78 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "shared-forward.h" + +typedef int (*qmp_event_callback_t)( + QmpClient *client, + const char *event, + sd_json_variant *data, + void *userdata); + +typedef void (*qmp_disconnect_callback_t)( + QmpClient *client, + void *userdata); + +/* Success: (result, NULL, 0). QMP error: (NULL, desc, -EIO). Transport: (NULL, NULL, -errno). */ +typedef int (*qmp_command_callback_t)( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata); + +/* Bundles arguments + fds for one command. Construct fresh per invoke via the macros below. */ +typedef struct QmpClientArgs { + sd_json_variant *arguments; + int *fds_consume; + size_t n_fds; +} QmpClientArgs; + +#define QMP_CLIENT_ARGS(args_) \ + (&(QmpClientArgs){ .arguments = (args_) }) +#define QMP_CLIENT_ARGS_FD(args_, fd_) \ + (&(QmpClientArgs){ .arguments = (args_), .fds_consume = (int[]){ (fd_) }, .n_fds = 1 }) + +/* Takes ownership of fd; handshake runs lazily on first invoke or from the event loop. */ +int qmp_client_connect_fd(QmpClient **ret, int fd); + +int qmp_client_attach_event(QmpClient *c, sd_event *event, int64_t priority); + +/* Single non-blocking pump step: write → dispatch → parse → read → disconnect. Returns >0 if + * progress was made, 0 if idle, <0 on error (-ENOTCONN on disconnect). Same contract as + * sd_varlink_process(). */ +int qmp_client_process(QmpClient *c); + +/* Block on the transport fd until readable, or until timeout (USEC_INFINITY for no timeout). + * Same contract as sd_varlink_wait(). */ +int qmp_client_wait(QmpClient *c, uint64_t timeout_usec); + +/* True iff there are no outstanding command replies (slots set is empty). Useful as the pump-loop + * exit condition for callers driving the client synchronously via process() + wait(). */ +bool qmp_client_is_idle(QmpClient *c); + +/* True iff the connection is dead. Stable terminal state — once set, it stays set. */ +bool qmp_client_is_disconnected(QmpClient *c); + +/* Async send. Returns 0 on send (callback will fire later), negative errno on failure. */ +int qmp_client_invoke( + QmpClient *client, + const char *command, + QmpClientArgs *args, + qmp_command_callback_t callback, + void *userdata); + +void qmp_client_bind_event(QmpClient *c, qmp_event_callback_t callback, void *userdata); +void qmp_client_bind_disconnect(QmpClient *c, qmp_disconnect_callback_t callback, void *userdata); +int qmp_client_set_description(QmpClient *c, const char *description); +sd_event* qmp_client_get_event(QmpClient *c); +unsigned qmp_client_next_fdset_id(QmpClient *client); + +QmpClient* qmp_client_unref(QmpClient *p); + +DEFINE_TRIVIAL_CLEANUP_FUNC(QmpClient *, qmp_client_unref); + +/* Returns true iff any object entry in schema (result of query-qmp-schema) has a member with this + * name. QEMU's introspection replaces type names with opaque numeric ids, so lookup-by-type-name is + * impossible — but member names are real. Use only when the member name is unique in the schema. */ +bool qmp_schema_has_member(sd_json_variant *schema, const char *member_name); diff --git a/src/shared/shared-forward.h b/src/shared/shared-forward.h index e07b25c056..38349d9dbb 100644 --- a/src/shared/shared-forward.h +++ b/src/shared/shared-forward.h @@ -78,6 +78,7 @@ typedef struct MountOptions MountOptions; typedef struct MStack MStack; typedef struct OpenFile OpenFile; typedef struct Pkcs11EncryptedKey Pkcs11EncryptedKey; +typedef struct QmpClient QmpClient; typedef struct Table Table; typedef struct Tpm2Context Tpm2Context; typedef struct Tpm2Handle Tpm2Handle; diff --git a/src/shared/varlink-io.systemd.Machine.c b/src/shared/varlink-io.systemd.Machine.c index 9f6d36ad77..da373a3c20 100644 --- a/src/shared/varlink-io.systemd.Machine.c +++ b/src/shared/varlink-io.systemd.Machine.c @@ -57,6 +57,8 @@ static SD_VARLINK_DEFINE_METHOD( SD_VARLINK_DEFINE_INPUT(vSockCid, SD_VARLINK_INT, SD_VARLINK_NULLABLE), SD_VARLINK_DEFINE_INPUT(sshAddress, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_DEFINE_INPUT(sshPrivateKeyPath, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Varlink socket address for direct machine control. The server at this address is expected to implement io.systemd.MachineInstance and optionally io.systemd.VirtualMachineInstance and io.systemd.QemuMachineInstance."), + SD_VARLINK_DEFINE_INPUT(controlAddress, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("Controls whether to allocate a scope unit for the machine to register. If false, the client already took care of that and registered a service/scope specific to the machine."), SD_VARLINK_DEFINE_INPUT(allocateUnit, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE), VARLINK_DEFINE_POLKIT_INPUT); @@ -107,6 +109,8 @@ static SD_VARLINK_DEFINE_METHOD_FULL( SD_VARLINK_DEFINE_OUTPUT(sshAddress, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("Path to private SSH key"), SD_VARLINK_DEFINE_OUTPUT(sshPrivateKeyPath, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Varlink socket address for direct machine control, implementing io.systemd.MachineInstance and optionally further interfaces"), + SD_VARLINK_DEFINE_OUTPUT(controlAddress, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("List of addresses of the machine"), SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(addresses, Address, SD_VARLINK_ARRAY | SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("OS release information of the machine. It contains an array of key value pairs read from the os-release(5) file in the image."), diff --git a/src/shared/varlink-io.systemd.MachineInstance.c b/src/shared/varlink-io.systemd.MachineInstance.c new file mode 100644 index 0000000000..365b6f5f9e --- /dev/null +++ b/src/shared/varlink-io.systemd.MachineInstance.c @@ -0,0 +1,51 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.MachineInstance.h" + +static SD_VARLINK_DEFINE_METHOD(Terminate); +static SD_VARLINK_DEFINE_METHOD(PowerOff); +static SD_VARLINK_DEFINE_METHOD(Reboot); +static SD_VARLINK_DEFINE_METHOD(Pause); +static SD_VARLINK_DEFINE_METHOD(Resume); + +static SD_VARLINK_DEFINE_METHOD( + Describe, + SD_VARLINK_FIELD_COMMENT("True iff vCPUs are executing"), + SD_VARLINK_DEFINE_OUTPUT(running, SD_VARLINK_BOOL, 0), + SD_VARLINK_FIELD_COMMENT("Backend-specific state string (e.g. 'running', 'paused', 'shutdown'); 'unknown' if unavailable"), + SD_VARLINK_DEFINE_OUTPUT(status, SD_VARLINK_STRING, 0)); + +static SD_VARLINK_DEFINE_METHOD_FULL( + SubscribeEvents, + SD_VARLINK_REQUIRES_MORE, + SD_VARLINK_FIELD_COMMENT("If specified, only deliver events whose name matches one of these strings; null means all events"), + SD_VARLINK_DEFINE_INPUT(filter, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("Name of the event"), + SD_VARLINK_DEFINE_OUTPUT(event, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("Event-specific payload"), + SD_VARLINK_DEFINE_OUTPUT(data, SD_VARLINK_OBJECT, SD_VARLINK_NULLABLE)); + +static SD_VARLINK_DEFINE_ERROR(NotConnected); +static SD_VARLINK_DEFINE_ERROR(NotSupported); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_MachineInstance, + "io.systemd.MachineInstance", + SD_VARLINK_SYMBOL_COMMENT("Forcefully terminate the machine immediately"), + &vl_method_Terminate, + SD_VARLINK_SYMBOL_COMMENT("Request a clean shutdown of the machine"), + &vl_method_PowerOff, + SD_VARLINK_SYMBOL_COMMENT("Reboot the machine"), + &vl_method_Reboot, + SD_VARLINK_SYMBOL_COMMENT("Pause/freeze the machine"), + &vl_method_Pause, + SD_VARLINK_SYMBOL_COMMENT("Resume a paused machine"), + &vl_method_Resume, + SD_VARLINK_SYMBOL_COMMENT("Query the current status of the machine"), + &vl_method_Describe, + SD_VARLINK_SYMBOL_COMMENT("Subscribe to machine events. Returns a stream of events as they occur."), + &vl_method_SubscribeEvents, + SD_VARLINK_SYMBOL_COMMENT("The connection to the machine backend is not available"), + &vl_error_NotConnected, + SD_VARLINK_SYMBOL_COMMENT("The requested operation is not supported"), + &vl_error_NotSupported); diff --git a/src/shared/varlink-io.systemd.MachineInstance.h b/src/shared/varlink-io.systemd.MachineInstance.h new file mode 100644 index 0000000000..fa473b2151 --- /dev/null +++ b/src/shared/varlink-io.systemd.MachineInstance.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_MachineInstance; diff --git a/src/shared/varlink-io.systemd.QemuMachineInstance.c b/src/shared/varlink-io.systemd.QemuMachineInstance.c new file mode 100644 index 0000000000..b03fa2199c --- /dev/null +++ b/src/shared/varlink-io.systemd.QemuMachineInstance.c @@ -0,0 +1,17 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.QemuMachineInstance.h" + +static SD_VARLINK_DEFINE_METHOD_FULL( + AcquireQMP, + SD_VARLINK_REQUIRES_UPGRADE); + +static SD_VARLINK_DEFINE_ERROR(AlreadyAcquired); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_QemuMachineInstance, + "io.systemd.QemuMachineInstance", + SD_VARLINK_SYMBOL_COMMENT("Acquire a direct QMP connection to the QEMU instance via protocol upgrade"), + &vl_method_AcquireQMP, + SD_VARLINK_SYMBOL_COMMENT("A QMP connection has already been acquired by another client"), + &vl_error_AlreadyAcquired); diff --git a/src/shared/varlink-io.systemd.QemuMachineInstance.h b/src/shared/varlink-io.systemd.QemuMachineInstance.h new file mode 100644 index 0000000000..203dacb40c --- /dev/null +++ b/src/shared/varlink-io.systemd.QemuMachineInstance.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_QemuMachineInstance; diff --git a/src/shared/varlink-io.systemd.VirtualMachineInstance.c b/src/shared/varlink-io.systemd.VirtualMachineInstance.c new file mode 100644 index 0000000000..f491418a2c --- /dev/null +++ b/src/shared/varlink-io.systemd.VirtualMachineInstance.c @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.VirtualMachineInstance.h" + +/* VM-specific control interface. Currently empty — reserved for methods that apply to virtual + * machines generically but not to containers (e.g. snapshot, migration, device hotplug). */ +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_VirtualMachineInstance, + "io.systemd.VirtualMachineInstance"); diff --git a/src/shared/varlink-io.systemd.VirtualMachineInstance.h b/src/shared/varlink-io.systemd.VirtualMachineInstance.h new file mode 100644 index 0000000000..b6d0600f34 --- /dev/null +++ b/src/shared/varlink-io.systemd.VirtualMachineInstance.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_VirtualMachineInstance; diff --git a/src/test/meson.build b/src/test/meson.build index fb0aac27f8..87ea6ae15e 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -431,6 +431,14 @@ executables += [ test_template + { 'sources' : files('test-progress-bar.c'), }, + test_template + { + 'sources' : files('test-qmp-client.c'), + }, + test_template + { + 'sources' : files('test-qmp-client-qemu.c'), + 'objects' : ['systemd-vmspawn'], + 'conditions' : ['ENABLE_VMSPAWN'], + }, test_template + { 'sources' : files('test-qrcode-util.c'), 'dependencies' : libdl, diff --git a/src/test/test-qmp-client-qemu.c b/src/test/test-qmp-client-qemu.c new file mode 100644 index 0000000000..ec520cc270 --- /dev/null +++ b/src/test/test-qmp-client-qemu.c @@ -0,0 +1,264 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +/* Integration test for the QMP client library against a real QEMU instance. + * + * Launches QEMU with -machine none (no bootable image needed) to get a live QMP monitor, then exercises the + * client library against it. Validates the blocking handshake, large response buffering (~200KB for + * query-qmp-schema), response correlation by id, and async command execution. + * + * Skipped automatically if QEMU is not installed. */ + +#include +#include + +#include "sd-event.h" +#include "sd-json.h" + +#include "fd-util.h" +#include "pidref.h" +#include "process-util.h" +#include "qmp-client.h" +#include "string-util.h" +#include "tests.h" +#include "time-util.h" +#include "vmspawn-util.h" + +static int start_qemu(const char *qemu_binary, int fd, PidRef *ret) { + _cleanup_free_ char *chardev_arg = NULL; + int r; + + assert(qemu_binary); + assert(fd >= 0); + assert(ret); + + if (asprintf(&chardev_arg, "socket,id=qmp,fd=%d", fd) < 0) + return -ENOMEM; + + r = pidref_safe_fork_full( + "(qemu)", + (const int[3]) { STDIN_FILENO, -EBADF, -EBADF }, + &fd, 1, + FORK_RESET_SIGNALS|FORK_CLOSE_ALL_FDS|FORK_DEATHSIG_SIGTERM|FORK_LOG|FORK_RLIMIT_NOFILE_SAFE|FORK_CLOEXEC_OFF, + ret); + if (r < 0) + return r; + if (r == 0) { + /* Child */ + execl(qemu_binary, qemu_binary, + "-machine", "none", + "-nographic", + "-nodefaults", + "-chardev", chardev_arg, + "-mon", "chardev=qmp,mode=control", + NULL); + log_error_errno(errno, "Failed to exec %s: %m", qemu_binary); + _exit(EXIT_FAILURE); + } + + return 0; +} + +/* Test helper: tracks an async QMP command result and signals completion. */ +typedef struct { + sd_json_variant *result; + int error; + bool done; +} QmpTestResult; + +static int on_test_result( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + QmpTestResult *t = ASSERT_PTR(userdata); + + t->error = error; + if (result) + t->result = sd_json_variant_ref(result); + t->done = true; + return 0; +} + +static void qmp_test_wait(sd_event *event, QmpTestResult *t) { + assert(event); + assert(t); + + usec_t deadline = usec_add(now(CLOCK_MONOTONIC), 5 * USEC_PER_MINUTE); + + while (!t->done) { + usec_t n = now(CLOCK_MONOTONIC); + ASSERT_LT(n, deadline); + ASSERT_OK(sd_event_run(event, usec_sub_unsigned(deadline, n))); + } +} + +static void qmp_test_result_done(QmpTestResult *t) { + assert(t); + + sd_json_variant_unref(t->result); + *t = (QmpTestResult) {}; +} + +TEST(qmp_client_qemu_handshake_and_schema) { + _cleanup_free_ char *qemu = NULL; + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; + QmpTestResult t = {}; + _cleanup_close_pair_ int qmp_fds[2] = EBADF_PAIR; + int r; + + if (find_qemu_binary(&qemu) < 0) { + log_tests_skipped("QEMU not found"); + return; + } + log_info("Using QEMU: %s", qemu); + + ASSERT_OK(sd_event_new(&event)); + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + ASSERT_OK(start_qemu(qemu, qmp_fds[1], &pidref)); + qmp_fds[1] = safe_close(qmp_fds[1]); + + r = qmp_client_connect_fd(&client, qmp_fds[0]); + if (r < 0) { + log_tests_skipped_errno(r, "QMP connect failed (QEMU may not support -machine none)"); + return; + } + TAKE_FD(qmp_fds[0]); + + ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL)); + + /* query-qmp-schema returns ~200KB -- validates the buffered reader handles large multi-read() + * responses correctly. The handshake completes transparently inside invoke(). */ + r = qmp_client_invoke(client, "query-qmp-schema", NULL, on_test_result, &t); + if (r < 0) { + log_tests_skipped_errno(r, "QMP invoke failed (handshake or send)"); + return; + } + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + ASSERT_NOT_NULL(t.result); + ASSERT_TRUE(sd_json_variant_is_array(t.result)); + ASSERT_GT(sd_json_variant_elements(t.result), (size_t) 0); + log_info("query-qmp-schema returned %zu entries", sd_json_variant_elements(t.result)); + + /* Smoke-test the schema walker against the real schema. node-name is on every BlockdevOptions* + * object since blockdev-add was introduced. Don't assert discard-no-unref — CI may have QEMU < 8.1. */ + ASSERT_TRUE(qmp_schema_has_member(t.result, "node-name")); + ASSERT_FALSE(qmp_schema_has_member(t.result, "definitely-not-a-real-field")); + + qmp_test_result_done(&t); + + /* Clean shutdown */ + ASSERT_OK(qmp_client_invoke(client, "quit", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + siginfo_t si = {}; + ASSERT_OK(pidref_wait_for_terminate(&pidref, &si)); + ASSERT_EQ(si.si_code, CLD_EXITED); + ASSERT_EQ(si.si_status, EXIT_SUCCESS); + pidref_done(&pidref); +} + +TEST(qmp_client_qemu_query_status) { + _cleanup_free_ char *qemu = NULL; + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; + QmpTestResult t = {}; + sd_json_variant *running, *status; + _cleanup_close_pair_ int qmp_fds[2] = EBADF_PAIR; + int r; + + if (find_qemu_binary(&qemu) < 0) { + log_tests_skipped("QEMU not found"); + return; + } + + ASSERT_OK(sd_event_new(&event)); + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + ASSERT_OK(start_qemu(qemu, qmp_fds[1], &pidref)); + qmp_fds[1] = safe_close(qmp_fds[1]); + + r = qmp_client_connect_fd(&client, qmp_fds[0]); + if (r < 0) { + log_tests_skipped_errno(r, "QMP connect failed"); + return; + } + TAKE_FD(qmp_fds[0]); + + ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL)); + + /* query-status validates response parsing against real QEMU output format. + * The handshake completes transparently inside invoke(). */ + r = qmp_client_invoke(client, "query-status", NULL, on_test_result, &t); + if (r < 0) { + log_tests_skipped_errno(r, "QMP invoke failed (handshake or send)"); + return; + } + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + ASSERT_NOT_NULL(t.result); + + status = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "status")); + ASSERT_TRUE(sd_json_variant_is_string(status)); + + running = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "running")); + ASSERT_TRUE(sd_json_variant_is_boolean(running)); + + log_info("QEMU status: %s, running: %s", + sd_json_variant_string(status), + true_false(sd_json_variant_boolean(running))); + + qmp_test_result_done(&t); + + /* Test stop + cont to exercise command sequencing and id correlation */ + ASSERT_OK(qmp_client_invoke(client, "stop", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + /* Verify status changed */ + ASSERT_OK(qmp_client_invoke(client, "query-status", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + ASSERT_NOT_NULL(t.result); + + running = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "running")); + ASSERT_FALSE(sd_json_variant_boolean(running)); + log_info("After stop: running=%s", true_false(sd_json_variant_boolean(running))); + + qmp_test_result_done(&t); + + ASSERT_OK(qmp_client_invoke(client, "cont", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + /* Clean shutdown */ + ASSERT_OK(qmp_client_invoke(client, "quit", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + siginfo_t si = {}; + ASSERT_OK(pidref_wait_for_terminate(&pidref, &si)); + ASSERT_EQ(si.si_code, CLD_EXITED); + ASSERT_EQ(si.si_status, EXIT_SUCCESS); + pidref_done(&pidref); +} + +static int intro(void) { + /* QEMU dies between our last write and read on the QMP socket — without this we'd + * get killed by the SIGPIPE the kernel raises on write-after-EOF. */ + ASSERT_TRUE(signal(SIGPIPE, SIG_IGN) != SIG_ERR); + return 0; +} + +DEFINE_TEST_MAIN_FULL(LOG_DEBUG, intro, NULL); diff --git a/src/test/test-qmp-client.c b/src/test/test-qmp-client.c new file mode 100644 index 0000000000..9bfe48770f --- /dev/null +++ b/src/test/test-qmp-client.c @@ -0,0 +1,516 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include +#include + +#include "sd-event.h" +#include "sd-json.h" + +#include "errno-util.h" +#include "fd-util.h" +#include "io-util.h" +#include "pidref.h" +#include "process-util.h" +#include "qmp-client.h" +#include "socket-util.h" +#include "string-util.h" +#include "tests.h" + +/* Mock QMP server: runs in the child process of a fork, communicates via one end of a socketpair. */ + +static void mock_qmp_write_json(int fd, sd_json_variant *v) { + _cleanup_free_ char *s = NULL; + + ASSERT_OK(sd_json_variant_format(v, 0, &s)); + ASSERT_NOT_NULL(strextend(&s, "\r\n")); + ASSERT_OK(loop_write(fd, s, SIZE_MAX)); +} + +static void mock_qmp_write_literal(int fd, const char *msg) { + ASSERT_OK(loop_write(fd, msg, SIZE_MAX)); + ASSERT_OK(loop_write(fd, "\r\n", 2)); +} + +/* Read a command from the QMP client, verify it contains the expected command name, extract the id, + * and send a reply with that id. If reply_data is NULL, an empty return object is sent. */ +static void mock_qmp_expect_and_reply(int fd, const char *expected_command, sd_json_variant *reply_data) { + _cleanup_free_ char *buf = NULL; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd = NULL, *reply_obj = NULL, *response = NULL; + + buf = ASSERT_NOT_NULL(new(char, 4096)); + + ssize_t n = read(fd, buf, 4095); + assert_se(n > 0); + buf[n] = '\0'; + + ASSERT_OK(sd_json_parse(buf, 0, &cmd, NULL, NULL)); + + sd_json_variant *execute = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "execute")); + ASSERT_STREQ(sd_json_variant_string(execute), expected_command); + + sd_json_variant *id = ASSERT_NOT_NULL(sd_json_variant_by_key(cmd, "id")); + + if (!reply_data) + ASSERT_OK(sd_json_variant_new_object(&reply_obj, NULL, 0)); + + ASSERT_OK(sd_json_buildo( + &response, + SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(reply_data ?: reply_obj)), + SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(id)))); + + mock_qmp_write_json(fd, response); +} + +static _noreturn_ void mock_qmp_server(int fd) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *status_return = NULL; + + /* Send QMP greeting */ + mock_qmp_write_literal(fd, + "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 2, \"major\": 9}}, \"capabilities\": [\"oob\"]}}"); + + /* Accept qmp_capabilities */ + mock_qmp_expect_and_reply(fd, "qmp_capabilities", NULL); + + /* Accept query-status, reply with running state */ + ASSERT_OK(sd_json_buildo( + &status_return, + SD_JSON_BUILD_PAIR_BOOLEAN("running", true), + SD_JSON_BUILD_PAIR_STRING("status", "running"))); + mock_qmp_expect_and_reply(fd, "query-status", status_return); + + /* Accept stop */ + mock_qmp_expect_and_reply(fd, "stop", NULL); + + /* Send a STOP event */ + mock_qmp_write_literal(fd, + "{\"event\": \"STOP\", \"timestamp\": {\"seconds\": 1234, \"microseconds\": 5678}}"); + + /* Accept cont */ + mock_qmp_expect_and_reply(fd, "cont", NULL); + + /* Close to trigger EOF */ + safe_close(fd); + _exit(EXIT_SUCCESS); +} + +/* Test helper: tracks an async QMP command result and signals completion. */ +typedef struct { + sd_json_variant *result; + char *error_desc; + int error; + bool done; +} QmpTestResult; + +static int on_test_result( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + QmpTestResult *t = ASSERT_PTR(userdata); + + t->error = error; + if (result) + t->result = sd_json_variant_ref(result); + if (error_desc) + t->error_desc = strdup(error_desc); + t->done = true; + return 0; +} + +/* Run the event loop until the test result callback fires. */ +static void qmp_test_wait(sd_event *event, QmpTestResult *t) { + assert(event); + assert(t); + + while (!t->done) + ASSERT_OK(sd_event_run(event, UINT64_MAX)); +} + +static void qmp_test_result_done(QmpTestResult *t) { + assert(t); + + sd_json_variant_unref(t->result); + free(t->error_desc); + *t = (QmpTestResult) {}; +} + +static int test_event_callback( + QmpClient *client, + const char *event, + sd_json_variant *data, + void *userdata) { + + bool *event_received = ASSERT_PTR(userdata); + + /* We may also receive a synthetic SHUTDOWN event when the mock server closes the connection; + * only validate the STOP event we actually care about. */ + if (streq(event, "STOP")) + *event_received = true; + + return 0; +} + +TEST(qmp_client_basic) { + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + _cleanup_(pidref_done) PidRef pid = PIDREF_NULL; + QmpTestResult t = {}; + sd_json_variant *running, *status; + int qmp_fds[2]; + int r; + + ASSERT_OK(sd_event_new(&event)); + + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + r = pidref_safe_fork("(mock-qmp)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid); + ASSERT_OK(r); + + if (r == 0) { + safe_close(qmp_fds[0]); + mock_qmp_server(qmp_fds[1]); + } + + safe_close(qmp_fds[1]); + + /* Connect then attach to event loop — handshake completes transparently + * inside the first call()/invoke(). */ + ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0])); + ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL)); + + /* Set event callback to catch STOP event during cont */ + bool event_received = false; + qmp_client_bind_event(client, test_event_callback, &event_received); + + /* Execute query-status */ + ASSERT_OK(qmp_client_invoke(client, "query-status", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + ASSERT_NOT_NULL(t.result); + + running = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "running")); + ASSERT_TRUE(sd_json_variant_boolean(running)); + + status = ASSERT_NOT_NULL(sd_json_variant_by_key(t.result, "status")); + ASSERT_STREQ(sd_json_variant_string(status), "running"); + + qmp_test_result_done(&t); + + /* Execute stop */ + ASSERT_OK(qmp_client_invoke(client, "stop", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + /* Execute cont -- the STOP event should be dispatched by the IO callback */ + ASSERT_OK(qmp_client_invoke(client, "cont", NULL, on_test_result, &t)); + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + qmp_test_result_done(&t); + + /* Verify the STOP event was received */ + ASSERT_TRUE(event_received); + + /* Wait for child and verify clean exit */ + siginfo_t si = {}; + ASSERT_OK(pidref_wait_for_terminate(&pid, &si)); + ASSERT_EQ(si.si_code, CLD_EXITED); + ASSERT_EQ(si.si_status, EXIT_SUCCESS); +} + +TEST(qmp_client_eof) { + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + _cleanup_(pidref_done) PidRef pid = PIDREF_NULL; + QmpTestResult t = {}; + int qmp_fds[2]; + int r; + + ASSERT_OK(sd_event_new(&event)); + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + r = pidref_safe_fork("(mock-qmp-eof)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid); + ASSERT_OK(r); + + if (r == 0) { + safe_close(qmp_fds[0]); + + /* Send greeting and accept capabilities, then die */ + mock_qmp_write_literal(qmp_fds[1], + "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}"); + + mock_qmp_expect_and_reply(qmp_fds[1], "qmp_capabilities", NULL); + + /* Close immediately to trigger EOF */ + safe_close(qmp_fds[1]); + _exit(EXIT_SUCCESS); + } + + safe_close(qmp_fds[1]); + + ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0])); + ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL)); + + /* Executing a command should fail with a disconnect error because the server + * closed. The handshake may succeed or fail inside invoke() — either way the + * invoke itself or the async callback should report a disconnect. */ + r = qmp_client_invoke(client, "query-status", NULL, on_test_result, &t); + if (r < 0) + ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r)); + else { + qmp_test_wait(event, &t); + ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(t.error)); + qmp_test_result_done(&t); + } + + siginfo_t si = {}; + ASSERT_OK(pidref_wait_for_terminate(&pid, &si)); + ASSERT_EQ(si.si_code, CLD_EXITED); + ASSERT_EQ(si.si_status, EXIT_SUCCESS); +} + +/* Read one QMP command from fd (one recvmsg, expecting it fits in the buffer for typical + * test commands). Returns the number of SCM_RIGHTS fds that arrived attached to the read, + * stores the first received fd in *ret_received_fd (or -EBADF if none) and closes any extras, + * and parses the JSON into *ret_cmd. */ +static size_t mock_qmp_recv_command(int fd, sd_json_variant **ret_cmd, int *ret_received_fd) { + char buf[4096]; + char ctrl[CMSG_SPACE(sizeof(int) * 4)]; + struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) - 1 }; + struct msghdr mh = { + .msg_iov = &iov, .msg_iovlen = 1, + .msg_control = ctrl, .msg_controllen = sizeof(ctrl), + }; + size_t n_fds = 0; + int received_fd = -EBADF; + + ssize_t n = recvmsg(fd, &mh, MSG_CMSG_CLOEXEC); + assert_se(n > 0); + buf[n] = '\0'; + + struct cmsghdr *cmsg; + CMSG_FOREACH(cmsg, &mh) { + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) + continue; + size_t k = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int); + int *fds = (int*) CMSG_DATA(cmsg); + for (size_t i = 0; i < k; i++) { + if (received_fd < 0) + received_fd = fds[i]; + else + safe_close(fds[i]); + } + n_fds += k; + } + + ASSERT_OK(sd_json_parse(buf, 0, ret_cmd, NULL, NULL)); + + if (ret_received_fd) + *ret_received_fd = received_fd; + else if (received_fd >= 0) + safe_close(received_fd); + + return n_fds; +} + +/* Mock QMP server for the fd-on-first-invoke regression. Drives the wire dance: + * greeting → (recv qmp_capabilities, expect 0 fds) → reply → + * (recv add-fd, expect exactly 1 fd) → reply + * Asserts the cmsg fd counts directly so a regression flips the child to + * exit_failure and the parent test fails on the wait-for-terminate. */ +static _noreturn_ void mock_qmp_server_fd_first(int fd) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *cap_cmd = NULL, + *addfd_cmd = NULL, + *cap_reply = NULL, + *addfd_return = NULL, + *addfd_reply = NULL; + size_t n_fds; + int received_fd = -EBADF; + + /* Greeting */ + mock_qmp_write_literal(fd, + "{\"QMP\": {\"version\": {\"qemu\": {\"micro\": 0, \"minor\": 0, \"major\": 9}}, \"capabilities\": []}}"); + + /* Receive qmp_capabilities — must arrive with NO fds attached. */ + n_fds = mock_qmp_recv_command(fd, &cap_cmd, /* ret_received_fd= */ NULL); + ASSERT_EQ(n_fds, (size_t) 0); + ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(cap_cmd, "execute")), "qmp_capabilities"); + + sd_json_variant *cap_id = ASSERT_NOT_NULL(sd_json_variant_by_key(cap_cmd, "id")); + ASSERT_OK(sd_json_buildo( + &cap_reply, + SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_EMPTY_OBJECT), + SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(cap_id)))); + mock_qmp_write_json(fd, cap_reply); + + /* Receive add-fd — must arrive with EXACTLY ONE fd attached. */ + n_fds = mock_qmp_recv_command(fd, &addfd_cmd, &received_fd); + ASSERT_EQ(n_fds, (size_t) 1); + ASSERT_TRUE(received_fd >= 0); + ASSERT_STREQ(sd_json_variant_string(sd_json_variant_by_key(addfd_cmd, "execute")), "add-fd"); + safe_close(received_fd); + + sd_json_variant *addfd_id = ASSERT_NOT_NULL(sd_json_variant_by_key(addfd_cmd, "id")); + ASSERT_OK(sd_json_buildo( + &addfd_return, + SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0), + SD_JSON_BUILD_PAIR_UNSIGNED("fd", 42))); + ASSERT_OK(sd_json_buildo( + &addfd_reply, + SD_JSON_BUILD_PAIR("return", SD_JSON_BUILD_VARIANT(addfd_return)), + SD_JSON_BUILD_PAIR("id", SD_JSON_BUILD_VARIANT(addfd_id)))); + mock_qmp_write_json(fd, addfd_reply); + + safe_close(fd); + _exit(EXIT_SUCCESS); +} + +/* Regression: pass an fd in the very first qmp_client_invoke() against a fresh client + * (lazy-bootstrap state, handshake not yet done). The previous push_fd+invoke split would + * stage the fd on the stream BEFORE qmp_client_ensure_running() drove the handshake; the + * handshake's qmp_capabilities enqueue would then steal the staged fd onto its own + * sendmsg. The new QmpClientArgs API stages fds inside invoke AFTER ensure_running, so + * the fd lands on add-fd's sendmsg as it should. */ +TEST(qmp_client_first_invoke_with_fd) { + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + _cleanup_(pidref_done) PidRef pid = PIDREF_NULL; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + _cleanup_close_ int fd_to_pass = -EBADF; + QmpTestResult t = {}; + int qmp_fds[2]; + int r; + + ASSERT_OK(sd_event_new(&event)); + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + r = pidref_safe_fork("(mock-qmp-fd-first)", FORK_DEATHSIG_SIGKILL|FORK_LOG, &pid); + ASSERT_OK(r); + + if (r == 0) { + safe_close(qmp_fds[0]); + mock_qmp_server_fd_first(qmp_fds[1]); + } + + safe_close(qmp_fds[1]); + + /* Open a real fd to pass — /dev/null is universally available. */ + fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC); + ASSERT_OK(fd_to_pass); + + ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0])); + ASSERT_OK(qmp_client_attach_event(client, event, SD_EVENT_PRIORITY_NORMAL)); + + /* Build add-fd args. The fdset-id value is irrelevant — the mock server only + * cares that the fd arrived with the correct sendmsg. */ + ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0))); + + /* THIS is the previously-broken pattern: very first invoke against the client, + * carrying an fd, with the handshake still pending. */ + ASSERT_OK(qmp_client_invoke(client, "add-fd", + QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)), + on_test_result, &t)); + + qmp_test_wait(event, &t); + ASSERT_EQ(t.error, 0); + ASSERT_NOT_NULL(t.result); + qmp_test_result_done(&t); + + /* Wait for the mock server child. If it received fds in the wrong order it + * exited via the test-assertion failure path and si.si_status will be non-zero. */ + siginfo_t si = {}; + ASSERT_OK(pidref_wait_for_terminate(&pid, &si)); + ASSERT_EQ(si.si_code, CLD_EXITED); + ASSERT_EQ(si.si_status, EXIT_SUCCESS); +} + +/* Regression: when qmp_client_invoke() fails before stage_fds runs (e.g. + * ensure_running() returns -ENOTCONN because the peer closed mid-handshake), the + * caller-supplied fds — already TAKE_FD()'d through QMP_CLIENT_ARGS_FD() — must be + * closed inside invoke. Otherwise they leak. */ +TEST(qmp_client_invoke_failure_closes_fds) { + _cleanup_(qmp_client_unrefp) QmpClient *client = NULL; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + _cleanup_close_ int fd_to_pass = -EBADF; + QmpTestResult t = {}; + int qmp_fds[2]; + int saved_fd_value; + + ASSERT_OK_ERRNO(socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, qmp_fds)); + + /* Close the peer end immediately so ensure_running()'s read sees EOF and + * the client transitions straight to DISCONNECTED inside the first invoke. */ + safe_close(qmp_fds[1]); + + ASSERT_OK(qmp_client_connect_fd(&client, qmp_fds[0])); + /* Deliberately do NOT attach to an event loop — invoke uses ensure_running()'s + * synchronous process+wait pump for the handshake. */ + + fd_to_pass = open("/dev/null", O_RDWR|O_CLOEXEC); + ASSERT_OK(fd_to_pass); + saved_fd_value = fd_to_pass; /* remember the int value for the closed-check */ + + ASSERT_OK(sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", 0))); + + /* invoke must fail because the peer is gone. The TAKE_FD inside the macro + * has already zeroed our local fd_to_pass; if invoke leaked the fd here, + * the fd would stay open in our process. */ + int r = qmp_client_invoke(client, "add-fd", + QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd_to_pass)), + on_test_result, &t); + ASSERT_TRUE(r < 0); + ASSERT_TRUE(ERRNO_IS_NEG_DISCONNECT(r)); + + /* fd_to_pass should now be -EBADF (TAKE_FD'd) and the underlying kernel fd + * should have been closed by the qmp_client_args_close_fds cleanup in + * qmp_client_invoke(). fcntl on the old int returns EBADF only if the slot + * is genuinely free. */ + ASSERT_EQ(fd_to_pass, -EBADF); + ASSERT_EQ(fcntl(saved_fd_value, F_GETFD), -1); + ASSERT_EQ(errno, EBADF); +} + +TEST(qmp_schema_has_member) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *schema = NULL; + + /* QEMU introspection uses opaque numeric type ids ("0", "1", ...) — only member names are + * the actual QAPI strings. Verify we walk all object entries and find the member by name. */ + ASSERT_OK(sd_json_build(&schema, + SD_JSON_BUILD_ARRAY( + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "0"), + SD_JSON_BUILD_PAIR_STRING("meta-type", "object"), + SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY( + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "offset"), + SD_JSON_BUILD_PAIR_STRING("type", "int"))))), + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "SomeEnum"), + SD_JSON_BUILD_PAIR_STRING("meta-type", "enum")), + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "1"), + SD_JSON_BUILD_PAIR_STRING("meta-type", "object"), + SD_JSON_BUILD_PAIR("members", SD_JSON_BUILD_ARRAY( + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "lazy-refcounts"), + SD_JSON_BUILD_PAIR_STRING("type", "bool")), + SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("name", "discard-no-unref"), + SD_JSON_BUILD_PAIR_STRING("type", "bool")))))))); + + ASSERT_TRUE(qmp_schema_has_member(schema, "discard-no-unref")); + ASSERT_TRUE(qmp_schema_has_member(schema, "offset")); + ASSERT_FALSE(qmp_schema_has_member(schema, "definitely-not-a-real-field")); + ASSERT_FALSE(qmp_schema_has_member(NULL, "discard-no-unref")); +} + +static int intro(void) { + /* Ignore SIGPIPE so that write() to a closed socket returns EPIPE instead of killing us */ + ASSERT_TRUE(signal(SIGPIPE, SIG_IGN) != SIG_ERR); + return 0; +} + +DEFINE_TEST_MAIN_FULL(LOG_DEBUG, intro, NULL); diff --git a/src/vmspawn/meson.build b/src/vmspawn/meson.build index 99bad2d618..6d08755fed 100644 --- a/src/vmspawn/meson.build +++ b/src/vmspawn/meson.build @@ -7,6 +7,8 @@ endif vmspawn_sources = files( 'vmspawn.c', 'vmspawn-qemu-config.c', + 'vmspawn-qmp.c', + 'vmspawn-varlink.c', 'vmspawn-settings.c', 'vmspawn-scope.c', 'vmspawn-mount.c', diff --git a/src/vmspawn/vmspawn-qmp.c b/src/vmspawn/vmspawn-qmp.c new file mode 100644 index 0000000000..4171772ee7 --- /dev/null +++ b/src/vmspawn/vmspawn-qmp.c @@ -0,0 +1,1059 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include +#include + +#include "sd-event.h" +#include "sd-json.h" + +#include "alloc-util.h" +#include "blockdev-util.h" +#include "ether-addr-util.h" +#include "fd-util.h" +#include "hashmap.h" +#include "json-util.h" +#include "log.h" +#include "qmp-client.h" +#include "stat-util.h" +#include "string-util.h" +#include "strv.h" +#include "vmspawn-qmp.h" + +DEFINE_PRIVATE_HASH_OPS_FULL( + pending_job_hash_ops, + char, string_hash_func, string_compare_func, free, + PendingJob, pending_job_free); + +void drive_info_done(DriveInfo *info) { + assert(info); + info->serial = mfree(info->serial); + info->node_name = mfree(info->node_name); + info->pcie_port = mfree(info->pcie_port); + info->fd = safe_close(info->fd); + info->overlay_fd = safe_close(info->overlay_fd); +} + +void drive_infos_done(DriveInfos *infos) { + assert(infos); + FOREACH_ARRAY(d, infos->drives, infos->n_drives) + drive_info_done(d); + infos->drives = mfree(infos->drives); + infos->n_drives = 0; + infos->scsi_pcie_port = mfree(infos->scsi_pcie_port); +} + +void network_info_done(NetworkInfo *info) { + assert(info); + info->ifname = mfree(info->ifname); + info->pcie_port = mfree(info->pcie_port); + info->fd = safe_close(info->fd); +} + +void virtiofs_info_done(VirtiofsInfo *info) { + assert(info); + info->id = mfree(info->id); + info->socket_path = mfree(info->socket_path); + info->tag = mfree(info->tag); + info->pcie_port = mfree(info->pcie_port); +} + +void virtiofs_infos_done(VirtiofsInfos *infos) { + assert(infos); + FOREACH_ARRAY(e, infos->entries, infos->n_entries) + virtiofs_info_done(e); + infos->entries = mfree(infos->entries); + infos->n_entries = 0; +} + +void vsock_info_done(VsockInfo *info) { + assert(info); + info->pcie_port = mfree(info->pcie_port); + info->fd = safe_close(info->fd); +} + +void machine_config_done(MachineConfig *c) { + if (!c) + return; + + drive_infos_done(&c->drives); + network_info_done(&c->network); + virtiofs_infos_done(&c->virtiofs); + vsock_info_done(&c->vsock); +} + +/* Generic async QMP setup-completion callback. The userdata argument carries the + * command name (as a string literal) for logging. On failure, request a clean + * event loop exit so vmspawn shuts down instead of running a VM with missing devices. */ +static int on_qmp_setup_complete( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + const char *label = ASSERT_PTR(userdata); + + assert(client); + + if (error < 0) { + log_error_errno(error, "%s failed: %s", label, strna(error_desc)); + return sd_event_exit(qmp_client_get_event(client), error); + } + + return 0; +} + +/* Send add-fd via SCM_RIGHTS; return /dev/fdset/N. Allocations run before invoke so a late + * OOM cannot orphan an fdset on QEMU's side; *ret_path is only written on full success. */ +static int qmp_fdset_add(QmpClient *qmp, int fd_consume, char **ret_path) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + _cleanup_close_ int fd = fd_consume; + _cleanup_free_ char *path = NULL; + unsigned id; + int r; + + assert(qmp); + assert(fd_consume >= 0); + assert(ret_path); + + id = qmp_client_next_fdset_id(qmp); + + r = sd_json_buildo(&args, SD_JSON_BUILD_PAIR_UNSIGNED("fdset-id", id)); + if (r < 0) + return r; + + if (asprintf(&path, "/dev/fdset/%u", id) < 0) + return -ENOMEM; + + r = qmp_client_invoke(qmp, "add-fd", QMP_CLIENT_ARGS_FD(args, TAKE_FD(fd)), + on_qmp_setup_complete, (void*) "add-fd"); + if (r < 0) + return r; + + *ret_path = TAKE_PTR(path); + return 0; +} + +typedef struct QmpFileNodeParams { + const char *node_name; + const char *filename; + const char *driver; /* "file" or "host_device" */ + QmpDriveFlags flags; +} QmpFileNodeParams; + +/* Build blockdev-add JSON for the protocol-level (file) node */ +static int qmp_build_blockdev_add_file(const QmpFileNodeParams *p, sd_json_variant **ret) { + assert(p); + assert(p->node_name); + assert(p->filename); + assert(p->driver); + assert(ret); + + /* cache.direct=false uses the page cache (QEMU default). cache.no-flush suppresses host + * flush on guest fsync — only safe for ephemeral/extra drives where data loss is acceptable. */ + return sd_json_buildo( + ret, + SD_JSON_BUILD_PAIR_STRING("node-name", p->node_name), + SD_JSON_BUILD_PAIR_STRING("driver", p->driver), + SD_JSON_BUILD_PAIR_STRING("filename", p->filename), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(p->flags, QMP_DRIVE_READ_ONLY), "read-only", SD_JSON_BUILD_BOOLEAN(true)), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(p->flags, QMP_DRIVE_IO_URING), "aio", JSON_BUILD_CONST_STRING("io_uring")), + SD_JSON_BUILD_PAIR("cache", SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_BOOLEAN("direct", false), + SD_JSON_BUILD_PAIR_BOOLEAN("no-flush", FLAGS_SET(p->flags, QMP_DRIVE_NO_FLUSH))))); +} + +typedef struct QmpFormatNodeParams { + const char *node_name; + const char *format; /* "raw", "qcow2", etc. */ + const char *file_node_name; /* reference to the underlying file node */ + const char *backing; /* reference to a backing format node (NULL if none) */ + QmpDriveFlags flags; +} QmpFormatNodeParams; + +/* Build blockdev-add JSON for the format-level node */ +static int qmp_build_blockdev_add_format(const QmpFormatNodeParams *p, sd_json_variant **ret) { + assert(p); + assert(p->node_name); + assert(p->format); + assert(p->file_node_name); + assert(ret); + + /* When "file" is a string (not an object), QEMU interprets it as a reference to an + * existing node-name. The "backing" field likewise references a format-level node. */ + return sd_json_buildo( + ret, + SD_JSON_BUILD_PAIR_STRING("node-name", p->node_name), + SD_JSON_BUILD_PAIR_STRING("driver", p->format), + SD_JSON_BUILD_PAIR_STRING("file", p->file_node_name), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(p->flags, QMP_DRIVE_READ_ONLY), "read-only", SD_JSON_BUILD_BOOLEAN(true)), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(p->flags, QMP_DRIVE_DISCARD), "discard", JSON_BUILD_CONST_STRING("unmap")), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(p->flags, QMP_DRIVE_DISCARD_NO_UNREF), "discard-no-unref", SD_JSON_BUILD_BOOLEAN(true)), + SD_JSON_BUILD_PAIR_CONDITION(!!p->backing, "backing", SD_JSON_BUILD_STRING(p->backing))); +} + +/* Build device_add JSON arguments for a drive */ +static int qmp_build_device_add(const DriveInfo *drive, sd_json_variant **ret) { + assert(drive); + assert(ret); + + return sd_json_buildo( + ret, + SD_JSON_BUILD_PAIR_STRING("driver", drive->disk_driver), + SD_JSON_BUILD_PAIR_STRING("drive", drive->node_name), + SD_JSON_BUILD_PAIR_STRING("id", drive->node_name), + SD_JSON_BUILD_PAIR_CONDITION(FLAGS_SET(drive->flags, QMP_DRIVE_BOOT), "bootindex", SD_JSON_BUILD_INTEGER(1)), + SD_JSON_BUILD_PAIR_CONDITION(!!drive->serial, "serial", SD_JSON_BUILD_STRING(drive->serial)), + SD_JSON_BUILD_PAIR_CONDITION(STR_IN_SET(drive->disk_driver, "scsi-hd", "scsi-cd"), + "bus", JSON_BUILD_CONST_STRING("vmspawn_scsi.0")), + SD_JSON_BUILD_PAIR_CONDITION( + !STR_IN_SET(drive->disk_driver, "scsi-hd", "scsi-cd") && !!drive->pcie_port, + "bus", SD_JSON_BUILD_STRING(drive->pcie_port))); +} + +/* Issue blockdev-add for a file node. */ +static int qmp_add_file_node(QmpClient *qmp, const QmpFileNodeParams *p) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + int r; + + r = qmp_build_blockdev_add_file(p, &args); + if (r < 0) + return r; + + return qmp_client_invoke(qmp, "blockdev-add", QMP_CLIENT_ARGS(args), on_qmp_setup_complete, (void*) "blockdev-add"); +} + +/* Get the virtual size of an image from the fd directly. For raw images the virtual size + * equals the file/device size. For qcow2 the virtual size is a big-endian uint64 at header + * offset 24 (the "size" field in the qcow2 header). */ +static int get_image_virtual_size(int fd, const char *format, bool is_block_device, uint64_t *ret) { + int r; + + assert(fd >= 0); + assert(format); + assert(ret); + + if (streq(format, "raw")) { + if (is_block_device) + return blockdev_get_device_size(fd, ret); + + struct stat st; + if (fstat(fd, &st) < 0) + return log_error_errno(errno, "Failed to stat image: %m"); + + r = stat_verify_regular(&st); + if (r < 0) + return log_error_errno(r, "Raw device is neither a regular file nor a block device"); + + *ret = st.st_size; + return 0; + } + + if (streq(format, "qcow2")) { + uint32_t magic = 0; + ssize_t n = pread(fd, &magic, sizeof(magic), 0); + if (n < 0) + return log_error_errno(errno, "Failed to read qcow2 magic: %m"); + if (n != sizeof(magic) || be32toh(magic) != UINT32_C(0x514649fb)) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Not a valid qcow2 image (bad magic)"); + + uint64_t size_be = 0; + n = pread(fd, &size_be, sizeof(size_be), 24); + if (n < 0) + return log_error_errno(errno, "Failed to read qcow2 header: %m"); + if (n != sizeof(size_be)) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Short read on qcow2 header"); + + *ret = be64toh(size_be); + return 0; + } + + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Unsupported image format '%s'", format); +} + +/* Ephemeral drive continuation — fired when the blockdev-create job concludes. + * Completes the drive setup by adding the overlay format node and the device. */ +typedef struct EphemeralDriveCtx { + char *node_name; /* overlay format node name (= drive node name) */ + char *overlay_file_node; + char *base_fmt_node; + /* Fields for device_add */ + char *disk_driver; + char *serial; /* NULL if unset */ + char *pcie_port; /* pcie-root-port bus for device_add (NULL on non-PCIe) */ + QmpDriveFlags flags; /* subset: QMP_DRIVE_DISCARD, QMP_DRIVE_DISCARD_NO_UNREF, QMP_DRIVE_BOOT */ +} EphemeralDriveCtx; + +static EphemeralDriveCtx* ephemeral_drive_ctx_free(EphemeralDriveCtx *ctx) { + if (!ctx) + return NULL; + free(ctx->node_name); + free(ctx->overlay_file_node); + free(ctx->base_fmt_node); + free(ctx->disk_driver); + free(ctx->serial); + free(ctx->pcie_port); + return mfree(ctx); +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(EphemeralDriveCtx *, ephemeral_drive_ctx_free); + +static void ephemeral_drive_ctx_free_void(void *p) { + ephemeral_drive_ctx_free(p); +} + +static int on_ephemeral_create_concluded(QmpClient *qmp, void *userdata) { + _cleanup_(ephemeral_drive_ctx_freep) EphemeralDriveCtx *ctx = ASSERT_PTR(userdata); + _cleanup_(sd_json_variant_unrefp) sd_json_variant *fmt_args = NULL, *device_args = NULL; + int r; + + /* Open formatted overlay as qcow2 with backing reference */ + QmpFormatNodeParams overlay_fmt_params = { + .node_name = ctx->node_name, + .format = "qcow2", + .file_node_name = ctx->overlay_file_node, + .backing = ctx->base_fmt_node, + .flags = ctx->flags & (QMP_DRIVE_DISCARD|QMP_DRIVE_DISCARD_NO_UNREF), + }; + r = qmp_build_blockdev_add_format(&overlay_fmt_params, &fmt_args); + if (r < 0) + return log_error_errno(r, "Failed to build overlay format JSON for '%s': %m", ctx->node_name); + + r = qmp_client_invoke(qmp, "blockdev-add", QMP_CLIENT_ARGS(fmt_args), on_qmp_setup_complete, (void*) "blockdev-add"); + if (r < 0) + return r; + + /* device_add: attach to virtual hardware. Build a temporary DriveInfo as a + * read-only view into the continuation context to reuse qmp_build_device_add(). */ + const DriveInfo tmp = { + .disk_driver = ctx->disk_driver, + .node_name = ctx->node_name, + .serial = ctx->serial, + .pcie_port = ctx->pcie_port, + .flags = ctx->flags & QMP_DRIVE_BOOT, + .fd = -EBADF, + .overlay_fd = -EBADF, + }; + r = qmp_build_device_add(&tmp, &device_args); + if (r < 0) + return log_error_errno(r, "Failed to build device_add JSON for '%s': %m", ctx->node_name); + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(device_args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return r; + + log_debug("Queued ephemeral drive completion for '%s'", ctx->node_name); + return 0; +} + +/* Set up an ephemeral drive: base image (read-only) + anonymous qcow2 overlay (read-write). + * The final steps (overlay format + device_add) are deferred to a job continuation that + * fires when the blockdev-create job concludes. */ +static int qmp_setup_ephemeral_drive(VmspawnQmpBridge *bridge, QmpClient *qmp, DriveInfo *drive) { + int r; + + assert(bridge); + assert(qmp); + assert(drive); + assert(drive->fd >= 0); + assert(drive->overlay_fd >= 0); + + /* Node names: -base-file, -base-fmt, -overlay-file, */ + _cleanup_free_ char *base_file_node = strjoin(drive->node_name, "-base-file"); + _cleanup_free_ char *base_fmt_node = strjoin(drive->node_name, "-base-fmt"); + _cleanup_free_ char *overlay_file_node = strjoin(drive->node_name, "-overlay-file"); + if (!base_file_node || !base_fmt_node || !overlay_file_node) + return log_oom(); + + /* Read virtual size before passing the fd to QEMU (TAKE_FD consumes it) */ + uint64_t virtual_size; + r = get_image_virtual_size(drive->fd, drive->format, FLAGS_SET(drive->flags, QMP_DRIVE_BLOCK_DEVICE), &virtual_size); + if (r < 0) + return r; + + /* Step 1-2: Pass both fds to QEMU */ + _cleanup_free_ char *base_path = NULL; + r = qmp_fdset_add(qmp, TAKE_FD(drive->fd), &base_path); + if (r < 0) + return log_error_errno(r, "Failed to send add-fd for base image '%s': %m", drive->path); + + _cleanup_free_ char *overlay_path = NULL; + r = qmp_fdset_add(qmp, TAKE_FD(drive->overlay_fd), &overlay_path); + if (r < 0) + return log_error_errno(r, "Failed to send add-fd for overlay of '%s': %m", drive->path); + + /* Step 3: Base image file node (read-only) */ + QmpFileNodeParams base_file_params = { + .node_name = base_file_node, + .filename = base_path, + .driver = FLAGS_SET(drive->flags, QMP_DRIVE_BLOCK_DEVICE) ? "host_device" : "file", + .flags = QMP_DRIVE_READ_ONLY | (drive->flags & QMP_DRIVE_NO_FLUSH), + }; + if (FLAGS_SET(bridge->features, VMSPAWN_QMP_FEATURE_IO_URING)) + base_file_params.flags |= QMP_DRIVE_IO_URING; + r = qmp_add_file_node(qmp, &base_file_params); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-add for base file '%s': %m", drive->path); + + /* Step 4: Base image format node (read-only) */ + QmpFormatNodeParams base_fmt_params = { + .node_name = base_fmt_node, + .format = drive->format, + .file_node_name = base_file_node, + .flags = QMP_DRIVE_READ_ONLY, + }; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *base_fmt_args = NULL; + r = qmp_build_blockdev_add_format(&base_fmt_params, &base_fmt_args); + if (r < 0) + return r; + + r = qmp_client_invoke(qmp, "blockdev-add", QMP_CLIENT_ARGS(base_fmt_args), on_qmp_setup_complete, (void*) "blockdev-add"); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-add for base format '%s': %m", drive->path); + + /* Step 5: Overlay file node (read-write, no io_uring for anon overlay) */ + QmpFileNodeParams overlay_file_params = { + .node_name = overlay_file_node, + .filename = overlay_path, + .driver = "file", + .flags = QMP_DRIVE_NO_FLUSH, + }; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *overlay_file_args = NULL; + r = qmp_build_blockdev_add_file(&overlay_file_params, &overlay_file_args); + if (r < 0) + return r; + + r = qmp_client_invoke(qmp, "blockdev-add", QMP_CLIENT_ARGS(overlay_file_args), on_qmp_setup_complete, (void*) "blockdev-add"); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-add for overlay file '%s': %m", drive->path); + + /* Step 6: Fire blockdev-create to format the overlay */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *create_options = NULL; + r = sd_json_buildo(&create_options, + SD_JSON_BUILD_PAIR_STRING("driver", "qcow2"), + SD_JSON_BUILD_PAIR_STRING("file", overlay_file_node), + SD_JSON_BUILD_PAIR_UNSIGNED("size", virtual_size), + SD_JSON_BUILD_PAIR_STRING("backing-file", base_fmt_node), + SD_JSON_BUILD_PAIR_STRING("backing-fmt", drive->format)); + if (r < 0) + return log_error_errno(r, "Failed to build blockdev-create options: %m"); + + _cleanup_free_ char *job_id = strjoin("create-", drive->node_name); + if (!job_id) + return log_oom(); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *cmd_args = NULL; + r = sd_json_buildo(&cmd_args, + SD_JSON_BUILD_PAIR_STRING("job-id", job_id), + SD_JSON_BUILD_PAIR_VARIANT("options", create_options)); + if (r < 0) + return log_error_errno(r, "Failed to build blockdev-create JSON: %m"); + + /* Register continuation: when the job concludes, fire overlay format + device_add */ + _cleanup_(ephemeral_drive_ctx_freep) EphemeralDriveCtx *ectx = new(EphemeralDriveCtx, 1); + if (!ectx) + return log_oom(); + + QmpDriveFlags ectx_flags = drive->flags & (QMP_DRIVE_DISCARD|QMP_DRIVE_BOOT); + if (FLAGS_SET(drive->flags, QMP_DRIVE_DISCARD) && + FLAGS_SET(bridge->features, VMSPAWN_QMP_FEATURE_DISCARD_NO_UNREF)) + ectx_flags |= QMP_DRIVE_DISCARD_NO_UNREF; + + *ectx = (EphemeralDriveCtx) { + .node_name = strdup(drive->node_name), + .overlay_file_node = strdup(overlay_file_node), + .base_fmt_node = strdup(base_fmt_node), + .disk_driver = strdup(drive->disk_driver), + .serial = drive->serial ? strdup(drive->serial) : NULL, + .pcie_port = drive->pcie_port ? strdup(drive->pcie_port) : NULL, + .flags = ectx_flags, + }; + if (!ectx->node_name || !ectx->overlay_file_node || !ectx->base_fmt_node || + !ectx->disk_driver || (drive->serial && !ectx->serial) || + (drive->pcie_port && !ectx->pcie_port)) + return log_oom(); + + r = vmspawn_qmp_bridge_register_job(bridge, job_id, + on_ephemeral_create_concluded, ectx, + ephemeral_drive_ctx_free_void); + if (r < 0) + return log_error_errno(r, "Failed to register job continuation: %m"); + + TAKE_PTR(ectx); + + r = qmp_client_invoke(qmp, "blockdev-create", QMP_CLIENT_ARGS(cmd_args), on_qmp_setup_complete, (void*) "blockdev-create"); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-create for '%s': %m", drive->path); + + log_debug("Queued ephemeral drive setup for '%s' (job %s)", drive->path, job_id); + return 0; +} + +/* Set up a regular (non-ephemeral) drive: single file node + format node + device_add. */ +static int qmp_setup_regular_drive(VmspawnQmpBridge *bridge, QmpClient *qmp, DriveInfo *drive) { + int r; + + assert(bridge); + assert(qmp); + assert(drive); + assert(drive->fd >= 0); + + /* Node names: -file, */ + _cleanup_free_ char *file_node_name = strjoin(drive->node_name, "-file"); + if (!file_node_name) + return log_oom(); + + _cleanup_free_ char *fdset_path = NULL; + r = qmp_fdset_add(qmp, TAKE_FD(drive->fd), &fdset_path); + if (r < 0) + return log_error_errno(r, "Failed to send add-fd for '%s': %m", drive->path); + + QmpFileNodeParams file_params = { + .node_name = file_node_name, + .filename = fdset_path, + .driver = FLAGS_SET(drive->flags, QMP_DRIVE_BLOCK_DEVICE) ? "host_device" : "file", + .flags = drive->flags & (QMP_DRIVE_READ_ONLY|QMP_DRIVE_NO_FLUSH), + }; + if (FLAGS_SET(bridge->features, VMSPAWN_QMP_FEATURE_IO_URING)) + file_params.flags |= QMP_DRIVE_IO_URING; + r = qmp_add_file_node(qmp, &file_params); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-add for '%s': %m", drive->path); + + QmpFormatNodeParams fmt_params = { + .node_name = drive->node_name, + .format = drive->format, + .file_node_name = file_node_name, + .flags = drive->flags & (QMP_DRIVE_READ_ONLY|QMP_DRIVE_DISCARD), + }; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *fmt_args = NULL; + r = qmp_build_blockdev_add_format(&fmt_params, &fmt_args); + if (r < 0) + return r; + + r = qmp_client_invoke(qmp, "blockdev-add", QMP_CLIENT_ARGS(fmt_args), on_qmp_setup_complete, (void*) "blockdev-add"); + if (r < 0) + return log_error_errno(r, "Failed to send blockdev-add format for '%s': %m", drive->path); + + /* device_add: attach to virtual hardware */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *device_args = NULL; + r = qmp_build_device_add(drive, &device_args); + if (r < 0) + return r; + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(device_args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return log_error_errno(r, "Failed to send device_add for '%s': %m", drive->path); + + log_debug("Queued drive setup for '%s'", drive->path); + return 0; +} + +/* Configure a single drive via QMP. Dispatches to ephemeral or regular setup. */ +static int qmp_setup_drive(VmspawnQmpBridge *bridge, QmpClient *qmp, DriveInfo *drive) { + assert(drive); + + if (drive->overlay_fd >= 0) + return qmp_setup_ephemeral_drive(bridge, qmp, drive); + + return qmp_setup_regular_drive(bridge, qmp, drive); +} + +int vmspawn_qmp_setup_network(VmspawnQmpBridge *bridge, NetworkInfo *network) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *netdev_args = NULL, *device_args = NULL; + bool tap_by_fd; + int r; + + assert(bridge); + + QmpClient *qmp = vmspawn_qmp_bridge_get_qmp(bridge); + assert(network); + assert(network->type); + + tap_by_fd = streq(network->type, "tap") && network->fd >= 0; + + /* For TAP-by-fd: pass the TAP fd to QEMU via getfd + SCM_RIGHTS, then reference it by name + * in netdev_add. QEMU stores the received fd under the given fdname and closes it on removal. */ + if (tap_by_fd) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *getfd_args = NULL; + + r = sd_json_buildo( + &getfd_args, + SD_JSON_BUILD_PAIR_STRING("fdname", "vmspawn_tap")); + if (r < 0) + return log_error_errno(r, "Failed to build getfd JSON: %m"); + + r = qmp_client_invoke(qmp, "getfd", QMP_CLIENT_ARGS_FD(getfd_args, TAKE_FD(network->fd)), + on_qmp_setup_complete, (void*) "getfd"); + if (r < 0) + return log_error_errno(r, "Failed to send getfd for TAP fd: %m"); + } + + /* netdev_add: create the network backend */ + r = sd_json_buildo( + &netdev_args, + SD_JSON_BUILD_PAIR_STRING("type", network->type), + SD_JSON_BUILD_PAIR_STRING("id", "net0"), + SD_JSON_BUILD_PAIR_CONDITION(tap_by_fd, + "fd", JSON_BUILD_CONST_STRING("vmspawn_tap")), + SD_JSON_BUILD_PAIR_CONDITION(!tap_by_fd && !!network->ifname, + "ifname", SD_JSON_BUILD_STRING(network->ifname)), + SD_JSON_BUILD_PAIR_CONDITION(!tap_by_fd && streq(network->type, "tap"), + "script", JSON_BUILD_CONST_STRING("no")), + SD_JSON_BUILD_PAIR_CONDITION(!tap_by_fd && streq(network->type, "tap"), + "downscript", JSON_BUILD_CONST_STRING("no"))); + if (r < 0) + return log_error_errno(r, "Failed to build netdev_add JSON: %m"); + + r = qmp_client_invoke(qmp, "netdev_add", QMP_CLIENT_ARGS(netdev_args), on_qmp_setup_complete, (void*) "netdev_add"); + if (r < 0) + return log_error_errno(r, "Failed to send netdev_add: %m"); + + /* device_add: attach NIC frontend */ + r = sd_json_buildo( + &device_args, + SD_JSON_BUILD_PAIR_STRING("driver", "virtio-net-pci"), + SD_JSON_BUILD_PAIR_STRING("netdev", "net0"), + SD_JSON_BUILD_PAIR_STRING("id", "nic0"), + SD_JSON_BUILD_PAIR_CONDITION(network->mac_set, + "mac", SD_JSON_BUILD_STRING(network->mac_set ? ETHER_ADDR_TO_STR(&network->mac) : NULL)), + SD_JSON_BUILD_PAIR_CONDITION(!!network->pcie_port, + "bus", SD_JSON_BUILD_STRING(network->pcie_port))); + if (r < 0) + return log_error_errno(r, "Failed to build NIC device_add JSON: %m"); + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(device_args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return log_error_errno(r, "Failed to send NIC device_add: %m"); + + log_debug("Queued %s network setup%s", network->type, tap_by_fd ? " (fd via getfd)" : ""); + return 0; +} + +static int vmspawn_qmp_setup_one_virtiofs(QmpClient *qmp, const VirtiofsInfo *vfs) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *chardev_args = NULL, *device_args = NULL; + int r; + + assert(qmp); + assert(vfs); + assert(vfs->id); + assert(vfs->socket_path); + assert(vfs->tag); + + /* chardev-add: connect to virtiofsd socket. + * ChardevBackend and SocketAddressLegacy are QAPI legacy unions with explicit "data" + * wrapper objects at each level — the nesting is mandatory on the wire. */ + r = sd_json_buildo( + &chardev_args, + SD_JSON_BUILD_PAIR_STRING("id", vfs->id), + SD_JSON_BUILD_PAIR("backend", SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("type", "socket"), + SD_JSON_BUILD_PAIR("data", SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR("addr", SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("type", "unix"), + SD_JSON_BUILD_PAIR("data", SD_JSON_BUILD_OBJECT( + SD_JSON_BUILD_PAIR_STRING("path", vfs->socket_path))))), + SD_JSON_BUILD_PAIR_BOOLEAN("server", false)))))); + if (r < 0) + return log_error_errno(r, "Failed to build chardev-add JSON for '%s': %m", vfs->id); + + r = qmp_client_invoke(qmp, "chardev-add", QMP_CLIENT_ARGS(chardev_args), on_qmp_setup_complete, (void*) "chardev-add"); + if (r < 0) + return log_error_errno(r, "Failed to send chardev-add '%s': %m", vfs->id); + + /* device_add: create vhost-user-fs-pci device */ + r = sd_json_buildo( + &device_args, + SD_JSON_BUILD_PAIR_STRING("driver", "vhost-user-fs-pci"), + SD_JSON_BUILD_PAIR_STRING("id", vfs->id), + SD_JSON_BUILD_PAIR_STRING("chardev", vfs->id), + SD_JSON_BUILD_PAIR_STRING("tag", vfs->tag), + SD_JSON_BUILD_PAIR_UNSIGNED("queue-size", 1024), + SD_JSON_BUILD_PAIR_CONDITION(!!vfs->pcie_port, + "bus", SD_JSON_BUILD_STRING(vfs->pcie_port))); + if (r < 0) + return log_error_errno(r, "Failed to build virtiofs device_add JSON for '%s': %m", vfs->id); + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(device_args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return log_error_errno(r, "Failed to send virtiofs device_add '%s': %m", vfs->id); + + log_debug("Queued virtiofs device '%s' (tag=%s)", vfs->id, vfs->tag); + return 0; +} + +int vmspawn_qmp_setup_virtiofs(VmspawnQmpBridge *bridge, const VirtiofsInfos *virtiofs) { + int r; + + assert(bridge); + + QmpClient *qmp = vmspawn_qmp_bridge_get_qmp(bridge); + assert(virtiofs); + + FOREACH_ARRAY(e, virtiofs->entries, virtiofs->n_entries) { + r = vmspawn_qmp_setup_one_virtiofs(qmp, e); + if (r < 0) + return r; + } + + return 0; +} + +int vmspawn_qmp_setup_vsock(VmspawnQmpBridge *bridge, VsockInfo *vsock) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *getfd_args = NULL, *device_args = NULL; + int r; + + assert(bridge); + assert(vsock); + + if (vsock->fd < 0) + return 0; + + QmpClient *qmp = vmspawn_qmp_bridge_get_qmp(bridge); + + /* getfd: pass the vhost-vsock fd to QEMU via SCM_RIGHTS */ + r = sd_json_buildo( + &getfd_args, + SD_JSON_BUILD_PAIR_STRING("fdname", "vmspawn_vsock")); + if (r < 0) + return log_error_errno(r, "Failed to build getfd JSON for VSOCK: %m"); + + r = qmp_client_invoke(qmp, "getfd", QMP_CLIENT_ARGS_FD(getfd_args, TAKE_FD(vsock->fd)), + on_qmp_setup_complete, (void*) "getfd"); + if (r < 0) + return log_error_errno(r, "Failed to send getfd for VSOCK fd: %m"); + + /* device_add: create vhost-vsock-pci device referencing the named fd */ + r = sd_json_buildo( + &device_args, + SD_JSON_BUILD_PAIR_STRING("driver", "vhost-vsock-pci"), + SD_JSON_BUILD_PAIR_STRING("id", "vsock0"), + SD_JSON_BUILD_PAIR_UNSIGNED("guest-cid", vsock->cid), + SD_JSON_BUILD_PAIR_STRING("vhostfd", "vmspawn_vsock"), + SD_JSON_BUILD_PAIR_CONDITION(!!vsock->pcie_port, + "bus", SD_JSON_BUILD_STRING(vsock->pcie_port))); + if (r < 0) + return log_error_errno(r, "Failed to build VSOCK device_add JSON: %m"); + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(device_args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return log_error_errno(r, "Failed to send VSOCK device_add: %m"); + + log_debug("Queued vhost-vsock-pci device setup (cid=%u)", vsock->cid); + return 0; +} + +static bool drives_need_scsi_controller(DriveInfos *drives) { + assert(drives); + + FOREACH_ARRAY(d, drives->drives, drives->n_drives) + if (STR_IN_SET(d->disk_driver, "scsi-hd", "scsi-cd")) + return true; + + return false; +} + +static int qmp_setup_scsi_controller(QmpClient *qmp, const char *pcie_port) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + int r; + + r = sd_json_buildo( + &args, + SD_JSON_BUILD_PAIR_STRING("driver", "virtio-scsi-pci"), + SD_JSON_BUILD_PAIR_STRING("id", "vmspawn_scsi"), + SD_JSON_BUILD_PAIR_CONDITION(!!pcie_port, "bus", SD_JSON_BUILD_STRING(pcie_port))); + if (r < 0) + return log_error_errno(r, "Failed to build SCSI controller JSON: %m"); + + r = qmp_client_invoke(qmp, "device_add", QMP_CLIENT_ARGS(args), on_qmp_setup_complete, (void*) "device_add"); + if (r < 0) + return log_error_errno(r, "Failed to send SCSI controller device_add: %m"); + + log_debug("Queued virtio-scsi-pci controller setup"); + return 0; +} + +int vmspawn_qmp_setup_drives(VmspawnQmpBridge *bridge, DriveInfos *drives) { + int r; + + assert(bridge); + assert(drives); + + QmpClient *qmp = vmspawn_qmp_bridge_get_qmp(bridge); + + /* io_uring support was probed during vmspawn_qmp_init(). The cached result in + * bridge->features is passed to each file node setup call. */ + + if (drives_need_scsi_controller(drives)) { + r = qmp_setup_scsi_controller(qmp, drives->scsi_pcie_port); + if (r < 0) + return r; + } + + FOREACH_ARRAY(d, drives->drives, drives->n_drives) { + r = qmp_setup_drive(bridge, qmp, d); + if (r < 0) + return r; + } + + return 0; +} + +PendingJob* pending_job_free(PendingJob *j) { + if (!j) + return NULL; + if (j->free_userdata) + j->free_userdata(j->userdata); + return mfree(j); +} + +VmspawnQmpBridge* vmspawn_qmp_bridge_free(VmspawnQmpBridge *b) { + if (!b) + return NULL; + + hashmap_free(b->pending_jobs); + + qmp_client_unref(b->qmp); + return mfree(b); +} + +int vmspawn_qmp_bridge_register_job( + VmspawnQmpBridge *b, + const char *job_id, + pending_job_callback_t on_concluded, + void *userdata, + pending_job_free_t free_userdata) { + + _cleanup_free_ PendingJob *job = NULL; + _cleanup_free_ char *id = NULL; + int r; + + assert(b); + assert(job_id); + + id = strdup(job_id); + if (!id) + return -ENOMEM; + + job = new(PendingJob, 1); + if (!job) + return -ENOMEM; + + *job = (PendingJob) { + .on_concluded = on_concluded, + .free_userdata = free_userdata, + .userdata = userdata, + }; + + r = hashmap_ensure_put(&b->pending_jobs, &pending_job_hash_ops, id, job); + if (r < 0) + return r; + + TAKE_PTR(id); + TAKE_PTR(job); + return 0; +} + +QmpClient* vmspawn_qmp_bridge_get_qmp(VmspawnQmpBridge *b) { + assert(b); + return b->qmp; +} + +/* Probe-reply convention: ignore -EIO (QMP rejection = "feature absent", log at debug + * and leave the feature flag clear) and transport errors (caught by the post-loop + * qmp_client_is_disconnected() check in vmspawn_qmp_probe_features()). Cleanup calls + * are best-effort — failing to delete a private probe node leaves a harmless /dev/null + * blockdev in QEMU until it exits. */ + +static int on_io_uring_probe_del_reply( + QmpClient *c, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + assert(c); + + if (error_desc) + log_debug("Failed to remove io_uring probe node: %s", error_desc); + return 0; +} + +static int on_io_uring_probe_add_reply( + QmpClient *c, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + VmspawnQmpBridge *bridge = ASSERT_PTR(userdata); + _cleanup_(sd_json_variant_unrefp) sd_json_variant *del_args = NULL; + int r; + + assert(c); + + if (error < 0 && !error_desc) + return log_debug_errno(error, "io_uring probe did not execute: %m"); + if (error_desc) { + log_debug("QEMU does not support aio=io_uring: %s", error_desc); + return 0; + } + + bridge->features |= VMSPAWN_QMP_FEATURE_IO_URING; + log_debug("QEMU supports aio=io_uring"); + + /* Best-effort cleanup; the chained reply keeps the pump busy via the slots set. */ + r = sd_json_buildo(&del_args, + SD_JSON_BUILD_PAIR_STRING("node-name", "__io_uring_probe")); + if (r < 0) + return r; + + return qmp_client_invoke(c, "blockdev-del", QMP_CLIENT_ARGS(del_args), + on_io_uring_probe_del_reply, bridge); +} + +static int probe_io_uring(QmpClient *c, VmspawnQmpBridge *bridge) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *args = NULL; + int r; + + assert(c); + assert(bridge); + + r = sd_json_buildo( + &args, + SD_JSON_BUILD_PAIR_STRING("node-name", "__io_uring_probe"), + SD_JSON_BUILD_PAIR_STRING("driver", "file"), + SD_JSON_BUILD_PAIR_STRING("filename", "/dev/null"), + SD_JSON_BUILD_PAIR_BOOLEAN("read-only", true), + SD_JSON_BUILD_PAIR_STRING("aio", "io_uring")); + if (r < 0) + return r; + + return qmp_client_invoke(c, "blockdev-add", QMP_CLIENT_ARGS(args), + on_io_uring_probe_add_reply, bridge); +} + +static int on_probe_schema_reply( + QmpClient *c, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + VmspawnQmpBridge *bridge = ASSERT_PTR(userdata); + + assert(c); + + if (error < 0 && !error_desc) + return log_debug_errno(error, "query-qmp-schema probe did not execute: %m"); + if (error_desc) { + log_debug("query-qmp-schema rejected: %s", error_desc); + return 0; + } + + if (qmp_schema_has_member(result, "discard-no-unref")) { + bridge->features |= VMSPAWN_QMP_FEATURE_DISCARD_NO_UNREF; + log_debug("QEMU supports qcow2 discard-no-unref"); + } else + log_debug("QEMU does not support qcow2 discard-no-unref"); + + return 0; +} + +static int probe_schema(QmpClient *c, VmspawnQmpBridge *bridge) { + assert(c); + assert(bridge); + + return qmp_client_invoke(c, "query-qmp-schema", QMP_CLIENT_ARGS(NULL), + on_probe_schema_reply, bridge); +} + +int vmspawn_qmp_init(VmspawnQmpBridge **ret, int fd, sd_event *event) { + _cleanup_(vmspawn_qmp_bridge_freep) VmspawnQmpBridge *bridge = NULL; + int r; + + assert(ret); + assert(fd >= 0); + assert(event); + + bridge = new0(VmspawnQmpBridge, 1); + if (!bridge) + return log_oom(); + + r = qmp_client_connect_fd(&bridge->qmp, fd); + if (r < 0) + return log_error_errno(r, "Failed to create QMP client: %m"); + + r = qmp_client_set_description(bridge->qmp, "vmspawn-qmp-client"); + if (r < 0) + return log_error_errno(r, "Failed to set QMP client description: %m"); + + r = qmp_client_attach_event(bridge->qmp, event, SD_EVENT_PRIORITY_NORMAL); + if (r < 0) + return log_error_errno(r, "Failed to attach QMP client to event loop: %m"); + + *ret = TAKE_PTR(bridge); + return 0; +} + +int vmspawn_qmp_probe_features(VmspawnQmpBridge *bridge) { + int r; + + assert(bridge); + + /* probe_io_uring() and probe_schema() both call qmp_client_invoke(), which internally + * drives the handshake to RUNNING via qmp_client_ensure_running() on its first call. */ + r = probe_io_uring(bridge->qmp, bridge); + if (r < 0) + return log_error_errno(r, "Failed to issue io_uring probe: %m"); + + r = probe_schema(bridge->qmp, bridge); + if (r < 0) + return log_error_errno(r, "Failed to issue schema probe: %m"); + + /* Canonical sync-on-async pump, matching varlink_call_internal(). The QMP client tracks + * outstanding replies in its own slots set; drain until it's idle. */ + while (!qmp_client_is_idle(bridge->qmp)) { + r = qmp_client_process(bridge->qmp); + if (r < 0) + return log_error_errno(r, "QMP probe pump failed: %m"); + if (r > 0) + continue; + + r = qmp_client_wait(bridge->qmp, USEC_INFINITY); + if (r < 0) + return log_error_errno(r, "QMP probe wait failed: %m"); + } + + /* If fail_pending() drained the slots (transport dropped mid-probe), features can't be + * trusted and we have no QMP channel for device setup anyway. */ + if (qmp_client_is_disconnected(bridge->qmp)) + return log_error_errno(SYNTHETIC_ERRNO(ECONNRESET), + "QMP connection dropped during feature probing"); + + return 0; +} + +static int on_cont_complete( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + assert(client); + + if (error < 0) { + log_error_errno(error, "Failed to resume QEMU execution: %s", strna(error_desc)); + return sd_event_exit(qmp_client_get_event(client), error); + } + + return 0; +} + +int vmspawn_qmp_start(VmspawnQmpBridge *bridge) { + assert(bridge); + + return qmp_client_invoke(bridge->qmp, "cont", /* args= */ NULL, on_cont_complete, /* userdata= */ NULL); +} diff --git a/src/vmspawn/vmspawn-qmp.h b/src/vmspawn/vmspawn-qmp.h new file mode 100644 index 0000000000..8f8c26fb03 --- /dev/null +++ b/src/vmspawn/vmspawn-qmp.h @@ -0,0 +1,145 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include + +#include "shared-forward.h" + +/* Pending job continuation — called when a QMP background job reaches "concluded" state. + * Used by blockdev-create to chain remaining drive setup after the job completes. */ +typedef int (*pending_job_callback_t)(QmpClient *qmp, void *userdata); +typedef void (*pending_job_free_t)(void *userdata); + +typedef struct PendingJob { + pending_job_callback_t on_concluded; + pending_job_free_t free_userdata; + void *userdata; +} PendingJob; + +PendingJob* pending_job_free(PendingJob *j); +DEFINE_TRIVIAL_CLEANUP_FUNC(PendingJob *, pending_job_free); + +typedef enum VmspawnQmpFeatureFlags { + VMSPAWN_QMP_FEATURE_IO_URING = 1u << 0, + VMSPAWN_QMP_FEATURE_DISCARD_NO_UNREF = 1u << 1, +} VmspawnQmpFeatureFlags; + +typedef struct VmspawnQmpBridge { + QmpClient *qmp; + Hashmap *pending_jobs; /* job_id (string, owned) -> PendingJob* */ + VmspawnQmpFeatureFlags features; +} VmspawnQmpBridge; + +VmspawnQmpBridge* vmspawn_qmp_bridge_free(VmspawnQmpBridge *b); +DEFINE_TRIVIAL_CLEANUP_FUNC(VmspawnQmpBridge *, vmspawn_qmp_bridge_free); + +QmpClient* vmspawn_qmp_bridge_get_qmp(VmspawnQmpBridge *b); + +/* Phase 1: Connect to VMM backend. Returns an opaque bridge ready for device setup. */ +int vmspawn_qmp_init(VmspawnQmpBridge **ret, int fd, sd_event *event); + +/* Phase 1b: Feature probing. Fires one-shot QMP commands and drives the client + * synchronously until every reply has been delivered. Populates bridge->features. + * Must run before the device-setup phase; both io_uring and discard-no-unref flags + * are consumed by vmspawn_qmp_setup_drives(). */ +int vmspawn_qmp_probe_features(VmspawnQmpBridge *bridge); + +/* Phase 3: Resume vCPUs. All commands are async — responses arrive during sd_event_loop(). */ +int vmspawn_qmp_start(VmspawnQmpBridge *bridge); + +int vmspawn_qmp_bridge_register_job( + VmspawnQmpBridge *b, + const char *job_id, + pending_job_callback_t on_concluded, + void *userdata, + pending_job_free_t free_userdata); + +typedef enum QmpDriveFlags { + QMP_DRIVE_BLOCK_DEVICE = 1u << 0, + QMP_DRIVE_READ_ONLY = 1u << 1, + QMP_DRIVE_DISCARD = 1u << 2, + QMP_DRIVE_NO_FLUSH = 1u << 3, + QMP_DRIVE_BOOT = 1u << 4, + QMP_DRIVE_IO_URING = 1u << 5, + QMP_DRIVE_DISCARD_NO_UNREF = 1u << 6, /* qcow2 only */ +} QmpDriveFlags; + +/* Drive info for QMP-based drive setup */ +typedef struct DriveInfo { + const char *path; /* kept for logging only — not passed to QEMU */ + const char *format; /* "raw" or "qcow2" */ + const char *disk_driver; /* "virtio-blk-pci", "scsi-hd", "scsi-cd", "nvme" */ + char *serial; /* owned */ + char *node_name; /* owned */ + char *pcie_port; /* owned: pcie-root-port id for device_add bus (NULL on non-PCIe) */ + int fd; /* pre-opened image fd (owned, -EBADF if unused) */ + int overlay_fd; /* pre-opened anonymous overlay fd for ephemeral (owned, -EBADF if unused) */ + QmpDriveFlags flags; +} DriveInfo; + +void drive_info_done(DriveInfo *info); + +typedef struct DriveInfos { + DriveInfo *drives; + size_t n_drives; + char *scsi_pcie_port; /* owned: pcie-root-port id for SCSI controller (NULL if no SCSI or non-PCIe) */ +} DriveInfos; + +void drive_infos_done(DriveInfos *infos); + +/* Network info for QMP-based network setup. Covers privileged TAP (by name), + * nsresourced TAP (by FD via getfd), and user-mode networking. The no-network + * case (-nic none) stays on the QEMU command line. */ +typedef struct NetworkInfo { + const char *type; /* "tap" or "user" — points to a string literal */ + char *ifname; /* owned: TAP interface name (tap by name only, NULL if unset) */ + struct ether_addr mac; /* VM-side MAC address (tap only, valid iff mac_set) */ + bool mac_set; + char *pcie_port; /* owned: pcie-root-port id for device_add bus (NULL on non-PCIe) */ + int fd; /* TAP fd to pass via getfd (tap by fd only, -EBADF if unused) */ +} NetworkInfo; + +void network_info_done(NetworkInfo *info); + +/* Virtiofs device info for QMP-based chardev + device setup */ +typedef struct VirtiofsInfo { + char *id; /* owned: chardev and device id (e.g. "rootdir", "mnt0") */ + char *socket_path; /* owned: virtiofsd listen socket path */ + char *tag; /* owned: virtiofs mount tag visible to guest */ + char *pcie_port; /* owned: pcie-root-port id for device_add bus (NULL on non-PCIe) */ +} VirtiofsInfo; + +void virtiofs_info_done(VirtiofsInfo *info); + +typedef struct VirtiofsInfos { + VirtiofsInfo *entries; + size_t n_entries; +} VirtiofsInfos; + +void virtiofs_infos_done(VirtiofsInfos *infos); + +/* VSOCK device info for QMP-based setup via getfd + device_add */ +typedef struct VsockInfo { + int fd; /* vhost-vsock fd to pass via getfd (-EBADF if unused) */ + unsigned cid; /* guest CID */ + char *pcie_port; /* owned: pcie-root-port id for device_add bus (NULL on non-PCIe) */ +} VsockInfo; + +void vsock_info_done(VsockInfo *info); + +/* Aggregate of the per-device info structures populated before the bridge-based + * device setup phase. Keeps lifetime and cleanup of all device state in one place. */ +typedef struct MachineConfig { + DriveInfos drives; + NetworkInfo network; + VirtiofsInfos virtiofs; + VsockInfo vsock; +} MachineConfig; + +void machine_config_done(MachineConfig *c); + +/* Phase 2: Device setup — call any subset in any order before vmspawn_qmp_start(). */ +int vmspawn_qmp_setup_drives(VmspawnQmpBridge *bridge, DriveInfos *drives); +int vmspawn_qmp_setup_network(VmspawnQmpBridge *bridge, NetworkInfo *network); +int vmspawn_qmp_setup_virtiofs(VmspawnQmpBridge *bridge, const VirtiofsInfos *virtiofs); +int vmspawn_qmp_setup_vsock(VmspawnQmpBridge *bridge, VsockInfo *vsock); diff --git a/src/vmspawn/vmspawn-util.h b/src/vmspawn/vmspawn-util.h index 4e3e4c1313..38bb331dfc 100644 --- a/src/vmspawn/vmspawn-util.h +++ b/src/vmspawn/vmspawn-util.h @@ -59,20 +59,33 @@ # define KERNEL_CMDLINE_SIZE 512 #endif +/* ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS is co-located with QEMU_MACHINE_TYPE so they stay in + * sync: q35 and virt machine types need pcie-root-port bridges for QMP device_add hotplug. + * Exception: m68k's "virt" uses virtio-mmio, not PCIe, so it doesn't need root ports. */ #if defined(__x86_64__) || defined(__i386__) # define QEMU_MACHINE_TYPE "q35" -#elif defined(__arm__) || defined(__aarch64__) || defined(__riscv) || defined(__loongarch64) || defined(__m68k__) +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 1 +#elif defined(__m68k__) # define QEMU_MACHINE_TYPE "virt" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 +#elif defined(__arm__) || defined(__aarch64__) || defined(__riscv) || defined(__loongarch64) +# define QEMU_MACHINE_TYPE "virt" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 1 #elif defined(__s390__) || defined(__s390x__) # define QEMU_MACHINE_TYPE "s390-ccw-virtio" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 #elif defined(__powerpc__) || defined(__powerpc64__) # define QEMU_MACHINE_TYPE "pseries" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 #elif defined(__mips__) # define QEMU_MACHINE_TYPE "malta" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 #elif defined(__sparc__) # define QEMU_MACHINE_TYPE "sun4u" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 #else # define QEMU_MACHINE_TYPE "none" +# define ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS 0 #endif #if defined(__arm__) || defined(__aarch64__) diff --git a/src/vmspawn/vmspawn-varlink.c b/src/vmspawn/vmspawn-varlink.c new file mode 100644 index 0000000000..c73372e7a1 --- /dev/null +++ b/src/vmspawn/vmspawn-varlink.c @@ -0,0 +1,410 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "alloc-util.h" +#include "errno-util.h" +#include "hashmap.h" +#include "log.h" +#include "path-util.h" +#include "qmp-client.h" +#include "string-util.h" +#include "strv.h" +#include "varlink-io.systemd.MachineInstance.h" +#include "varlink-io.systemd.QemuMachineInstance.h" +#include "varlink-io.systemd.VirtualMachineInstance.h" +#include "varlink-util.h" +#include "vmspawn-varlink.h" + +DEFINE_PRIVATE_HASH_OPS_FULL( + varlink_subscriber_hash_ops, + void, trivial_hash_func, trivial_compare_func, sd_varlink_close_unref, + char*, strv_free); + +struct VmspawnVarlinkContext { + sd_varlink_server *varlink_server; + VmspawnQmpBridge *bridge; + /* Key: sd_varlink* (ref'd), Value: strv filter (NULL = all events). + * varlink_subscriber_hash_ops handles cleanup of both on removal. */ + Hashmap *subscribed; +}; + +/* Translate a QMP async completion into a varlink error reply */ +static int qmp_error_to_varlink(sd_varlink *link, const char *error_desc, int error) { + assert(link); + + if (ERRNO_IS_DISCONNECT(error)) + return sd_varlink_error(link, "io.systemd.MachineInstance.NotConnected", NULL); + if (error == -EIO) + log_warning("QMP command failed: %s", strna(error_desc)); + return sd_varlink_error_errno(link, error); +} + +/* Shared async completion for simple QMP commands that return no data. + * Errors are translated to varlink replies, not propagated through sd_event. */ +static int on_qmp_simple_complete( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + sd_varlink *link = ASSERT_PTR(userdata); + + assert(client); + + if (error == 0) + (void) sd_varlink_reply(link, NULL); + else + (void) qmp_error_to_varlink(link, error_desc, error); + + sd_varlink_unref(link); + return 0; +} + +static int qmp_execute_varlink_async( + VmspawnVarlinkContext *ctx, + sd_varlink *link, + const char *command, + sd_json_variant *arguments, + qmp_command_callback_t callback) { + + int r; + + sd_varlink_ref(link); + + r = qmp_client_invoke(ctx->bridge->qmp, command, QMP_CLIENT_ARGS(arguments), callback, link); + if (r < 0) + sd_varlink_unref(link); + + return r; +} + +static int qmp_execute_simple_async(sd_varlink *link, VmspawnVarlinkContext *ctx, const char *qmp_command) { + assert(link); + assert(ctx); + assert(qmp_command); + + return qmp_execute_varlink_async(ctx, link, qmp_command, /* arguments= */ NULL, on_qmp_simple_complete); +} + +static int vl_method_terminate(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return qmp_execute_simple_async(link, ASSERT_PTR(userdata), "quit"); +} + +static int vl_method_pause(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return qmp_execute_simple_async(link, ASSERT_PTR(userdata), "stop"); +} + +static int vl_method_resume(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return qmp_execute_simple_async(link, ASSERT_PTR(userdata), "cont"); +} + +static int vl_method_power_off(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return qmp_execute_simple_async(link, ASSERT_PTR(userdata), "system_powerdown"); +} + +static int vl_method_reboot(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return qmp_execute_simple_async(link, ASSERT_PTR(userdata), "system_reset"); +} + +/* Async completion for query-status: extract running/status from QMP result */ +static int on_qmp_describe_complete( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + _cleanup_(sd_varlink_unrefp) sd_varlink *link = ASSERT_PTR(userdata); + + assert(client); + + if (error != 0) { + (void) qmp_error_to_varlink(link, error_desc, error); + return 0; + } + + sd_json_variant *running = sd_json_variant_by_key(result, "running"); + sd_json_variant *status = sd_json_variant_by_key(result, "status"); + + (void) sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_BOOLEAN("running", running ? sd_json_variant_boolean(running) : false), + SD_JSON_BUILD_PAIR_STRING("status", status && sd_json_variant_is_string(status) ? sd_json_variant_string(status) : "unknown")); + + return 0; +} + +static int vl_method_describe(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + VmspawnVarlinkContext *ctx = ASSERT_PTR(userdata); + + return qmp_execute_varlink_async(ctx, link, "query-status", /* arguments= */ NULL, on_qmp_describe_complete); +} + +static int vl_method_subscribe_events(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + VmspawnVarlinkContext *ctx = ASSERT_PTR(userdata); + _cleanup_strv_free_ char **filter = NULL; + int r; + + /* SD_VARLINK_REQUIRES_MORE in the IDL rejects non-streaming callers before we get here */ + + r = sd_varlink_dispatch(link, parameters, (const sd_json_dispatch_field[]) { + { "filter", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_strv, 0, SD_JSON_NULLABLE }, + {}, + }, &filter); + if (r != 0) + return r; + + sd_varlink_ref(link); + + r = hashmap_ensure_put(&ctx->subscribed, &varlink_subscriber_hash_ops, link, filter); + if (r < 0) { + sd_varlink_unref(link); + return r; + } + + TAKE_PTR(filter); + + r = sd_varlink_notifybo(link, SD_JSON_BUILD_PAIR_STRING("event", "READY")); + if (r < 0) { + strv_free(hashmap_remove(ctx->subscribed, link)); + sd_varlink_close_unref(link); + return r; + } + + return 0; +} + +static int vl_method_acquire_qmp(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return sd_varlink_error_errno(link, -EOPNOTSUPP); +} + +static void vl_disconnect(sd_varlink_server *server, sd_varlink *link, void *userdata) { + VmspawnVarlinkContext *ctx = ASSERT_PTR(userdata); + + assert(server); + assert(link); + + /* Only subscribers hold an extra ref on the link (taken in vl_method_subscribe_events). + * Non-subscriber connections (one-shot commands like Pause, Describe) must not be unref'd + * here — their extra ref is consumed by the async completion callback. Only unref, never + * close — the server handles close after this callback returns (matching resolved's + * vl_on_notification_disconnect pattern). + * + * Use hashmap_remove2() so the returned key (non-NULL iff the entry was present) + * disambiguates "no filter subscriber" (value=NULL) from "not a subscriber". */ + void *removed_key = NULL; + strv_free(hashmap_remove2(ctx->subscribed, link, &removed_key)); + if (!removed_key) + return; + + sd_varlink_unref(link); +} + +static int on_job_dismiss_complete( + QmpClient *client, + sd_json_variant *result, + const char *error_desc, + int error, + void *userdata) { + + if (error < 0) + log_debug_errno(error, "job-dismiss failed: %s", strna(error_desc)); + + return 0; +} + +static int dispatch_pending_job(VmspawnQmpBridge *bridge, sd_json_variant *data) { + const char *job_id, *status; + int r; + + assert(bridge); + + if (!data) + return 0; + + job_id = sd_json_variant_string(sd_json_variant_by_key(data, "id")); + status = sd_json_variant_string(sd_json_variant_by_key(data, "status")); + + if (!job_id || !streq_ptr(status, "concluded")) + return 0; + + _cleanup_free_ char *key = NULL; + _cleanup_(pending_job_freep) PendingJob *job = hashmap_remove2(bridge->pending_jobs, job_id, (void**) &key); + if (!job) + return 0; + + log_debug("QMP job '%s' concluded, firing continuation", job_id); + + /* Dismiss the concluded job before running the continuation */ + _cleanup_(sd_json_variant_unrefp) sd_json_variant *dismiss_args = NULL; + r = sd_json_buildo(&dismiss_args, SD_JSON_BUILD_PAIR_STRING("id", job_id)); + if (r < 0) + return sd_event_exit(qmp_client_get_event(bridge->qmp), r); + + r = qmp_client_invoke(bridge->qmp, "job-dismiss", QMP_CLIENT_ARGS(dismiss_args), + on_job_dismiss_complete, /* userdata= */ NULL); + if (r < 0) + return sd_event_exit(qmp_client_get_event(bridge->qmp), r); + + if (!job->on_concluded) + return 1; + + r = job->on_concluded(bridge->qmp, TAKE_PTR(job->userdata)); + if (r < 0) { + log_error_errno(r, "Job continuation failed: %m"); + return sd_event_exit(qmp_client_get_event(bridge->qmp), r); + } + + return 1; +} + +static int on_qmp_event( + QmpClient *client, + const char *event, + sd_json_variant *data, + void *userdata) { + + VmspawnVarlinkContext *ctx = ASSERT_PTR(userdata); + _cleanup_(sd_json_variant_unrefp) sd_json_variant *notification = NULL; + sd_varlink *link; + char **filter; + int r; + + assert(client); + assert(event); + + /* Dispatch job status changes to pending continuations (e.g. blockdev-create) */ + if (streq(event, "JOB_STATUS_CHANGE")) + return dispatch_pending_job(ctx->bridge, data); + + if (hashmap_isempty(ctx->subscribed)) + return 0; + + r = sd_json_buildo( + ¬ification, + SD_JSON_BUILD_PAIR_STRING("event", event), + SD_JSON_BUILD_PAIR_CONDITION(!!data, "data", SD_JSON_BUILD_VARIANT(data))); + if (r < 0) { + log_warning_errno(r, "Failed to build event notification, ignoring: %m"); + return 0; + } + + HASHMAP_FOREACH_KEY(filter, link, ctx->subscribed) { + if (filter && !strv_contains(filter, event)) + continue; + + r = sd_varlink_notify(link, notification); + if (r < 0) + log_warning_errno(r, "Failed to notify event subscriber, ignoring: %m"); + } + + return 0; +} + +/* Free all subscriber entries — varlink_subscriber_hash_ops handles + * close + unref for each key and strv_free for each value. */ +static void drain_event_subscribers(Hashmap **subscribed) { + assert(subscribed); + *subscribed = hashmap_free(*subscribed); +} + +static void on_qmp_disconnect(QmpClient *client, void *userdata) { + VmspawnVarlinkContext *ctx = ASSERT_PTR(userdata); + + assert(client); + + log_debug("Backend connection lost"); + + /* Propagate connection loss by closing all subscriber connections */ + drain_event_subscribers(&ctx->subscribed); +} + +int vmspawn_varlink_setup( + VmspawnVarlinkContext **ret, + VmspawnQmpBridge *bridge, + const char *runtime_dir, + char **ret_control_address) { + + _cleanup_(vmspawn_varlink_context_freep) VmspawnVarlinkContext *ctx = NULL; + _cleanup_free_ char *listen_address = NULL; + int r; + + assert(ret); + assert(bridge); + assert(runtime_dir); + + sd_event *event = qmp_client_get_event(bridge->qmp); + assert(event); + + ctx = new0(VmspawnVarlinkContext, 1); + if (!ctx) + return log_oom(); + + /* Create varlink server for VM control */ + r = varlink_server_new(&ctx->varlink_server, + SD_VARLINK_SERVER_INHERIT_USERDATA, + ctx); + if (r < 0) + return log_error_errno(r, "Failed to create varlink server: %m"); + + r = sd_varlink_server_add_interface_many( + ctx->varlink_server, + &vl_interface_io_systemd_MachineInstance, + &vl_interface_io_systemd_VirtualMachineInstance, + &vl_interface_io_systemd_QemuMachineInstance); + if (r < 0) + return log_error_errno(r, "Failed to add varlink interfaces: %m"); + + r = sd_varlink_server_bind_method_many( + ctx->varlink_server, + "io.systemd.MachineInstance.Terminate", vl_method_terminate, + "io.systemd.MachineInstance.PowerOff", vl_method_power_off, + "io.systemd.MachineInstance.Pause", vl_method_pause, + "io.systemd.MachineInstance.Resume", vl_method_resume, + "io.systemd.MachineInstance.Reboot", vl_method_reboot, + "io.systemd.MachineInstance.Describe", vl_method_describe, + "io.systemd.MachineInstance.SubscribeEvents", vl_method_subscribe_events, + "io.systemd.QemuMachineInstance.AcquireQMP", vl_method_acquire_qmp); + if (r < 0) + return log_error_errno(r, "Failed to bind varlink methods: %m"); + + r = sd_varlink_server_bind_disconnect(ctx->varlink_server, vl_disconnect); + if (r < 0) + return log_error_errno(r, "Failed to bind disconnect handler: %m"); + + listen_address = path_join(runtime_dir, "control"); + if (!listen_address) + return log_oom(); + + r = sd_varlink_server_listen_address(ctx->varlink_server, listen_address, 0600); + if (r < 0) + return log_error_errno(r, "Failed to listen on %s: %m", listen_address); + + r = sd_varlink_server_attach_event(ctx->varlink_server, event, SD_EVENT_PRIORITY_NORMAL); + if (r < 0) + return log_error_errno(r, "Failed to attach varlink server to event loop: %m"); + + ctx->bridge = bridge; + qmp_client_bind_event(ctx->bridge->qmp, on_qmp_event, ctx); + qmp_client_bind_disconnect(ctx->bridge->qmp, on_qmp_disconnect, ctx); + + log_debug("Varlink control server listening on %s", listen_address); + + if (ret_control_address) + *ret_control_address = TAKE_PTR(listen_address); + + *ret = TAKE_PTR(ctx); + return 0; +} + +VmspawnVarlinkContext* vmspawn_varlink_context_free(VmspawnVarlinkContext *ctx) { + if (!ctx) + return NULL; + + sd_varlink_server_unref(ctx->varlink_server); + vmspawn_qmp_bridge_free(ctx->bridge); + + drain_event_subscribers(&ctx->subscribed); + + return mfree(ctx); +} diff --git a/src/vmspawn/vmspawn-varlink.h b/src/vmspawn/vmspawn-varlink.h new file mode 100644 index 0000000000..1833416a56 --- /dev/null +++ b/src/vmspawn/vmspawn-varlink.h @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "cleanup-util.h" +#include "shared-forward.h" +#include "vmspawn-qmp.h" + +typedef struct VmspawnVarlinkContext VmspawnVarlinkContext; + +/* Varlink server for VM control on top of an established bridge connection */ +int vmspawn_varlink_setup( + VmspawnVarlinkContext **ret, + VmspawnQmpBridge *bridge, + const char *runtime_dir, + char **ret_control_address); + +VmspawnVarlinkContext* vmspawn_varlink_context_free(VmspawnVarlinkContext *ctx); + +DEFINE_TRIVIAL_CLEANUP_FUNC(VmspawnVarlinkContext *, vmspawn_varlink_context_free); diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index fe7d307a63..548a083fac 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -53,6 +53,7 @@ #include "machine-credential.h" #include "machine-register.h" #include "main-func.h" +#include "memfd-util.h" #include "mkdir.h" #include "namespace-util.h" #include "netif-util.h" @@ -91,14 +92,20 @@ #include "utf8.h" #include "vmspawn-mount.h" #include "vmspawn-qemu-config.h" +#include "vmspawn-qmp.h" #include "vmspawn-scope.h" #include "vmspawn-settings.h" #include "vmspawn-util.h" +#include "vmspawn-varlink.h" #define VM_TAP_HASH_KEY SD_ID128_MAKE(01,d0,c6,4c,2b,df,24,fb,c0,f8,b2,09,7d,59,b2,93) -#define DISK_SERIAL_MAX_LEN_SCSI 30 -#define DISK_SERIAL_MAX_LEN_NVME 20 +#define DISK_SERIAL_MAX_LEN_SCSI 30 +#define DISK_SERIAL_MAX_LEN_NVME 20 +#define DISK_SERIAL_MAX_LEN_VIRTIO_BLK 20 + +/* Spare pcie-root-ports reserved for future runtime hotplug beyond the pre-wired devices. */ +#define VMSPAWN_PCIE_HOTPLUG_SPARES 10u /* An enum controlling how auxiliary state for the VM are maintained, i.e. the TPM state and the EFI variable * NVRAM. */ @@ -2288,6 +2295,264 @@ static int cmdline_add_ovmf(FILE *config_file, const OvmfConfig *ovmf_config, ch return 0; } +/* Create a QMP control socketpair, add QEMU's end to pass_fds, and write the chardev + monitor + * config sections. Returns with bridge_fds populated: [0] is vmspawn's end, [1] is QEMU's end + * (also in pass_fds). FORK_CLOEXEC_OFF clears CLOEXEC on pass_fds in the child. */ +static int qemu_config_add_qmp_monitor(FILE *config_file, int bridge_fds[2], int **pass_fds, size_t *n_pass_fds) { + int r; + + assert(config_file); + assert(bridge_fds); + assert(pass_fds); + assert(n_pass_fds); + + if (socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0, bridge_fds) < 0) + return log_error_errno(errno, "Failed to create QMP socketpair: %m"); + + if (!GREEDY_REALLOC(*pass_fds, *n_pass_fds + 1)) + return log_oom(); + (*pass_fds)[(*n_pass_fds)++] = bridge_fds[1]; + + r = qemu_config_section(config_file, "chardev", "qmp", + "backend", "socket"); + if (r < 0) + return r; + + r = qemu_config_keyf(config_file, "fd", "%d", bridge_fds[1]); + if (r < 0) + return r; + + return qemu_config_section(config_file, "mon", "qmp", + "chardev", "qmp", + "mode", "control"); +} + +static int resolve_disk_driver(DiskType dt, const char *filename, DriveInfo *info) { + int r; + + assert(filename); + assert(info); + + switch (dt) { + case DISK_TYPE_VIRTIO_BLK: + info->disk_driver = "virtio-blk-pci"; + r = disk_serial(filename, DISK_SERIAL_MAX_LEN_VIRTIO_BLK, &info->serial); + if (r < 0) + return r; + break; + case DISK_TYPE_VIRTIO_SCSI: + info->disk_driver = "scsi-hd"; + r = disk_serial(filename, DISK_SERIAL_MAX_LEN_SCSI, &info->serial); + if (r < 0) + return r; + break; + case DISK_TYPE_NVME: + info->disk_driver = "nvme"; + r = disk_serial(filename, DISK_SERIAL_MAX_LEN_NVME, &info->serial); + if (r < 0) + return r; + break; + case DISK_TYPE_VIRTIO_SCSI_CDROM: + info->disk_driver = "scsi-cd"; + info->flags |= QMP_DRIVE_READ_ONLY; + r = disk_serial(filename, DISK_SERIAL_MAX_LEN_SCSI, &info->serial); + if (r < 0) + return r; + break; + default: + assert_not_reached(); + } + + return 0; +} + +static int prepare_primary_drive(const char *runtime_dir, DriveInfos *drives) { + int r; + + assert(runtime_dir); + assert(drives); + + if (!arg_image) + return 0; + + _cleanup_free_ char *image_fn = NULL; + r = path_extract_filename(arg_image, &image_fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from path '%s': %m", arg_image); + + DriveInfo *d = &drives->drives[drives->n_drives++]; + *d = (DriveInfo) { .fd = -EBADF, .overlay_fd = -EBADF }; + + r = resolve_disk_driver(arg_image_disk_type, image_fn, d); + if (r < 0) + return log_error_errno(r, "Failed to resolve disk driver for '%s': %m", image_fn); + + int open_flags = ((arg_ephemeral || FLAGS_SET(d->flags, QMP_DRIVE_READ_ONLY)) ? O_RDONLY : O_RDWR) | O_CLOEXEC | O_NOCTTY; + + _cleanup_close_ int image_fd = open(arg_image, open_flags); + if (image_fd < 0) + return log_error_errno(errno, "Failed to open '%s': %m", arg_image); + + struct stat st; + if (fstat(image_fd, &st) < 0) + return log_error_errno(errno, "Failed to stat '%s': %m", arg_image); + + r = stat_verify_regular_or_block(&st); + if (r < 0) + return log_error_errno(r, "Expected regular file or block device for image: %s", arg_image); + + d->path = arg_image; + d->format = image_format_to_string(arg_image_format); + d->node_name = strdup("vmspawn"); + if (!d->node_name) + return log_oom(); + d->fd = TAKE_FD(image_fd); + if (S_ISBLK(st.st_mode)) + d->flags |= QMP_DRIVE_BLOCK_DEVICE; + if (arg_discard_disk && !FLAGS_SET(d->flags, QMP_DRIVE_READ_ONLY)) + d->flags |= QMP_DRIVE_DISCARD; + d->flags |= QMP_DRIVE_BOOT; + + /* For ephemeral mode, create an anonymous overlay file. QEMU will format it + * as qcow2 via blockdev-create, so no filesystem path is needed. + * Skip for read-only drives (e.g. CDROM) where overlays are not meaningful. */ + if (arg_ephemeral && !FLAGS_SET(d->flags, QMP_DRIVE_READ_ONLY)) { + _cleanup_close_ int overlay_fd = open(runtime_dir, O_TMPFILE | O_RDWR | O_CLOEXEC, 0600); + if (overlay_fd < 0) { + if (!ERRNO_IS_NOT_SUPPORTED(errno)) + return log_error_errno(errno, "Failed to create ephemeral overlay in '%s': %m", runtime_dir); + + /* Fallback to memfd if O_TMPFILE is not supported */ + overlay_fd = memfd_new("vmspawn-overlay"); + if (overlay_fd < 0) + return log_error_errno(overlay_fd, "Failed to create ephemeral overlay via memfd: %m"); + } + d->overlay_fd = TAKE_FD(overlay_fd); + d->flags |= QMP_DRIVE_NO_FLUSH; + } + + return 0; +} + +static int prepare_extra_drives(DriveInfos *drives) { + int r; + + assert(drives); + + size_t extra_idx = 0; + FOREACH_ARRAY(drive, arg_extra_drives.drives, arg_extra_drives.n_drives) { + _cleanup_free_ char *drive_fn = NULL; + r = path_extract_filename(drive->path, &drive_fn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from path '%s': %m", drive->path); + + DiskType dt = drive->disk_type >= 0 ? drive->disk_type : arg_image_disk_type; + + DriveInfo *d = &drives->drives[drives->n_drives++]; + *d = (DriveInfo) { .fd = -EBADF, .overlay_fd = -EBADF }; + + r = resolve_disk_driver(dt, drive_fn, d); + if (r < 0) + return log_error_errno(r, "Failed to resolve disk driver for '%s': %m", drive_fn); + + _cleanup_close_ int drive_fd = open(drive->path, (FLAGS_SET(d->flags, QMP_DRIVE_READ_ONLY) ? O_RDONLY : O_RDWR) | O_CLOEXEC | O_NOCTTY); + if (drive_fd < 0) + return log_error_errno(errno, "Failed to open '%s': %m", drive->path); + + struct stat drive_st; + if (fstat(drive_fd, &drive_st) < 0) + return log_error_errno(errno, "Failed to stat '%s': %m", drive->path); + r = stat_verify_regular_or_block(&drive_st); + if (r < 0) + return log_error_errno(r, "Expected regular file or block device, not '%s'.", drive->path); + if (S_ISBLK(drive_st.st_mode) && drive->format == IMAGE_FORMAT_QCOW2) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "Block device '%s' cannot be used with 'qcow2' format, only 'raw' is supported.", + drive->path); + + d->path = drive->path; + d->format = image_format_to_string(drive->format); + d->fd = TAKE_FD(drive_fd); + if (S_ISBLK(drive_st.st_mode)) + d->flags |= QMP_DRIVE_BLOCK_DEVICE; + d->flags |= QMP_DRIVE_NO_FLUSH; + + if (asprintf(&d->node_name, "vmspawn_extra_%zu", extra_idx++) < 0) + return log_oom(); + } + + return 0; +} + +/* Assign PCIe root port names to devices. The ports were pre-allocated in the config + * file. Each PCI device that will be hotplugged via QMP device_add gets a port. */ +static int assign_pcie_ports(MachineConfig *c) { + assert(c); + + if (!ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS) + return 0; + + DriveInfos *drives = &c->drives; + NetworkInfo *network = &c->network; + VirtiofsInfos *virtiofs = &c->virtiofs; + VsockInfo *vsock = &c->vsock; + + size_t port = 0; + + /* Drives: non-SCSI drives get individual ports, SCSI controller gets one port */ + bool need_scsi = false; + FOREACH_ARRAY(d, drives->drives, drives->n_drives) { + if (STR_IN_SET(d->disk_driver, "scsi-hd", "scsi-cd")) { + need_scsi = true; + continue; + } + if (asprintf(&d->pcie_port, "vmspawn-pcieport-%zu", port++) < 0) + return log_oom(); + } + if (need_scsi) + if (asprintf(&drives->scsi_pcie_port, "vmspawn-pcieport-%zu", port++) < 0) + return log_oom(); + + if (network->type) + if (asprintf(&network->pcie_port, "vmspawn-pcieport-%zu", port++) < 0) + return log_oom(); + + FOREACH_ARRAY(v, virtiofs->entries, virtiofs->n_entries) + if (asprintf(&v->pcie_port, "vmspawn-pcieport-%zu", port++) < 0) + return log_oom(); + + if (vsock->fd >= 0) + if (asprintf(&vsock->pcie_port, "vmspawn-pcieport-%zu", port++) < 0) + return log_oom(); + + return 0; +} + +static int prepare_device_info(const char *runtime_dir, MachineConfig *c) { + int r; + + assert(runtime_dir); + assert(c); + + DriveInfos *drives = &c->drives; + + /* Build drive info for QMP-based setup. vmspawn opens all image files and + * passes fds to QEMU via add-fd — QEMU never needs filesystem access. */ + drives->drives = new0(DriveInfo, 1 + arg_extra_drives.n_drives); + if (!drives->drives) + return log_oom(); + + r = prepare_primary_drive(runtime_dir, drives); + if (r < 0) + return r; + + r = prepare_extra_drives(drives); + if (r < 0) + return r; + + return assign_pcie_ports(c); +} + static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { _cleanup_(ovmf_config_freep) OvmfConfig *ovmf_config = NULL; _cleanup_free_ char *qemu_binary = NULL, *mem = NULL; @@ -2297,6 +2562,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { _cleanup_close_ int notify_sock_fd = -EBADF; _cleanup_strv_free_ char **cmdline = NULL; _cleanup_free_ int *pass_fds = NULL; + _cleanup_(machine_config_done) MachineConfig config = { + .network = { .fd = -EBADF }, + .vsock = { .fd = -EBADF }, + }; sd_event_source **children = NULL; size_t n_children = 0, n_pass_fds = 0; int r; @@ -2386,8 +2655,8 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (asprintf(&mem, "%" PRIu64 "M", DIV_ROUND_UP(arg_ram, U64_MB)) < 0) return log_oom(); - /* Create our runtime directory. We need this for the QEMU config file, TPM state, virtiofsd - * sockets, runtime mounts, and SSH key material. + /* Create our runtime directory. We need this for the QMP varlink control socket, the QEMU + * config file, TPM state, virtiofsd sockets, runtime mounts, and SSH key material. * * Use runtime_directory() (not _generic()) so that when vmspawn runs in a systemd service * with RuntimeDirectory= set, we pick up $RUNTIME_DIRECTORY and place our stuff into the @@ -2522,8 +2791,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return r; } - /* Start building the cmdline for items that must remain as command line arguments */ + /* Start building the cmdline for items that must remain as command line arguments. + * -S starts QEMU with vCPUs paused — we set up devices via QMP then resume with "cont". */ cmdline = strv_new(qemu_binary, + "-S", "-no-user-config"); if (!cmdline) return log_oom(); @@ -2533,8 +2804,14 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return log_oom(); _cleanup_close_ int delegate_userns_fd = -EBADF, tap_fd = -EBADF; + _cleanup_free_ char *tap_name = NULL; + struct ether_addr mac_vm = {}; + if (arg_network_stack == NETWORK_STACK_TAP) { if (have_effective_cap(CAP_NET_ADMIN) <= 0) { + /* Without CAP_NET_ADMIN we use nsresourced to create a TAP device. + * The TAP fd is passed to QEMU via QMP getfd + SCM_RIGHTS after + * the handshake, then referenced by name in netdev_add. */ delegate_userns_fd = userns_acquire_self_root(); if (delegate_userns_fd < 0) return log_error_errno(delegate_userns_fd, "Failed to acquire userns: %m"); @@ -2556,65 +2833,39 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (tap_fd < 0) return log_error_errno(tap_fd, "Failed to allocate network tap device: %m"); - r = strv_extend(&cmdline, "-netdev"); - if (r < 0) - return log_oom(); - - r = strv_extendf(&cmdline, "tap,id=net0,fd=%i", tap_fd); - if (r < 0) - return log_oom(); - - r = strv_extend_many(&cmdline, "-device", "virtio-net-pci,netdev=net0"); - if (r < 0) - return log_oom(); - - if (!GREEDY_REALLOC(pass_fds, n_pass_fds + 1)) - return log_oom(); - - pass_fds[n_pass_fds++] = tap_fd; + config.network = (NetworkInfo) { + .type = "tap", + .fd = TAKE_FD(tap_fd), + }; } else { - _cleanup_free_ char *tap_name = NULL; - struct ether_addr mac_vm = {}; - + /* With CAP_NET_ADMIN we create the TAP interface by name. + * Configure via QMP after QEMU starts. */ tap_name = strjoin("vt-", arg_machine); if (!tap_name) return log_oom(); (void) net_shorten_ifname(tap_name, /* check_naming_scheme= */ false); - if (ether_addr_is_null(&arg_network_provided_mac)){ + if (ether_addr_is_null(&arg_network_provided_mac)) { r = net_generate_mac(arg_machine, &mac_vm, VM_TAP_HASH_KEY, 0); if (r < 0) return log_error_errno(r, "Failed to generate predictable MAC address for VM side: %m"); } else mac_vm = arg_network_provided_mac; - r = qemu_config_section(config_file, "netdev", "net0", - "type", "tap", - "ifname", tap_name, - "script", "no", - "downscript", "no"); - if (r < 0) - return r; - - r = qemu_config_section(config_file, "device", "nic0", - "driver", "virtio-net-pci", - "netdev", "net0", - "mac", ETHER_ADDR_TO_STR(&mac_vm)); - if (r < 0) - return r; + config.network = (NetworkInfo) { + .type = "tap", + .ifname = TAKE_PTR(tap_name), + .mac = mac_vm, + .mac_set = true, + .fd = -EBADF, + }; } } else if (arg_network_stack == NETWORK_STACK_USER) { - r = qemu_config_section(config_file, "netdev", "net0", - "type", "user"); - if (r < 0) - return r; - - r = qemu_config_section(config_file, "device", "nic0", - "driver", "virtio-net-pci", - "netdev", "net0"); - if (r < 0) - return r; + config.network = (NetworkInfo) { + .type = "user", + .fd = -EBADF, + }; } else { r = strv_extend_many(&cmdline, "-nic", "none"); if (r < 0) @@ -2671,40 +2922,21 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return r; } - _cleanup_close_ int child_vsock_fd = -EBADF; unsigned child_cid = arg_vsock_cid; if (use_vsock) { - int device_fd = vhost_device_fd; + config.vsock.fd = TAKE_FD(vhost_device_fd); - if (device_fd < 0) { - child_vsock_fd = open("/dev/vhost-vsock", O_RDWR|O_CLOEXEC); - if (child_vsock_fd < 0) + if (config.vsock.fd < 0) { + config.vsock.fd = open("/dev/vhost-vsock", O_RDWR|O_CLOEXEC); + if (config.vsock.fd < 0) return log_error_errno(errno, "Failed to open /dev/vhost-vsock as read/write: %m"); - - device_fd = child_vsock_fd; } - r = vsock_fix_child_cid(device_fd, &child_cid, arg_machine); + r = vsock_fix_child_cid(config.vsock.fd, &child_cid, arg_machine); if (r < 0) return log_error_errno(r, "Failed to fix CID for the guest VSOCK socket: %m"); - r = qemu_config_section(config_file, "device", "vsock0", - "driver", "vhost-vsock-pci"); - if (r < 0) - return r; - - r = qemu_config_keyf(config_file, "guest-cid", "%u", child_cid); - if (r < 0) - return r; - - r = qemu_config_keyf(config_file, "vhostfd", "%d", device_fd); - if (r < 0) - return r; - - if (!GREEDY_REALLOC(pass_fds, n_pass_fds + 1)) - return log_oom(); - - pass_fds[n_pass_fds++] = device_fd; + config.vsock.cid = child_cid; } /* -cpu stays on cmdline since not all flags are supported in config */ @@ -2846,24 +3078,6 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { } } - bool need_scsi_controller = - IN_SET(arg_image_disk_type, DISK_TYPE_VIRTIO_SCSI, DISK_TYPE_VIRTIO_SCSI_CDROM) && arg_image; - if (!need_scsi_controller) - FOREACH_ARRAY(drive, arg_extra_drives.drives, arg_extra_drives.n_drives) { - DiskType dt = drive->disk_type >= 0 ? drive->disk_type : arg_image_disk_type; - if (IN_SET(dt, DISK_TYPE_VIRTIO_SCSI, DISK_TYPE_VIRTIO_SCSI_CDROM)) { - need_scsi_controller = true; - break; - } - } - - if (need_scsi_controller) { - r = qemu_config_section(config_file, "device", "vmspawn_scsi", - "driver", "virtio-scsi-pci"); - if (r < 0) - return r; - } - if (arg_image) { assert(!arg_directory); @@ -2875,74 +3089,6 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { arg_image); } - if (arg_image_disk_type == DISK_TYPE_VIRTIO_SCSI_CDROM) - r = qemu_config_section(config_file, "drive", "vmspawn", - "if", "none", - "file", arg_image, - "format", image_format_to_string(arg_image_format), - "media", "cdrom", - "readonly", "on"); - else - r = qemu_config_section(config_file, "drive", "vmspawn", - "if", "none", - "file", arg_image, - "format", image_format_to_string(arg_image_format), - "discard", on_off(arg_discard_disk), - "snapshot", on_off(arg_ephemeral)); - if (r < 0) - return r; - - _cleanup_free_ char *image_fn = NULL; - r = path_extract_filename(arg_image, &image_fn); - if (r < 0) - return log_error_errno(r, "Failed to extract filename from path '%s': %m", image_fn); - - const char *disk_driver; - _cleanup_free_ char *serial = NULL; - - switch (arg_image_disk_type) { - case DISK_TYPE_VIRTIO_BLK: - disk_driver = "virtio-blk-pci"; - serial = strdup(image_fn); - if (!serial) - return log_oom(); - break; - case DISK_TYPE_VIRTIO_SCSI: - disk_driver = "scsi-hd"; - r = disk_serial(image_fn, DISK_SERIAL_MAX_LEN_SCSI, &serial); - if (r < 0) - return log_oom(); - break; - case DISK_TYPE_NVME: - disk_driver = "nvme"; - r = disk_serial(image_fn, DISK_SERIAL_MAX_LEN_NVME, &serial); - if (r < 0) - return log_oom(); - break; - case DISK_TYPE_VIRTIO_SCSI_CDROM: - disk_driver = "scsi-cd"; - r = disk_serial(image_fn, DISK_SERIAL_MAX_LEN_SCSI, &serial); - if (r < 0) - return log_oom(); - break; - default: - assert_not_reached(); - } - - r = qemu_config_section(config_file, "device", "vmspawn-disk", - "driver", disk_driver, - "drive", "vmspawn", - "bootindex", "1", - "serial", serial); - if (r < 0) - return r; - - if (IN_SET(arg_image_disk_type, DISK_TYPE_VIRTIO_SCSI, DISK_TYPE_VIRTIO_SCSI_CDROM)) { - r = qemu_config_key(config_file, "bus", "vmspawn_scsi.0"); - if (r < 0) - return r; - } - if (arg_image_disk_type != DISK_TYPE_VIRTIO_SCSI_CDROM) { r = grow_image(arg_image, arg_grow_image); if (r < 0) @@ -3012,104 +3158,25 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { pidref_done(&child); children[n_children++] = TAKE_PTR(source); - r = qemu_config_section(config_file, "chardev", "rootdir", - "backend", "socket", - "path", listen_address); - if (r < 0) - return r; + _cleanup_free_ char *id = strdup("rootdir"), *tag = strdup("root"); + if (!id || !tag) + return log_oom(); - r = qemu_config_section(config_file, "device", "rootdir", - "driver", "vhost-user-fs-pci", - "queue-size", "1024", - "chardev", "rootdir", - "tag", "root"); - if (r < 0) - return r; + if (!GREEDY_REALLOC(config.virtiofs.entries, config.virtiofs.n_entries + 1)) + return log_oom(); + + config.virtiofs.entries[config.virtiofs.n_entries++] = (VirtiofsInfo) { + .id = TAKE_PTR(id), + .socket_path = TAKE_PTR(listen_address), + .tag = TAKE_PTR(tag), + }; if (strv_extend(&arg_kernel_cmdline_extra, "root=root rootfstype=virtiofs rw") < 0) return log_oom(); } - size_t i = 0; - FOREACH_ARRAY(drive, arg_extra_drives.drives, arg_extra_drives.n_drives) { - if (strv_extend(&cmdline, "-blockdev") < 0) - return log_oom(); - - _cleanup_free_ char *escaped_drive = escape_qemu_value(drive->path); - if (!escaped_drive) - return log_oom(); - - struct stat st; - if (stat(drive->path, &st) < 0) - return log_error_errno(errno, "Failed to stat '%s': %m", drive->path); - - const char *driver = NULL; - if (S_ISREG(st.st_mode)) - driver = "file"; - else if (S_ISBLK(st.st_mode)) { - if (drive->format == IMAGE_FORMAT_QCOW2) - return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), - "Block device '%s' cannot be used with 'qcow2' format, only 'raw' is supported.", - drive->path); - driver = "host_device"; - } else - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Expected regular file or block device, not '%s'.", drive->path); - - DiskType dt = drive->disk_type >= 0 ? drive->disk_type : arg_image_disk_type; - - if (strv_extendf(&cmdline, "driver=%s,cache.direct=off,cache.no-flush=on,file.driver=%s,file.filename=%s,node-name=vmspawn_extra_%zu%s", - image_format_to_string(drive->format), driver, escaped_drive, i, - dt == DISK_TYPE_VIRTIO_SCSI_CDROM ? ",read-only=on" : "") < 0) - return log_oom(); - - _cleanup_free_ char *drive_fn = NULL; - r = path_extract_filename(drive->path, &drive_fn); - if (r < 0) - return log_error_errno(r, "Failed to extract filename from path '%s': %m", drive->path); - - _cleanup_free_ char *escaped_drive_fn = escape_qemu_value(drive_fn); - if (!escaped_drive_fn) - return log_oom(); - - if (strv_extend(&cmdline, "-device") < 0) - return log_oom(); - - switch (dt) { - case DISK_TYPE_VIRTIO_BLK: - if (strv_extendf(&cmdline, "virtio-blk-pci,drive=vmspawn_extra_%zu,serial=%s", i++, escaped_drive_fn) < 0) - return log_oom(); - break; - case DISK_TYPE_VIRTIO_SCSI: { - _cleanup_free_ char *serial = NULL; - r = disk_serial(escaped_drive_fn, DISK_SERIAL_MAX_LEN_SCSI, &serial); - if (r < 0) - return log_oom(); - if (strv_extendf(&cmdline, "scsi-hd,bus=vmspawn_scsi.0,drive=vmspawn_extra_%zu,serial=%s", i++, serial) < 0) - return log_oom(); - break; - } - case DISK_TYPE_NVME: { - _cleanup_free_ char *serial = NULL; - r = disk_serial(escaped_drive_fn, DISK_SERIAL_MAX_LEN_NVME, &serial); - if (r < 0) - return log_oom(); - if (strv_extendf(&cmdline, "nvme,drive=vmspawn_extra_%zu,serial=%s", i++, serial) < 0) - return log_oom(); - break; - } - case DISK_TYPE_VIRTIO_SCSI_CDROM: { - _cleanup_free_ char *serial = NULL; - r = disk_serial(escaped_drive_fn, DISK_SERIAL_MAX_LEN_SCSI, &serial); - if (r < 0) - return log_oom(); - if (strv_extendf(&cmdline, "scsi-cd,bus=vmspawn_scsi.0,drive=vmspawn_extra_%zu,serial=%s", i++, serial) < 0) - return log_oom(); - break; - } - default: - assert_not_reached(); - } - } + /* Extra drive validation is done in the post-fork drive info construction loop + * to avoid stat()'ing each drive twice. */ if (!IN_SET(arg_console_mode, CONSOLE_GUI, CONSOLE_HEADLESS)) { r = strv_prepend(&arg_kernel_cmdline_extra, @@ -3141,7 +3208,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { for (size_t j = 0; j < arg_runtime_mounts.n_mounts; j++) { RuntimeMount *m = arg_runtime_mounts.mounts + j; - _cleanup_free_ char *listen_address = NULL; + _cleanup_free_ char *listen_address = NULL, *id = NULL, *tag = NULL; _cleanup_(fork_notify_terminate) PidRef child = PIDREF_NULL; if (!GREEDY_REALLOC(children, n_children + 1)) @@ -3167,23 +3234,12 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { pidref_done(&child); children[n_children++] = TAKE_PTR(source); - _cleanup_free_ char *id = NULL; if (asprintf(&id, "mnt%zu", j) < 0) return log_oom(); - r = qemu_config_section(config_file, "chardev", id, - "backend", "socket", - "path", listen_address); - if (r < 0) - return r; - - r = qemu_config_section(config_file, "device", id, - "driver", "vhost-user-fs-pci", - "queue-size", "1024", - "chardev", id, - "tag", id); - if (r < 0) - return r; + tag = strdup(id); + if (!tag) + return log_oom(); /* fstab uses whitespace as field separator, so octal-escape spaces in paths */ _cleanup_free_ char *escaped_target = octescape_full(m->target, SIZE_MAX, " \t"); @@ -3193,6 +3249,15 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (strextendf(&fstab_extra, "%s %s virtiofs %s,x-initrd.mount\n", id, escaped_target, m->read_only ? "ro" : "rw") < 0) return log_oom(); + + if (!GREEDY_REALLOC(config.virtiofs.entries, config.virtiofs.n_entries + 1)) + return log_oom(); + + config.virtiofs.entries[config.virtiofs.n_entries++] = (VirtiofsInfo) { + .id = TAKE_PTR(id), + .socket_path = TAKE_PTR(listen_address), + .tag = TAKE_PTR(tag), + }; } if (fstab_extra) { @@ -3443,6 +3508,63 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (r < 0) return r; + _cleanup_close_pair_ int bridge_fds[2] = EBADF_PAIR; + r = qemu_config_add_qmp_monitor(config_file, bridge_fds, &pass_fds, &n_pass_fds); + if (r < 0) + return r; + + /* Pre-allocate PCIe root ports for QMP device_add hotplug. On PCIe machine types + * (q35, virt), QMP device_add is always hotplug — the root bus (pcie.0) does not support + * it. Each root port provides one slot for hotplug. We create enough ports for all devices + * that will be set up via QMP, plus VMSPAWN_PCIE_HOTPLUG_SPARES spare ports for future + * runtime hotplug. */ + if (ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS) { + /* Count maximum possible PCI devices: root image + extra drives + SCSI controller + + * network + virtiofs mounts + vsock. The actual count may be lower (e.g. no network, + * no SCSI), but unused ports have negligible overhead. */ + size_t n_pcie_ports = 1 + + arg_extra_drives.n_drives + /* drives */ + 1 + /* SCSI controller */ + 1 + /* network */ + (arg_directory ? 1 : 0) + /* rootdir virtiofs */ + arg_runtime_mounts.n_mounts + /* extra virtiofs mounts */ + 1 + /* vsock */ + VMSPAWN_PCIE_HOTPLUG_SPARES; /* reserved for future hotplug */ + + /* Guard the unsigned subtraction below against future refactors that might drop the + * fixed additions. */ + assert(n_pcie_ports >= VMSPAWN_PCIE_HOTPLUG_SPARES); + + /* QEMU's pcie-root-port chassis/slot are uint8_t — i+1 must fit. */ + if (n_pcie_ports > UINT8_MAX) + return log_error_errno(SYNTHETIC_ERRNO(E2BIG), + "Too many PCIe root ports requested (%zu, max 255). " + "Reduce the number of extra drives or runtime mounts.", + n_pcie_ports); + + size_t n_builtin_ports = n_pcie_ports - VMSPAWN_PCIE_HOTPLUG_SPARES; + for (size_t i = 0; i < n_pcie_ports; i++) { + char id[STRLEN("vmspawn-hotplug-pci-root-port-") + DECIMAL_STR_MAX(size_t)]; + if (i < n_builtin_ports) + xsprintf(id, "vmspawn-pcieport-%zu", i); + else + xsprintf(id, "vmspawn-hotplug-pci-root-port-%zu", i - n_builtin_ports); + + r = qemu_config_section(config_file, "device", id, + "driver", "pcie-root-port"); + if (r < 0) + return r; + + r = qemu_config_keyf(config_file, "chassis", "%zu", i + 1); + if (r < 0) + return r; + + r = qemu_config_keyf(config_file, "slot", "%zu", i + 1); + if (r < 0) + return r; + } + } + /* Finalize the config file and add -readconfig to the cmdline */ r = fflush_and_check(config_file); if (r < 0) @@ -3485,7 +3607,8 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return log_error_errno(child_pty, "Failed to open PTY slave: %m"); } - _cleanup_(pidref_done) PidRef child_pidref = PIDREF_NULL; + /* SIGTERM, not SIGKILL — let QEMU flush state on error-path early exits. */ + _cleanup_(pidref_done_sigterm_wait) PidRef child_pidref = PIDREF_NULL; r = pidref_safe_fork_full( qemu_binary, child_pty >= 0 ? (const int[]) { child_pty, child_pty, child_pty } : NULL, @@ -3507,10 +3630,59 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { _exit(EXIT_FAILURE); } - /* Close relevant fds we passed to qemu in the parent. We don't need them anymore. */ + /* Close QEMU's end of the QMP socketpair in the parent. We don't need it anymore. */ child_pty = safe_close(child_pty); - child_vsock_fd = safe_close(child_vsock_fd); - tap_fd = safe_close(tap_fd); + bridge_fds[1] = safe_close(bridge_fds[1]); + + r = prepare_device_info(runtime_dir, &config); + if (r < 0) + return r; + + /* Connect to VMM backend */ + _cleanup_(vmspawn_qmp_bridge_freep) VmspawnQmpBridge *bridge = NULL; + r = vmspawn_qmp_init(&bridge, bridge_fds[0], event); + if (r < 0) + return r; + + TAKE_FD(bridge_fds[0]); + + /* Probe QEMU feature availability synchronously before device setup consumes the flags. */ + r = vmspawn_qmp_probe_features(bridge); + if (r < 0) + return r; + + /* Device setup — all before resuming vCPUs */ + r = vmspawn_qmp_setup_drives(bridge, &config.drives); + if (r < 0) + return r; + + if (config.network.type) { + r = vmspawn_qmp_setup_network(bridge, &config.network); + if (r < 0) + return r; + } + + r = vmspawn_qmp_setup_virtiofs(bridge, &config.virtiofs); + if (r < 0) + return r; + + r = vmspawn_qmp_setup_vsock(bridge, &config.vsock); + if (r < 0) + return r; + + /* Resume vCPUs and switch to async event processing */ + r = vmspawn_qmp_start(bridge); + if (r < 0) + return r; + + /* Varlink server for VM control */ + _cleanup_(vmspawn_varlink_context_freep) VmspawnVarlinkContext *varlink_ctx = NULL; + _cleanup_free_ char *control_address = NULL; + r = vmspawn_varlink_setup(&varlink_ctx, bridge, runtime_dir, &control_address); + if (r < 0) + return r; + + TAKE_PTR(bridge); if (!arg_keep_unit) { /* When a new scope is created for this container, then we'll be registered as its controller, in which @@ -3575,6 +3747,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { .vsock_cid = child_cid, .ssh_address = child_cid != VMADDR_CID_ANY ? vm_address : NULL, .ssh_private_key_path = ssh_private_key_path, + .control_address = control_address, .allocate_unit = !arg_keep_unit, }; diff --git a/test/units/TEST-87-AUX-UTILS-VM.vmspawn-drives.sh b/test/units/TEST-87-AUX-UTILS-VM.vmspawn-drives.sh new file mode 100755 index 0000000000..5ec2fd2f7b --- /dev/null +++ b/test/units/TEST-87-AUX-UTILS-VM.vmspawn-drives.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# Test vmspawn QMP-based multi-drive setup and ephemeral overlay. +# +# Exercises the async QMP command pipeline with multiple drives: +# - Multiple fdset allocations (counter correctness) +# - Pipelined blockdev-add commands (FIFO ordering) +# - io_uring retry callbacks (if QEMU lacks io_uring support) +# - Multiple device_add commands +# - blockdev-create job watching with deferred continuation (ephemeral) +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +if [[ -v ASAN_OPTIONS ]]; then + echo "vmspawn launches QEMU which doesn't work under ASan, skipping" + exit 0 +fi + +if ! command -v systemd-vmspawn >/dev/null 2>&1; then + echo "systemd-vmspawn not found, skipping" + exit 0 +fi + +if ! find_qemu_binary; then + echo "QEMU not found, skipping" + exit 0 +fi + +if ! command -v mke2fs >/dev/null 2>&1; then + echo "mke2fs not found, skipping" + exit 0 +fi + +# Find a kernel for direct boot +KERNEL="" +for k in /usr/lib/modules/"$(uname -r)"/vmlinuz /boot/vmlinuz-"$(uname -r)" /boot/vmlinuz; do + if [[ -f "$k" ]]; then + KERNEL="$k" + break + fi +done + +if [[ -z "$KERNEL" ]]; then + echo "No kernel found for direct VM boot, skipping" + exit 0 +fi +echo "Using kernel: $KERNEL" + +WORKDIR="$(mktemp -d)" + +at_exit() { + set +e + for m in "${MACHINE_MULTI:-}" "${MACHINE_EPHEMERAL:-}"; do + [[ -n "$m" ]] || continue + if machinectl status "$m" &>/dev/null; then + machinectl terminate "$m" 2>/dev/null + timeout 10 bash -c "while machinectl status '$m' &>/dev/null; do sleep .5; done" 2>/dev/null + fi + done + [[ -n "${VMSPAWN_MULTI_PID:-}" ]] && kill "$VMSPAWN_MULTI_PID" 2>/dev/null && wait "$VMSPAWN_MULTI_PID" 2>/dev/null + [[ -n "${VMSPAWN_EPHEMERAL_PID:-}" ]] && kill "$VMSPAWN_EPHEMERAL_PID" 2>/dev/null && wait "$VMSPAWN_EPHEMERAL_PID" 2>/dev/null + rm -rf "$WORKDIR" +} +trap at_exit EXIT + +# Create a minimal root filesystem directory, then bake it into a raw ext4 image. +# The guest doesn't need to fully boot — 'sleep infinity' keeps QEMU alive for QMP testing. +mkdir -p "$WORKDIR/rootfs/sbin" +cat >"$WORKDIR/rootfs/sbin/init" <<'INITEOF' +#!/bin/sh +exec sleep infinity +INITEOF +chmod +x "$WORKDIR/rootfs/sbin/init" + +truncate -s 256M "$WORKDIR/root.raw" +mke2fs -t ext4 -q -d "$WORKDIR/rootfs" "$WORKDIR/root.raw" + +# Create extra raw drive images (different sizes to be distinguishable) +truncate -s 64M "$WORKDIR/extra1.raw" +truncate -s 32M "$WORKDIR/extra2.raw" + +wait_for_machine() { + local machine="$1" pid="$2" log="$3" + timeout 30 bash -c " + while ! machinectl list --no-legend 2>/dev/null | grep >/dev/null '$machine'; do + if ! kill -0 $pid 2>/dev/null; then + echo 'vmspawn exited before machine registration' + cat '$log' + exit 1 + fi + sleep .5 + done + " +} + +# --- Test 1: Multi-drive setup (root + 2 extra drives) --- +# Verifies that --image with multiple --extra-drive flags works with the async +# QMP pipeline. Three drives means three fdset allocations, three blockdev-add +# file nodes (each with io_uring retry), three blockdev-add format nodes, and +# three device_add commands — all pipelined without waiting for responses. + +MACHINE_MULTI="test-vmspawn-drives-$$" +systemd-vmspawn \ + --machine="$MACHINE_MULTI" \ + --ram=256M \ + --image="$WORKDIR/root.raw" \ + --extra-drive="$WORKDIR/extra1.raw" \ + --extra-drive="$WORKDIR/extra2.raw" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + root=/dev/vda rw \ + &>"$WORKDIR/vmspawn-multi.log" & +VMSPAWN_MULTI_PID=$! + +wait_for_machine "$MACHINE_MULTI" "$VMSPAWN_MULTI_PID" "$WORKDIR/vmspawn-multi.log" +echo "Multi-drive machine '$MACHINE_MULTI' registered with machined" + +# Verify varlink control address is present and the VM is running +VARLINK_ADDR=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$MACHINE_MULTI\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR" "null" + +STATUS=$(varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}') +echo "$STATUS" | jq -e '.running == true' +echo "Multi-drive VM running — async QMP drive pipeline succeeded" + +# Verify no on_setup_complete failures in the vmspawn log +if grep -E '(add-fd|blockdev-add|blockdev-create|device_add|getfd|netdev_add|chardev-add) failed:' "$WORKDIR/vmspawn-multi.log"; then + echo "Full vmspawn log:" + cat "$WORKDIR/vmspawn-multi.log" + exit 1 +fi +echo "No QMP device setup errors in log" + +machinectl terminate "$MACHINE_MULTI" +timeout 10 bash -c "while machinectl status '$MACHINE_MULTI' &>/dev/null; do sleep .5; done" +timeout 10 bash -c "while kill -0 '$VMSPAWN_MULTI_PID' 2>/dev/null; do sleep .5; done" +echo "Multi-drive VM terminated cleanly" + +# --- Test 2: Ephemeral overlay (blockdev-create job continuation) --- +# Verifies that --image with --ephemeral works. This is the most complex async +# path: blockdev-create returns immediately, the qcow2 overlay is formatted in a +# background job, JOB_STATUS_CHANGE events are watched, and when the job +# concludes the deferred continuation fires blockdev-add (overlay format) + +# device_add. If any step fails, the root drive is never attached and the kernel +# panics — vmspawn exits without registering. + +MACHINE_EPHEMERAL="test-vmspawn-ephemeral-$$" +systemd-vmspawn \ + --machine="$MACHINE_EPHEMERAL" \ + --ram=256M \ + --image="$WORKDIR/root.raw" \ + --ephemeral \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + root=/dev/vda rw \ + &>"$WORKDIR/vmspawn-ephemeral.log" & +VMSPAWN_EPHEMERAL_PID=$! + +wait_for_machine "$MACHINE_EPHEMERAL" "$VMSPAWN_EPHEMERAL_PID" "$WORKDIR/vmspawn-ephemeral.log" +echo "Ephemeral machine '$MACHINE_EPHEMERAL' registered with machined" + +VARLINK_ADDR_E=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$MACHINE_EPHEMERAL\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR_E" "null" + +STATUS_E=$(varlinkctl call "$VARLINK_ADDR_E" io.systemd.MachineInstance.Describe '{}') +echo "$STATUS_E" | jq -e '.running == true' +echo "Ephemeral VM running — blockdev-create job continuation succeeded" + +if grep -E '(add-fd|blockdev-add|blockdev-create|device_add|getfd|netdev_add|chardev-add) failed:' "$WORKDIR/vmspawn-ephemeral.log"; then + echo "Full vmspawn log:" + cat "$WORKDIR/vmspawn-ephemeral.log" + exit 1 +fi +echo "No QMP device setup errors in ephemeral log" + +machinectl terminate "$MACHINE_EPHEMERAL" +timeout 10 bash -c "while machinectl status '$MACHINE_EPHEMERAL' &>/dev/null; do sleep .5; done" +timeout 10 bash -c "while kill -0 '$VMSPAWN_EPHEMERAL_PID' 2>/dev/null; do sleep .5; done" +echo "Ephemeral VM terminated cleanly" + +echo "All vmspawn drive setup tests passed" diff --git a/test/units/TEST-87-AUX-UTILS-VM.vmspawn.sh b/test/units/TEST-87-AUX-UTILS-VM.vmspawn.sh new file mode 100755 index 0000000000..52de5b2f20 --- /dev/null +++ b/test/units/TEST-87-AUX-UTILS-VM.vmspawn.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# Test vmspawn QMP-varlink bridge and machinectl VM control verbs. +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +if [[ -v ASAN_OPTIONS ]]; then + echo "vmspawn launches QEMU which doesn't work under ASan, skipping" + exit 0 +fi + +if ! command -v systemd-vmspawn >/dev/null 2>&1; then + echo "systemd-vmspawn not found, skipping" + exit 0 +fi + +if ! find_qemu_binary; then + echo "QEMU not found, skipping" + exit 0 +fi + +# --directory= needs virtiofsd (on Fedora it lives in /usr/libexec, not in PATH) +if ! command -v virtiofsd >/dev/null 2>&1 && + ! test -x /usr/libexec/virtiofsd && + ! test -x /usr/lib/virtiofsd; then + echo "virtiofsd not found, skipping" + exit 0 +fi + +# Find a kernel for direct boot +KERNEL="" +for k in /usr/lib/modules/"$(uname -r)"/vmlinuz /boot/vmlinuz-"$(uname -r)" /boot/vmlinuz; do + if [[ -f "$k" ]]; then + KERNEL="$k" + break + fi +done + +if [[ -z "$KERNEL" ]]; then + echo "No kernel found for direct VM boot, skipping" + exit 0 +fi +echo "Using kernel: $KERNEL" + +MACHINE="test-vmspawn-qmp-$$" +WORKDIR="$(mktemp -d)" + +at_exit() { + set +e + + for m in "$MACHINE" "${MACHINE2:-}" "${STRESS_MACHINE:-}"; do + [[ -n "$m" ]] || continue + if machinectl status "$m" &>/dev/null; then + machinectl terminate "$m" 2>/dev/null + timeout 10 bash -c "while machinectl status '$m' &>/dev/null; do sleep .5; done" 2>/dev/null + fi + done + + [[ -n "${SUBSCRIBE_ALL_PID:-}" ]] && kill "$SUBSCRIBE_ALL_PID" 2>/dev/null && wait "$SUBSCRIBE_ALL_PID" 2>/dev/null + [[ -n "${SUBSCRIBE_FILTER_PID:-}" ]] && kill "$SUBSCRIBE_FILTER_PID" 2>/dev/null && wait "$SUBSCRIBE_FILTER_PID" 2>/dev/null + [[ -n "${STRESS_PID:-}" ]] && kill "$STRESS_PID" 2>/dev/null && wait "$STRESS_PID" 2>/dev/null + [[ -n "${VMSPAWN_PID:-}" ]] && kill "$VMSPAWN_PID" 2>/dev/null && wait "$VMSPAWN_PID" 2>/dev/null + [[ -n "${VMSPAWN2_PID:-}" ]] && kill "$VMSPAWN2_PID" 2>/dev/null && wait "$VMSPAWN2_PID" 2>/dev/null + rm -rf "$WORKDIR" +} +trap at_exit EXIT + +# Create a minimal root filesystem. The guest does not need to fully boot -- we only need QEMU running +# with QMP. A trivial init that sleeps is sufficient. +mkdir -p "$WORKDIR/root/sbin" +cat >"$WORKDIR/root/sbin/init" <<'EOF' +#!/bin/sh +exec sleep infinity +EOF +chmod +x "$WORKDIR/root/sbin/init" + +# Wait for a vmspawn machine to register with machined. +# Skips the test gracefully if vmspawn fails due to missing vhost-user-fs support (nested VM). +wait_for_machine() { + local machine="$1" pid="$2" log="$3" + timeout 30 bash -c " + while ! machinectl list --no-legend 2>/dev/null | grep >/dev/null '$machine'; do + if ! kill -0 $pid 2>/dev/null; then + if grep >/dev/null 'virtiofs.*QMP\|vhost-user-fs-pci' '$log'; then + echo 'vhost-user-fs not supported (nested VM?), skipping' + exit 77 + fi + echo 'vmspawn exited before registering' + cat '$log' + exit 1 + fi + sleep .5 + done + " || { + local rc=$? + if [[ $rc -eq 77 ]]; then exit 0; fi + exit "$rc" + } +} + +# Launch vmspawn in the background with direct kernel boot and headless console. +systemd-vmspawn \ + --machine="$MACHINE" \ + --ram=256M \ + --directory="$WORKDIR/root" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + &>"$WORKDIR/vmspawn.log" & +VMSPAWN_PID=$! + +wait_for_machine "$MACHINE" "$VMSPAWN_PID" "$WORKDIR/vmspawn.log" +echo "Machine '$MACHINE' registered with machined" + +# Verify that controlAddress is present in Machine.List output +varlinkctl call /run/systemd/machine/io.systemd.Machine io.systemd.Machine.List "{\"name\":\"$MACHINE\"}" | grep >/dev/null controlAddress +echo "controlAddress exposed in Machine.List" + +# Exercise the MachineInstance varlink interface directly via varlinkctl. +# Look up the varlink address from machined. Do this BEFORE machinectl poweroff since poweroff +# is destructive (either kills the machine via signal or sends ACPI shutdown). +VARLINK_ADDR=$(varlinkctl call /run/systemd/machine/io.systemd.Machine io.systemd.Machine.List "{\"name\":\"$MACHINE\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR" "null" + +# Describe should reflect a running VM +STATUS=$(varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}') +echo "$STATUS" | jq -e '.running == true' +echo "$STATUS" | jq -e '.status == "running"' +echo "Describe returned running state" + +# Pause, verify, resume via varlinkctl +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Pause '{}' +STATUS=$(varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}') +echo "$STATUS" | jq -e '.running == false' +echo "Verified paused state via Describe" + +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Resume '{}' +STATUS=$(varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}') +echo "$STATUS" | jq -e '.running == true' +echo "Verified resumed state via Describe" + +# --- SubscribeEvents tests --- +# Subscribe to all events in the background, collect output +varlinkctl call --more --timeout=10 "$VARLINK_ADDR" io.systemd.MachineInstance.SubscribeEvents '{}' \ + >"$WORKDIR/events-all.json" 2>&1 & +SUBSCRIBE_ALL_PID=$! +sleep 0.5 + +# Trigger STOP + RESUME events via pause/resume +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Pause '{}' +sleep 0.2 +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Resume '{}' +sleep 0.5 + +# Kill the subscriber and check output +kill "$SUBSCRIBE_ALL_PID" 2>/dev/null; wait "$SUBSCRIBE_ALL_PID" 2>/dev/null || true +cat "$WORKDIR/events-all.json" + +# Verify initial READY event +grep >/dev/null '"READY"' "$WORKDIR/events-all.json" +echo "SubscribeEvents sent READY event" + +# Verify we got both STOP and RESUME events +grep >/dev/null '"STOP"' "$WORKDIR/events-all.json" +grep >/dev/null '"RESUME"' "$WORKDIR/events-all.json" +echo "SubscribeEvents received STOP and RESUME events" + +# Test filtered subscription: only STOP events +varlinkctl call --more --timeout=10 "$VARLINK_ADDR" io.systemd.MachineInstance.SubscribeEvents '{"filter":["STOP"]}' \ + >"$WORKDIR/events-filtered.json" 2>&1 & +SUBSCRIBE_FILTER_PID=$! +sleep 0.5 + +# Trigger both events again +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Pause '{}' +sleep 0.2 +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Resume '{}' +sleep 0.5 + +kill "$SUBSCRIBE_FILTER_PID" 2>/dev/null; wait "$SUBSCRIBE_FILTER_PID" 2>/dev/null || true +cat "$WORKDIR/events-filtered.json" + +# Should have STOP but not RESUME +grep >/dev/null '"STOP"' "$WORKDIR/events-filtered.json" +(! grep >/dev/null '"RESUME"' "$WORKDIR/events-filtered.json") +echo "Filtered subscription correctly received only STOP events" + +# Test machinectl pause/resume +machinectl pause "$MACHINE" +echo "machinectl pause succeeded" + +machinectl resume "$MACHINE" +echo "machinectl resume succeeded" + +# Test machinectl poweroff -- sends ACPI powerdown via QMP (system_powerdown). +# The guest won't handle it (our init is just 'sleep infinity'), but the QMP command should succeed. +machinectl poweroff "$MACHINE" +echo "machinectl poweroff succeeded" + +# --- Stress test: repeated start/pause/resume/terminate cycles --- +# Exercises the varlink disconnect path, QMP reconnection, and ref counting under repeated use. +# This catches use-after-free and double-close bugs that only manifest after multiple cycles. +machinectl terminate "$MACHINE" 2>/dev/null +timeout 10 bash -c "while machinectl status '$MACHINE' &>/dev/null; do sleep .5; done" 2>/dev/null +timeout 10 bash -c "while kill -0 '$VMSPAWN_PID' 2>/dev/null; do sleep .5; done" 2>/dev/null + +for i in $(seq 1 5); do + echo "Stress cycle $i/5" + + STRESS_MACHINE="test-vmspawn-stress-$i-$$" + systemd-vmspawn \ + --machine="$STRESS_MACHINE" \ + --ram=256M \ + --directory="$WORKDIR/root" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + &>"$WORKDIR/vmspawn-stress.log" & + STRESS_PID=$! + + wait_for_machine "$STRESS_MACHINE" "$STRESS_PID" "$WORKDIR/vmspawn-stress.log" + + STRESS_ADDR=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$STRESS_MACHINE\"}" | jq -r '.controlAddress') + assert_neq "$STRESS_ADDR" "null" + + # Rapid pause/resume/describe cycles + for _ in $(seq 1 3); do + machinectl pause "$STRESS_MACHINE" + varlinkctl call "$STRESS_ADDR" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == false' >/dev/null + machinectl resume "$STRESS_MACHINE" + varlinkctl call "$STRESS_ADDR" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == true' >/dev/null + done + + machinectl terminate "$STRESS_MACHINE" + timeout 10 bash -c "while machinectl status '$STRESS_MACHINE' &>/dev/null; do sleep .5; done" + timeout 10 bash -c "while kill -0 '$STRESS_PID' 2>/dev/null; do sleep .5; done" + echo "Stress cycle $i/5 passed" +done +echo "All stress cycles passed" + +# Restart a fresh VM for the remaining tests +systemd-vmspawn \ + --machine="$MACHINE" \ + --ram=256M \ + --directory="$WORKDIR/root" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + &>"$WORKDIR/vmspawn.log" & +VMSPAWN_PID=$! + +wait_for_machine "$MACHINE" "$VMSPAWN_PID" "$WORKDIR/vmspawn.log" +VARLINK_ADDR=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$MACHINE\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR" "null" + +# --- Parallel multi-machine dispatch tests --- +# Launch a second VM to test machinectl operating on multiple machines simultaneously. +# Use a separate rootfs so each VM gets independent sidecar state (TPM, EFI NVRAM). +MACHINE2="test-vmspawn-qmp2-$$" + +mkdir -p "$WORKDIR/root2/sbin" +cp "$WORKDIR/root/sbin/init" "$WORKDIR/root2/sbin/init" + +systemd-vmspawn \ + --machine="$MACHINE2" \ + --ram=256M \ + --directory="$WORKDIR/root2" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + &>"$WORKDIR/vmspawn2.log" & +VMSPAWN2_PID=$! + +wait_for_machine "$MACHINE2" "$VMSPAWN2_PID" "$WORKDIR/vmspawn2.log" +echo "Second machine '$MACHINE2' registered" + +VARLINK_ADDR2=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$MACHINE2\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR2" "null" + +# Parallel pause: both machines at once +machinectl pause "$MACHINE" "$MACHINE2" +echo "Parallel pause of two machines succeeded" + +# Verify both are paused +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == false' +varlinkctl call "$VARLINK_ADDR2" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == false' +echo "Both machines verified paused" + +# Parallel resume +machinectl resume "$MACHINE" "$MACHINE2" +echo "Parallel resume of two machines succeeded" + +# Verify both resumed +varlinkctl call "$VARLINK_ADDR" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == true' +varlinkctl call "$VARLINK_ADDR2" io.systemd.MachineInstance.Describe '{}' | jq -e '.running == true' +echo "Both machines verified running" + +# --- Terminate and verify cleanup --- +# Parallel terminate: both machines at once (QMP quit) +machinectl terminate "$MACHINE" "$MACHINE2" +timeout 10 bash -c " + while machinectl status '$MACHINE' &>/dev/null || machinectl status '$MACHINE2' &>/dev/null; do + sleep .5 + done +" +echo "Parallel terminate succeeded, both VMs gone" + +# Both vmspawn processes should have exited +timeout 10 bash -c "while kill -0 '$VMSPAWN_PID' 2>/dev/null; do sleep .5; done" +timeout 10 bash -c "while kill -0 '$VMSPAWN2_PID' 2>/dev/null; do sleep .5; done" +echo "Both vmspawn processes exited" + +echo "All vmspawn QMP-varlink bridge tests passed" diff --git a/test/units/util.sh b/test/units/util.sh index d9e561e791..248e676eae 100755 --- a/test/units/util.sh +++ b/test/units/util.sh @@ -525,3 +525,36 @@ check_nss_module() ( return 0 ) + +find_qemu_binary() { + # Mirrors find_qemu_binary() from src/vmspawn/vmspawn-util.c. + # Returns 0 if a usable QEMU binary exists, 1 otherwise. + for binary in qemu qemu-kvm; do + if command -v "$binary" >/dev/null 2>&1; then + return 0 + fi + done + + if test -x /usr/libexec/qemu-kvm; then + return 0 + fi + + local arch + case "$(uname -m)" in + x86_64) arch=x86_64 ;; + i?86) arch=i386 ;; + aarch64) arch=aarch64 ;; + armv*l|arm*) arch=arm ;; + alpha) arch=alpha ;; + loongarch64) arch=loongarch64 ;; + mips*) arch=mips ;; + parisc*) arch=hppa ;; + ppc64*|ppc*) arch=ppc ;; + riscv32) arch=riscv32 ;; + riscv64) arch=riscv64 ;; + s390x) arch=s390x ;; + *) return 1 ;; + esac + + command -v "qemu-system-$arch" >/dev/null 2>&1 +}