diff --git a/docs/FILE_DESCRIPTOR_STORE.md b/docs/FILE_DESCRIPTOR_STORE.md
index 231af87c91..c333645378 100644
--- a/docs/FILE_DESCRIPTOR_STORE.md
+++ b/docs/FILE_DESCRIPTOR_STORE.md
@@ -181,6 +181,39 @@ continuously).
For further details see [Resource
Pass-Through](https://www.freedesktop.org/software/systemd/man/latest/systemd-soft-reboot.service.html#Resource%20Pass-Through).
+## Kernel Live Update (kexec)
+
+On kernels that support the [Live Update
+Orchestrator](https://docs.kernel.org/userspace-api/liveupdate.html)
+(LUO), the fdstore may also be preserved across a `kexec`-based reboot into a
+new kernel. This allows updating the kernel itself without losing pinned
+resources such as serialized service state, analogous to soft reboot, but for
+the kernel.
+
+Only file descriptors that reference LUO-compatible kernel objects can be
+preserved this way. Currently the kernel supports `memfd` only for LUO, but
+more types are being worked on. Other kinds of file descriptors (sockets,
+regular files, etc.) will be dropped from the store during the kexec transition.
+
+LUO preservation of the fdstore is triggered automatically whenever a
+kexec-based reboot is initiated on an LUO-capable kernel, and is gated by a
+similar rule as soft-reboot: the service must have
+`FileDescriptorStorePreserve=yes` set, so that its fdstore remains loaded. On
+the other side of the kexec, the system manager rebuilds the mapping of fds
+back to their original service units, so that when those services are
+re-activated the fds are passed to them using the normal fdstore protocol.
+Adding a `FDNAME=…` string identifying the fd is also highly recommended,
+otherwise in case multiple fds are stored, it will be impossible to
+distinguish them, as they will all carry the default name (`stored`).
+
+Services that need to preserve additional kernel state may also create their
+own LUO sessions by opening `/dev/liveupdate` directly (see the kernel
+documentation linked above) and pushing the obtained session fd into their
+fdstore (it is recommended to use a `FDNAME=…` string, as above). systemd
+detects such fds and arranges for them to survive the kexec as well, so that
+the session, and any supported file descriptors preserved inside it, is
+handed back to the service on the other side of the reboot.
+
## Initrd Transitions
The fdstore may also be used to pass file descriptors for resources from the
@@ -198,6 +231,27 @@ The soft reboot cycle transition and the initrd→host transition are
semantically very similar, hence similar rules apply, and in both cases it is
recommended to use the fdstore if pinned resources shall be passed over.
+## Propagation Across Manager Boundaries
+
+When a service that has `FileDescriptorStorePreserve=yes` set is itself running
+under another service manager, for example a service of the per-user manager
+(`user@.service`), or a payload running inside a
+[`systemd-nspawn`](https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html)
+container, fds pushed into its fdstore are automatically forwarded one level up
+the supervisor chain via the enveloping manager's `$NOTIFY_SOCKET`. This allows
+the fdstore contents of inner services to be preserved across restarts, re-execs,
+soft-reboots, etc. of the *outer* manager, even when the inner manager (or the
+container payload) is itself restarted along the way. On the way up, each fd is
+tagged with its originating unit id and the original `FDNAME=…` value, so that
+when the fds are eventually handed back down (via the regular
+`$LISTEN_FDS`/`$LISTEN_FDNAMES` protocol), each manager along the chain can
+route them back to the correct unit's fdstore. `FDSTOREREMOVE=1` notifications
+are forwarded the same way, so that explicit removals propagate all the way up too.
+
+For this to work the enveloping unit must itself enable the fdstore (i.e. set
+`FileDescriptorStoreMax=` to a sufficiently large value and
+`FileDescriptorStorePreserve=yes`).
+
## Debugging
The
diff --git a/man/sd_notify.xml b/man/sd_notify.xml
index f9bb56b2d4..52685c404b 100644
--- a/man/sd_notify.xml
+++ b/man/sd_notify.xml
@@ -428,6 +428,17 @@
For further information on the file descriptor store see the File Descriptor Store overview.
+ On kernels that support the Live Update Orchestrator,
+ compatible file descriptors stored this way (such as memfd_create2)
+ are additionally preserved across kexec-based reboots and handed back to the
+ service on the other side, provided FileDescriptorStorePreserve=yes is set on
+ the service (see
+ systemd.service5).
+ See the File Descriptor Store
+ overview for details.
+
diff --git a/man/systemd-nspawn.xml b/man/systemd-nspawn.xml
index c93fad3776..f8b82a4a6e 100644
--- a/man/systemd-nspawn.xml
+++ b/man/systemd-nspawn.xml
@@ -681,6 +681,20 @@
systemd-vmspawn1
that defaults to true.)
+ If systemd-nspawn itself is invoked with a $NOTIFY_SOCKET
+ set in its environment (i.e. it is itself supervised by a service manager that uses the
+ sd_notify3
+ protocol), FDSTORE=1 and FDSTOREREMOVE=1 messages received
+ from the container payload (along with any accompanying file descriptors and
+ FDNAME= tag) are forwarded one level up to the enveloping service manager. This
+ allows the file descriptor store of services running inside the container to be preserved across
+ container restarts (and, transitively, across restarts, re-execs, soft-reboots and LUO-based kexecs
+ of any outer service manager), provided
+ FileDescriptorStoreMax=/FileDescriptorStorePreserve=yes are
+ configured on the unit running systemd-nspawn. See the
+ File Descriptor Store documentation
+ for details.
+
diff --git a/man/systemd.service.xml b/man/systemd.service.xml
index d4a1978523..028c1144b0 100644
--- a/man/systemd.service.xml
+++ b/man/systemd.service.xml
@@ -1255,6 +1255,26 @@ RestartMaxDelaySec=160s
is removed, the service manager exits, or the file descriptors get EPOLLHUP or
EPOLLERR.
+ When set to yes, and the service is itself running under another service
+ manager (e.g. a service of user@.service, or a payload inside
+ systemd-nspawn1),
+ file descriptors pushed into the store are also forwarded one level up via the enveloping manager's
+ $NOTIFY_SOCKET, tagged with the originating unit id, so that they are preserved
+ across restarts of the inner manager and handed back to the originating unit when it is started
+ again. For this to take effect, the enveloping unit must itself enable
+ FileDescriptorStoreMax= and FileDescriptorStorePreserve=yes.
+ See the File Descriptor Store
+ overview for details.
+
+ Setting this to yes also ensures the file descriptor store is kept loaded
+ across a kexec-based reboot on kernels supporting the Live Update Orchestrator,
+ so that compatible file descriptors (such as memfd_create2)
+ are preserved and handed back to the service on the other side. See the File Descriptor Store overview for
+ details.
+
Use systemctl clean --what=fdstore … to release the file descriptor store
explicitly.
diff --git a/mkosi/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf b/mkosi/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf
new file mode 100644
index 0000000000..3b023f7832
--- /dev/null
+++ b/mkosi/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf
@@ -0,0 +1,3 @@
+[Service]
+FileDescriptorStoreMax=16
+FileDescriptorStorePreserve=yes
diff --git a/mkosi/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf b/mkosi/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf
new file mode 100644
index 0000000000..8a0b417e97
--- /dev/null
+++ b/mkosi/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf
@@ -0,0 +1,5 @@
+# For tests exercising the fd store we need the unit in the rootfs to have these
+# settings, or the fdstore content will be dropped in the initrd -> rootfs transition
+[Service]
+FileDescriptorStoreMax=20
+FileDescriptorStorePreserve=yes
diff --git a/mkosi/mkosi.images/minimal-base/mkosi.postinst b/mkosi/mkosi.images/minimal-base/mkosi.postinst
index 6feaebc19a..ba3f4aec31 100755
--- a/mkosi/mkosi.images/minimal-base/mkosi.postinst
+++ b/mkosi/mkosi.images/minimal-base/mkosi.postinst
@@ -16,3 +16,8 @@ chmod +x "$BUILDROOT/sbin/init"
if [ ! -e "$BUILDROOT/etc/os-release" ]; then
ln -s ../usr/lib/os-release "$BUILDROOT/etc/os-release"
fi
+
+# For use in the minimal containers, only needs libsystemd and libc
+if [[ -x "$BUILDDIR/test-fdstore" ]]; then
+ cp "$BUILDDIR/test-fdstore" "$BUILDROOT/usr/bin/test-fdstore"
+fi
diff --git a/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf b/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf
new file mode 100644
index 0000000000..3b023f7832
--- /dev/null
+++ b/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/systemd-nspawn@.service.d/fdstore.conf
@@ -0,0 +1,3 @@
+[Service]
+FileDescriptorStoreMax=16
+FileDescriptorStorePreserve=yes
diff --git a/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf b/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf
new file mode 100644
index 0000000000..311feddad0
--- /dev/null
+++ b/mkosi/mkosi.initrd.conf/mkosi.extra/usr/lib/systemd/system/user@.service.d/fdstore.conf
@@ -0,0 +1,5 @@
+# For tests exercising the FD store we need the unit in the initrd to have these
+# settings, or the fdstore content will be dropped in the initrd
+[Service]
+FileDescriptorStoreMax=20
+FileDescriptorStorePreserve=yes
diff --git a/src/analyze/analyze-condition.c b/src/analyze/analyze-condition.c
index a928f84ef4..5c5177c3be 100644
--- a/src/analyze/analyze-condition.c
+++ b/src/analyze/analyze-condition.c
@@ -98,7 +98,7 @@ static int verify_conditions(char **lines, RuntimeScope scope, const char *unit,
return log_error_errno(r, "Failed to initialize manager: %m");
log_debug("Starting manager...");
- r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, root);
+ r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, /* named_listen_fds= */ NULL, root);
if (r < 0)
return r;
diff --git a/src/analyze/analyze-security.c b/src/analyze/analyze-security.c
index 08bff2cf9e..fdeaf69e17 100644
--- a/src/analyze/analyze-security.c
+++ b/src/analyze/analyze-security.c
@@ -2713,7 +2713,7 @@ static int offline_security_checks(
log_debug("Starting manager...");
- r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, root);
+ r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, /* named_listen_fds= */ NULL, root);
if (r < 0)
return r;
diff --git a/src/analyze/analyze-verify-util.c b/src/analyze/analyze-verify-util.c
index e7ffae5a28..ad553078d4 100644
--- a/src/analyze/analyze-verify-util.c
+++ b/src/analyze/analyze-verify-util.c
@@ -315,7 +315,7 @@ int verify_units(
log_debug("Starting manager...");
- r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, root);
+ r = manager_startup(m, /* serialization= */ NULL, /* fds= */ NULL, /* named_listen_fds= */ NULL, root);
if (r < 0)
return r;
diff --git a/src/core/luo.c b/src/core/luo.c
new file mode 100644
index 0000000000..9dc892d5d6
--- /dev/null
+++ b/src/core/luo.c
@@ -0,0 +1,288 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "sd-json.h"
+
+#include "errno-util.h"
+#include "fd-util.h"
+#include "fdset.h"
+#include "fileio.h"
+#include "json-util.h"
+#include "log.h"
+#include "luo.h"
+#include "luo-util.h"
+#include "manager.h"
+#include "serialize.h"
+#include "service.h"
+#include "unit.h"
+#include "unit-name.h"
+
+static int luo_read_mapping(int session_fd, sd_json_variant **ret) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL;
+ _cleanup_close_ int mapping_fd = -EBADF;
+ _cleanup_fclose_ FILE *f = NULL;
+ int r;
+
+ assert(session_fd >= 0);
+ assert(ret);
+
+ mapping_fd = luo_session_retrieve_fd(session_fd, LUO_MAPPING_INDEX);
+ if (mapping_fd < 0)
+ return log_warning_errno(mapping_fd, "Failed to retrieve LUO mapping fd (fd_index 0): %m");
+
+ r = fdopen_independent(mapping_fd, "r", &f);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to open LUO mapping fd for reading: %m");
+
+ r = sd_json_parse_file(f, "luo-mapping", SD_JSON_PARSE_MUST_BE_OBJECT, &v, /* reterr_line= */ NULL, /* reterr_column= */ NULL);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to parse LUO mapping JSON: %m");
+
+ *ret = TAKE_PTR(v);
+ return 0;
+}
+
+static void luo_session_finishp(int *fd) {
+ assert(fd);
+
+ if (*fd >= 0)
+ (void) luo_session_finish(*fd);
+ safe_close(*fd);
+}
+
+int manager_luo_restore_fd_stores(Manager *m) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *mapping = NULL;
+ _cleanup_close_ int device_fd = -EBADF;
+ _cleanup_(luo_session_finishp) int session_fd = -EBADF;
+ const char *unit_id;
+ sd_json_variant *fds_json;
+ int r, n_total = 0;
+
+ assert(m);
+
+ if (MANAGER_IS_USER(m))
+ return 0;
+
+ device_fd = luo_open_device();
+ if (ERRNO_IS_NEG_DEVICE_ABSENT(device_fd)) {
+ log_debug_errno(device_fd, "No /dev/liveupdate device found, skipping LUO fd store restoration.");
+ return 0;
+ }
+ if (device_fd < 0)
+ return log_warning_errno(device_fd, "Failed to open /dev/liveupdate: %m");
+
+ session_fd = luo_retrieve_session(device_fd, LUO_SESSION_NAME);
+ if (session_fd == -ENOENT) {
+ log_debug("No LUO session '%s' found, skipping fd store restoration.", LUO_SESSION_NAME);
+ return 0;
+ }
+ if (session_fd < 0)
+ return log_warning_errno(session_fd, "Failed to retrieve LUO session '%s': %m", LUO_SESSION_NAME);
+
+ log_debug("Found LUO session '%s', restoring fd stores.", LUO_SESSION_NAME);
+
+ r = luo_read_mapping(session_fd, &mapping);
+ if (r < 0)
+ return r;
+
+ /* Retrieve all fds from the session and dispatch each to the named unit, eagerly loading the
+ * unit if necessary. */
+ JSON_VARIANT_OBJECT_FOREACH(unit_id, fds_json, mapping) {
+ sd_json_variant *entry;
+
+ if (!unit_name_is_valid(unit_id, UNIT_NAME_ANY)) {
+ log_warning("Invalid unit name '%s' in LUO mapping, skipping.", unit_id);
+ continue;
+ }
+
+ if (!sd_json_variant_is_array(fds_json)) {
+ log_warning("LUO mapping for unit '%s' is not a JSON array, skipping.", unit_id);
+ continue;
+ }
+
+ JSON_VARIANT_ARRAY_FOREACH(entry, fds_json) {
+ struct {
+ const char *type;
+ const char *name;
+ uint64_t token;
+ const char *session_name;
+ } p = {
+ .token = UINT64_MAX,
+ };
+
+ static const sd_json_dispatch_field dispatch_table[] = {
+ { "type", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, type), SD_JSON_MANDATORY },
+ { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), SD_JSON_MANDATORY },
+ { "token", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, token), 0 },
+ { "sessionName", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, session_name), 0 },
+ {}
+ };
+
+ _cleanup_close_ int fd = -EBADF;
+
+ r = sd_json_dispatch(entry, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG|SD_JSON_WARNING, &p);
+ if (r < 0)
+ continue;
+
+ if (streq(p.type, "fd")) {
+ if (p.token == UINT64_MAX) {
+ log_warning("LUO mapping for unit '%s' fd '%s': missing 'token' field.", unit_id, p.name);
+ continue;
+ }
+ if (p.token == LUO_MAPPING_INDEX) {
+ log_warning("LUO mapping for unit '%s' fd '%s': token %" PRIu64 " is reserved for the mapping memfd.", unit_id, p.name, p.token);
+ continue;
+ }
+
+ fd = luo_session_retrieve_fd(session_fd, p.token);
+ if (fd < 0) {
+ log_warning_errno(fd, "Failed to retrieve LUO fd for unit '%s' name '%s' token %" PRIu64 ": %m",
+ unit_id, p.name, p.token);
+ continue;
+ }
+ } else if (streq(p.type, "luo_session")) {
+ if (!p.session_name) {
+ log_warning("LUO mapping for unit '%s' fd '%s': missing sessionName.", unit_id, p.name);
+ continue;
+ }
+
+ fd = luo_retrieve_session(device_fd, p.session_name);
+ if (fd < 0) {
+ log_warning_errno(fd, "Failed to retrieve LUO session '%s' for unit '%s' name '%s': %m",
+ p.session_name, unit_id, p.name);
+ continue;
+ }
+
+ log_debug("Retrieved LUO session '%s' for unit fd store '%s'.", p.session_name, p.name);
+ } else {
+ log_warning("LUO mapping for unit '%s' fd '%s': unknown type '%s', skipping.",
+ unit_id, p.name, p.type);
+ continue;
+ }
+
+ r = manager_dispatch_external_fd_to_unit(m, unit_id, p.name, /* index= */ 0, TAKE_FD(fd), "LUO");
+ if (r > 0)
+ n_total++;
+ /* On error fd is already consumed by manager_dispatch_external_fd_to_unit. */
+ }
+ }
+
+ if (n_total > 0)
+ log_debug("Restored %d fd(s) total from LUO session.", n_total);
+
+ return n_total;
+}
+
+int manager_luo_serialize_fd_stores(Manager *m, FILE **ret_f, FDSet **ret_fds) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *root = NULL;
+ _cleanup_fclose_ FILE *f = NULL;
+ _cleanup_fdset_free_ FDSet *fds = NULL;
+ Unit *u;
+ int r, n_serialized = 0;
+
+ assert(m);
+ assert(ret_f);
+ assert(ret_fds);
+
+ if (MANAGER_IS_USER(m)) {
+ *ret_f = NULL;
+ *ret_fds = NULL;
+ return 0;
+ }
+
+ fds = fdset_new();
+ if (!fds)
+ return log_oom();
+
+ /* Build a JSON object: { "unit_id": [ { "type": "fd", "name": "...", "fd": N },
+ * { "type": "luo_session", "name": "...", "fd": N, "sessionName": "..." } ], ... }
+ * This is passed to systemd-shutdown which will create a LUO session and preserve the fds. */
+ HASHMAP_FOREACH(u, m->units) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *entries = NULL;
+ Service *s;
+
+ if (u->type != UNIT_SERVICE)
+ continue;
+
+ s = SERVICE(u);
+
+ if (s->fd_store_preserve_mode != EXEC_PRESERVE_YES)
+ continue;
+
+ if (!s->fd_store)
+ continue;
+
+ LIST_FOREACH(fd_store, fs, s->fd_store) {
+ _cleanup_free_ char *session_name = NULL;
+ int copy;
+
+ /* Check if this fd is itself a LUO session, as those cannot be nested and need
+ * special handling */
+ r = fd_get_luo_session_name(fs->fd, &session_name);
+ if (r < 0 && r != -EMEDIUMTYPE) {
+ log_warning_errno(r, "Failed to check if fd '%s' of unit '%s' is a LUO session, skipping: %m",
+ fs->fdname, u->id);
+ continue;
+ }
+
+ /* Ensure nobody tries to hijack our session, as we will create this later before
+ * kexec */
+ if (streq_ptr(session_name, LUO_SESSION_NAME)) {
+ log_warning("Skipping fd '%s' of unit '%s' for LUO serialization, as the session name '%s' infringes systemd's namespace.",
+ fs->fdname, u->id, session_name);
+ continue;
+ }
+
+ copy = fdset_put_dup(fds, fs->fd);
+ if (copy < 0)
+ return log_error_errno(copy, "Failed to duplicate fd for LUO serialization: %m");
+
+ r = sd_json_variant_append_arraybo(
+ &entries,
+ SD_JSON_BUILD_PAIR_STRING("type", session_name ? "luo_session" : "fd"),
+ SD_JSON_BUILD_PAIR_STRING("name", fs->fdname),
+ SD_JSON_BUILD_PAIR_INTEGER("fd", copy),
+ JSON_BUILD_PAIR_STRING_NON_EMPTY("sessionName", session_name));
+ if (r < 0)
+ return log_error_errno(r, "Failed to build JSON for LUO serialization: %m");
+
+ n_serialized++;
+ }
+
+ r = sd_json_variant_set_field(&root, u->id, entries);
+ if (r < 0)
+ return log_error_errno(r, "Failed to add unit to LUO serialization JSON: %m");
+ }
+
+ if (n_serialized == 0) {
+ log_debug("No fd store entries to serialize for LUO.");
+ *ret_f = NULL;
+ *ret_fds = NULL;
+ return 0;
+ }
+
+ r = open_serialization_file("luo-fd-store", &f);
+ if (r < 0)
+ return log_error_errno(r, "Failed to create LUO serialization file: %m");
+
+ r = sd_json_variant_dump(root, /* flags= */ 0, f, /* prefix= */ NULL);
+ if (r < 0)
+ return log_error_errno(r, "Failed to dump LUO serialization JSON: %m");
+
+ r = finish_serialization_file(f);
+ if (r < 0)
+ return log_error_errno(r, "Failed to finish LUO serialization file: %m");
+
+ r = fd_cloexec(fileno(f), false);
+ if (r < 0)
+ return log_error_errno(r, "Failed to disable O_CLOEXEC for LUO serialization: %m");
+
+ r = fdset_cloexec(fds, false);
+ if (r < 0)
+ return log_error_errno(r, "Failed to disable O_CLOEXEC for LUO serialization fds: %m");
+
+ log_info("Serialized %d fd store entries for LUO.", n_serialized);
+
+ *ret_f = TAKE_PTR(f);
+ *ret_fds = TAKE_PTR(fds);
+ return n_serialized;
+}
diff --git a/src/core/luo.h b/src/core/luo.h
new file mode 100644
index 0000000000..314006c76b
--- /dev/null
+++ b/src/core/luo.h
@@ -0,0 +1,7 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include "core-forward.h"
+
+int manager_luo_restore_fd_stores(Manager *m);
+int manager_luo_serialize_fd_stores(Manager *m, FILE **ret_f, FDSet **ret_fds);
diff --git a/src/core/main.c b/src/core/main.c
index c6d66c9ec7..f98d0edb2d 100644
--- a/src/core/main.c
+++ b/src/core/main.c
@@ -15,6 +15,7 @@
#include "sd-bus.h"
#include "sd-daemon.h"
+#include "sd-json.h"
#include "sd-messages.h"
#include "alloc-util.h"
@@ -44,12 +45,15 @@
#include "emergency-action.h"
#include "env-util.h"
#include "escape.h"
+#include "extract-word.h"
#include "fd-util.h"
#include "fdset.h"
#include "fileio.h"
#include "format-table.h"
#include "format-util.h"
#include "glyph-util.h"
+#include "hash-funcs.h"
+#include "hashmap.h"
#include "help-util.h"
#include "hexdecoct.h"
#include "hostname-setup.h"
@@ -59,6 +63,7 @@
#include "initrd-util.h"
#include "io-util.h"
#include "ipe-setup.h"
+#include "json-util.h"
#include "killall.h"
#include "kmod-setup.h"
#include "label-util.h"
@@ -67,6 +72,7 @@
#include "load-fragment.h"
#include "log.h"
#include "loopback-setup.h"
+#include "luo.h"
#include "machine-id-setup.h"
#include "main.h"
#include "manager.h"
@@ -82,6 +88,7 @@
#include "parse-argument.h"
#include "parse-util.h"
#include "path-util.h"
+#include "pidfd-util.h"
#include "proc-cmdline.h"
#include "process-util.h"
#include "random-util.h"
@@ -91,6 +98,7 @@
#include "selinux-setup.h"
#include "selinux-util.h"
#include "serialize.h"
+#include "service.h"
#include "set.h"
#include "signal-util.h"
#include "smack-setup.h"
@@ -157,6 +165,7 @@ static RestrictFileSystemAccess arg_restrict_filesystem_access;
static nsec_t arg_timer_slack_nsec;
static Set* arg_syscall_archs;
static FILE* arg_serialization;
+static FILE* arg_luo_serialization;
static sd_id128_t arg_machine_id;
static bool arg_machine_id_from_firmware = false;
static EmergencyAction arg_cad_burst_action;
@@ -1799,6 +1808,13 @@ static int become_shutdown(int objective, int retval) {
if (arg_minimum_uptime_usec != USEC_INFINITY)
(void) strv_extendf(&env_block, "MINIMUM_UPTIME_USEC=" USEC_FMT, arg_minimum_uptime_usec);
+ /* If we have a LUO serialization file, pass the fd to systemd-shutdown so it can
+ * preserve FD store entries across kexec via the kernel Live Update Orchestrator. */
+ if (arg_luo_serialization) {
+ log_debug("Passing LUO serialization fd to systemd-shutdown.");
+ (void) strv_extendf(&env_block, "SYSTEMD_LUO_SERIALIZE_FD=%i", fileno(arg_luo_serialization));
+ }
+
(void) write_boot_or_shutdown_osc("shutdown");
execve(SYSTEMD_SHUTDOWN_BINARY_PATH, (char **) command_line, env_block);
@@ -3069,10 +3085,254 @@ static int initialize_security(
return 0;
}
-static int collect_fds(FDSet **ret_fds, const char **ret_error_message) {
+static int parse_listen_fds_env(unsigned *ret_n_fds, char ***ret_names) {
+ _cleanup_strv_free_ char **names = NULL;
+ const char *e;
+ unsigned n_fds;
+ int r;
+
+ assert(ret_n_fds);
+ assert(ret_names);
+
+ /* Parse and validate the LISTEN_PID=/LISTEN_PIDFDID=/LISTEN_FDS=/LISTEN_FDNAMES= environment
+ * variables. */
+
+ e = secure_getenv("LISTEN_PID");
+ if (!e)
+ return -ENXIO;
+
+ pid_t pid;
+ r = parse_pid(e, &pid);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to parse LISTEN_PID=%s: %m", e);
+ if (pid != getpid_cached())
+ return log_debug_errno(SYNTHETIC_ERRNO(EINVAL),
+ "LISTEN_PID=%s does not match our own PID " PID_FMT ", ignoring.",
+ e,
+ getpid_cached());
+
+ e = secure_getenv("LISTEN_PIDFDID");
+ if (e) {
+ uint64_t own_pidfdid, pidfdid;
+
+ r = safe_atou64(e, &pidfdid);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to parse LISTEN_PIDFDID=%s: %m", e);
+
+ if (pidfd_get_inode_id_self_cached(&own_pidfdid) >= 0 && pidfdid != own_pidfdid)
+ return log_debug_errno(SYNTHETIC_ERRNO(EINVAL),
+ "LISTEN_PIDFDID=%s does not match our own pidfdid %" PRIu64 ", ignoring.",
+ e,
+ own_pidfdid);
+ }
+
+ e = secure_getenv("LISTEN_FDS");
+ if (!e)
+ return -ENXIO;
+
+ r = safe_atou(e, &n_fds);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to parse LISTEN_FDS= value '%s': %m", e);
+ if (n_fds == 0)
+ return -ENXIO;
+ if (n_fds > INT_MAX - SD_LISTEN_FDS_START)
+ return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid number of fds in LISTEN_FDS= value '%s'", e);
+
+ e = secure_getenv("LISTEN_FDNAMES");
+ if (!e)
+ return -ENXIO;
+
+ r = strv_split_full(&names, e, ":", EXTRACT_DONT_COALESCE_SEPARATORS);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to parse LISTEN_FDNAMES=%s: %m", e);
+ if (strv_length(names) != (size_t) n_fds)
+ return log_debug_errno(SYNTHETIC_ERRNO(EINVAL),
+ "Mismatch between number of LISTEN_FDS= and LISTEN_FDNAMES= entries: %u vs %zu",
+ n_fds, strv_length(names));
+
+ *ret_n_fds = n_fds;
+ *ret_names = TAKE_PTR(names);
+ return 0;
+}
+
+DEFINE_PRIVATE_HASH_OPS_WITH_VALUE_DESTRUCTOR(
+ index_to_tag_hash_ops,
+ uint64_t, uint64_hash_func, uint64_compare_func,
+ ListenFDsTag, listen_fds_tag_free);
+
+static int parse_listen_fds_mapping(int mapping_fd, Hashmap **ret_index_to_tag) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *root = NULL;
+ _cleanup_hashmap_free_ Hashmap *index_to_tag = NULL;
+ const char *unit_id;
+ sd_json_variant *fds_json;
+ int r;
+
+ assert(mapping_fd >= 0);
+ assert(ret_index_to_tag);
+
+ /* Parse the JSON mapping memfd that the downstream manager pushed alongside the indexed fds:
+ * { "unit-name.service": [ { "name": "fdname1", "index": 1 }, ... ], ... }
+ * Returns a hashmap keyed by stringified index ("1", "2", ...) with ListenFDsTag* values
+ * carrying the resolved (unit_id, original fdname, upstream index). */
+
+ _cleanup_fclose_ FILE *f = NULL;
+ r = fdopen_independent(mapping_fd, "r", &f);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to open fdstore-mapping memfd: %m");
+
+ r = sd_json_parse_file(f, "fdstore-mapping", SD_JSON_PARSE_MUST_BE_OBJECT, &root,
+ /* reterr_line= */ NULL, /* reterr_column= */ NULL);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to parse fdstore-mapping JSON: %m");
+
+ JSON_VARIANT_OBJECT_FOREACH(unit_id, fds_json, root) {
+ sd_json_variant *entry;
+
+ if (!unit_name_is_valid(unit_id, UNIT_NAME_ANY)) {
+ log_warning("fdstore-mapping has invalid unit name '%s', skipping.", unit_id);
+ continue;
+ }
+
+ JSON_VARIANT_ARRAY_FOREACH(entry, fds_json) {
+ struct {
+ const char *name;
+ uint64_t index;
+ } p = { };
+
+ static const sd_json_dispatch_field dispatch_table[] = {
+ { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), SD_JSON_MANDATORY },
+ { "index", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, index), SD_JSON_MANDATORY },
+ {}
+ };
+
+ r = sd_json_dispatch(entry, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG|SD_JSON_WARNING, &p);
+ if (r < 0)
+ continue;
+
+ if (p.index == 0) {
+ log_warning("fdstore-mapping entry for unit '%s' name '%s' has zero index, skipping.", unit_id, p.name);
+ continue;
+ }
+
+ _cleanup_(listen_fds_tag_freep) ListenFDsTag *t = new(ListenFDsTag, 1);
+ if (!t)
+ return log_oom();
+
+ *t = (ListenFDsTag) {
+ .index = p.index,
+ };
+
+ t->unit_id = strdup(unit_id);
+ t->fdname = strdup(p.name);
+ if (!t->unit_id || !t->fdname)
+ return log_oom();
+
+ /* Key points into the value struct, so freeing the value frees the key. */
+ r = hashmap_ensure_put(&index_to_tag, &index_to_tag_hash_ops, &t->index, t);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to insert fdstore-mapping entry into hashmap: %m");
+ if (r > 0)
+ TAKE_PTR(t);
+ }
+ }
+
+ *ret_index_to_tag = TAKE_PTR(index_to_tag);
+ return 0;
+}
+
+static int collect_listen_fds_named(FDSet *fds, Hashmap **ret_named_fds) {
+ _cleanup_hashmap_free_ Hashmap *named_fds = NULL, *index_to_tag = NULL;
+ _cleanup_strv_free_ char **names = NULL;
+ unsigned n_fds;
+ int r;
+
+ assert(fds);
+ assert(ret_named_fds);
+
+ /* Pull entries from the LISTEN_FDS=/LISTEN_FDNAMES= protocol out of 'fds' into a hashmap
+ * keyed by fd. Two flavours of named entries are recognized:
+ *
+ * - A single mapping memfd whose fdname matches SERVICE_FDSTORE_MAPPING_FDNAME, which
+ * contains a JSON map pairing numeric indices to (unit-id, original-fdname).
+ * - Numeric indices (matching entries in the mapping document) for the actual fds.
+ *
+ * The hashmap owns the fds (closed via destructor on cleanup) so any entries the dispatcher
+ * does not consume are correctly cleaned up. */
+
+ r = parse_listen_fds_env(&n_fds, &names);
+ if (r < 0) {
+ /* Fail gracefully here, just warn and ignore but otherwise proceed on parsing failure */
+ if (r != -ENXIO)
+ log_warning_errno(r, "Failed to parse LISTEN_FDS environment, ignoring: %m");
+ *ret_named_fds = NULL;
+ return 0;
+ }
+
+ /* First pass: locate and parse the mapping memfd, if any. */
+ for (unsigned i = 0; i < n_fds; i++) {
+ int fd = SD_LISTEN_FDS_START + i;
+
+ if (!streq(names[i], SERVICE_FDSTORE_MAPPING_FDNAME))
+ continue;
+
+ if (!fdset_contains(fds, fd))
+ continue;
+
+ (void) parse_listen_fds_mapping(fd, &index_to_tag);
+
+ /* The mapping memfd itself is not routed to any unit; close it and remove from fds
+ * so it doesn't get redistributed */
+ assert_se(fdset_remove(fds, fd) == fd);
+ safe_close(fd);
+ break;
+ }
+
+ /* Second pass: route fds whose name matches an entry in the mapping. */
+ for (unsigned i = 0; i < n_fds; i++) {
+ int fd = SD_LISTEN_FDS_START + i;
+ const char *name = names[i], *suffix;
+ ListenFDsTag *t;
+ uint64_t idx;
+
+ if (!fdset_contains(fds, fd))
+ continue;
+
+ if (!index_to_tag)
+ continue;
+
+ suffix = startswith(name, SERVICE_FDSTORE_SUB_FDNAME_PREFIX);
+ if (!suffix || safe_atou64(suffix, &idx) < 0)
+ continue;
+
+ /* Steal the matching mapping entry — we transfer ownership of the parsed
+ * (unit_id, fdname, index) struct into the per-fd hashmap that the manager
+ * will consume. */
+ t = hashmap_remove(index_to_tag, &idx);
+ if (!t)
+ continue;
+
+ _cleanup_(listen_fds_tag_freep) ListenFDsTag *t_owned = t;
+
+ r = hashmap_ensure_put(&named_fds, &fd_to_listen_fds_tag_hash_ops, FD_TO_PTR(fd), t_owned);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to insert named fd into hashmap: %m");
+ if (r == 0)
+ continue; /* fd already inserted, cannot really happen */
+
+ TAKE_PTR(t_owned);
+
+ assert_se(fdset_remove(fds, fd) == fd);
+ }
+
+ *ret_named_fds = TAKE_PTR(named_fds);
+ return 1;
+}
+
+static int collect_fds(FDSet **ret_fds, Hashmap **ret_named_fds, const char **ret_error_message) {
int r;
assert(ret_fds);
+ assert(ret_named_fds);
assert(ret_error_message);
/* Pick up all fds passed to us. We apply a filter here: we only take the fds that have O_CLOEXEC
@@ -3094,6 +3354,8 @@ static int collect_fds(FDSet **ret_fds, const char **ret_error_message) {
/* The serialization fd should have O_CLOEXEC turned on already, let's verify that we didn't pick it up here */
assert_se(!arg_serialization || !fdset_contains(*ret_fds, fileno(arg_serialization)));
+ (void) collect_listen_fds_named(*ret_fds, ret_named_fds);
+
return 0;
}
@@ -3151,6 +3413,7 @@ int main(int argc, char *argv[]) {
* for the two that indicate whether
* these fields are initialized! */
bool skip_setup, loaded_policy = false, queue_default_job = false, first_boot = false;
+ _cleanup_hashmap_free_ Hashmap *named_listen_fds = NULL;
char *switch_root_dir = NULL, *switch_root_init = NULL;
usec_t before_startup, after_startup;
static char systemd[] = "systemd";
@@ -3396,7 +3659,7 @@ int main(int argc, char *argv[]) {
log_close();
/* Remember open file descriptors for later deserialization */
- r = collect_fds(&fds, &error_message);
+ r = collect_fds(&fds, &named_listen_fds, &error_message);
if (r < 0)
goto finish;
@@ -3471,7 +3734,7 @@ int main(int argc, char *argv[]) {
before_startup = now(CLOCK_MONOTONIC);
- r = manager_startup(m, arg_serialization, fds, /* root= */ NULL);
+ r = manager_startup(m, arg_serialization, fds, named_listen_fds, /* root= */ NULL);
if (r < 0) {
error_message = "Failed to start up manager";
goto finish;
@@ -3523,6 +3786,12 @@ finish:
if (m) {
arg_reboot_watchdog = manager_get_watchdog(m, WATCHDOG_REBOOT);
arg_kexec_watchdog = manager_get_watchdog(m, WATCHDOG_KEXEC);
+
+ /* For kexec, serialize fd stores now. Services have stopped and sent
+ * their FDs to the store, but the manager (and its fd stores) is still alive. */
+ if (r == MANAGER_KEXEC)
+ (void) manager_luo_serialize_fd_stores(m, &arg_luo_serialization, &fds);
+
m = manager_free(m);
}
@@ -3540,7 +3809,13 @@ finish:
&error_message); /* This only returns if reexecution failed */
arg_serialization = safe_fclose(arg_serialization);
- fds = fdset_free(fds);
+
+ /* For kexec, the FDSet and LUO serialization file must survive until become_shutdown() calls
+ * execve() (CLOEXEC is already cleared on these FDs). For all other paths, free them now. */
+ if (r != MANAGER_KEXEC) {
+ fds = fdset_free(fds);
+ arg_luo_serialization = safe_fclose(arg_luo_serialization);
+ }
saved_env = strv_free(saved_env);
diff --git a/src/core/manager-serialize.c b/src/core/manager-serialize.c
index 6bc41f15f9..bef4021771 100644
--- a/src/core/manager-serialize.c
+++ b/src/core/manager-serialize.c
@@ -124,6 +124,7 @@ int manager_serialize(
(void) serialize_item(f, "previous-objective", manager_objective_to_string(m->objective));
(void) serialize_item_format(f, "soft-reboots-count", "%u", m->soft_reboots_count);
+ (void) serialize_item_format(f, "fd-store-upstream-next-index", "%" PRIu64, m->fd_store_upstream_next_index);
for (ManagerTimestamp q = 0; q < _MANAGER_TIMESTAMP_MAX; q++) {
_cleanup_free_ char *joined = NULL;
@@ -757,6 +758,9 @@ int manager_deserialize(Manager *m, FILE *f, FDSet *fds) {
log_notice("Failed to parse soft reboots counter '%s', ignoring.", val);
else
m->soft_reboots_count = n;
+ } else if ((val = startswith(l, "fd-store-upstream-next-index="))) {
+ if (safe_atou64(val, &m->fd_store_upstream_next_index) < 0)
+ log_notice("Failed to parse fd-store-upstream-next-index '%s', ignoring.", val);
} else if ((val = startswith(l, "previous-objective="))) {
ManagerObjective objective;
diff --git a/src/core/manager.c b/src/core/manager.c
index 038690a808..accf9c8ff9 100644
--- a/src/core/manager.c
+++ b/src/core/manager.c
@@ -57,6 +57,7 @@
#include "libaudit-util.h"
#include "locale-setup.h"
#include "log.h"
+#include "luo.h"
#include "manager-dump.h"
#include "manager-serialize.h"
#include "manager.h"
@@ -76,6 +77,7 @@
#include "rm-rf.h"
#include "selinux-util.h"
#include "serialize.h"
+#include "service.h"
#include "set.h"
#include "signal-util.h"
#include "socket-util.h"
@@ -1878,6 +1880,93 @@ static void manager_catchup(Manager *m) {
}
}
+ListenFDsTag* listen_fds_tag_free(ListenFDsTag *t) {
+ if (!t)
+ return NULL;
+
+ free(t->unit_id);
+ free(t->fdname);
+ return mfree(t);
+}
+
+DEFINE_HASH_OPS_FULL(
+ fd_to_listen_fds_tag_hash_ops,
+ void, trivial_hash_func, trivial_compare_func, close_fd_ptr,
+ ListenFDsTag, listen_fds_tag_free);
+
+int manager_dispatch_external_fd_to_unit(
+ Manager *m,
+ const char *unit_id,
+ const char *fdname,
+ uint64_t index,
+ int fd_in,
+ const char *log_context) {
+
+ _cleanup_close_ int fd = ASSERT_FD(fd_in);
+ Unit *u = NULL;
+ int r;
+
+ assert(m);
+ assert(unit_id);
+ assert(fdname);
+ assert(log_context);
+
+ /* Load the unit eagerly: if the unit file exists this brings it into UNIT_LOADED, otherwise it
+ * lands in UNIT_NOT_FOUND. In both cases we want to attach the fd so it's preserved until the
+ * unit is fully stopped (or its file appears via daemon-reload). */
+ r = manager_load_unit(m, unit_id, /* path= */ NULL, /* e= */ NULL, &u);
+ if (r < 0)
+ return log_warning_errno(r, "%s: failed to load unit '%s', closing fd '%s': %m",
+ log_context, unit_id, fdname);
+
+ if (!UNIT_VTABLE(u)->attach_external_fd_to_fdstore)
+ return log_warning_errno(SYNTHETIC_ERRNO(EINVAL),
+ "%s: unit '%s' does not support fd restoration, closing fd '%s'.",
+ log_context, unit_id, fdname);
+
+ r = UNIT_VTABLE(u)->attach_external_fd_to_fdstore(u, TAKE_FD(fd), fdname, index);
+ if (r < 0)
+ return log_unit_warning_errno(u, r, "%s: failed to attach fd '%s' to fd store: %m",
+ log_context, fdname);
+
+ return 1; /* fd consumed */
+}
+
+static int manager_distribute_listen_fds_named(Manager *m, Hashmap *named_listen_fds) {
+ assert(m);
+
+ /* Route fds whose LISTEN_FDNAMES name was a numeric index into the matching unit's fd store.
+ * The hashmap is built and owned by main.c's collect_fds(), keyed by fd, with ListenFDsTag* values
+ * that already carry the parsed unit-id, original fdname and index (resolved against the
+ * upstream-pushed fdstore-mapping memfd). We steal entries here so any leftover (skipped) entries
+ * are still cleaned up by the hashmap's destructor on the caller side. */
+
+ if (MANAGER_IS_TEST_RUN(m))
+ return 0;
+
+ for (;;) {
+ _cleanup_(listen_fds_tag_freep) ListenFDsTag *t = NULL;
+ _cleanup_close_ int fd = -EBADF;
+ void *key;
+
+ t = hashmap_steal_first_key_and_value(named_listen_fds, &key);
+ if (!t)
+ break;
+
+ fd = PTR_TO_FD(key);
+
+ if (!t->unit_id || !t->fdname)
+ continue;
+
+ if (!unit_name_is_valid(t->unit_id, UNIT_NAME_ANY))
+ continue;
+
+ (void) manager_dispatch_external_fd_to_unit(m, t->unit_id, t->fdname, t->index, TAKE_FD(fd), "LISTEN_FDS");
+ }
+
+ return 0;
+}
+
static void manager_distribute_fds(Manager *m, FDSet *fds) {
Unit *u;
@@ -2034,7 +2123,7 @@ static int manager_make_runtime_dir(Manager *m) {
return 0;
}
-int manager_startup(Manager *m, FILE *serialization, FDSet *fds, const char *root) {
+int manager_startup(Manager *m, FILE *serialization, FDSet *fds, Hashmap *named_listen_fds, const char *root) {
int r;
assert(m);
@@ -2103,6 +2192,15 @@ int manager_startup(Manager *m, FILE *serialization, FDSet *fds, const char *roo
if (m->previous_objective == MANAGER_SOFT_REBOOT)
m->soft_reboots_count++;
+ /* If a LUO (Live Update Orchestrator) session from a previous kexec is available, restore
+ * preserved file descriptors into the appropriate service fd stores now, before coldplug. */
+ (void) manager_luo_restore_fd_stores(m);
+
+ /* Pick up fds passed via the LISTEN_FDS=/LISTEN_FDNAMES= protocol that are tagged with a
+ * unit id ("unit-id|fdname"), and route them into the matching unit's fd store. Untagged
+ * fds remain in 'fds' and are handed to socket units below as before. */
+ (void) manager_distribute_listen_fds_named(m, named_listen_fds);
+
/* Any fds left? Find some unit which wants them. This is useful to allow container managers to pass
* some file descriptors to us pre-initialized. This enables socket-based activation of entire
* containers. */
@@ -2142,6 +2240,10 @@ int manager_startup(Manager *m, FILE *serialization, FDSet *fds, const char *roo
/* Clean up runtime objects */
manager_vacuum(m);
+ /* After deserialization, refresh the upstream JSON mapping memfd so the supervisor's
+ * view of our fd store stays consistent with the indices we just restored. */
+ (void) service_propagate_fd_store_mapping_upstream(m);
+
if (serialization)
/* Let's wait for the UnitNew/JobNew messages being sent, before we notify that the
* reload is finished */
diff --git a/src/core/manager.h b/src/core/manager.h
index d17693369a..4695112c04 100644
--- a/src/core/manager.h
+++ b/src/core/manager.h
@@ -513,6 +513,11 @@ typedef struct Manager {
/* The number of successfully completed configuration reloads. */
uint64_t reload_count;
+ /* Monotonic counter for fdstore entries propagated to a NOTIFY_SOCKET supervisor. Each propagated
+ * fd is sent upstream using this index as the FDNAME. The mapping (index -> unit_id + original fdname)
+ * is pushed alongside as a JSON memfd named "systemd-fdstore-mapping". */
+ uint64_t fd_store_upstream_next_index;
+
/* Original ambient capabilities when we were initialized */
uint64_t saved_ambient_set;
} Manager;
@@ -542,7 +547,20 @@ int manager_new(RuntimeScope scope, ManagerTestRunFlags test_run_flags, Manager
Manager* manager_free(Manager *m);
DEFINE_TRIVIAL_CLEANUP_FUNC(Manager*, manager_free);
-int manager_startup(Manager *m, FILE *serialization, FDSet *fds, const char *root);
+/* One entry parsed out of the upstream "systemd-fdstore-mapping" memfd. Pairs the numeric index from the
+ * JSON map to the (unit-id, original fdname) the fd was originally stored as. */
+typedef struct ListenFDsTag {
+ char *unit_id;
+ char *fdname;
+ uint64_t index;
+} ListenFDsTag;
+
+ListenFDsTag* listen_fds_tag_free(ListenFDsTag *t);
+DEFINE_TRIVIAL_CLEANUP_FUNC(ListenFDsTag*, listen_fds_tag_free);
+
+extern const struct hash_ops fd_to_listen_fds_tag_hash_ops;
+
+int manager_startup(Manager *m, FILE *serialization, FDSet *fds, Hashmap *named_listen_fds, const char *root);
Job *manager_get_job(Manager *m, uint32_t id);
Unit *manager_get_unit(Manager *m, const char *name);
@@ -552,6 +570,7 @@ int manager_get_job_from_dbus_path(Manager *m, const char *s, Job **_j);
bool manager_unit_cache_should_retry_load(Unit *u);
int manager_load_unit_prepare(Manager *m, const char *name, const char *path, sd_bus_error *e, Unit **ret);
int manager_load_unit(Manager *m, const char *name, const char *path, sd_bus_error *e, Unit **ret);
+int manager_dispatch_external_fd_to_unit(Manager *m, const char *unit_id, const char *fdname, uint64_t index, int fd, const char *log_context);
int manager_load_startable_unit_or_warn(Manager *m, const char *name, const char *path, Unit **ret);
int manager_load_unit_from_dbus_path(Manager *m, const char *s, sd_bus_error *e, Unit **_u);
diff --git a/src/core/meson.build b/src/core/meson.build
index 98cf02aef8..2bd8170c6a 100644
--- a/src/core/meson.build
+++ b/src/core/meson.build
@@ -43,6 +43,7 @@ libcore_sources = files(
'kill.c',
'load-dropin.c',
'load-fragment.c',
+ 'luo.c',
'manager-dump.c',
'manager-serialize.c',
'manager.c',
diff --git a/src/core/service.c b/src/core/service.c
index 445812f952..6a02ca8d6f 100644
--- a/src/core/service.c
+++ b/src/core/service.c
@@ -6,6 +6,7 @@
#include
#include "sd-bus.h"
+#include "sd-json.h"
#include "sd-messages.h"
#include "alloc-util.h"
@@ -15,6 +16,7 @@
#include "bus-util.h"
#include "cgroup.h"
#include "chase.h"
+#include "daemon-util.h"
#include "dbus-service.h"
#include "dbus-unit.h"
#include "devnum-util.h"
@@ -33,6 +35,7 @@
#include "image-policy.h"
#include "log.h"
#include "manager.h"
+#include "memfd-util.h"
#include "mount-util.h"
#include "namespace.h"
#include "open-file.h"
@@ -462,12 +465,27 @@ static void service_override_watchdog_timeout(Service *s, usec_t watchdog_overri
log_unit_debug(UNIT(s), "watchdog_override_usec="USEC_FMT, s->watchdog_override_usec);
}
-static ServiceFDStore* service_fd_store_unlink(ServiceFDStore *fs) {
+static ServiceFDStore* service_fd_store_unlink_full(ServiceFDStore *fs, bool propagate_upstream) {
if (!fs)
return NULL;
if (fs->service) {
assert(fs->service->n_fd_store > 0);
+
+ /* If we previously propagated this fd to an enveloping service/container manager via
+ * the FDSTORE=1 protocol on its NOTIFY_SOCKET (only done when persistence is on),
+ * tell that supervisor to drop it now too, so the upstream fd store stays in sync.
+ * Only do this for explicit removals (EPOLLHUP/EPOLLERR or app FDSTOREREMOVE), not
+ * for local cleanup like service shutdown or fdstore-limit truncation: in those
+ * cases we want the upstream copy to survive so it can be handed back to us later. */
+ if (propagate_upstream && fs->index > 0) {
+ (void) notify_remove_fd_warnf(SERVICE_FDSTORE_SUB_FDNAME_PREFIX "%" PRIu64, fs->index);
+ fs->index = 0;
+ /* Refresh the upstream JSON mapping so the supervisor's view stays in sync
+ * with what fds are actually still around. */
+ (void) service_propagate_fd_store_mapping_upstream(UNIT(fs->service)->manager);
+ }
+
LIST_REMOVE(fd_store, fs->service->fd_store, fs);
fs->service->n_fd_store--;
}
@@ -479,6 +497,10 @@ static ServiceFDStore* service_fd_store_unlink(ServiceFDStore *fs) {
return mfree(fs);
}
+static ServiceFDStore* service_fd_store_unlink(ServiceFDStore *fs) {
+ return service_fd_store_unlink_full(fs, /* propagate_upstream= */ false);
+}
+
DEFINE_TRIVIAL_CLEANUP_FUNC(ServiceFDStore*, service_fd_store_unlink);
static void service_release_fd_store(Service *s) {
@@ -495,6 +517,21 @@ static void service_release_fd_store(Service *s) {
assert(s->n_fd_store == 0);
}
+static void service_truncate_fd_store(Service *s) {
+ assert(s);
+
+ /* Drop fds that exceed the (possibly newly lowered) n_fd_store_max, e.g. after the fragment was
+ * parsed and FileDescriptorStoreMax= shrunk the configured limit. Newest entries are at the head
+ * of the list, so drop from the head (newest first). */
+
+ while (s->n_fd_store > s->n_fd_store_max) {
+ ServiceFDStore *fs = ASSERT_PTR(s->fd_store);
+ log_unit_debug(UNIT(s), "Dropping stored fd '%s' to honor FileDescriptorStoreMax=%u.",
+ strna(fs->fdname), s->n_fd_store_max);
+ service_fd_store_unlink(fs);
+ }
+}
+
static void service_release_extra_fds(Service *s) {
assert(s);
@@ -512,6 +549,15 @@ static void service_release_extra_fds(Service *s) {
s->n_extra_fds = 0;
}
+ServiceExtraFD* service_extra_fd_free(ServiceExtraFD *fd) {
+ if (!fd)
+ return NULL;
+
+ safe_close(fd->fd);
+ free(fd->fdname);
+ return mfree(fd);
+}
+
static void service_release_stdio_fd(Service *s) {
assert(s);
@@ -585,7 +631,7 @@ static int on_fd_store_io(sd_event_source *e, int fd, uint32_t revents, void *us
"Received %s on stored fd %d (%s), closing.",
revents & EPOLLERR ? "EPOLLERR" : "EPOLLHUP",
fs->fd, strna(fs->fdname));
- service_fd_store_unlink(fs);
+ service_fd_store_unlink_full(fs, /* propagate_upstream= */ true);
if (s->state == SERVICE_DEAD_RESOURCES_PINNED && !SERVICE_FD_STORE_POPULATED(s))
service_set_state(s, SERVICE_DEAD);
@@ -593,7 +639,7 @@ static int on_fd_store_io(sd_event_source *e, int fd, uint32_t revents, void *us
return 0;
}
-static int service_add_fd_store(Service *s, int fd_in, const char *name, bool do_poll) {
+int service_add_fd_store(Service *s, int fd_in, const char *name, bool do_poll, bool propagate_upstream) {
_cleanup_(service_fd_store_unlinkp) ServiceFDStore *fs = NULL;
_cleanup_(asynchronous_closep) int fd = ASSERT_FD(fd_in);
struct stat st;
@@ -647,14 +693,43 @@ static int service_add_fd_store(Service *s, int fd_in, const char *name, bool do
log_unit_debug(UNIT(s), "Added fd %i (%s) to fd store.", fs->fd, fs->fdname);
+ /* If fd-store persistence is enabled and we have an enveloping service/container manager (i.e.
+ * NOTIFY_SOCKET is set), forward the fd to it via sd_notify(FDSTORE=1) tagged with a fresh
+ * incrementing index, and (re-)push the JSON mapping memfd that pairs the index back to this
+ * unit and the original fdname. This way fdstore persistence chains all the way up to whichever
+ * entity is ultimately responsible for surviving across kexec/restart, regardless of fdname
+ * length or charset constraints. */
+ if (propagate_upstream && s->fd_store_preserve_mode == EXEC_PRESERVE_YES) {
+ Manager *m = ASSERT_PTR(UNIT(s)->manager);
+ char idx_str[STRLEN(SERVICE_FDSTORE_SUB_FDNAME_PREFIX) + DECIMAL_STR_MAX(uint64_t)];
+
+ assert(m->fd_store_upstream_next_index < UINT64_MAX);
+ uint64_t idx = ++m->fd_store_upstream_next_index;
+
+ xsprintf(idx_str, SERVICE_FDSTORE_SUB_FDNAME_PREFIX "%" PRIu64, idx);
+
+ r = notify_push_fd(fs->fd, idx_str);
+ if (r < 0)
+ log_unit_debug_errno(UNIT(s), r,
+ "Failed to propagate fd '%s' to upstream supervisor as index %" PRIu64 ", ignoring: %m",
+ fs->fdname, idx);
+ else
+ fs->index = idx;
+ }
+
fs->service = s;
LIST_PREPEND(fd_store, s->fd_store, TAKE_PTR(fs));
s->n_fd_store++;
+ if (propagate_upstream && s->fd_store_preserve_mode == EXEC_PRESERVE_YES)
+ /* Refresh the JSON mapping memfd so the supervisor can resolve the new index. Do this
+ * after LIST_PREPEND so the new entry is visible to the helper. */
+ (void) service_propagate_fd_store_mapping_upstream(UNIT(s)->manager);
+
return 1; /* fd newly stored */
}
-static int service_add_fd_store_set(Service *s, FDSet *fds, const char *name, bool do_poll) {
+static int service_add_fd_store_set(Service *s, FDSet *fds, const char *name, bool do_poll, bool propagate_upstream) {
int r;
assert(s);
@@ -666,7 +741,7 @@ static int service_add_fd_store_set(Service *s, FDSet *fds, const char *name, bo
if (fd < 0)
break;
- r = service_add_fd_store(s, fd, name, do_poll);
+ r = service_add_fd_store(s, fd, name, do_poll, propagate_upstream);
if (r == -EXFULL)
return log_unit_warning_errno(UNIT(s), r,
"Cannot store more fds than FileDescriptorStoreMax=%u, closing remaining.",
@@ -678,6 +753,139 @@ static int service_add_fd_store_set(Service *s, FDSet *fds, const char *name, bo
return 0;
}
+int service_propagate_fd_store_mapping_upstream(Manager *m) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *root = NULL;
+ _cleanup_close_ int fd = -EBADF;
+ _cleanup_free_ char *text = NULL;
+ Unit *u;
+ int r;
+
+ assert(m);
+
+ /* Build a JSON object listing all fdstore entries that have been propagated upstream:
+ *
+ * {
+ * "unit-name.service": [
+ * { "name": "fdname1", "index": 1 },
+ * { "name": "fdname2", "index": 2 }
+ * ],
+ * ...
+ * }
+ *
+ * Push it as a sealed memfd to the upstream supervisor under a fixed FDNAME so it can resolve
+ * the per-fd numeric indices back to (unit_id, original fdname) at startup. The mapping is
+ * regenerated and re-pushed after every add/remove, so the supervisor's view stays in sync. */
+ HASHMAP_FOREACH(u, m->units) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *entries = NULL;
+ Service *s;
+
+ if (u->type != UNIT_SERVICE)
+ continue;
+
+ s = SERVICE(u);
+ if (!s->fd_store)
+ continue;
+
+ LIST_FOREACH(fd_store, fs, s->fd_store) {
+ if (fs->index == 0)
+ continue;
+
+ r = sd_json_variant_append_arraybo(
+ &entries,
+ SD_JSON_BUILD_PAIR_STRING("name", fs->fdname),
+ SD_JSON_BUILD_PAIR_UNSIGNED("index", fs->index));
+ if (r < 0)
+ return log_warning_errno(r, "Failed to build fdstore-mapping JSON entry: %m");
+ }
+
+ if (!entries)
+ continue;
+
+ r = sd_json_variant_set_field(&root, u->id, entries);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to add unit to fdstore-mapping JSON: %m");
+ }
+
+ if (!root) {
+ /* Nothing to map: tell the supervisor to drop any previously-pushed mapping memfd
+ * so it doesn't keep stale entries around. Only do this if we have actually pushed
+ * one in the past (i.e. we ever assigned an upstream index, either in this
+ * incarnation or in a previous one whose counter we deserialized), otherwise we
+ * might inadvertently remove a mapping that was just handed back to us via
+ * LISTEN_FDS during a fresh manager startup. */
+ if (m->fd_store_upstream_next_index > 0)
+ (void) notify_remove_fd_warn(SERVICE_FDSTORE_MAPPING_FDNAME);
+ return 0;
+ }
+
+ r = sd_json_variant_format(root, /* flags= */ 0, &text);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to format fdstore-mapping JSON: %m");
+
+ fd = memfd_new_and_seal_string(SERVICE_FDSTORE_MAPPING_FDNAME, text);
+ if (fd < 0)
+ return log_warning_errno(fd, "Failed to create fdstore-mapping memfd: %m");
+
+ r = notify_push_fd(fd, SERVICE_FDSTORE_MAPPING_FDNAME);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to propagate fdstore-mapping to upstream supervisor: %m");
+
+ return 0;
+}
+
+static int service_attach_external_fd_to_fdstore(Unit *u, int fd, const char *fdname, uint64_t index) {
+ Service *s = ASSERT_PTR(SERVICE(u));
+ int r;
+
+ assert(u->type == UNIT_SERVICE);
+
+ /* If the unit file is absent, bump the limit by one and force preserve so the fd is
+ * accepted and pins the unit until a daemon-reload picks up the unit file or it is
+ * explicitly stopped. */
+ if (u->load_state == UNIT_NOT_FOUND) {
+ s->fd_store_preserve_mode = EXEC_PRESERVE_YES;
+ s->n_fd_store_max++;
+ }
+
+ /* Don't propagate upstream: the fd just came back from upstream, forwarding it would loop. */
+ r = service_add_fd_store(s, fd, fdname, /* do_poll= */ true, /* propagate_upstream= */ false);
+ if (r <= 0 && u->load_state == UNIT_NOT_FOUND)
+ s->n_fd_store_max--;
+ if (r < 0)
+ return log_unit_debug_errno(u, r, "Failed to add LUO fd '%s' to fd store: %m", fdname);
+
+ /* If the fd was previously propagated to an upstream supervisor under a numeric index,
+ * preserve that index on the freshly-added entry so that future FDSTOREREMOVE messages
+ * (and the fdstore-mapping memfd we re-push to the supervisor) reference the same index
+ * the supervisor already knows about. service_add_fd_store() does LIST_PREPEND() on
+ * success, so the new entry is at the head. Also keep the manager's allocator counter
+ * past the highest restored index, to avoid collisions with newly allocated indices. */
+ if (r > 0 && index > 0 && s->fd_store) {
+ Manager *m = ASSERT_PTR(u->manager);
+
+ s->fd_store->index = index;
+ if (index > m->fd_store_upstream_next_index)
+ m->fd_store_upstream_next_index = index;
+ }
+
+ /* If the unit is otherwise inactive (typical for LUO/upstream restore), pin its resources so it
+ * isn't garbage-collected before something explicitly stops it. Only flip the state when both
+ * runtime and deserialized state agree on DEAD, to avoid clobbering a just-deserialized live
+ * state (e.g. SERVICE_RUNNING after daemon-reload, where service_coldplug() will set the proper
+ * state later). */
+ if (r > 0 &&
+ s->state == SERVICE_DEAD &&
+ s->deserialized_state == SERVICE_DEAD &&
+ s->fd_store_preserve_mode == EXEC_PRESERVE_YES) {
+ service_set_state(s, SERVICE_DEAD_RESOURCES_PINNED);
+ s->deserialized_state = SERVICE_DEAD_RESOURCES_PINNED;
+ }
+
+ if (r > 0)
+ log_unit_debug(u, "Restored fd '%s'.", fdname);
+ return r;
+}
+
static void service_remove_fd_store(Service *s, const char *name) {
assert(s);
assert(name);
@@ -687,7 +895,7 @@ static void service_remove_fd_store(Service *s, const char *name) {
continue;
log_unit_debug(UNIT(s), "Got explicit request to remove fd %i (%s), closing.", fs->fd, name);
- service_fd_store_unlink(fs);
+ service_fd_store_unlink_full(fs, /* propagate_upstream= */ true);
}
}
@@ -952,6 +1160,11 @@ static int service_load(Unit *u) {
if (u->load_state != UNIT_LOADED)
return 0;
+ /* The fragment may have lowered FileDescriptorStoreMax= below the number of fds currently in the
+ * store (e.g. fds restored from LUO into a synthesized UNIT_NOT_FOUND service that just got a real
+ * fragment via lazy reload, but which now disables the fd store). */
+ service_truncate_fd_store(s);
+
/* This is a new unit? Then let's add in some extras */
r = service_add_extras(s);
if (r < 0)
@@ -1437,7 +1650,8 @@ static int service_coldplug(Unit *u) {
int r;
assert(s);
- assert(s->state == SERVICE_DEAD);
+ /* Ensure we can insert FD store into units at boot */
+ assert(IN_SET(s->state, SERVICE_DEAD, SERVICE_DEAD_RESOURCES_PINNED));
if (s->deserialized_state == s->state)
return 0;
@@ -3470,7 +3684,8 @@ static int service_serialize(Unit *u, FILE *f, FDSet *fds) {
if (!c)
return log_oom();
- (void) serialize_item_format(f, "fd-store-fd", "%i \"%s\" %s", copy, c, one_zero(fs->do_poll));
+ (void) serialize_item_format(f, "fd-store-fd", "%i \"%s\" %s %" PRIu64,
+ copy, c, one_zero(fs->do_poll), fs->index);
}
FOREACH_ARRAY(i, s->extra_fds, s->n_extra_fds) {
@@ -3740,12 +3955,13 @@ static int service_deserialize_item(Unit *u, const char *key, const char *value,
s->socket_fd = deserialize_fd(fds, value);
} else if (streq(key, "fd-store-fd")) {
- _cleanup_free_ char *fdv = NULL, *fdn = NULL, *fdp = NULL;
+ _cleanup_free_ char *fdv = NULL, *fdn = NULL, *fdp = NULL, *fdi = NULL;
_cleanup_close_ int fd = -EBADF;
int do_poll;
+ uint64_t index = 0;
- r = extract_many_words(&value, " ", EXTRACT_CUNESCAPE|EXTRACT_UNQUOTE, &fdv, &fdn, &fdp);
- if (r < 2 || r > 3) {
+ r = extract_many_words(&value, " ", EXTRACT_CUNESCAPE|EXTRACT_UNQUOTE, &fdv, &fdn, &fdp, &fdi);
+ if (r < 2 || r > 4) {
log_unit_debug(u, "Failed to deserialize fd-store-fd, ignoring: %s", value);
return 0;
}
@@ -3754,19 +3970,45 @@ static int service_deserialize_item(Unit *u, const char *key, const char *value,
if (fd < 0)
return 0;
- do_poll = r == 3 ? parse_boolean(fdp) : true;
+ do_poll = r >= 3 ? parse_boolean(fdp) : true;
if (do_poll < 0) {
log_unit_debug_errno(u, do_poll,
"Failed to deserialize fd-store-fd do_poll, ignoring: %s", fdp);
return 0;
}
- r = service_add_fd_store(s, TAKE_FD(fd), fdn, do_poll);
+ if (r == 4 && safe_atou64(fdi, &index) < 0) {
+ log_unit_debug(u, "Failed to parse fd-store-fd index '%s', ignoring.", fdi);
+ index = 0;
+ }
+
+ /* If the unit file is currently absent (e.g. after switch-root, before the unit file is
+ * available in the new root), the synthesized service has n_fd_store_max=0 and
+ * preserve_mode=NO, which would reject the fd. Grow the limit by one per fd so it matches
+ * exactly what was handed back, and force EXEC_PRESERVE_YES, so the fd survives until
+ * either a daemon-reload picks up the unit file or the service is explicitly stopped.
+ * Same logic as in luo_dispatch_fd(). */
+ if (u->load_state == UNIT_NOT_FOUND) {
+ s->fd_store_preserve_mode = EXEC_PRESERVE_YES;
+ s->n_fd_store_max++;
+ }
+
+ /* Don't propagate upstream during deserialization: the upstream supervisor (if any)
+ * already has these fds from when they were originally pushed. */
+ r = service_add_fd_store(s, TAKE_FD(fd), fdn, do_poll, /* propagate_upstream= */ false);
+ if (r <= 0 && u->load_state == UNIT_NOT_FOUND)
+ /* The fd was not actually stored, roll back the limit bump. */
+ s->n_fd_store_max--;
if (r < 0) {
log_unit_debug_errno(u, r,
"Failed to store deserialized fd '%s', ignoring: %m", fdn);
return 0;
}
+ /* If preservation is enabled then this fd was previously propagated upstream when it
+ * was first pushed. Restore the index so future removals can be forwarded upstream
+ * and the JSON mapping memfd can be regenerated. */
+ if (r > 0 && s->fd_store && index > 0)
+ s->fd_store->index = index;
} else if (streq(key, "extra-fd")) {
_cleanup_free_ char *fdv = NULL, *fdn = NULL;
_cleanup_close_ int fd = -EBADF;
@@ -5280,7 +5522,7 @@ static void service_notify_message(
name = NULL;
}
- (void) service_add_fd_store_set(s, fds, name, !strv_contains(tags, "FDPOLL=0"));
+ (void) service_add_fd_store_set(s, fds, name, !strv_contains(tags, "FDPOLL=0"), /* propagate_upstream= */ fdstore_detected());
}
/* Notify clients about changed status or main pid */
@@ -6178,6 +6420,8 @@ const UnitVTable service_vtable = {
.serialize = service_serialize,
.deserialize_item = service_deserialize_item,
+ .attach_external_fd_to_fdstore = service_attach_external_fd_to_fdstore,
+
.active_state = service_active_state,
.sub_state_to_string = service_sub_state_to_string,
diff --git a/src/core/service.h b/src/core/service.h
index 9750b19ce2..b57634cdb0 100644
--- a/src/core/service.h
+++ b/src/core/service.h
@@ -8,6 +8,18 @@
#include "pidref.h"
#include "unit.h"
+/* FDNAME used to push the JSON mapping memfd that pairs upstream-propagated fdstore indices with
+ * (unit-id, original fdname) tuples. The receiving manager looks for this fdname in LISTEN_FDNAMES
+ * to find the mapping document. */
+#define SERVICE_FDSTORE_MAPPING_FDNAME "systemd-fdstore-mapping"
+
+/* Prefix for the upstream FDNAME used when forwarding individual fd-store entries to a parent
+ * supervisor: the entries are exposed as "sub-fdstore-" so the supervisor's own fd-store
+ * namespace doesn't collide with names a downstream service manager assigns. The trailing index
+ * is matched up with an entry in the SERVICE_FDSTORE_MAPPING_FDNAME memfd to recover the original
+ * (unit, fdname) pair. */
+#define SERVICE_FDSTORE_SUB_FDNAME_PREFIX "sub-fdstore-"
+
typedef enum ServiceRestart {
SERVICE_RESTART_NO,
SERVICE_RESTART_ON_SUCCESS,
@@ -112,6 +124,10 @@ typedef struct ServiceFDStore {
char *fdname;
sd_event_source *event_source;
bool do_poll;
+ /* If non-zero, this fd was forwarded to the NOTIFY_SOCKET supervisor via FDSTORE=1, with the
+ * stringified value of this index as its FDNAME. The originating unit-id and original fdname
+ * are recorded in a JSON mapping memfd that is also pushed upstream. */
+ uint64_t index;
LIST_FIELDS(struct ServiceFDStore, fd_store);
} ServiceFDStore;
@@ -279,6 +295,12 @@ extern const UnitVTable service_vtable;
int service_set_socket_fd(Service *s, int fd, struct Socket *socket, struct SocketPeer *peer, bool selinux_context_net);
void service_release_socket_fd(Service *s);
+int service_add_fd_store(Service *s, int fd_in, const char *name, bool do_poll, bool propagate_upstream);
+
+int service_propagate_fd_store_mapping_upstream(Manager *m);
+
+ServiceExtraFD* service_extra_fd_free(ServiceExtraFD *fd);
+
usec_t service_restart_usec_next(const Service *s) _pure_;
int service_determine_exec_selinux_label(Service *s, char **ret);
diff --git a/src/core/unit.h b/src/core/unit.h
index d20e46ab57..f96ae27936 100644
--- a/src/core/unit.h
+++ b/src/core/unit.h
@@ -618,6 +618,13 @@ typedef struct UnitVTable {
/* Try to match up fds with what we need for this unit */
void (*distribute_fds)(Unit *u, FDSet *fds);
+ /* Restore one file descriptor that PID 1 retrieved from a Live Update Orchestrator session into the
+ * unit's per-instance state (e.g. fd store). Always consumes 'fd', even on failure. If the fd
+ * was previously propagated to an upstream NOTIFY_SOCKET supervisor under a numeric index,
+ * 'index' carries that index so it can be re-claimed (avoiding collisions with newly allocated
+ * indices and keeping FDSTOREREMOVE messages routable). Pass 0 to indicate no preserved index. */
+ int (*attach_external_fd_to_fdstore)(Unit *u, int fd, const char *fdname, uint64_t index);
+
/* Boils down the more complex internal state of this unit to
* a simpler one that the engine can understand */
UnitActiveState (*active_state)(Unit *u);
diff --git a/src/include/uapi/linux/liveupdate.h b/src/include/uapi/linux/liveupdate.h
new file mode 100644
index 0000000000..30bc66ee94
--- /dev/null
+++ b/src/include/uapi/linux/liveupdate.h
@@ -0,0 +1,216 @@
+/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
+
+/*
+ * Userspace interface for /dev/liveupdate
+ * Live Update Orchestrator
+ *
+ * Copyright (c) 2025, Google LLC.
+ * Pasha Tatashin
+ */
+
+#ifndef _UAPI_LIVEUPDATE_H
+#define _UAPI_LIVEUPDATE_H
+
+#include
+#include
+
+/**
+ * DOC: General ioctl format
+ *
+ * The ioctl interface follows a general format to allow for extensibility. Each
+ * ioctl is passed in a structure pointer as the argument providing the size of
+ * the structure in the first u32. The kernel checks that any structure space
+ * beyond what it understands is 0. This allows userspace to use the backward
+ * compatible portion while consistently using the newer, larger, structures.
+ *
+ * ioctls use a standard meaning for common errnos:
+ *
+ * - ENOTTY: The IOCTL number itself is not supported at all
+ * - E2BIG: The IOCTL number is supported, but the provided structure has
+ * non-zero in a part the kernel does not understand.
+ * - EOPNOTSUPP: The IOCTL number is supported, and the structure is
+ * understood, however a known field has a value the kernel does not
+ * understand or support.
+ * - EINVAL: Everything about the IOCTL was understood, but a field is not
+ * correct.
+ * - ENOENT: A provided token does not exist.
+ * - ENOMEM: Out of memory.
+ * - EOVERFLOW: Mathematics overflowed.
+ *
+ * As well as additional errnos, within specific ioctls.
+ */
+
+/* The ioctl type, documented in ioctl-number.rst */
+#define LIVEUPDATE_IOCTL_TYPE 0xBA
+
+/* The maximum length of session name including null termination */
+#define LIVEUPDATE_SESSION_NAME_LENGTH 64
+
+/* The /dev/liveupdate ioctl commands */
+enum {
+ LIVEUPDATE_CMD_BASE = 0x00,
+ LIVEUPDATE_CMD_CREATE_SESSION = LIVEUPDATE_CMD_BASE,
+ LIVEUPDATE_CMD_RETRIEVE_SESSION = 0x01,
+};
+
+/* ioctl commands for session file descriptors */
+enum {
+ LIVEUPDATE_CMD_SESSION_BASE = 0x40,
+ LIVEUPDATE_CMD_SESSION_PRESERVE_FD = LIVEUPDATE_CMD_SESSION_BASE,
+ LIVEUPDATE_CMD_SESSION_RETRIEVE_FD = 0x41,
+ LIVEUPDATE_CMD_SESSION_FINISH = 0x42,
+};
+
+/**
+ * struct liveupdate_ioctl_create_session - ioctl(LIVEUPDATE_IOCTL_CREATE_SESSION)
+ * @size: Input; sizeof(struct liveupdate_ioctl_create_session)
+ * @fd: Output; The new file descriptor for the created session.
+ * @name: Input; A null-terminated string for the session name, max
+ * length %LIVEUPDATE_SESSION_NAME_LENGTH including termination
+ * character.
+ *
+ * Creates a new live update session for managing preserved resources.
+ * This ioctl can only be called on the main /dev/liveupdate device.
+ *
+ * Return: 0 on success, negative error code on failure.
+ */
+struct liveupdate_ioctl_create_session {
+ __u32 size;
+ __s32 fd;
+ __u8 name[LIVEUPDATE_SESSION_NAME_LENGTH];
+};
+
+#define LIVEUPDATE_IOCTL_CREATE_SESSION \
+ _IO(LIVEUPDATE_IOCTL_TYPE, LIVEUPDATE_CMD_CREATE_SESSION)
+
+/**
+ * struct liveupdate_ioctl_retrieve_session - ioctl(LIVEUPDATE_IOCTL_RETRIEVE_SESSION)
+ * @size: Input; sizeof(struct liveupdate_ioctl_retrieve_session)
+ * @fd: Output; The new file descriptor for the retrieved session.
+ * @name: Input; A null-terminated string identifying the session to retrieve.
+ * The name must exactly match the name used when the session was
+ * created in the previous kernel.
+ *
+ * Retrieves a handle (a new file descriptor) for a preserved session by its
+ * name. This is the primary mechanism for a userspace agent to regain control
+ * of its preserved resources after a live update.
+ *
+ * The userspace application provides the null-terminated `name` of a session
+ * it created before the live update. If a preserved session with a matching
+ * name is found, the kernel instantiates it and returns a new file descriptor
+ * in the `fd` field. This new session FD can then be used for all file-specific
+ * operations, such as restoring individual file descriptors with
+ * LIVEUPDATE_SESSION_RETRIEVE_FD.
+ *
+ * It is the responsibility of the userspace application to know the names of
+ * the sessions it needs to retrieve. If no session with the given name is
+ * found, the ioctl will fail with -ENOENT.
+ *
+ * This ioctl can only be called on the main /dev/liveupdate device when the
+ * system is in the LIVEUPDATE_STATE_UPDATED state.
+ */
+struct liveupdate_ioctl_retrieve_session {
+ __u32 size;
+ __s32 fd;
+ __u8 name[LIVEUPDATE_SESSION_NAME_LENGTH];
+};
+
+#define LIVEUPDATE_IOCTL_RETRIEVE_SESSION \
+ _IO(LIVEUPDATE_IOCTL_TYPE, LIVEUPDATE_CMD_RETRIEVE_SESSION)
+
+/* Session specific IOCTLs */
+
+/**
+ * struct liveupdate_session_preserve_fd - ioctl(LIVEUPDATE_SESSION_PRESERVE_FD)
+ * @size: Input; sizeof(struct liveupdate_session_preserve_fd)
+ * @fd: Input; The user-space file descriptor to be preserved.
+ * @token: Input; An opaque, unique token for preserved resource.
+ *
+ * Holds parameters for preserving a file descriptor.
+ *
+ * User sets the @fd field identifying the file descriptor to preserve
+ * (e.g., memfd, kvm, iommufd, VFIO). The kernel validates if this FD type
+ * and its dependencies are supported for preservation. If validation passes,
+ * the kernel marks the FD internally and *initiates the process* of preparing
+ * its state for saving. The actual snapshotting of the state typically occurs
+ * during the subsequent %LIVEUPDATE_IOCTL_PREPARE execution phase, though
+ * some finalization might occur during freeze.
+ * On successful validation and initiation, the kernel uses the @token
+ * field with an opaque identifier representing the resource being preserved.
+ * This token confirms the FD is targeted for preservation and is required for
+ * the subsequent %LIVEUPDATE_SESSION_RETRIEVE_FD call after the live update.
+ *
+ * Return: 0 on success (validation passed, preservation initiated), negative
+ * error code on failure (e.g., unsupported FD type, dependency issue,
+ * validation failed).
+ */
+struct liveupdate_session_preserve_fd {
+ __u32 size;
+ __s32 fd;
+ __aligned_u64 token;
+};
+
+#define LIVEUPDATE_SESSION_PRESERVE_FD \
+ _IO(LIVEUPDATE_IOCTL_TYPE, LIVEUPDATE_CMD_SESSION_PRESERVE_FD)
+
+/**
+ * struct liveupdate_session_retrieve_fd - ioctl(LIVEUPDATE_SESSION_RETRIEVE_FD)
+ * @size: Input; sizeof(struct liveupdate_session_retrieve_fd)
+ * @fd: Output; The new file descriptor representing the fully restored
+ * kernel resource.
+ * @token: Input; An opaque, token that was used to preserve the resource.
+ *
+ * Retrieve a previously preserved file descriptor.
+ *
+ * User sets the @token field to the value obtained from a successful
+ * %LIVEUPDATE_IOCTL_FD_PRESERVE call before the live update. On success,
+ * the kernel restores the state (saved during the PREPARE/FREEZE phases)
+ * associated with the token and populates the @fd field with a new file
+ * descriptor referencing the restored resource in the current (new) kernel.
+ * This operation must be performed *before* signaling completion via
+ * %LIVEUPDATE_IOCTL_FINISH.
+ *
+ * Return: 0 on success, negative error code on failure (e.g., invalid token).
+ */
+struct liveupdate_session_retrieve_fd {
+ __u32 size;
+ __s32 fd;
+ __aligned_u64 token;
+};
+
+#define LIVEUPDATE_SESSION_RETRIEVE_FD \
+ _IO(LIVEUPDATE_IOCTL_TYPE, LIVEUPDATE_CMD_SESSION_RETRIEVE_FD)
+
+/**
+ * struct liveupdate_session_finish - ioctl(LIVEUPDATE_SESSION_FINISH)
+ * @size: Input; sizeof(struct liveupdate_session_finish)
+ * @reserved: Input; Must be zero. Reserved for future use.
+ *
+ * Signals the completion of the restoration process for a retrieved session.
+ * This is the final operation that should be performed on a session file
+ * descriptor after a live update.
+ *
+ * This ioctl must be called once all required file descriptors for the session
+ * have been successfully retrieved (using %LIVEUPDATE_SESSION_RETRIEVE_FD) and
+ * are fully restored from the userspace and kernel perspective.
+ *
+ * Upon success, the kernel releases its ownership of the preserved resources
+ * associated with this session. This allows internal resources to be freed,
+ * typically by decrementing reference counts on the underlying preserved
+ * objects.
+ *
+ * If this operation fails, the resources remain preserved in memory. Userspace
+ * may attempt to call finish again. The resources will otherwise be reset
+ * during the next live update cycle.
+ *
+ * Return: 0 on success, negative error code on failure.
+ */
+struct liveupdate_session_finish {
+ __u32 size;
+ __u32 reserved;
+};
+
+#define LIVEUPDATE_SESSION_FINISH \
+ _IO(LIVEUPDATE_IOCTL_TYPE, LIVEUPDATE_CMD_SESSION_FINISH)
+
+#endif /* _UAPI_LIVEUPDATE_H */
diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c
index 0e532cf7b0..16ea48eaf8 100644
--- a/src/nspawn/nspawn.c
+++ b/src/nspawn/nspawn.c
@@ -3782,9 +3782,13 @@ static int setup_notify_child(const void *directory) {
if (r < 0)
log_debug_errno(r, "Failed to enable SO_PASSPIDFD, ignoring: %m");
- r = setsockopt_int(fd, SOL_SOCKET, SO_PASSRIGHTS, false);
- if (r < 0)
- log_debug_errno(r, "Failed to turn off SO_PASSRIGHTS, ignoring: %m");
+ /* Only allow the container payload to pass file descriptors to us if we ourselves are
+ * supervised by a service manager that enabled the FD store. */
+ if (!fdstore_detected()) {
+ r = setsockopt_int(fd, SOL_SOCKET, SO_PASSRIGHTS, false);
+ if (r < 0)
+ log_debug_errno(r, "Failed to turn off SO_PASSRIGHTS, ignoring: %m");
+ }
return TAKE_FD(fd);
}
@@ -4613,6 +4617,76 @@ static int setup_uid_map(
return 0;
}
+static int forward_fd_store(char **tags, FDSet *fds) {
+ int r;
+
+ /* Forward fd-store related messages to our own service manager, so that file descriptors stored
+ * by the inner payload propagate up the chain and are preserved across restarts. Skip entirely
+ * if we have no upstream supervisor (no NOTIFY_SOCKET) or no fd store available (no FDSTORE).
+ *
+ * Forwarded entries are namespaced with a "payload-" prefix on their FDNAME so that they
+ * cannot collide with fd-store entries that nspawn itself might want to push to its own
+ * upstream supervisor (the container payload and nspawn share a single upstream fdstore
+ * namespace, since there's only one init system per container). */
+ if (!getenv("NOTIFY_SOCKET") || !fdstore_detected())
+ return 0;
+
+ if (strv_contains(tags, "FDSTOREREMOVE=1")) {
+ const char *fdname = strv_find_startswith(tags, "FDNAME=");
+ if (!fdname)
+ return log_warning_errno(SYNTHETIC_ERRNO(EINVAL),
+ "Got FDSTOREREMOVE=1 from container payload without FDNAME=, ignoring.");
+ if (!fdname_is_valid(fdname))
+ return log_warning_errno(SYNTHETIC_ERRNO(EINVAL),
+ "Got FDSTOREREMOVE=1 from container payload with invalid FDNAME='%s', ignoring.",
+ fdname);
+
+ r = sd_notifyf(/* unset_environment= */ false,
+ "FDSTOREREMOVE=1\nFDNAME=payload-%s", fdname);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to forward FDSTOREREMOVE upstream, ignoring: %m");
+ } else if (strv_contains(tags, "FDSTORE=1")) {
+ if (fdset_isempty(fds)) {
+ log_debug("Got FDSTORE=1 from container payload without any attached file descriptors, ignoring.");
+ return 0;
+ }
+
+ _cleanup_free_ int *fds_array = NULL;
+ int n;
+
+ n = fdset_to_array(fds, &fds_array);
+ if (n < 0)
+ return log_warning_errno(n, "Failed to convert fdset to array, ignoring FDSTORE forward: %m");
+
+ const char *fdname = strv_find_startswith(tags, "FDNAME=");
+ bool fdpoll_off = strv_contains(tags, "FDPOLL=0");
+ _cleanup_free_ char *msg = NULL;
+ unsigned n_fds = (unsigned) n;
+
+ if (fdname && !fdname_is_valid(fdname)) {
+ log_warning("Got FDSTORE=1 from container payload with invalid FDNAME='%s', ignoring name.", fdname);
+ fdname = NULL;
+ }
+
+ if (asprintf(&msg, "FDSTORE=1\nFDNAME=payload-%s%s%s",
+ fdname ?: "stored",
+ fdpoll_off ? "\nFDPOLL=" : "",
+ fdpoll_off ? "0" : "") < 0)
+ return log_oom();
+
+ r = sd_pid_notify_with_fds(
+ 0,
+ /* unset_environment= */ false,
+ msg,
+ fds_array,
+ n_fds);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to forward FDSTORE upstream, ignoring: %m");
+ }
+
+ return 0;
+}
+
static int nspawn_dispatch_notify_fd(sd_event_source *source, int fd, uint32_t revents, void *userdata) {
PidRef *inner_child_pid = ASSERT_PTR(userdata);
int r;
@@ -4621,7 +4695,8 @@ static int nspawn_dispatch_notify_fd(sd_event_source *source, int fd, uint32_t r
_cleanup_(pidref_done) PidRef sender_pid = PIDREF_NULL;
_cleanup_strv_free_ char **tags = NULL;
- r = notify_recv_strv(fd, &tags, /* ret_ucred= */ NULL, &sender_pid);
+ _cleanup_(fdset_freep) FDSet *fds = NULL;
+ r = notify_recv_with_fds_strv(fd, &tags, /* ret_ucred= */ NULL, &sender_pid, &fds);
if (r == -EAGAIN)
return 0;
if (r < 0)
@@ -4656,6 +4731,8 @@ static int nspawn_dispatch_notify_fd(sd_event_source *source, int fd, uint32_t r
(void) sd_notifyf(/* unset_environment= */ false, "STATUS=Container running.");
}
+ (void) forward_fd_store(tags, fds);
+
return 0;
}
diff --git a/src/shared/daemon-util.c b/src/shared/daemon-util.c
index 321a9e58da..d85e52bcb6 100644
--- a/src/shared/daemon-util.c
+++ b/src/shared/daemon-util.c
@@ -7,6 +7,7 @@
#include "errno-util.h"
#include "fd-util.h"
#include "log.h"
+#include "parse-util.h"
#include "string-util.h"
#include "time-util.h"
@@ -84,6 +85,27 @@ int notify_push_fdf(int fd, const char *format, ...) {
return notify_push_fd(fd, name);
}
+bool fdstore_detected(void) {
+ static int cached = -1;
+ int r;
+
+ if (cached >= 0)
+ return cached;
+
+ const char *e = getenv("FDSTORE");
+ if (isempty(e))
+ return (cached = 0);
+
+ unsigned u;
+ r = safe_atou(e, &u);
+ if (r < 0) {
+ log_debug_errno(r, "Failed to parse 'FDSTORE=%s', ignoring: %m", e);
+ return (cached = 0);
+ }
+
+ return (cached = u > 0);
+}
+
int notify_reloading_full(const char *status) {
int r;
diff --git a/src/shared/daemon-util.h b/src/shared/daemon-util.h
index 708a32985c..089e418f7b 100644
--- a/src/shared/daemon-util.h
+++ b/src/shared/daemon-util.h
@@ -27,6 +27,8 @@ int close_and_notify_warn(int fd, const char *name);
int notify_push_fd(int fd, const char *name);
int notify_push_fdf(int fd, const char *format, ...) _printf_(2, 3);
+bool fdstore_detected(void);
+
int notify_reloading_full(const char *status);
static inline int notify_reloading(void) {
return notify_reloading_full("Reloading configuration...");
diff --git a/src/shared/luo-util.c b/src/shared/luo-util.c
new file mode 100644
index 0000000000..898e51efa5
--- /dev/null
+++ b/src/shared/luo-util.c
@@ -0,0 +1,376 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "sd-json.h"
+
+#include "alloc-util.h"
+#include "errno-util.h"
+#include "fd-util.h"
+#include "fileio.h"
+#include "json-util.h"
+#include "log.h"
+#include "luo-util.h"
+#include "memfd-util.h"
+#include "parse-util.h"
+#include "stat-util.h"
+#include "string-util.h"
+
+/* Kernel API defined at https://docs.kernel.org/userspace-api/liveupdate.html The /dev/liveupdate is a
+ * single-owner singleton, only a single process at any given time can open it. Callers can create named
+ * "sessions", and then add FDs to them. The session name can be used to retrieve the session after reboot.
+ * To identify an FD, a 64bit token (what we would call an 'index' in our codebase) is passed in, and the
+ * caller is responsible for coming up with the token and tracking them. */
+
+int luo_open_device(void) {
+ return RET_NERRNO(open("/dev/liveupdate", O_RDWR|O_CLOEXEC));
+}
+
+int luo_create_session(int device_fd, const char *name) {
+ struct liveupdate_ioctl_create_session args = {
+ .size = sizeof(args),
+ .fd = -EBADF,
+ };
+
+ assert(device_fd >= 0);
+ assert(name);
+
+ if (strlen(name) >= sizeof(args.name))
+ return -ENAMETOOLONG;
+
+ strncpy_exact((char *) args.name, name, sizeof(args.name));
+
+ if (ioctl(device_fd, LIVEUPDATE_IOCTL_CREATE_SESSION, &args) < 0)
+ return -errno;
+
+ return args.fd;
+}
+
+int luo_retrieve_session(int device_fd, const char *name) {
+ struct liveupdate_ioctl_retrieve_session args = {
+ .size = sizeof(args),
+ .fd = -EBADF,
+ };
+
+ assert(device_fd >= 0);
+ assert(name);
+
+ if (strlen(name) >= sizeof(args.name))
+ return -ENAMETOOLONG;
+
+ strncpy_exact((char *) args.name, name, sizeof(args.name));
+
+ if (ioctl(device_fd, LIVEUPDATE_IOCTL_RETRIEVE_SESSION, &args) < 0)
+ return -errno;
+
+ return args.fd;
+}
+
+int luo_session_preserve_fd(int session_fd, int fd, uint64_t token) {
+ struct liveupdate_session_preserve_fd args = {
+ .size = sizeof(args),
+ .fd = fd,
+ .token = token,
+ };
+
+ assert(session_fd >= 0);
+ assert(fd >= 0);
+
+ return RET_NERRNO(ioctl(session_fd, LIVEUPDATE_SESSION_PRESERVE_FD, &args));
+}
+
+int luo_session_retrieve_fd(int session_fd, uint64_t token) {
+ struct liveupdate_session_retrieve_fd args = {
+ .size = sizeof(args),
+ .fd = -EBADF,
+ .token = token,
+ };
+ int r;
+
+ assert(session_fd >= 0);
+
+ if (ioctl(session_fd, LIVEUPDATE_SESSION_RETRIEVE_FD, &args) < 0)
+ return -errno;
+
+ r = fd_cloexec(args.fd, true);
+ if (r < 0) {
+ safe_close(args.fd);
+ return r;
+ }
+
+ return args.fd;
+}
+
+int luo_session_finish(int session_fd) {
+ struct liveupdate_session_finish args = {
+ .size = sizeof(args),
+ };
+
+ assert(session_fd >= 0);
+
+ return RET_NERRNO(ioctl(session_fd, LIVEUPDATE_SESSION_FINISH, &args));
+}
+
+int luo_parse_serialization(sd_json_variant **ret, int **ret_fds, size_t *ret_n_fds) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *root = NULL;
+ _cleanup_free_ int *fd_list = NULL;
+ _cleanup_fclose_ FILE *f = NULL;
+ size_t n_fds = 0;
+ int serialize_fd = -EBADF, r;
+
+ assert(ret);
+ assert(ret_fds);
+ assert(ret_n_fds);
+
+ const char *luo_fd_str = secure_getenv("SYSTEMD_LUO_SERIALIZE_FD");
+ if (!luo_fd_str) {
+ *ret = NULL;
+ *ret_fds = NULL;
+ *ret_n_fds = 0;
+ return 0;
+ }
+
+ serialize_fd = parse_fd(luo_fd_str);
+ if (serialize_fd < 0)
+ return log_warning_errno(serialize_fd,
+ "Failed to parse SYSTEMD_LUO_SERIALIZE_FD='%s': %m", luo_fd_str);
+
+ r = fdopen_independent(serialize_fd, "r", &f);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to open LUO serialization fd %d: %m", serialize_fd);
+
+ r = sd_json_parse_file(f, /* path= */ NULL, SD_JSON_PARSE_MUST_BE_OBJECT, &root, /* reterr_line= */ NULL, /* reterr_column= */ NULL);
+ if (r < 0)
+ return log_warning_errno(r, "Failed to parse LUO serialization JSON: %m");
+
+ /* Collect all fd numbers referenced in the JSON (plus the serialization fd itself)
+ * so the caller can protect them from close_all_fds(). */
+ const char *unit_id _unused_;
+ sd_json_variant *unit_entries;
+
+ JSON_VARIANT_OBJECT_FOREACH(unit_id, unit_entries, root) {
+ sd_json_variant *entry;
+
+ JSON_VARIANT_ARRAY_FOREACH(entry, unit_entries) {
+ struct {
+ int fd;
+ } p = {
+ .fd = -EBADF,
+ };
+
+ static const sd_json_dispatch_field dispatch_table[] = {
+ { "fd", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_int, voffsetof(p, fd), 0 },
+ {}
+ };
+
+ r = sd_json_dispatch(entry, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG|SD_JSON_WARNING, &p);
+ if (r < 0)
+ continue;
+
+ if (p.fd < 0)
+ continue;
+
+ if (!GREEDY_REALLOC(fd_list, n_fds + 1))
+ return log_oom();
+
+ fd_list[n_fds++] = p.fd;
+ }
+ }
+
+ /* Also protect the serialization fd itself */
+ if (!GREEDY_REALLOC(fd_list, n_fds + 1))
+ return log_oom();
+ fd_list[n_fds++] = serialize_fd;
+
+ log_debug("Parsed LUO serialization with %zu fd(s) to preserve.", n_fds);
+
+ *ret = TAKE_PTR(root);
+ *ret_fds = TAKE_PTR(fd_list);
+ *ret_n_fds = n_fds;
+ return 0;
+}
+
+int luo_preserve_fd_stores(sd_json_variant *serialization, int *ret_session_fd) {
+ _cleanup_close_ int device_fd = -EBADF, session_fd = -EBADF;
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *mapping = NULL;
+ const char *unit_id;
+ sd_json_variant *entries;
+ uint64_t token = LUO_MAPPING_INDEX + 1;
+ int r;
+
+ assert(ret_session_fd);
+
+ if (!serialization) {
+ *ret_session_fd = -EBADF;
+ return 0; /* No LUO serialization, nothing to preserve */
+ }
+
+ device_fd = luo_open_device();
+ if (ERRNO_IS_NEG_DEVICE_ABSENT(device_fd)) {
+ *ret_session_fd = -EBADF;
+ return 0; /* LUO not supported, ignore */
+ }
+ if (device_fd < 0)
+ return log_error_errno(device_fd, "Failed to open /dev/liveupdate: %m");
+
+ session_fd = luo_create_session(device_fd, LUO_SESSION_NAME);
+ if (session_fd < 0)
+ return log_error_errno(session_fd, "Failed to create LUO session '%s': %m", LUO_SESSION_NAME);
+
+ /* Build the mapping JSON for the new kernel's PID 1 and preserve each fd.
+ * JSON format: { "unit_id": [ {"type": "fd", "name": "...", "token": N},
+ * {"type": "luo_session", "name": "...", "sessionName": "..."} ], ... }
+ *
+ * For regular fds: type=fd, preserved in the systemd session with the given LUO token.
+ * For LUO session fds: type=luo_session, the session survives kexec independently, as it cannot be
+ * nested. */
+ JSON_VARIANT_OBJECT_FOREACH(unit_id, entries, serialization) {
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *fd_list = NULL;
+ sd_json_variant *entry;
+
+ JSON_VARIANT_ARRAY_FOREACH(entry, entries) {
+ struct {
+ const char *type;
+ const char *name;
+ int fd;
+ const char *session_name;
+ } p = {
+ .fd = -EBADF,
+ };
+
+ static const sd_json_dispatch_field dispatch_table[] = {
+ { "type", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, type), SD_JSON_MANDATORY },
+ { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), SD_JSON_MANDATORY },
+ { "fd", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_int, voffsetof(p, fd), 0 },
+ { "sessionName", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, session_name), 0 },
+ {}
+ };
+
+ r = sd_json_dispatch(entry, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG|SD_JSON_WARNING, &p);
+ if (r < 0)
+ continue;
+
+ if (streq(p.type, "fd")) {
+ if (p.fd < 0) {
+ log_warning("LUO mapping for unit '%s' fd '%s': missing or negative fd, skipping.", unit_id, p.name);
+ continue;
+ }
+
+ /* Regular fd, preserve in the systemd session */
+ r = luo_session_preserve_fd(session_fd, p.fd, token);
+ if (r < 0) {
+ log_warning_errno(r, "Failed to preserve LUO fd %i (name '%s') with token %" PRIu64 ", will be lost across kexec: %m", p.fd, p.name, token);
+ continue;
+ }
+
+ r = sd_json_variant_append_arraybo(
+ &fd_list,
+ SD_JSON_BUILD_PAIR_STRING("type", "fd"),
+ SD_JSON_BUILD_PAIR_STRING("name", p.name),
+ SD_JSON_BUILD_PAIR_UNSIGNED("token", token));
+ if (r < 0)
+ return log_error_errno(r, "Failed to build LUO mapping: %m");
+
+ ++token;
+ } else if (streq(p.type, "luo_session")) {
+ if (!p.session_name) {
+ log_warning("LUO mapping for unit '%s' fd '%s': missing sessionName, skipping.", unit_id, p.name);
+ continue;
+ }
+
+ /* Remember the FDStore name to session name mapping */
+ r = sd_json_variant_append_arraybo(
+ &fd_list,
+ SD_JSON_BUILD_PAIR_STRING("type", "luo_session"),
+ SD_JSON_BUILD_PAIR_STRING("name", p.name),
+ SD_JSON_BUILD_PAIR_STRING("sessionName", p.session_name));
+ if (r < 0)
+ return log_error_errno(r, "Failed to build LUO mapping for session fd: %m");
+
+ log_debug("LUO session fd '%s' (session '%s') recorded in mapping.",
+ p.name, p.session_name);
+ } else
+ log_warning("Unknown fd type '%s' for unit '%s' fd '%s', skipping.", p.type, unit_id, p.name);
+ }
+
+ if (fd_list) {
+ r = sd_json_variant_set_field(&mapping, unit_id, fd_list);
+ if (r < 0)
+ return log_error_errno(r, "Failed to add unit to LUO mapping: %m");
+ }
+ }
+
+ if (!mapping) {
+ log_debug("No fds were preserved in LUO session.");
+ *ret_session_fd = -EBADF;
+ return 0;
+ }
+
+ /* Store the mapping as a memfd at LUO token 0 */
+ _cleanup_free_ char *mapping_text = NULL;
+
+ r = sd_json_variant_format(mapping, /* flags= */ 0, &mapping_text);
+ if (r < 0)
+ return log_error_errno(r, "Failed to format LUO mapping JSON: %m");
+
+ _cleanup_close_ int mapping_fd = -EBADF;
+
+ mapping_fd = memfd_new_and_seal_string("luo-mapping", mapping_text);
+ if (mapping_fd < 0)
+ return log_error_errno(mapping_fd, "Failed to create LUO mapping memfd: %m");
+
+ r = luo_session_preserve_fd(session_fd, mapping_fd, LUO_MAPPING_INDEX);
+ if (r < 0)
+ return log_error_errno(r, "Failed to preserve LUO mapping memfd: %m");
+
+ log_info("Preserved fd stores in LUO session '%s' for kexec.", LUO_SESSION_NAME);
+
+ /* Return the session fd to the caller as it must stay open until the kexec syscall,
+ * otherwise the kernel discards the session. */
+ *ret_session_fd = TAKE_FD(session_fd);
+ return 1;
+}
+
+int fd_get_luo_session_name(int fd, char **ret) {
+ _cleanup_free_ char *path = NULL;
+ int r;
+
+ assert(fd >= 0);
+
+ // TODO: switch to LUO specific inode magic once available
+ r = fd_is_fs_type(fd, ANON_INODE_FS_MAGIC);
+ if (r < 0)
+ return r;
+ if (r == 0)
+ return -EMEDIUMTYPE;
+
+ r = fd_get_path(fd, &path);
+ if (r < 0)
+ return r;
+
+ /* Path is "anon_inode:[luo_session] " */
+ const char *suffix = startswith(path, "anon_inode:[luo_session] ");
+ if (isempty(suffix))
+ return -EMEDIUMTYPE;
+
+ if (ret)
+ return strdup_to(ret, suffix);
+
+ return 0;
+}
+
+int fd_is_luo_session(int fd) {
+ int r;
+
+ r = fd_get_luo_session_name(fd, /* ret= */ NULL);
+ if (r == -EMEDIUMTYPE)
+ return false;
+ if (r < 0)
+ return r;
+
+ return true;
+}
diff --git a/src/shared/luo-util.h b/src/shared/luo-util.h
new file mode 100644
index 0000000000..1ca7ca6d71
--- /dev/null
+++ b/src/shared/luo-util.h
@@ -0,0 +1,40 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include "basic-forward.h"
+#include "sd-forward.h"
+
+#define LUO_SESSION_NAME "systemd"
+
+/* Index (token) 0 in the LUO session is always the mapping memfd, which contains a JSON document mapping
+ * unit ids to arrays of fd store entries:
+ *
+ * {
+ * "unit-name.service": [
+ * { "type": "fd", "name": "fdname1", "token": 1 },
+ * { "type": "fd", "name": "fdname2", "token": 2 },
+ * { "type": "luo_session", "name": "fdname3", "sessionName": "unit.service/myapp" }
+ * ],
+ * "other-unit.service": [
+ * { "type": "fd", "name": "stored", "token": 3 }
+ * ]
+ * }
+ *
+ * type=fd: the fd was preserved in the "systemd" LUO session with the given token.
+ * type=luo_session: a service-owned LUO session that survives kexec independently,
+ * retrieved by session_name on the next boot.
+ */
+#define LUO_MAPPING_INDEX UINT64_C(0)
+
+int luo_open_device(void);
+int luo_create_session(int device_fd, const char *name);
+int luo_retrieve_session(int device_fd, const char *name);
+int luo_session_preserve_fd(int session_fd, int fd, uint64_t token);
+int luo_session_retrieve_fd(int session_fd, uint64_t token);
+int luo_session_finish(int session_fd);
+
+int luo_parse_serialization(sd_json_variant **ret, int **ret_fds, size_t *ret_n_fds);
+int luo_preserve_fd_stores(sd_json_variant *serialization, int *ret_session_fd);
+
+int fd_is_luo_session(int fd);
+int fd_get_luo_session_name(int fd, char **ret);
diff --git a/src/shared/meson.build b/src/shared/meson.build
index d4cd5ca431..9b539f7983 100644
--- a/src/shared/meson.build
+++ b/src/shared/meson.build
@@ -129,6 +129,7 @@ shared_sources = files(
'loop-util.c',
'loopback-setup.c',
'lsm-util.c',
+ 'luo-util.c',
'machine-bind-user.c',
'machine-credential.c',
'machine-id-setup.c',
diff --git a/src/shutdown/shutdown.c b/src/shutdown/shutdown.c
index c3c114a667..c310d5c981 100644
--- a/src/shutdown/shutdown.c
+++ b/src/shutdown/shutdown.c
@@ -10,6 +10,7 @@
#include
#include "sd-daemon.h"
+#include "sd-json.h"
#include "sd-messages.h"
#include "alloc-util.h"
@@ -31,6 +32,7 @@
#include "initrd-util.h"
#include "killall.h"
#include "log.h"
+#include "luo-util.h"
#include "options.h"
#include "parse-util.h"
#include "pidref.h"
@@ -363,14 +365,21 @@ int main(int argc, char *argv[]) {
SYSTEM_SHUTDOWN_PATH,
NULL
};
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *luo_serialization = NULL;
+ _cleanup_close_ int luo_session_fd = -EBADF;
+ _cleanup_free_ int *luo_fds = NULL;
_cleanup_free_ char *cgroup = NULL;
+ size_t n_luo_fds = 0;
int cmd, r;
+ /* If PID 1 passed us an LUO serialization fd, parse it first so we know which fds to keep open. */
+ (void) luo_parse_serialization(&luo_serialization, &luo_fds, &n_luo_fds);
+
/* Close random fds we might have get passed, just for paranoia, before we open any new fds, for
* example for logging. After all this tool's purpose is about detaching any pinned resources, and
* open file descriptors are the primary way to pin resources. Note that we don't really expect any
- * fds to be passed here. */
- (void) close_all_fds(NULL, 0);
+ * fds to be passed here, except for LUO fds that need to survive until kexec. */
+ (void) close_all_fds(luo_fds, n_luo_fds);
/* The log target defaults to console, but the original systemd process will pass its log target in through a
* command line argument, which will override this default. Also, ensure we'll never log to the journal or
@@ -646,6 +655,10 @@ int main(int argc, char *argv[]) {
case LINUX_REBOOT_CMD_KEXEC:
if (!in_container) {
+ /* Preserve fd stores via the kernel Live Update Orchestrator before kexec.
+ * The session fd must stay open until the kexec syscall. */
+ (void) luo_preserve_fd_stores(luo_serialization, &luo_session_fd);
+
log_info("Rebooting with kexec.");
(void) kexec();
diff --git a/src/test/meson.build b/src/test/meson.build
index faf075c103..20960d0b95 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -323,6 +323,11 @@ executables += [
'sources' : files('test-fd-util.c'),
'dependencies' : libseccomp_cflags,
},
+ test_template + {
+ 'sources' : files('test-fdstore.c'),
+ 'link_with' : libsystemd,
+ 'type' : 'manual',
+ },
test_template + {
'sources' : files(
'test-hashmap.c',
@@ -364,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-bpf-firewall.c b/src/test/test-bpf-firewall.c
index c3d8e7d5d5..e591d0d5ea 100644
--- a/src/test/test-bpf-firewall.c
+++ b/src/test/test-bpf-firewall.c
@@ -75,7 +75,7 @@ int main(int argc, char *argv[]) {
/* The simple tests succeeded. Now let's try full unit-based use-case. */
ASSERT_OK(manager_new(RUNTIME_SCOPE_USER, MANAGER_TEST_RUN_BASIC, &m));
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
ASSERT_EQ(unit_add_name(u, "foo.service"), 0);
diff --git a/src/test/test-bpf-foreign-programs.c b/src/test/test-bpf-foreign-programs.c
index bf56451d25..9d9093bee7 100644
--- a/src/test/test-bpf-foreign-programs.c
+++ b/src/test/test-bpf-foreign-programs.c
@@ -303,7 +303,7 @@ int main(int argc, char *argv[]) {
assert_se(runtime_dir = setup_fake_runtime_dir());
ASSERT_OK(manager_new(RUNTIME_SCOPE_USER, MANAGER_TEST_RUN_BASIC, &m));
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_OK(test_bpf_cgroup_programs(m,
"single_prog.service", single_prog, ELEMENTSOF(single_prog)));
diff --git a/src/test/test-bpf-restrict-fs.c b/src/test/test-bpf-restrict-fs.c
index 9d54ad0337..8e41537f21 100644
--- a/src/test/test-bpf-restrict-fs.c
+++ b/src/test/test-bpf-restrict-fs.c
@@ -87,7 +87,7 @@ int main(int argc, char *argv[]) {
ASSERT_NOT_NULL((runtime_dir = setup_fake_runtime_dir()));
ASSERT_OK(manager_new(RUNTIME_SCOPE_SYSTEM, MANAGER_TEST_RUN_BASIC, &m));
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
/* We need to enable access to the filesystem where the binary is so we
* add @common-block and @application */
diff --git a/src/test/test-cgroup-mask.c b/src/test/test-cgroup-mask.c
index 6b123f8761..9673415080 100644
--- a/src/test/test-cgroup-mask.c
+++ b/src/test/test-cgroup-mask.c
@@ -51,7 +51,7 @@ TEST_RET(cgroup_mask, .sd_booted = true) {
m->defaults.tasks_accounting = false;
m->defaults.tasks_max = CGROUP_TASKS_MAX_UNSET;
- assert_se(manager_startup(m, NULL, NULL, NULL) >= 0);
+ assert_se(manager_startup(m, NULL, NULL, NULL, NULL) >= 0);
/* Load units and verify hierarchy. */
ASSERT_OK(manager_load_startable_unit_or_warn(m, "parent.slice", NULL, &parent));
diff --git a/src/test/test-engine.c b/src/test/test-engine.c
index e1a2f7ea04..5a6a1a4204 100644
--- a/src/test/test-engine.c
+++ b/src/test/test-engine.c
@@ -97,7 +97,7 @@ int main(int argc, char *argv[]) {
if (manager_errno_skip_test(r))
return log_tests_skipped_errno(r, "manager_new");
assert_se(r >= 0);
- assert_se(manager_startup(m, NULL, NULL, NULL) >= 0);
+ assert_se(manager_startup(m, NULL, NULL, NULL, NULL) >= 0);
printf("Load1:\n");
assert_se(manager_load_startable_unit_or_warn(m, "a.service", NULL, &a) >= 0);
diff --git a/src/test/test-execute.c b/src/test/test-execute.c
index e14205bdf8..3a124f9674 100644
--- a/src/test/test-execute.c
+++ b/src/test/test-execute.c
@@ -1435,7 +1435,7 @@ static void run_tests(RuntimeScope scope, char **patterns) {
ASSERT_OK(r);
m->defaults.std_output = EXEC_OUTPUT_INHERIT; /* don't rely on host journald */
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
/* Uncomment below if you want to make debugging logs stored to journal. */
//manager_override_log_target(m, LOG_TARGET_AUTO);
diff --git a/src/test/test-fdstore.c b/src/test/test-fdstore.c
new file mode 100644
index 0000000000..6469d396e1
--- /dev/null
+++ b/src/test/test-fdstore.c
@@ -0,0 +1,166 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* In 'store' mode pushes a couple of memfds with known content into the supervisor's fd store via FDSTORE=1
+ * sd_notify() messages. In 'check' mode reads back the fds passed via LISTEN_FDS and verifies the content
+ * matches what was pushed.
+ *
+ * This binary is intentionally linked against libsystemd only so that it can go in the minimal image. */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "sd-daemon.h"
+
+#define DATA_A "fdstore-data-a"
+#define DATA_B "fdstore-data-b"
+
+#define _cleanup_(f) __attribute__((cleanup(f)))
+
+static void closep(int *fd) {
+ if (!fd || *fd < 0)
+ return;
+
+ close(*fd);
+ *fd = -EBADF;
+}
+
+static int push_one(const char *fdname, const char *content) {
+ _cleanup_(closep) int fd = -EBADF;
+ int r;
+
+ assert(fdname);
+ assert(content);
+
+ fd = memfd_create(fdname, MFD_CLOEXEC | MFD_ALLOW_SEALING);
+ if (fd < 0) {
+ fprintf(stderr, "memfd_create(%s) failed: %m\n", fdname);
+ return -errno;
+ }
+
+ size_t len = strlen(content);
+ if (write(fd, content, len) != (ssize_t) len) {
+ fprintf(stderr, "write(%s) failed: %m\n", fdname);
+ return -errno;
+ }
+
+ char msg[256];
+ r = snprintf(msg, sizeof(msg), "FDSTORE=1\nFDNAME=%s", fdname);
+ if (r < 0 || (size_t) r >= sizeof(msg)) {
+ if (r >= 0)
+ errno = ENOBUFS;
+ fprintf(stderr, "FDSTORE message for fdname=%s did not fit in buffer\n", fdname);
+ return -errno;
+ }
+
+ r = sd_pid_notify_with_fds(0, /* unset_environment= */ 0, msg, &fd, 1);
+ if (r < 0) {
+ errno = -r;
+ fprintf(stderr, "sd_pid_notify_with_fds(%s) failed: %m\n", fdname);
+ return r;
+ }
+ if (r == 0) {
+ fprintf(stderr, "NOTIFY_SOCKET not set\n");
+ return -ENOENT;
+ }
+
+ return 0;
+}
+
+static int do_store(void) {
+ int r;
+
+ if (push_one("test-fd-a", DATA_A) < 0)
+ return EXIT_FAILURE;
+
+ if (push_one("test-fd-b", DATA_B) < 0)
+ return EXIT_FAILURE;
+
+ /* Wait for our supervisor to actually process the FDSTORE messages before we exit, otherwise
+ * the cgroup-based pidref to unit lookup may fail once we're gone. */
+ r = sd_notify_barrier(0, 5 * 1000 * 1000);
+ if (r < 0) {
+ errno = -r;
+ fprintf(stderr, "sd_notify_barrier failed: %m\n");
+ return EXIT_FAILURE;
+ }
+
+ return EXIT_SUCCESS;
+}
+
+static int do_check(void) {
+ bool seen_a = false, seen_b = false;
+ int n;
+
+ n = sd_listen_fds(/* unset_environment= */ 0);
+ if (n < 0) {
+ errno = -n;
+ fprintf(stderr, "sd_listen_fds failed: %m\n");
+ return EXIT_FAILURE;
+ }
+ if (n < 2) {
+ fprintf(stderr, "Expected at least 2 fds via LISTEN_FDS, got %d\n", n);
+ return EXIT_FAILURE;
+ }
+
+ for (int i = 0; i < n; i++) {
+ int fd = SD_LISTEN_FDS_START + i;
+ char buf[256] = {};
+ ssize_t k;
+
+ if (lseek(fd, 0, SEEK_SET) < 0) {
+ fprintf(stderr, "lseek(fd=%d) failed: %m\n", fd);
+ return EXIT_FAILURE;
+ }
+ k = read(fd, buf, sizeof(buf) - 1);
+ if (k < 0) {
+ fprintf(stderr, "read(fd=%d) failed: %m\n", fd);
+ return EXIT_FAILURE;
+ }
+ buf[k] = 0;
+
+ if (strcmp(buf, DATA_A) == 0)
+ seen_a = true;
+ else if (strcmp(buf, DATA_B) == 0)
+ seen_b = true;
+ else
+ fprintf(stderr, "Unexpected fd content: '%s'\n", buf);
+ }
+
+ if (!seen_a || !seen_b) {
+ fprintf(stderr, "Missing expected fds: seen_a=%d seen_b=%d\n", seen_a, seen_b);
+ return EXIT_FAILURE;
+ }
+
+ printf("Payload received both preserved fds with matching content.\n");
+ return EXIT_SUCCESS;
+}
+
+int main(int argc, char *argv[]) {
+ int r;
+
+ if (argc < 2) {
+ fprintf(stderr, "Usage: %s store|check\n", argv[0]);
+ return EXIT_FAILURE;
+ }
+
+ if (strcmp(argv[1], "store") == 0)
+ r = do_store();
+ else if (strcmp(argv[1], "check") == 0)
+ r = do_check();
+ else {
+ fprintf(stderr, "Unknown verb: %s\n", argv[1]);
+ return EXIT_FAILURE;
+ }
+
+ if (r != EXIT_SUCCESS)
+ return r;
+
+ /* On success, become sleep so if we are a container payload it can stay alive. */
+ execlp("sleep", "sleep", "infinity", (char *) NULL);
+ fprintf(stderr, "execlp(sleep) failed: %m\n");
+ return EXIT_FAILURE;
+}
diff --git a/src/test/test-load-fragment.c b/src/test/test-load-fragment.c
index 3e318a36c9..259bd142e4 100644
--- a/src/test/test-load-fragment.c
+++ b/src/test/test-load-fragment.c
@@ -108,7 +108,7 @@ TEST(config_parse_exec) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
@@ -430,7 +430,7 @@ TEST(config_parse_log_extra_fields) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
@@ -788,7 +788,7 @@ TEST(config_parse_unit_env_file) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
ASSERT_OK_ZERO(unit_add_name(u, "foobar.service"));
@@ -912,7 +912,7 @@ TEST(unit_is_recursive_template_dependency) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
ASSERT_OK_ZERO(unit_add_name(u, "foobar@1.service"));
@@ -1006,7 +1006,7 @@ TEST(config_parse_open_file) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
ASSERT_OK_ZERO(unit_add_name(u, "foobar.service"));
@@ -1065,7 +1065,7 @@ TEST(config_parse_service_refresh_on_reload) {
}
ASSERT_OK(r);
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(u = unit_new(m, sizeof(Service)));
ASSERT_OK_ZERO(unit_add_name(u, "foobar.service"));
diff --git a/src/test/test-luo.c b/src/test/test-luo.c
new file mode 100644
index 0000000000..97ba66459d
--- /dev/null
+++ b/src/test/test-luo.c
@@ -0,0 +1,251 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* Helper for TEST-91-LIVEUPDATE: creates memfds and stores them in the fd store,
+ * creates a LUO session directly via /dev/liveupdate and stores a memfd in it,
+ * or verifies everything after kexec.
+ *
+ * Usage:
+ * test-luo store - create memfds and a LUO session, push all to the fd store
+ * test-luo check - verify fd store content and LUO session memfd after kexec
+ */
+
+#include
+#include
+#include
+
+#include "sd-daemon.h"
+
+#include "fd-util.h"
+#include "log.h"
+#include "luo-util.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"
+#define SESSION_MEMFD_DATA "luo-session-memfd-test-data"
+#define SESSION_MEMFD_TOKEN UINT64_C(42)
+
+static int do_store(const char *prefix) {
+ _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.");
+
+ /* Create a LUO session directly via /dev/liveupdate, put a memfd in it, and store the session fd */
+ _cleanup_close_ int device_fd = -EBADF, session_fd = -EBADF, session_memfd = -EBADF;
+ const char *session_name = strjoina(prefix, "-direct");
+
+ device_fd = luo_open_device();
+ if (device_fd < 0)
+ return log_error_errno(device_fd, "Failed to open /dev/liveupdate: %m");
+
+ session_fd = luo_create_session(device_fd, session_name);
+ if (session_fd < 0)
+ return log_error_errno(session_fd, "Failed to create LUO session '%s': %m", session_name);
+
+ session_memfd = memfd_new_and_seal("session-test", SESSION_MEMFD_DATA, strlen(SESSION_MEMFD_DATA));
+ if (session_memfd < 0)
+ return log_error_errno(session_memfd, "Failed to create session memfd: %m");
+
+ r = luo_session_preserve_fd(session_fd, session_memfd, SESSION_MEMFD_TOKEN);
+ if (r < 0)
+ return log_error_errno(r, "Failed to preserve memfd in session: %m");
+
+ r = sd_pid_notifyf_with_fds(0, false, &session_fd, 1, "FDSTORE=1\nFDNAME=%s-direct", prefix);
+ if (r < 0)
+ return log_error_errno(r, "Failed to store session fd in fd store: %m");
+ TAKE_FD(session_fd);
+
+ log_info("Stored LUO session '%s' with memfd in fd store.", session_name);
+
+ /* 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(const char *prefix) {
+ const char *e;
+ _cleanup_strv_free_ char **names = NULL;
+ const char *session_fdname = strjoina(prefix, "-direct");
+ 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.");
+
+ /* Verify the LUO session fd survived and its memfd content is intact */
+ int session_fd = -EBADF;
+ size_t idx = 0;
+ STRV_FOREACH(name, names) {
+ if (idx >= n_fds)
+ break;
+ if (streq(*name, session_fdname)) {
+ session_fd = SD_LISTEN_FDS_START + idx;
+ break;
+ }
+ idx++;
+ }
+
+ if (session_fd < 0)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
+ "LUO session fd '%s' not found in fd store", session_fdname);
+
+ r = fd_is_luo_session(session_fd);
+ if (r < 0)
+ return log_error_errno(r, "Failed to check if fd is LUO session: %m");
+ if (r == 0)
+ return log_error_errno(SYNTHETIC_ERRNO(EBADMSG),
+ "fd '%s' is not a LUO session!", session_fdname);
+
+ _cleanup_close_ int session_memfd = luo_session_retrieve_fd(session_fd, SESSION_MEMFD_TOKEN);
+ if (session_memfd < 0)
+ return log_error_errno(session_memfd, "Failed to retrieve memfd from session: %m");
+
+ char sbuf[256];
+ ssize_t sn = pread(session_memfd, sbuf, sizeof(sbuf) - 1, 0);
+ if (sn < 0)
+ return log_error_errno(errno, "Failed to read session memfd: %m");
+ sbuf[sn] = '\0';
+
+ if (!streq(sbuf, SESSION_MEMFD_DATA))
+ return log_error_errno(SYNTHETIC_ERRNO(EBADMSG),
+ "Session memfd content mismatch: expected '%s', got '%s'",
+ SESSION_MEMFD_DATA, sbuf);
+
+ /* Remove the LUO session fd from the fd store as well. */
+ r = sd_pid_notifyf(0, /* unset_environment= */ false,
+ "FDSTOREREMOVE=1\nFDNAME=%s", session_fdname);
+ if (r < 0)
+ return log_error_errno(r, "Failed to remove fd '%s' from fd store: %m", session_fdname);
+
+ log_info("Verified LUO session memfd content matches.");
+
+ /* 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 || argc > 3)
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Usage: %s store|check [PREFIX]", argv[0]);
+
+ const char *prefix = argc > 2 ? argv[2] : "luosession";
+
+ if (streq(argv[1], "store"))
+ return do_store(prefix);
+ if (streq(argv[1], "check"))
+ return do_check(prefix);
+
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Unknown command: %s", argv[1]);
+}
+
+DEFINE_MAIN_FUNCTION(run);
diff --git a/src/test/test-path.c b/src/test/test-path.c
index 8b02f5d0ff..512eb96ead 100644
--- a/src/test/test-path.c
+++ b/src/test/test-path.c
@@ -34,7 +34,7 @@ static int setup_test(Manager **m) {
if (manager_errno_skip_test(r))
return log_tests_skipped_errno(r, "manager_new");
assert_se(r >= 0);
- assert_se(manager_startup(tmp, NULL, NULL, NULL) >= 0);
+ assert_se(manager_startup(tmp, NULL, NULL, NULL, NULL) >= 0);
STRV_FOREACH(test_path, tests_path) {
_cleanup_free_ char *p = NULL;
diff --git a/src/test/test-sched-prio.c b/src/test/test-sched-prio.c
index c1305ac4ab..a523c01e8f 100644
--- a/src/test/test-sched-prio.c
+++ b/src/test/test-sched-prio.c
@@ -33,7 +33,7 @@ int main(int argc, char *argv[]) {
if (manager_errno_skip_test(r))
return log_tests_skipped_errno(r, "manager_new");
assert_se(r >= 0);
- assert_se(manager_startup(m, NULL, NULL, NULL) >= 0);
+ assert_se(manager_startup(m, NULL, NULL, NULL, NULL) >= 0);
/* load idle ok */
assert_se(manager_load_startable_unit_or_warn(m, "sched_idle_ok.service", NULL, &idle_ok) >= 0);
diff --git a/src/test/test-socket-bind.c b/src/test/test-socket-bind.c
index 4e4fdbedd0..63d249d6b4 100644
--- a/src/test/test-socket-bind.c
+++ b/src/test/test-socket-bind.c
@@ -133,7 +133,7 @@ int main(int argc, char *argv[]) {
assert_se(runtime_dir = setup_fake_runtime_dir());
assert_se(manager_new(RUNTIME_SCOPE_USER, MANAGER_TEST_RUN_BASIC, &m) >= 0);
- assert_se(manager_startup(m, NULL, NULL, NULL) >= 0);
+ assert_se(manager_startup(m, NULL, NULL, NULL, NULL) >= 0);
assert_se(test_socket_bind(m, "socket_bind_test.service", netcat_path, "2000", STRV_MAKE("2000"), STRV_MAKE("any")) >= 0);
assert_se(test_socket_bind(m, "socket_bind_test.service", netcat_path, "2000", STRV_MAKE("ipv6:2001-2002"), STRV_MAKE("any")) >= 0);
diff --git a/src/test/test-watch-pid.c b/src/test/test-watch-pid.c
index 0a325dfb08..59f043f49c 100644
--- a/src/test/test-watch-pid.c
+++ b/src/test/test-watch-pid.c
@@ -21,7 +21,7 @@ TEST(watch_pid) {
ASSERT_NOT_NULL(runtime_dir = setup_fake_runtime_dir());
ASSERT_OK(manager_new(RUNTIME_SCOPE_USER, MANAGER_TEST_RUN_BASIC, &m));
- ASSERT_OK(manager_startup(m, NULL, NULL, NULL));
+ ASSERT_OK(manager_startup(m, NULL, NULL, NULL, NULL));
ASSERT_NOT_NULL(a = unit_new(m, sizeof(Service)));
ASSERT_OK(unit_add_name(a, "a.service"));
diff --git a/test/integration-tests/TEST-91-LIVEUPDATE/meson.build b/test/integration-tests/TEST-91-LIVEUPDATE/meson.build
new file mode 100644
index 0000000000..460ef32476
--- /dev/null
+++ b/test/integration-tests/TEST-91-LIVEUPDATE/meson.build
@@ -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',
+ },
+]
diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build
index dd9eba9b67..867fdecda2 100644
--- a/test/integration-tests/meson.build
+++ b/test/integration-tests/meson.build
@@ -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-13-NSPAWN.unpriv.sh b/test/units/TEST-13-NSPAWN.unpriv.sh
index e0516449c7..19b1d445c8 100755
--- a/test/units/TEST-13-NSPAWN.unpriv.sh
+++ b/test/units/TEST-13-NSPAWN.unpriv.sh
@@ -22,14 +22,17 @@ at_exit() {
rm -rf /home/testuser/.local/state/machines/inodetest ||:
rm -rf /home/testuser/.local/state/machines/inodetest2 ||:
rm -rf /home/testuser/.local/state/machines/mangletest ||:
+ rm -rf /home/testuser/.local/state/machines/fdstore ||:
machinectl terminate zurps ||:
machinectl terminate exfiltrate ||:
systemctl --user --machine testuser@ stop exfiltrate.service ||:
+ systemctl --user --machine testuser@ stop systemd-nspawn@fdstore.service ||:
rm -f /etc/polkit-1/rules.d/registermachinetest.rules
machinectl terminate nurps ||:
machinectl terminate kurps ||:
machinectl terminate wumms ||:
machinectl terminate wamms ||:
+ machinectl terminate fdstore ||:
rm -f /usr/share/polkit-1/rules.d/registermachinetest.rules
rm -rf /var/tmp/mangletest
rm -f /var/tmp/mangletest.tar.gz
@@ -307,4 +310,85 @@ tar -C /var/tmp/mangletest/ -cvzf /var/tmp/mangletest.tar.gz mangletest-0.1
run0 --pipe -u testuser importctl -m --user import-tar /var/tmp/mangletest.tar.gz
cmp /var/tmp/mangletest/mangletest-0.1/usr/lib/os-release /home/testuser/.local/state/machines/mangletest/usr/lib/os-release
+# Verify the fd-store preservation chain works end-to-end across:
+# payload (inside container) -> systemd-nspawn (user manager) -> user manager
+# -> system PID 1 (user@.service fd store)
+# Then restart the nspawn service and verify the inner payload actually
+# receives the preserved fds back via LISTEN_FDS, with their original content.
+create_dummy_container /home/testuser/.local/state/machines/fdstore
+# The container init execs the helper directly so the FDSTORE notification is
+# sent from PID 1 (nspawn rejects notify messages from anyone but the inner
+# payload's init). The helper itself execs sleep on success to keep the
+# container alive, and on failure it exits non-zero making the systemd-nspawn
+# service fail.
+cat >/home/testuser/.local/state/machines/fdstore/sbin/init <<'EOF'
+#!/usr/bin/env bash
+set -e
+if [[ "${LISTEN_FDS:-0}" -gt 0 ]]; then
+ exec /usr/bin/test-fdstore check
+else
+ exec /usr/bin/test-fdstore store
+fi
+EOF
+chmod +x /home/testuser/.local/state/machines/fdstore/sbin/init
+systemd-dissect --shift /home/testuser/.local/state/machines/fdstore foreign
+
+run0 -u testuser mkdir -p .config/systemd/nspawn/
+run0 -u testuser -i "cat >.config/systemd/nspawn/fdstore.nspawn <.config/systemd/user/systemd-nspawn@fdstore.service.d/fdstore.conf < nspawn (user-side systemd-nspawn@fdstore.service fd store)
+n_nspawn_fds=$(run0 -u testuser systemctl --user show -P NFileDescriptorStore systemd-nspawn@fdstore.service)
+test "${n_nspawn_fds}" -ge 2
+
+# 2) nspawn -> user manager -> system PID 1 (user@.service fd store)
+TESTUSER_UID=$(id -u testuser)
+timeout 30s bash -c \
+ "until [[ \"\$(systemctl show -P NFileDescriptorStore user@${TESTUSER_UID}.service)\" -ge 2 ]]; do sleep 0.5; done"
+n_user_at_fds=$(systemctl show -P NFileDescriptorStore "user@${TESTUSER_UID}.service")
+test "${n_user_at_fds}" -ge 2
+
+# 3) Stop the nspawn service: payload is gone but FileDescriptorStorePreserve=yes
+# must keep the fds in the user-side fdstore (and propagated copy in PID 1).
+run0 -u testuser systemctl --user stop systemd-nspawn@fdstore.service
+n_nspawn_fds=$(run0 -u testuser systemctl --user show -P NFileDescriptorStore systemd-nspawn@fdstore.service)
+test "${n_nspawn_fds}" -ge 2
+
+# 4) Restart the service: nspawn must receive the preserved fds via LISTEN_FDS
+# and forward them into the inner payload, which verifies the content matches.
+run0 -u testuser systemctl start --user systemd-nspawn@fdstore.service
+run0 -u testuser systemctl is-active --user systemd-nspawn@fdstore.service
+
+# 5) Stop the nspawn service and the user session
+run0 -u testuser systemctl --user stop systemd-nspawn@fdstore.service
+n_nspawn_fds=$(run0 -u testuser systemctl --user show -P NFileDescriptorStore systemd-nspawn@fdstore.service)
+test "${n_nspawn_fds}" -ge 2
+systemctl stop "user@${TESTUSER_UID}.service"
+n_user_at_fds=$(systemctl show -P NFileDescriptorStore "user@${TESTUSER_UID}.service")
+test "${n_user_at_fds}" -ge 2
+
+# 6) Restart the user session and container payload
+systemctl start "user@${TESTUSER_UID}.service"
+timeout 30s bash -c \
+ "until systemctl is-active 'user@${TESTUSER_UID}.service' >/dev/null; do sleep 0.5; done"
+run0 -u testuser systemctl --user start systemd-nspawn@fdstore.service
+run0 -u testuser systemctl is-active --user systemd-nspawn@fdstore.service
+
+run0 -u testuser systemctl --user stop systemd-nspawn@fdstore.service
+machinectl terminate fdstore 2>/dev/null || true
+
loginctl disable-linger testuser
diff --git a/test/units/TEST-91-LIVEUPDATE.sh b/test/units/TEST-91-LIVEUPDATE.sh
new file mode 100755
index 0000000000..1d2df255cf
--- /dev/null
+++ b/test/units/TEST-91-LIVEUPDATE.sh
@@ -0,0 +1,200 @@
+#!/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" </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 < 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 <