From 49e2af9277b678b1b3ede1efeac1488016485f33 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 13:51:04 +0200 Subject: [PATCH 01/19] shared: add varlink interface definitions for machine instance control Add three varlink interface definitions for the machine instance control hierarchy: - io.systemd.MachineInstance: generic operations applicable to both containers and VMs (PowerOff, Reboot, Pause, Resume, QueryStatus, SubscribeEvents). nspawn could implement this same interface later. - io.systemd.VirtualMachineInstance: VM-specific but VMM-agnostic operations. Empty for now, future home for AddBlockDevice and similar. - io.systemd.QemuMachineInstance: QEMU-specific operations. Defines AcquireQMP() for protocol upgrade to a direct QMP connection. The "Instance" suffix avoids collision with machined's existing io.systemd.Machine interface. Signed-off-by: Christian Brauner (Amutable) --- src/shared/meson.build | 3 ++ .../varlink-io.systemd.MachineInstance.c | 51 +++++++++++++++++++ .../varlink-io.systemd.MachineInstance.h | 6 +++ .../varlink-io.systemd.QemuMachineInstance.c | 17 +++++++ .../varlink-io.systemd.QemuMachineInstance.h | 6 +++ ...arlink-io.systemd.VirtualMachineInstance.c | 9 ++++ ...arlink-io.systemd.VirtualMachineInstance.h | 6 +++ 7 files changed, 98 insertions(+) create mode 100644 src/shared/varlink-io.systemd.MachineInstance.c create mode 100644 src/shared/varlink-io.systemd.MachineInstance.h create mode 100644 src/shared/varlink-io.systemd.QemuMachineInstance.c create mode 100644 src/shared/varlink-io.systemd.QemuMachineInstance.h create mode 100644 src/shared/varlink-io.systemd.VirtualMachineInstance.c create mode 100644 src/shared/varlink-io.systemd.VirtualMachineInstance.h diff --git a/src/shared/meson.build b/src/shared/meson.build index 22dccf0e2a..924987b024 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -216,6 +216,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 +227,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 +236,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/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; From fb12d9c5557abae5024cf1210735946903cc2601 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 13:52:08 +0200 Subject: [PATCH 02/19] machined: add controlAddress field to Machine.Register and Machine.List Follow the existing sshAddress pattern to add a controlAddress field that allows machine registrants (like vmspawn) to advertise a varlink socket address for direct VM control. machined stores and exposes the address but never connects to it itself. Signed-off-by: Christian Brauner (Amutable) --- src/machine/machine-varlink.c | 1 + src/machine/machine.c | 3 +++ src/machine/machine.h | 1 + src/machine/machined-varlink.c | 1 + src/shared/varlink-io.systemd.Machine.c | 4 ++++ 5 files changed, 10 insertions(+) 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/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/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."), From 88a01ed2e3426e2db84968fe4fd2d182ebd7db11 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Sat, 11 Apr 2026 14:00:07 +0200 Subject: [PATCH 03/19] json-stream: expose log helpers for consumers Move json_stream_description(), json_stream_log(), and json_stream_log_errno() from json-stream.c into json-stream.h so that consumers like the QMP client can use the same description-prefixed logging that json-stream itself uses internally. Signed-off-by: Christian Brauner (Amutable) --- src/libsystemd/sd-json/json-stream.c | 10 ---------- src/libsystemd/sd-json/json-stream.h | 11 +++++++++++ 2 files changed, 11 insertions(+), 10 deletions(-) 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); From 50dd55bb6ebcf8ae43490b8db2a917330048f1ba Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 14:02:10 +0200 Subject: [PATCH 04/19] shared: add QMP client library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async QMP client for talking to QEMU's machine monitor from libsystemd-shared. The I/O core (buffered read/write, output queue, event source management) follows sd-varlink's patterns. State machine: INITIAL -> GREETING_RECEIVED -> CAPABILITIES_SENT -> RUNNING | v DISCONNECTED The QMP handshake (greeting + qmp_capabilities) is driven transparently by qmp_client_invoke() through an internal qmp_client_ensure_running() helper, matching sd-bus's bus_ensure_running() pattern. Callers never wait for it explicitly. qmp_client_invoke() is the only command interface: asynchronous, with per-command callback. Slots are tracked in a Set keyed by id; replies are dispatched by id match. SCM_RIGHTS fd passing is bundled through QmpClientArgs and the QMP_CLIENT_ARGS_FD macro. qmp_client_process() and qmp_client_wait() are exposed publicly, mirroring sd_varlink_process() and sd_varlink_wait(). Callers that need to drive the client synchronously — e.g. feature probing before entering the sd_event main loop — can loop on them exactly like varlink_call_internal() does on its varlink equivalents. Other features: - Buffered stream reader for QMP's \r\n-delimited JSON, handling multi-read responses (query-qmp-schema is ~200 KiB). - Fdset id allocation via qmp_client_next_fdset_id(). - Synthetic SHUTDOWN event on unexpected disconnect. - Disconnect detection with callback notification and pending-command cleanup. - -ENOBUFS from the 16 MiB input-buffer cap is treated as recoverable (not a transport error). Signed-off-by: Christian Brauner (Amutable) --- src/shared/meson.build | 1 + src/shared/qmp-client.c | 845 ++++++++++++++++++++++++++++++++++++ src/shared/qmp-client.h | 78 ++++ src/shared/shared-forward.h | 1 + 4 files changed, 925 insertions(+) create mode 100644 src/shared/qmp-client.c create mode 100644 src/shared/qmp-client.h diff --git a/src/shared/meson.build b/src/shared/meson.build index 924987b024..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', 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; From 1a7b6fd9dd44d53dd64d895b33ee070e7c9af0b8 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 14:08:40 +0200 Subject: [PATCH 05/19] vmspawn: set up varlink bridge infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create the QMP-to-varlink bridge layer (vmspawn-qmp.{c,h}) and the varlink server layer (vmspawn-varlink.{c,h}). The QMP bridge (VmspawnQmpBridge) owns the QmpClient connection and manages pending background jobs (e.g. blockdev-create continuations). vmspawn_qmp_init() creates the client and attaches it to the event loop. vmspawn_qmp_probe_features() drives io_uring and qcow2 discard-no-unref probes synchronously via a qmp_client_process() + qmp_client_wait() loop — the QMP handshake completes transparently on the first invoke. vmspawn_qmp_start() resumes vCPUs via an async "cont" command. The varlink server (VmspawnVarlinkContext) exposes three interfaces: - io.systemd.MachineInstance: generic machine control (Terminate, PowerOff, Reboot, Pause, Resume, Describe, SubscribeEvents). Method handlers forward to QMP commands asynchronously — the varlink reply is deferred until the QMP response arrives. - io.systemd.VirtualMachineInstance: VM-specific (placeholder for future snapshot/migration methods). - io.systemd.QemuMachineInstance: QEMU-specific (AcquireQMP stub). The server listens on /control with mode 0600. Event streaming follows the importd Pull pattern: SubscribeEvents sends an initial {ready:true} notification, then fans out QMP events to all subscribers. The disconnect handler only unrefs subscriber links (matching resolved's vl_on_notification_disconnect pattern). Introduce the MachineConfig aggregate in vmspawn-qmp.h grouping the per-device info structures (DriveInfos, NetworkInfo, VirtiofsInfos, VsockInfo) together with machine_config_done() that chains the individual done helpers. Callers populate it field-by-field and rely on the _cleanup_ attribute for orderly teardown regardless of which device types the invocation ends up using. Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/meson.build | 2 + src/vmspawn/vmspawn-qmp.c | 1059 +++++++++++++++++++++++++++++++++ src/vmspawn/vmspawn-qmp.h | 145 +++++ src/vmspawn/vmspawn-varlink.c | 410 +++++++++++++ src/vmspawn/vmspawn-varlink.h | 19 + 5 files changed, 1635 insertions(+) create mode 100644 src/vmspawn/vmspawn-qmp.c create mode 100644 src/vmspawn/vmspawn-qmp.h create mode 100644 src/vmspawn/vmspawn-varlink.c create mode 100644 src/vmspawn/vmspawn-varlink.h 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-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); From 6bfa7aa0509a3d612ffef5d56b7663454765e8ac Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 8 Apr 2026 09:02:26 +0200 Subject: [PATCH 06/19] vmspawn: pre-allocate PCIe root ports for device hotplug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On PCIe machine types (q35, virt), QMP device_add is always hotplug — even with vCPUs stopped. The root PCIe bus (pcie.0) does not support hotplugging; only pcie-root-port bridges do. Pre-allocate enough root ports in the QEMU config file for all devices that will be set up via QMP, plus 10 spare ports for future runtime hotplug. Add ARCHITECTURE_NEEDS_PCIE_ROOT_PORTS macro to guard PCIe-specific setup on x86, ARM, RISC-V, and LoongArch (the architectures whose QEMU machine type is q35 or virt). Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/vmspawn-util.h | 15 ++++++++++- src/vmspawn/vmspawn.c | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) 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.c b/src/vmspawn/vmspawn.c index fe7d307a63..587f2da800 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -100,6 +100,9 @@ #define DISK_SERIAL_MAX_LEN_SCSI 30 #define DISK_SERIAL_MAX_LEN_NVME 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. */ typedef enum StateMode { @@ -3443,6 +3446,56 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { 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 (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) From 0d8fb7d543e062efd3186ffe9da6b1e84fa63f95 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 8 Apr 2026 09:02:37 +0200 Subject: [PATCH 07/19] vmspawn: QMP-varlink bridge for VM runtime control Create a QMP socketpair for QEMU machine monitor control, configure the QMP chardev+mon via the QEMU config file, and wire up the bridge infrastructure. After fork, vmspawn initializes the QMP bridge, probes QEMU feature support synchronously (driving the QMP handshake to RUNNING transparently), resumes vCPUs, then sets up the varlink server for runtime VM control. The control socket path is passed to machined via the controlAddress field in machine registration. Device configuration still uses the legacy INI config path and will be converted to bridge calls in subsequent commits. Signed-off-by: Christian Brauner (Amutable) --- src/shared/machine-register.c | 1 + src/shared/machine-register.h | 1 + src/vmspawn/vmspawn.c | 85 ++++++++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 7 deletions(-) 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/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 587f2da800..8cf2f91129 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,9 +92,11 @@ #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) @@ -2291,6 +2294,38 @@ 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 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; @@ -2389,8 +2424,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 @@ -2525,8 +2560,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(); @@ -3446,6 +3483,10 @@ 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 @@ -3488,9 +3529,11 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { "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; @@ -3538,7 +3581,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, @@ -3560,10 +3604,36 @@ 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]); + + /* 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; + + /* 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 @@ -3628,6 +3698,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, }; From cc045f5326a15f5a3ed2b40afc7cc5ff5efcd999 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Tue, 7 Apr 2026 14:47:10 +0200 Subject: [PATCH 08/19] vmspawn: convert network device setup to bridge Remove the static netdev/nic INI config sections for both privileged TAP, nsresourced TAP, and user-mode networking. Replace them with a NetworkInfo struct that captures the network type, TAP fd or interface name, and MAC address, passed to vmspawn_varlink_setup_network() for runtime configuration via QMP. For the nsresourced TAP path the fd is now passed to QEMU via QMP getfd + SCM_RIGHTS instead of being inherited through pass_fds. Declare the MachineConfig aggregate that this and the following conversion patches populate, zero-initialized with explicit -EBADF for the fd fields so every sub-structure cleans up safely regardless of which device types the invocation ends up using. Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/vmspawn.c | 79 +++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 44 deletions(-) diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 8cf2f91129..2120df61af 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -2335,6 +2335,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; @@ -2573,8 +2577,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"); @@ -2596,65 +2606,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) @@ -3621,6 +3605,13 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (r < 0) return r; + /* Device setup — all before resuming vCPUs */ + if (config.network.type) { + r = vmspawn_qmp_setup_network(bridge, &config.network); + if (r < 0) + return r; + } + /* Resume vCPUs and switch to async event processing */ r = vmspawn_qmp_start(bridge); if (r < 0) From 41022693a8f917a0b21ad7c2d689ec5337b17569 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Tue, 7 Apr 2026 14:48:06 +0200 Subject: [PATCH 09/19] vmspawn: convert VSOCK device setup to bridge Remove the static vsock0 INI config section and the related pass_fds plumbing. Replace with a VsockInfo struct that captures the vhost fd and guest CID, passed to vmspawn_qmp_setup_vsock() for runtime configuration via QMP. The VSOCK fd is now sent to QEMU via QMP getfd + SCM_RIGHTS instead of being inherited. Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/vmspawn.c | 35 ++++++++++------------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 2120df61af..c385990b71 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -2695,40 +2695,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 */ @@ -3612,6 +3593,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { 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) From 723126736ff6c3e725dbe1512e8a6ade14d4f3b2 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Tue, 7 Apr 2026 14:50:23 +0200 Subject: [PATCH 10/19] vmspawn: convert virtiofs device setup to bridge Remove the static chardev/device INI config sections for both the root filesystem and runtime mount virtiofs instances. Replace with VirtiofsInfos that capture socket paths and tags for each virtiofs mount, passed to vmspawn_varlink_setup_virtiofs() for runtime configuration via QMP. Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/vmspawn.c | 55 ++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index c385990b71..8fdb64786c 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -3017,19 +3017,18 @@ 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(); @@ -3146,7 +3145,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)) @@ -3172,23 +3171,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"); @@ -3198,6 +3186,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) { @@ -3593,6 +3590,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { 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; From e7d84aa4de9b293c2f48b2f76100befd88d5ab3c Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 8 Apr 2026 08:54:23 +0200 Subject: [PATCH 11/19] vmspawn: convert drive device setup to bridge Remove the static blockdev/device/snapshot INI config sections and the SCSI controller setup for both the root image drive and extra drives. Replace with DriveInfos that are constructed in the parent after fork: vmspawn opens all image files and passes fds to QEMU via the add-fd path. For ephemeral mode, anonymous overlay files are created via O_TMPFILE or memfd. The resolve_disk_driver() helper maps DiskType to the appropriate QEMU driver name and serial format. The post-fork device-info preparation is split into helpers: prepare_primary_drive() and prepare_extra_drives() for per-drive construction, assign_pcie_ports() for naming the pre-allocated pcie-root-port bridges once every device type is known, and prepare_device_info() that stitches them together against the MachineConfig aggregate. Signed-off-by: Christian Brauner (Amutable) --- src/vmspawn/vmspawn.c | 410 +++++++++++++++++++++++++----------------- 1 file changed, 241 insertions(+), 169 deletions(-) diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 8fdb64786c..548a083fac 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -100,8 +100,9 @@ #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 @@ -2326,6 +2327,232 @@ static int qemu_config_add_qmp_monitor(FILE *config_file, int bridge_fds[2], int "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; @@ -2851,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); @@ -2880,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) @@ -3034,86 +3175,8 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { 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, @@ -3480,7 +3543,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { n_pcie_ports); size_t n_builtin_ports = n_pcie_ports - VMSPAWN_PCIE_HOTPLUG_SPARES; - for (i = 0; i < n_pcie_ports; i++) { + 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); @@ -3501,6 +3564,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return r; } } + /* Finalize the config file and add -readconfig to the cmdline */ r = fflush_and_check(config_file); if (r < 0) @@ -3570,6 +3634,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { child_pty = safe_close(child_pty); 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); @@ -3584,6 +3652,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { 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) From f26199631c7f00b5113057c785b89629ae9c63d9 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 14:28:29 +0200 Subject: [PATCH 12/19] machinectl: add VM control commands Add new verbs for controlling vmspawn VMs: machinectl poweroff machinectl pause machinectl resume Each verb discovers the machine's varlinkAddress via machined's Machine.List, connects directly to vmspawn's varlink socket, and calls the corresponding io.systemd.MachineInstance method. Signed-off-by: Christian Brauner (Amutable) --- src/machine/machinectl.c | 188 +++++++++++++++++++++++++++++++++------ 1 file changed, 163 insertions(+), 25 deletions(-) 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 }, From 3b796616cc8e6413fd693546f83c4a799e27a747 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 2 Apr 2026 16:21:21 +0200 Subject: [PATCH 13/19] man: document machinectl pause/resume and update poweroff for VMs Add manpage entries for the new pause and resume verbs. Update the poweroff description to cover VMs (ACPI powerdown via QMP) in addition to containers (SIGRTMIN+4). Signed-off-by: Christian Brauner (Amutable) --- man/machinectl.xml | 53 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 14 deletions(-) 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… From e2af53db04abaea0cd105b1a0d04e51f2b0f1793 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 2 Apr 2026 16:21:14 +0200 Subject: [PATCH 14/19] shell-completion: add bash/zsh completions for machinectl pause/resume Signed-off-by: Christian Brauner (Amutable) --- shell-completion/bash/machinectl | 2 +- shell-completion/zsh/_machinectl | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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) From f8a7931d3939ffb1c3b2506c6732b3782e24524c Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 22:17:59 +0200 Subject: [PATCH 15/19] vmspawn: add integration test for QMP client library Test the QMP client library using a mock QMP server over a socketpair: - test_qmp_client_basic: Verifies full handshake, query-status with response parsing, stop/cont commands, and asynchronous STOP event delivery via the sd-event I/O callback - test_qmp_client_eof: Verifies that the client properly detects server disconnection (EOF) and returns a disconnect error Signed-off-by: Christian Brauner (Amutable) --- src/test/meson.build | 3 + src/test/test-qmp-client.c | 516 +++++++++++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 src/test/test-qmp-client.c diff --git a/src/test/meson.build b/src/test/meson.build index fb0aac27f8..5fcf007f70 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -431,6 +431,9 @@ executables += [ test_template + { 'sources' : files('test-progress-bar.c'), }, + test_template + { + 'sources' : files('test-qmp-client.c'), + }, test_template + { 'sources' : files('test-qrcode-util.c'), 'dependencies' : libdl, 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); From 435097c6da04afc2f7717ae3122d7dc8594ac914 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 15:04:42 +0200 Subject: [PATCH 16/19] vmspawn: add integration test for QMP client library against real QEMU Add a test that launches QEMU with -machine none (no bootable image needed) and exercises the QMP client library against the real QMP implementation: - test_qmp_client_qemu_handshake_and_schema: sends query-qmp-schema (~200KB response that exercises the buffered multi-read() path) via qmp_client_invoke(), then cleanly shuts down QEMU via quit. The QMP handshake completes transparently inside invoke(). - test_qmp_client_qemu_query_status: validates query-status response parsing, stop/cont command sequencing with id correlation, and state verification between commands The test is automatically skipped when QEMU is not installed. Signed-off-by: Christian Brauner (Amutable) --- src/test/meson.build | 5 + src/test/test-qmp-client-qemu.c | 264 ++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/test/test-qmp-client-qemu.c diff --git a/src/test/meson.build b/src/test/meson.build index 5fcf007f70..87ea6ae15e 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -434,6 +434,11 @@ executables += [ 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); From d32bc9cd07e61e852c624f228dda8669f540bf6d Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Mon, 30 Mar 2026 15:15:17 +0200 Subject: [PATCH 17/19] vmspawn: add integration test for machinectl VM control verbs Add TEST-87-AUX-UTILS-VM.vmspawn.sh that validates the QMP-varlink bridge end-to-end using a real QEMU instance: - Launches vmspawn with --directory and --linux for direct kernel boot (no UEFI firmware or bootable image needed) - Waits for machine registration with machined - Verifies varlinkAddress is exposed in Machine.List - Tests machinectl pause, resume, poweroff - Exercises MachineInstance varlink interface directly via varlinkctl: QueryStatus state verification across pause/resume, Pause, Resume Skipped automatically if vmspawn, QEMU, or a bootable kernel is not available. Runs as part of TEST-87-AUX-UTILS-VM in the mkosi integration test suite. Signed-off-by: Christian Brauner (Amutable) --- mkosi/mkosi.conf.d/arch/mkosi.conf | 1 + mkosi/mkosi.conf.d/centos-fedora/mkosi.conf | 1 + mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf | 1 + mkosi/mkosi.conf.d/opensuse/mkosi.conf | 1 + test/units/TEST-87-AUX-UTILS-VM.vmspawn.sh | 319 ++++++++++++++++++++ test/units/util.sh | 33 ++ 6 files changed, 356 insertions(+) create mode 100755 test/units/TEST-87-AUX-UTILS-VM.vmspawn.sh 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/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 +} From d73628e85deec6c58eb18e608f8969d685b282f7 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Tue, 7 Apr 2026 21:40:40 +0200 Subject: [PATCH 18/19] vmspawn: add integration test for multi-drive and ephemeral QMP setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test the async QMP drive pipeline with real QEMU: Test 1 (multi-drive): launches vmspawn with --image plus two --extra-drive flags. This exercises multiple fdset allocations, pipelined blockdev-add commands relying on FIFO ordering, io_uring retry callbacks, and multiple device_add commands — all fired without waiting for responses. Test 2 (ephemeral): launches vmspawn with --image --ephemeral. This exercises the most complex async path: blockdev-create fires a background job, JOB_STATUS_CHANGE events are watched via the event callback, and when the job concludes the deferred continuation fires the overlay format node + device_add. If the continuation fails, the root drive is never attached, the kernel panics, and vmspawn exits without registering — so successful registration proves the pipeline works. Both tests use a raw ext4 image with a minimal init (sleep infinity) and direct kernel boot. No virtiofsd needed. Signed-off-by: Christian Brauner (Amutable) --- .../TEST-87-AUX-UTILS-VM.vmspawn-drives.sh | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100755 test/units/TEST-87-AUX-UTILS-VM.vmspawn-drives.sh 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" From 021adb2eee9194d52f87c59b4d1a9a147b8f3be6 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 2 Apr 2026 13:58:55 +0200 Subject: [PATCH 19/19] TODO: add some vmspawn todos Signed-off-by: Christian Brauner (Amutable) --- TODO.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) 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