From: Christian Brauner Date: Mon, 30 Mar 2026 20:17:59 +0000 (+0200) Subject: vmspawn: add integration test for QMP client library X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f8a7931d3939ffb1c3b2506c6732b3782e24524c;p=thirdparty%2Fsystemd.git 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) --- diff --git a/src/test/meson.build b/src/test/meson.build index fb0aac27f88..5fcf007f703 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 00000000000..9bfe48770f7 --- /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);