]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
test: add integration test for LUO
authorLuca Boccassi <luca.boccassi@gmail.com>
Mon, 30 Mar 2026 22:14:38 +0000 (23:14 +0100)
committerLuca Boccassi <luca.boccassi@gmail.com>
Fri, 15 May 2026 12:46:08 +0000 (13:46 +0100)
src/test/meson.build
src/test/test-luo.c [new file with mode: 0644]
test/integration-tests/TEST-91-LIVEUPDATE/meson.build [new file with mode: 0644]
test/integration-tests/meson.build
test/units/TEST-91-LIVEUPDATE.sh [new file with mode: 0755]

index 587a5c6159cd0bf98d355497f6ab91ad41fa2218..20960d0b9587ff2ae0bd51950ddfb129c0b9b4c6 100644 (file)
@@ -369,6 +369,10 @@ executables += [
                 'sources' : files('test-loopback.c'),
                 'dependencies' : common_test_dependencies,
         },
+        test_template + {
+                'sources' : files('test-luo.c'),
+                'type' : 'manual',
+        },
         test_template + {
                 'sources' : files('test-math-util.c'),
                 'dependencies' : libm,
diff --git a/src/test/test-luo.c b/src/test/test-luo.c
new file mode 100644 (file)
index 0000000..95ccc84
--- /dev/null
@@ -0,0 +1,170 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* Helper for TEST-91-LIVEUPDATE: creates memfds and stores them in the fd store,
+ * or verifies that inherited fd store entries contain the expected content.
+ *
+ * Usage:
+ *   test-luo store - create memfds with test data and push them to the fd store
+ *   test-luo check - verify fd store content matches expectations
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "sd-daemon.h"
+
+#include "fd-util.h"
+#include "log.h"
+#include "main-func.h"
+#include "memfd-util.h"
+#include "parse-util.h"
+#include "string-util.h"
+#include "strv.h"
+#include "time-util.h"
+
+#define TEST_DATA_1 "liveupdate-test-data-1"
+#define TEST_DATA_2 "liveupdate-test-data-2"
+
+static int do_store(void) {
+        _cleanup_close_ int fd1 = -EBADF, fd2 = -EBADF;
+        int r;
+
+        fd1 = memfd_new_and_seal("luo-test-1", TEST_DATA_1, strlen(TEST_DATA_1));
+        if (fd1 < 0)
+                return log_error_errno(fd1, "Failed to create memfd 1: %m");
+
+        fd2 = memfd_new_and_seal("luo-test-2", TEST_DATA_2, strlen(TEST_DATA_2));
+        if (fd2 < 0)
+                return log_error_errno(fd2, "Failed to create memfd 2: %m");
+
+        r = sd_pid_notify_with_fds(0, /* unset_environment= */ false, "FDSTORE=1\nFDNAME=testfd1", &fd1, 1);
+        if (r < 0)
+                return log_error_errno(r, "Failed to store memfd 1 in fd store: %m");
+
+        r = sd_pid_notify_with_fds(0, /* unset_environment= */ false, "FDSTORE=1\nFDNAME=testfd2", &fd2, 1);
+        if (r < 0)
+                return log_error_errno(r, "Failed to store memfd 2 in fd store: %m");
+
+        log_info("Stored 2 memfds in fd store.");
+
+        /* Wait for PID 1 to actually process all our FDSTORE notifications before we exit, otherwise
+         * the cgroup-based pidref to unit lookup may fail once we're gone, and the fds end up closed. */
+        r = sd_notify_barrier(0, 5 * USEC_PER_SEC);
+        if (r < 0)
+                return log_error_errno(r, "Failed to wait for notification barrier: %m");
+
+        return 0;
+}
+
+static int do_check(void) {
+        const char *e;
+        _cleanup_strv_free_ char **names = NULL;
+        size_t n_fds;
+        int r;
+
+        /* sd_listen_fds_with_names() checks LISTEN_PID which won't match since we're a child process.
+         * Read LISTEN_FDS and LISTEN_FDNAMES directly from the environment instead. */
+        e = getenv("LISTEN_FDS");
+        if (!e)
+                return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "No LISTEN_FDS environment variable set");
+
+        r = safe_atozu(e, &n_fds);
+        if (r < 0)
+                return log_error_errno(r, "Failed to parse LISTEN_FDS='%s': %m", e);
+        if (n_fds == 0)
+                return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "No file descriptors in fd store after kexec");
+
+        log_info("Got %zu fd(s) in fd store after kexec.", n_fds);
+
+        /* Parse LISTEN_FDNAMES to match fds by name, not position */
+        e = getenv("LISTEN_FDNAMES");
+        if (!e)
+                return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "No LISTEN_FDNAMES environment variable set");
+
+        names = strv_split(e, ":");
+        if (!names)
+                return log_oom();
+        assert(n_fds == strv_length(names));
+
+        static const struct {
+                const char *name;
+                const char *expected;
+        } checks[] = {
+                { "testfd1", TEST_DATA_1 },
+                { "testfd2", TEST_DATA_2 },
+        };
+
+        if (n_fds < ELEMENTSOF(checks))
+                return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
+                                       "Not enough fds in fd store after kexec: expected at least %zu, got %zu",
+                                       ELEMENTSOF(checks), n_fds);
+
+        for (size_t i = 0; i < ELEMENTSOF(checks); i++) {
+                char buf[256];
+                ssize_t n;
+                size_t idx = 0;
+                int fd = -EBADF;
+
+                /* Find the fd by name */
+                STRV_FOREACH(name, names) {
+                        if (idx >= n_fds)
+                                break;
+                        if (streq(*name, checks[i].name)) {
+                                fd = SD_LISTEN_FDS_START + idx;
+                                break;
+                        }
+                        idx++;
+                }
+
+                if (fd < 0)
+                        return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
+                                               "fd '%s' not found in LISTEN_FDNAMES", checks[i].name);
+
+                /* memfds are sealed; pread() avoids needing a separate lseek() */
+                n = pread(fd, buf, sizeof(buf) - 1, 0);
+                if (n < 0)
+                        return log_error_errno(errno, "Failed to read fd %d: %m", fd);
+
+                buf[n] = '\0';
+
+                if (!streq(buf, checks[i].expected))
+                        return log_error_errno(
+                                        SYNTHETIC_ERRNO(EBADMSG),
+                                        "Content mismatch for '%s': expected '%s', got '%s'",
+                                        checks[i].name, checks[i].expected, buf);
+
+                /* Remove the fd from the fd store so we don't keep accumulating duplicates across
+                 * repeated invocations (and across repeated kexec cycles). */
+                r = sd_pid_notifyf(0, /* unset_environment= */ false,
+                                   "FDSTOREREMOVE=1\nFDNAME=%s", checks[i].name);
+                if (r < 0)
+                        return log_error_errno(r, "Failed to remove fd '%s' from fd store: %m", checks[i].name);
+
+                log_info("Verified fd '%s': content matches.", checks[i].name);
+        }
+
+        log_info("All fd store checks passed.");
+
+        /* Wait for PID 1 to actually process all our FDSTORE notifications before we exit, otherwise
+         * the cgroup-based pidref to unit lookup may fail once we're gone, and the fds end up closed. */
+        r = sd_notify_barrier(0, 5 * USEC_PER_SEC);
+        if (r < 0)
+                return log_error_errno(r, "Failed to wait for notification barrier: %m");
+
+        return 0;
+}
+
+static int run(int argc, char *argv[]) {
+        if (argc != 2)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Usage: %s store|check", argv[0]);
+
+        if (streq(argv[1], "store"))
+                return do_store();
+        if (streq(argv[1], "check"))
+                return do_check();
+
+        return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Unknown command: %s", argv[1]);
+}
+
+DEFINE_MAIN_FUNCTION(run);
diff --git a/test/integration-tests/TEST-91-LIVEUPDATE/meson.build b/test/integration-tests/TEST-91-LIVEUPDATE/meson.build
new file mode 100644 (file)
index 0000000..460ef32
--- /dev/null
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+integration_tests += [
+        integration_test_template + {
+                'name' : 'TEST-91-LIVEUPDATE',
+                'configuration' : integration_test_template['configuration'] + {
+                        'unit' : integration_test_template['configuration']['unit'] + {
+                                'DefaultDependencies' : 'no',
+                        },
+                        'service' : integration_test_template['configuration']['service'] + {
+                                'FileDescriptorStoreMax' : '20',
+                                'FileDescriptorStorePreserve' : 'yes',
+                                'NotifyAccess' : 'all',
+                        },
+                },
+                'cmdline' : ['kho=on', 'liveupdate=on', 'systemd.minimum_uptime_sec=0'],
+                'storage' : 'persistent',
+                'vm' : true,
+                'firmware' : 'uefi',
+        },
+]
index dd9eba9b673b0bcc0bee65ba98a61dfb0dcc48f2..867fdecda2d37ef809885668c3410a64aa9044f1 100644 (file)
@@ -103,6 +103,7 @@ foreach dirname : [
         'TEST-88-UPGRADE',
         'TEST-89-RESOLVED-MDNS',
         'TEST-90-RESTRICT-FSACCESS',
+        'TEST-91-LIVEUPDATE',
 ]
         subdir(dirname)
 endforeach
diff --git a/test/units/TEST-91-LIVEUPDATE.sh b/test/units/TEST-91-LIVEUPDATE.sh
new file mode 100755 (executable)
index 0000000..f51f2f3
--- /dev/null
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+
+export SYSTEMD_LOG_LEVEL=debug
+
+# This test verifies that the Live Update Orchestrator (LUO) integration works:
+# - PID 1 can serialize fd stores and pass them to systemd-shutdown
+# - systemd-shutdown can preserve fds in a LUO session before kexec
+# - After kexec, PID 1 restores the fd stores from the LUO session
+#
+# The test requires KHO (Kexec HandOver) and LUO (Live Update Orchestrator) kernel support.
+
+if [[ ! -e /dev/liveupdate ]]; then
+    echo "/dev/liveupdate not available, skipping test"
+    exit 77
+fi
+
+# Ensure user units can also manage sessions
+chmod 666 /dev/liveupdate
+
+TESTUSER_UID=$(id -u testuser)
+TESTUSER_USER_SVC="user@${TESTUSER_UID}.service"
+
+at_exit() {
+    systemctl stop systemd-nspawn@fdstore.service ||:
+    machinectl terminate fdstore ||:
+    rm -rf /var/lib/machines/fdstore ||:
+    rm -f /run/systemd/nspawn/fdstore.nspawn
+}
+
+trap at_exit EXIT
+
+# To test the late-load path also create units that appear at runtime.
+# Three variants exercise different fragment scenarios on second boot:
+#  - late.service:          fragment present before fds are observed (daemon-reload triggered)
+#  - late-noreload.service: fragment dropped only after kexec, never daemon-reloaded explicitly
+#                           to exercise lazy load via systemctl start
+#  - late-zerofds.service:  fragment on second boot sets FileDescriptorStoreMax=0,
+#                           the previously stored fds must be dropped
+write_late_unit() {
+    local scope="${1:?}" name="${2:?}" cmd="${3:?}" maxfd="${4:-20}"
+    local dir
+
+    case "${scope}" in
+        system) dir=/run/systemd/system ;;
+        user)   dir=/run/systemd/user ;;
+        *)      echo "unknown scope: ${scope}" >&2; return 1 ;;
+    esac
+
+    mkdir -p "${dir}"
+    cat >"${dir}/${name}.service" <<EOF
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+FileDescriptorStoreMax=${maxfd}
+FileDescriptorStorePreserve=yes
+ExecStart=${cmd}
+EOF
+}
+
+if grep -qw luo_nboot=1 /proc/cmdline; then
+    # Verify that the fd store of the main test service survived the kexec.
+    /usr/lib/systemd/tests/unit-tests/manual/test-luo check
+
+    # Verify that the user manager also preserved its FD store
+    n_user_at_fds=$(systemctl show -P NFileDescriptorStore "${TESTUSER_USER_SVC}")
+    test "${n_user_at_fds}" -ge 2
+    write_late_unit user TEST-91-LIVEUPDATE-user-late \
+        "/usr/lib/systemd/tests/unit-tests/manual/test-luo check user-late"
+    systemctl restart "${TESTUSER_USER_SVC}"
+    timeout 30s bash -c "until systemctl is-active --quiet '${TESTUSER_USER_SVC}'; do sleep 0.5; done"
+    n_user_unit_fds=$(run0 -u testuser systemctl --user show -P NFileDescriptorStore TEST-91-LIVEUPDATE-user-late.service)
+    test "${n_user_unit_fds}" -eq 2
+    run0 -u testuser systemctl --user start TEST-91-LIVEUPDATE-user-late.service
+
+    # nspawn fdstore variant: after kexec, PID 1 propagated the
+    # systemd-nspawn@fdstore.service fdstore through LUO. Starting the service
+    # then forwards the preserved fds via LISTEN_FDS to a fresh nspawn payload,
+    # which verifies the content is intact.
+    create_dummy_container /var/lib/machines/fdstore
+    cat >/var/lib/machines/fdstore/sbin/init <<'EOF'
+#!/usr/bin/env bash
+set -e
+exec /usr/bin/test-fdstore check
+EOF
+    chmod +x /var/lib/machines/fdstore/sbin/init
+    mkdir -p /run/systemd/nspawn
+    cat >/run/systemd/nspawn/fdstore.nspawn <<EOF
+[Exec]
+KillSignal=SIGKILL
+EOF
+    n_nspawn_fds=$(systemctl show -P NFileDescriptorStore systemd-nspawn@fdstore.service)
+    test "${n_nspawn_fds}" -ge 2
+    systemctl start systemd-nspawn@fdstore.service
+    systemctl is-active systemd-nspawn@fdstore.service
+
+    # late.service: rewrite the fragment with the second-boot ExecStart and
+    # exercise the daemon-reload + daemon-reexec preservation paths.
+    write_late_unit system TEST-91-LIVEUPDATE-late \
+        "/usr/lib/systemd/tests/unit-tests/manual/test-luo check"
+
+    n_fds=$(systemctl show -P NFileDescriptorStore TEST-91-LIVEUPDATE-late.service)
+    test "$n_fds" -eq 2
+
+    systemctl daemon-reload
+
+    # Verify the late unit doesn't get GC'ed during daemon-reload
+    n_fds=$(systemctl show -P NFileDescriptorStore TEST-91-LIVEUPDATE-late.service)
+    test "$n_fds" -eq 2
+
+    systemctl daemon-reexec
+
+    # Verify the late unit doesn't get GC'ed during daemon-reexec
+    n_fds=$(systemctl show -P NFileDescriptorStore TEST-91-LIVEUPDATE-late.service)
+    test "$n_fds" -eq 2
+
+    systemctl start TEST-91-LIVEUPDATE-late.service
+
+    # No-reload variant: drop a brand-new fragment file but never call
+    # daemon-reload. Lazy load via systemctl start must pick it up while
+    # preserving the LUO-restored fds.
+    write_late_unit system TEST-91-LIVEUPDATE-late-noreload \
+        "/usr/lib/systemd/tests/unit-tests/manual/test-luo check late-noreload"
+    n_fds=$(systemctl show -P NFileDescriptorStore TEST-91-LIVEUPDATE-late-noreload.service)
+    test "$n_fds" -eq 2
+    systemctl start TEST-91-LIVEUPDATE-late-noreload.service
+
+    # Zero-fds variant: fragment on second boot sets FileDescriptorStoreMax=0,
+    # so the LUO-restored fds must be dropped on (lazy) load.
+    write_late_unit system TEST-91-LIVEUPDATE-late-zerofds \
+        "bash -c 'test \"\${LISTEN_FDS:-0}\" -eq 0'" 0
+    systemctl daemon-reload
+    n_fds=$(systemctl show -P NFileDescriptorStore TEST-91-LIVEUPDATE-late-zerofds.service)
+    test "$n_fds" -eq 0
+    systemctl start TEST-91-LIVEUPDATE-late-zerofds.service
+else
+    # Create memfds with known content and push them to our fd store.
+    /usr/lib/systemd/tests/unit-tests/manual/test-luo store
+
+    # Exercise the user manager FD preservation across kexec too
+    loginctl enable-linger testuser
+    timeout 30s bash -c "until systemctl is-active --quiet '${TESTUSER_USER_SVC}'; do sleep 0.5; done"
+    write_late_unit user TEST-91-LIVEUPDATE-user-late \
+        "/usr/lib/systemd/tests/unit-tests/manual/test-luo store user-late"
+    run0 -u testuser systemctl --user start TEST-91-LIVEUPDATE-user-late.service
+    n_user_unit_fds=$(run0 -u testuser systemctl --user show -P NFileDescriptorStore TEST-91-LIVEUPDATE-user-late.service)
+    test "${n_user_unit_fds}" -eq 2
+    n_user_at_fds=$(systemctl show -P NFileDescriptorStore "${TESTUSER_USER_SVC}")
+    test "${n_user_at_fds}" -ge 2
+
+    # Exercise the FD-store preservation chain across a kexec for a privileged
+    # nspawn container managed as a system service:
+    #   payload (inside container) -> systemd-nspawn@fdstore.service fdstore
+    #   -> LUO -> after kexec PID 1 restores the fdstore -> systemd-nspawn ->
+    #   payload verifies content matches.
+    create_dummy_container /var/lib/machines/fdstore
+    cat >/var/lib/machines/fdstore/sbin/init <<'EOF'
+#!/usr/bin/env bash
+set -e
+exec /usr/bin/test-fdstore store
+EOF
+    chmod +x /var/lib/machines/fdstore/sbin/init
+
+    mkdir -p /run/systemd/nspawn
+    cat >/run/systemd/nspawn/fdstore.nspawn <<EOF
+[Exec]
+KillSignal=SIGKILL
+EOF
+
+    systemctl start systemd-nspawn@fdstore.service
+    timeout 30s bash -c \
+        "until [[ \"\$(systemctl show -P NFileDescriptorStore systemd-nspawn@fdstore.service)\" -ge 2 ]]; do sleep 0.5; done"
+
+    # Write and start each late unit with distinct session name prefixes
+    # to avoid collisions in the LUO session namespace.
+    for variant in late late-noreload late-zerofds; do
+        write_late_unit system "TEST-91-LIVEUPDATE-${variant}" \
+            "/usr/lib/systemd/tests/unit-tests/manual/test-luo store"
+        systemctl start "TEST-91-LIVEUPDATE-${variant}.service"
+
+        n_fds=$(systemctl show -P NFileDescriptorStore "TEST-91-LIVEUPDATE-${variant}.service")
+        test "$n_fds" -eq 2
+    done
+
+    # 'systemctl kexec' auto-loads the default boot entry (i.e. the booted UKI,
+    # via EFI LoaderEntrySelected/LoaderEntryDefault). Append a marker to the
+    # kernel command line so we can tell the two boots apart, and also the current
+    # cmdline that is added by mkosi, otherwise the test framework will break.
+    systemctl kexec --kernel-cmdline="$(cat /proc/cmdline) luo_nboot=1"
+    exit 0
+fi
+
+touch /testok
+systemctl --no-block exit 123