diff --git a/man/rules/meson.build b/man/rules/meson.build
index 7f5d5af96f..299546df9a 100644
--- a/man/rules/meson.build
+++ b/man/rules/meson.build
@@ -1209,6 +1209,10 @@ manpages = [
'ENABLE_RANDOMSEED'],
['systemd-remount-fs.service', '8', ['systemd-remount-fs'], ''],
['systemd-repart', '8', ['systemd-repart.service'], 'ENABLE_REPART'],
+ ['systemd-report-diskspace@.service',
+ '8',
+ ['systemd-report-diskspace', 'systemd-report-diskspace.socket'],
+ 'HAVE_LIBMOUNT'],
['systemd-report-files@.service',
'8',
['systemd-report-files', 'systemd-report-files.socket'],
diff --git a/man/systemd-report-diskspace@.service.xml b/man/systemd-report-diskspace@.service.xml
new file mode 100644
index 0000000000..ee61c1d0ce
--- /dev/null
+++ b/man/systemd-report-diskspace@.service.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+ systemd-report-diskspace@.service
+ systemd
+
+
+
+ systemd-report-diskspace@.service
+ 8
+
+
+
+ systemd-report-diskspace@.service
+ systemd-report-diskspace.socket
+ systemd-report-diskspace
+ Report size and free disk space of mounted file systems as system report metrics
+
+
+
+ systemd-report-diskspace@.service
+ systemd-report-diskspace.socket
+ /usr/lib/systemd/systemd-report-diskspace
+
+
+
+ Description
+
+ systemd-report-diskspace@.service is a metrics source for
+ systemd-report1. It
+ reports the size of the mounted file systems and how much disk space is still available on them, by
+ implementing the io.systemd.Metrics Varlink interface on a socket linked into the
+ /run/systemd/report/ directory.
+
+ All mount points of the system are enumerated. Mount points of file systems that are not backed by
+ a block device (i.e. pseudo file systems such as proc, sysfs or
+ tmpfs, network file systems, overlay, …) are skipped, as are
+ read-only mount points. For each remaining mount point two gauge metrics are reported, with the mount
+ point path as metric object:
+
+
+
+ io.systemd.DiskSpace.SizeBytes
+ The total size of the file system, in bytes. This matches the
+ 1K-blocks column of
+ df1
+ (scaled to bytes).
+
+
+
+ io.systemd.DiskSpace.FreeBytes
+ The disk space still available to unprivileged users on the file system, in
+ bytes. This matches the Avail column of
+ df1,
+ i.e. space reserved for the superuser (as many file systems do) is not counted as
+ free.
+
+
+
+ The following metric fields are set on both metrics:
+
+
+
+ fileSystemType
+ The file system type, for example ext4 or
+ btrfs. Always set.
+
+
+
+ source
+ The mount source, typically the device node the file system is mounted from, for
+ example /dev/sda1. Omitted if unknown.
+
+
+
+ originatingSource
+ If the block device the file system is mounted from is a stacked one (for example a
+ LUKS or other device mapper volume), the device node of the block device it ultimately originates
+ from, for example /dev/sda2 for a file system mounted from
+ /dev/mapper/root. Omitted if the source is not a stacked block device, or its
+ origin cannot be determined. Note that stacked devices backed by multiple block devices (e.g. RAID)
+ are not covered by this.
+
+
+
+ A file system that is mounted multiple times (for example via bind mounts, or as multiple
+ subvolumes of the same volume) is reported only once, under the shortest of its mount point paths (and
+ among equally long ones, the alphabetically first). Mount points that are shadowed by another mount on
+ top of them, or that the service cannot access, are not considered.
+
+
+
+
+
+ See Also
+
+ systemd1
+ systemd-report1
+ df1
+ statfs2
+
+
+
+
diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset
index c76cdd511e..00fba45de0 100644
--- a/presets/90-systemd.preset
+++ b/presets/90-systemd.preset
@@ -35,6 +35,7 @@ enable systemd-nsresourced.socket
enable systemd-pstore.service
enable systemd-report-basic.socket
enable systemd-report-cgroup.socket
+enable systemd-report-diskspace.socket
enable systemd-report-files.socket
enable systemd-report-hwmon.socket
enable systemd-report-sign-plain.socket
diff --git a/src/report/meson.build b/src/report/meson.build
index 7390ce04ea..d2583cd84a 100644
--- a/src/report/meson.build
+++ b/src/report/meson.build
@@ -29,6 +29,15 @@ executables += [
'report-cgroup-server.c',
),
},
+ libexec_template + {
+ 'name' : 'systemd-report-diskspace',
+ 'sources' : files(
+ 'report-diskspace.c',
+ 'report-diskspace-server.c',
+ ),
+ 'dependencies' : libmount_cflags,
+ 'conditions' : ['HAVE_LIBMOUNT'],
+ },
libexec_template + {
'name' : 'systemd-report-files',
'sources' : files(
diff --git a/src/report/report-diskspace-server.c b/src/report/report-diskspace-server.c
new file mode 100644
index 0000000000..b37389bfa9
--- /dev/null
+++ b/src/report/report-diskspace-server.c
@@ -0,0 +1,95 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "sd-json.h"
+#include "sd-varlink.h"
+
+#include "build.h"
+#include "dlopen-note.h"
+#include "log.h"
+#include "main-func.h"
+#include "report-diskspace.h"
+#include "varlink-io.systemd.Metrics.h"
+#include "varlink-util.h"
+#include "verbs.h"
+
+COMMAND(
+ "systemd-report-diskspace\0",
+ "Report disk space metrics.",
+);
+
+static int vl_server(void) {
+ _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL;
+ int r;
+
+ r = varlink_server_new(&vs, SD_VARLINK_SERVER_INHERIT_USERDATA, /* userdata= */ NULL);
+ if (r < 0)
+ return log_error_errno(r, "Failed to allocate Varlink server: %m");
+
+ r = sd_varlink_server_add_interface(vs, &vl_interface_io_systemd_Metrics);
+ if (r < 0)
+ return log_error_errno(r, "Failed to add Varlink interface: %m");
+
+ r = sd_varlink_server_bind_method_many(
+ vs,
+ "io.systemd.Metrics.List", vl_method_list_metrics,
+ "io.systemd.Metrics.Describe", vl_method_describe_metrics);
+ if (r < 0)
+ return log_error_errno(r, "Failed to bind Varlink methods: %m");
+
+ r = sd_varlink_server_loop_auto(vs);
+ if (r < 0)
+ return log_error_errno(r, "Failed to run Varlink event loop: %m");
+
+ return 0;
+}
+
+static int parse_argv(int argc, char *argv[]) {
+ int r;
+
+ assert(argc >= 0);
+ assert(argv);
+
+ OptionParser opts = { argc, argv };
+
+ FOREACH_OPTION_OR_RETURN(c, &opts)
+ switch (c) {
+
+ OPTION_COMMON_HELP:
+ return command_print_help();
+
+ OPTION_COMMON_VERSION:
+ return version();
+
+ OPTION_COMMON_INTROSPECT_CLI:
+ return introspect_cli(SD_JSON_FORMAT_OFF);
+ }
+
+ if (option_parser_get_n_args(&opts) > 0)
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+ "This program takes no arguments.");
+
+ r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT);
+ if (r < 0)
+ return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m");
+ if (r == 0)
+ return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+ "This program can only run as a Varlink service.");
+
+ return 1;
+}
+
+static int run(int argc, char *argv[]) {
+ int r;
+
+ LIBMOUNT_NOTE(required);
+
+ log_setup();
+
+ r = parse_argv(argc, argv);
+ if (r <= 0)
+ return r;
+
+ return vl_server();
+}
+
+DEFINE_MAIN_FUNCTION(run);
diff --git a/src/report/report-diskspace.c b/src/report/report-diskspace.c
new file mode 100644
index 0000000000..0f75c32716
--- /dev/null
+++ b/src/report/report-diskspace.c
@@ -0,0 +1,366 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+#include
+
+#include "sd-device.h"
+#include "sd-json.h"
+#include "sd-varlink.h"
+
+#include "alloc-util.h"
+#include "blockdev-util.h"
+#include "device-util.h"
+#include "devnum-util.h"
+#include "fd-util.h"
+#include "hash-funcs.h"
+#include "hashmap.h"
+#include "json-util.h"
+#include "libmount-util.h"
+#include "log.h"
+#include "metrics.h"
+#include "mountpoint-util.h"
+#include "path-util.h"
+#include "report-diskspace.h"
+#include "sort-util.h"
+#include "stat-util.h"
+
+/* Reports the size of and the disk space still available on all mounted, writable, block device backed file
+ * systems, i.e. roughly what df(1) shows. Pseudo file systems (procfs, sysfs, tmpfs, …), network file
+ * systems and other file systems not backed by a block device are skipped, as are read-only mounts: their
+ * free space is not going to change, and typically is not meaningful anyway (squashfs, erofs, DDIs, …).
+ *
+ * A file system (i.e. a superblock, identified by its device number) that is mounted in multiple places
+ * (bind mounts, btrfs subvolumes, …) is reported only once, under its shortest mount path. */
+
+typedef struct FileSystem {
+ dev_t devno; /* the hashmap key: device number of the superblock */
+ struct libmnt_fs *fs; /* the mount we report the file system under, owned by the mount table */
+ int fd; /* O_PATH fd to the mount point */
+ uint64_t free_bytes;
+ uint64_t size_bytes;
+} FileSystem;
+
+static FileSystem* file_system_free(FileSystem *f) {
+ if (!f)
+ return NULL;
+
+ safe_close(f->fd);
+ return mfree(f);
+}
+
+DEFINE_TRIVIAL_CLEANUP_FUNC(FileSystem*, file_system_free);
+DEFINE_PRIVATE_HASH_OPS_WITH_VALUE_DESTRUCTOR(
+ file_system_hash_ops,
+ dev_t, devt_hash_func, devt_compare_func,
+ FileSystem, file_system_free);
+
+static const char* file_system_path(const FileSystem *f) {
+ assert(f);
+ assert(f->fs);
+
+ return ASSERT_PTR(sym_mnt_fs_get_target(f->fs));
+}
+
+/* Among the mount points of the same file system we prefer the shortest path, and among equally short ones
+ * the alphabetically earlier. */
+static bool path_is_preferred(const char *a, const char *b) {
+ assert(a);
+ assert(b);
+
+ int ret = CMP(strlen(a), strlen(b));
+ if (ret != 0)
+ return ret < 0;
+
+ return path_compare(a, b) < 0;
+}
+
+static int file_system_compare(FileSystem *const *a, FileSystem *const *b) {
+ assert(a);
+ assert(b);
+ return path_compare(file_system_path(*a), file_system_path(*b));
+}
+
+static int mount_collect(Hashmap **file_systems, struct libmnt_fs *fs) {
+ int r;
+
+ assert(file_systems);
+ assert(fs);
+
+ const char *target = sym_mnt_fs_get_target(fs);
+ if (!target)
+ return 0;
+
+ const char *fstype = sym_mnt_fs_get_fstype(fs);
+ if (!fstype)
+ return 0;
+
+ if (!fstype_is_blockdev_backed(fstype)) {
+ log_debug("Mount '%s' of type %s is not backed by a block device, skipping.", target, fstype);
+ return 0;
+ }
+
+ /* This covers both file systems that are read-only as a whole, and read-only bind mounts of
+ * otherwise writable file systems, since libmount searches both the superblock and the per-mount
+ * options here. */
+ r = sym_mnt_fs_get_option(fs, "ro", /* value= */ NULL, /* valsz= */ NULL);
+ if (r < 0)
+ return log_debug_errno(r, "Failed to check whether '%s' is mounted read-only: %m", target);
+ if (r == 0) {
+ log_debug("Mount '%s' is read-only, skipping.", target);
+ return 0;
+ }
+
+ dev_t devno = sym_mnt_fs_get_devno(fs);
+ if (devno == 0) {
+ log_debug("Mount '%s' has no device number, skipping.", target);
+ return 0;
+ }
+
+ FileSystem *existing = hashmap_get(*file_systems, &devno);
+ if (existing && !path_is_preferred(target, file_system_path(existing))) {
+ log_debug("File system mounted on '%s' is already known as '%s', skipping.",
+ target, file_system_path(existing));
+ return 0;
+ }
+
+ /* Mount points below a directory we lack access to (or that have been unmounted since the mount
+ * table was read) are simply not considered. */
+ _cleanup_close_ int fd = open(target, O_PATH|O_CLOEXEC);
+ if (fd < 0) {
+ log_debug_errno(errno, "Failed to open mount point '%s', ignoring: %m", target);
+ return 0;
+ }
+
+ /* Make sure the path still refers to the mount we are looking at, and not to something that was
+ * mounted on top of it since, shadowing it. Also catches mount points that are hidden entirely. */
+ int mnt_id;
+ r = path_get_mnt_id_at(fd, /* path= */ NULL, &mnt_id);
+ if (r < 0) {
+ log_debug_errno(r, "Failed to get mount ID of '%s', ignoring: %m", target);
+ return 0;
+ }
+ if (mnt_id != sym_mnt_fs_get_id(fs)) {
+ log_debug("Mount '%s' is shadowed by another mount, skipping.", target);
+ return 0;
+ }
+
+ struct statfs sfs;
+ r = xstatfsat(fd, /* path= */ NULL, &sfs);
+ if (r < 0) {
+ log_debug_errno(r, "Failed to statfs() '%s', ignoring: %m", target);
+ return 0;
+ }
+
+ /* Already excluded above based on the mount table, but let's check again on the authoritative data,
+ * in case things changed in the meantime. */
+ if (FLAGS_SET(sfs.f_flags, ST_RDONLY)) {
+ log_debug("Mount '%s' is read-only, skipping.", target);
+ return 0;
+ }
+
+ if (sfs.f_blocks == 0) {
+ log_debug("Mount '%s' has zero size, skipping.", target);
+ return 0;
+ }
+
+ /* Report the space available to unprivileged users as free, matching what df(1) shows as "Avail".
+ * Space reserved for the superuser (typically 5% on ext4) is hence not counted as free. */
+ uint64_t size_bytes, free_bytes;
+
+ if (!MUL_SAFE(&free_bytes, (uint64_t) sfs.f_frsize, (uint64_t) sfs.f_bavail) ||
+ !MUL_SAFE(&size_bytes, (uint64_t) sfs.f_frsize, (uint64_t) sfs.f_blocks)) {
+ log_debug("Sizes of '%s' overflow, skipping.", target);
+ return 0;
+ }
+
+ _cleanup_(file_system_freep) FileSystem *f = new(FileSystem, 1);
+ if (!f)
+ return log_oom();
+
+ *f = (FileSystem) {
+ .devno = devno,
+ .fs = fs,
+ .fd = TAKE_FD(fd),
+ .free_bytes = free_bytes,
+ .size_bytes = size_bytes,
+ };
+
+ /* Replace the entry we already have for this file system, if any, by the better one */
+ file_system_free(hashmap_remove(*file_systems, &f->devno));
+
+ if (hashmap_ensure_put(file_systems, &file_system_hash_ops, &f->devno, f) < 0)
+ return log_oom();
+
+ TAKE_PTR(f);
+ return 0;
+}
+
+/* If the file system's backing block device is a stacked one (LUKS-style DM, …), returns the device node of
+ * the block device it originates from, chased down through all layers. Returns NULL (and success) if the
+ * backing device is not a stacked block device, or is not a block device at all. */
+static int file_system_get_originating_source(const FileSystem *f, char **ret) {
+ int r;
+
+ assert(f);
+ assert(ret);
+
+ /* NB: We do not use f->devno here, but acquire the devno again from the fd. That's because on btrfs
+ * we want the devnum of the backing device node, and not of the anonymous superblock */
+ dev_t devno;
+ r = get_block_device_fd(f->fd, &devno);
+ if (r < 0) {
+ return log_debug_errno(r, "Failed to get backing block device of '%s', ignoring: %m", file_system_path(f));
+ *ret = NULL;
+ return 0;
+ }
+ if (devno == 0) {
+ log_debug("Path '%s' has no backing block device, ignoring.", file_system_path(f));
+ *ret = NULL;
+ return 0;
+ }
+
+ /* The mount's device number is the backing block device's (except for file systems that use
+ * anonymous device numbers, such as btrfs, which we hence cannot trace this way). */
+ _cleanup_(sd_device_unrefp) sd_device *dev = NULL;
+ r = sd_device_new_from_devnum(&dev, 'b', devno);
+ if (r < 0) {
+ log_debug_errno(r, "Failed to get block device " DEVNUM_FORMAT_STR " backing '%s', ignoring: %m",
+ DEVNUM_FORMAT_VAL(devno), file_system_path(f));
+ *ret = NULL;
+ return 0;
+ }
+
+ _cleanup_(sd_device_unrefp) sd_device *origin = NULL;
+ r = block_device_get_originating(dev, &origin, /* recursive= */ true);
+ if (r < 0) {
+ if (r != -ENOENT) /* -ENOENT means: not stacked */
+ log_device_debug_errno(dev, r, "Failed to determine originating block device, ignoring: %m");
+ *ret = NULL;
+ return 0;
+ }
+
+ const char *node;
+ r = sd_device_get_devname(origin, &node);
+ if (r < 0) {
+ log_device_debug_errno(origin, r, "Failed to get device node of originating block device, ignoring: %m");
+ *ret = NULL;
+ return 0;
+ }
+
+ return strdup_to(ret, node);
+}
+
+static int file_system_send(
+ const MetricFamily mf[static 2],
+ sd_varlink *link,
+ const FileSystem *f) {
+
+ int r;
+
+ assert(mf && mf[0].name && mf[1].name);
+ assert(link);
+ assert(f);
+
+ _cleanup_free_ char *originating_source = NULL;
+ r = file_system_get_originating_source(f, &originating_source);
+ if (r < 0)
+ return log_error_errno(r, "Failed to determine originating source: %m");
+
+ _cleanup_(sd_json_variant_unrefp) sd_json_variant *fields = NULL;
+ r = sd_json_buildo(
+ &fields,
+ JSON_BUILD_PAIR_STRING_NON_EMPTY("source", sym_mnt_fs_get_source(f->fs)),
+ JSON_BUILD_PAIR_STRING_NON_EMPTY("originatingSource", originating_source),
+ SD_JSON_BUILD_PAIR_STRING("fileSystemType", sym_mnt_fs_get_fstype(f->fs)));
+ if (r < 0)
+ return log_error_errno(r, "Failed to build metric fields: %m");
+
+ r = metric_build_send_unsigned(mf + 0, link, file_system_path(f), f->free_bytes, fields);
+ if (r < 0)
+ return r;
+
+ return metric_build_send_unsigned(mf + 1, link, file_system_path(f), f->size_bytes, fields);
+}
+
+static int diskspace_generate(
+ const MetricFamily mf[static 2],
+ sd_varlink *link,
+ void *userdata) {
+
+ int r;
+
+ assert(mf);
+ assert(link);
+
+ _cleanup_(mnt_free_tablep) struct libmnt_table *table = NULL;
+ _cleanup_(mnt_free_iterp) struct libmnt_iter *iter = NULL;
+ r = libmount_parse_mountinfo(/* source= */ NULL, &table, &iter);
+ if (r < 0)
+ return log_error_errno(r, "Failed to parse /proc/self/mountinfo: %m");
+
+ /* First pass: collect all file systems we care about, keyed by superblock */
+ _cleanup_hashmap_free_ Hashmap *file_systems = NULL;
+ for (;;) {
+ struct libmnt_fs *fs;
+
+ r = sym_mnt_table_next_fs(table, iter, &fs);
+ if (r == 1)
+ break;
+ if (r < 0)
+ return log_error_errno(r, "Failed to get next entry from /proc/self/mountinfo: %m");
+
+ r = mount_collect(&file_systems, fs);
+ if (r < 0)
+ return r;
+ }
+
+ /* Second pass: report them, sorted by mount path for stable output */
+ _cleanup_free_ FileSystem **sorted = NULL;
+ size_t n = 0;
+ FileSystem *f;
+ HASHMAP_FOREACH(f, file_systems) {
+ if (!GREEDY_REALLOC(sorted, n + 1))
+ return log_oom();
+
+ sorted[n++] = f;
+ }
+
+ typesafe_qsort(sorted, n, file_system_compare);
+
+ FOREACH_ARRAY(i, sorted, n) {
+ r = file_system_send(mf, link, *i);
+ if (r < 0)
+ return r;
+ }
+
+ return 0;
+}
+
+static const MetricFamily diskspace_metric_family_table[] = {
+ /* Keep metrics ordered alphabetically. Both are generated by the same function, attached to the
+ * first of them. */
+ {
+ "io.systemd.DiskSpace.FreeBytes",
+ "Per file system metric: disk space available to unprivileged users in bytes, on writable "
+ "block device backed file systems (object=mount point, fileSystemType=file system type, "
+ "source=backing device, originatingSource=device the backing device is stacked on, if any)",
+ METRIC_FAMILY_TYPE_GAUGE,
+ .generate = diskspace_generate,
+ },
+ {
+ "io.systemd.DiskSpace.SizeBytes",
+ "Per file system metric: file system size in bytes, on writable block device backed file "
+ "systems (object=mount point, fileSystemType=file system type, source=backing device, "
+ "originatingSource=device the backing device is stacked on, if any)",
+ METRIC_FAMILY_TYPE_GAUGE,
+ },
+ {}
+};
+
+int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) {
+ return metrics_method_describe(diskspace_metric_family_table, link, parameters, flags, userdata);
+}
+
+int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) {
+ return metrics_method_list(diskspace_metric_family_table, link, parameters, flags, userdata);
+}
diff --git a/src/report/report-diskspace.h b/src/report/report-diskspace.h
new file mode 100644
index 0000000000..152f5bc846
--- /dev/null
+++ b/src/report/report-diskspace.h
@@ -0,0 +1,7 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include "forward.h"
+
+int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata);
+int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata);
diff --git a/src/shared/libmount-util.c b/src/shared/libmount-util.c
index ad1afb651e..ddbe4b5abc 100644
--- a/src/shared/libmount-util.c
+++ b/src/shared/libmount-util.c
@@ -12,6 +12,7 @@
DLSYM_PROTOTYPE(mnt_free_iter) = NULL;
DLSYM_PROTOTYPE(mnt_free_table) = NULL;
+DLSYM_PROTOTYPE(mnt_fs_get_devno) = NULL;
DLSYM_PROTOTYPE(mnt_fs_get_fs_options) = NULL;
DLSYM_PROTOTYPE(mnt_fs_get_fstype) = NULL;
DLSYM_PROTOTYPE(mnt_fs_get_id) = NULL;
@@ -154,6 +155,7 @@ int dlopen_libmount(int log_level) {
log_level,
DLSYM_ARG(mnt_free_iter),
DLSYM_ARG(mnt_free_table),
+ DLSYM_ARG(mnt_fs_get_devno),
DLSYM_ARG(mnt_fs_get_fs_options),
DLSYM_ARG(mnt_fs_get_fstype),
DLSYM_ARG(mnt_fs_get_id),
diff --git a/src/shared/libmount-util.h b/src/shared/libmount-util.h
index c2ca56b7e9..6a8be7d7e3 100644
--- a/src/shared/libmount-util.h
+++ b/src/shared/libmount-util.h
@@ -16,6 +16,7 @@
extern DLSYM_PROTOTYPE(mnt_free_iter);
extern DLSYM_PROTOTYPE(mnt_free_table);
+extern DLSYM_PROTOTYPE(mnt_fs_get_devno);
extern DLSYM_PROTOTYPE(mnt_fs_get_fs_options);
extern DLSYM_PROTOTYPE(mnt_fs_get_fstype);
extern DLSYM_PROTOTYPE(mnt_fs_get_id);
diff --git a/test/units/TEST-74-AUX-UTILS.introspect-cli.sh b/test/units/TEST-74-AUX-UTILS.introspect-cli.sh
index 7ff9ec4f14..f8dc9d6e9c 100755
--- a/test/units/TEST-74-AUX-UTILS.introspect-cli.sh
+++ b/test/units/TEST-74-AUX-UTILS.introspect-cli.sh
@@ -109,6 +109,7 @@ INTROSPECTABLE=(
systemd-report
systemd-report-basic
systemd-report-cgroup
+ systemd-report-diskspace
systemd-report-files
systemd-report-hwmon
systemd-report-sign-plain
diff --git a/test/units/TEST-74-AUX-UTILS.report.sh b/test/units/TEST-74-AUX-UTILS.report.sh
index a833cc7c17..87f513d348 100755
--- a/test/units/TEST-74-AUX-UTILS.report.sh
+++ b/test/units/TEST-74-AUX-UTILS.report.sh
@@ -93,6 +93,105 @@ fi
"$REPORT" describe io.systemd.HWMon
"$REPORT" metrics io.systemd.HWMon
+# test io.systemd.DiskSpace Metrics
+
+# Mount a scratch ext4 file system and check that it is reported with plausible numbers, that a tmpfs next to
+# it is not, that additional bind mounts of it do not result in additional rows but the shortest writable
+# mount path wins regardless of mount order, that it disappears from the report once remounted read-only,
+# and that a device mapper device stacked on top of it is reported with the loop device as originating
+# source.
+diskspace_mount_tests() {
+ # Loop devices and device mapper are not available in containers
+ if systemd-detect-virt --quiet --container; then
+ echo "Skipping io.systemd.DiskSpace mount tests in container."
+ return
+ fi
+
+ DISKSPACE_WORK="$(mktemp -d /var/tmp/report-diskspace.XXXXXX)"
+ diskspace_cleanup() {
+ set +e
+ umount "$DISKSPACE_WORK/tmpfs" "$DISKSPACE_WORK/a" "$DISKSPACE_WORK/b" "$DISKSPACE_WORK/longer" "$DISKSPACE_WORK/ext4" "$DISKSPACE_WORK/dm"
+ dmsetup remove report-diskspace
+ systemd-dissect --detach "$DISKSPACE_WORK/scratch.raw"
+ rm -rf "$DISKSPACE_WORK"
+ set -e
+ }
+ trap diskspace_cleanup EXIT
+ mkdir "$DISKSPACE_WORK/ext4" "$DISKSPACE_WORK/a" "$DISKSPACE_WORK/b" "$DISKSPACE_WORK/longer" "$DISKSPACE_WORK/dm" "$DISKSPACE_WORK/tmpfs"
+ truncate -s 64M "$DISKSPACE_WORK/scratch.raw"
+ mkfs.ext4 -q "$DISKSPACE_WORK/scratch.raw"
+ DISKSPACE_LOOP="$(systemd-dissect --attach --loop-ref=report-diskspace "$DISKSPACE_WORK/scratch.raw")"
+ mount "$DISKSPACE_LOOP" "$DISKSPACE_WORK/ext4"
+ # "a" sorts before "b" and is just as short, but is read-only, hence "b" must be picked
+ mount --bind -o ro "$DISKSPACE_WORK/ext4" "$DISKSPACE_WORK/a"
+ mount --bind "$DISKSPACE_WORK/ext4" "$DISKSPACE_WORK/b"
+ # "longer" is writable too, but has a longer path, hence must not win even though it is mounted last
+ mount --bind "$DISKSPACE_WORK/ext4" "$DISKSPACE_WORK/longer"
+ mount -t tmpfs tmpfs "$DISKSPACE_WORK/tmpfs"
+ dd if=/dev/urandom of="$DISKSPACE_WORK/ext4/fill" bs=1M count=8
+ sync -f "$DISKSPACE_WORK/ext4"
+
+ diskspace_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.DiskSpace io.systemd.Metrics.List {} | tr -d '\036')"
+ # Exactly one row per metric for the scratch file system, reported under the shortest writable mount path
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg s "$DISKSPACE_LOOP" 'select(.name == "io.systemd.DiskSpace.SizeBytes" and .fields.source == $s) | .object')" == "$DISKSPACE_WORK/b" ]]
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg s "$DISKSPACE_LOOP" 'select(.name == "io.systemd.DiskSpace.FreeBytes" and .fields.source == $s) | .object')" == "$DISKSPACE_WORK/b" ]]
+ diskspace_size="$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/b" 'select(.name == "io.systemd.DiskSpace.SizeBytes" and .object == $o) | .value')"
+ diskspace_free="$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/b" 'select(.name == "io.systemd.DiskSpace.FreeBytes" and .object == $o) | .value')"
+ # The file system cannot be larger than the image, and at least the 8M we wrote must be missing from the
+ # free space.
+ [[ "$diskspace_size" -gt 0 ]]
+ [[ "$diskspace_size" -le $((64 * 1024 * 1024)) ]]
+ [[ "$diskspace_free" -gt 0 ]]
+ [[ $((diskspace_free + 8 * 1024 * 1024)) -le "$diskspace_size" ]]
+ # df(1) derives the same two numbers from statfs(), hence they must match exactly.
+ read -r diskspace_df_size diskspace_df_avail < <(df -B1 --output=size,avail "$DISKSPACE_WORK/ext4" | sed 1d)
+ [[ "$diskspace_size" == "$diskspace_df_size" ]]
+ [[ "$diskspace_free" == "$diskspace_df_avail" ]]
+ # Both rows must carry the file system type as field, and no originating source, as a loop device is not
+ # stacked on anything
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/b" 'select(.object == $o) | .fields.fileSystemType' | sort -u)" == "ext4" ]]
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/b" 'select(.object == $o) | .fields | has("originatingSource") | tostring' | sort -u)" == "false" ]]
+ # The tmpfs may not be reported
+ (! echo "$diskspace_metrics" | jq -r '.object' | grep -Fx "$DISKSPACE_WORK/tmpfs" >/dev/null)
+
+ # Once remounted read-only the file system must no longer be reported, regardless of the mount path
+ mount -o remount,ro "$DISKSPACE_WORK/ext4"
+ diskspace_metrics="$(varlinkctl call --more --graceful=io.systemd.Metrics.NoSuchMetric /run/systemd/report/io.systemd.DiskSpace io.systemd.Metrics.List {} | tr -d '\036')"
+ (! echo "$diskspace_metrics" | jq -r '.fields.source' | grep -Fx "$DISKSPACE_LOOP" >/dev/null)
+
+ # Now stack a (linear) device mapper device on top of the loop device, and mount the file system through
+ # that: the DM device must be reported as source, and the loop device it sits on as originating source. The
+ # loop device must be unmounted first, as DM needs exclusive access to it.
+ umount "$DISKSPACE_WORK/a" "$DISKSPACE_WORK/b" "$DISKSPACE_WORK/longer" "$DISKSPACE_WORK/ext4"
+ dmsetup create report-diskspace --table "0 $(blockdev --getsz "$DISKSPACE_LOOP") linear $DISKSPACE_LOOP 0"
+ udevadm wait --timeout=30 --settle /dev/mapper/report-diskspace
+ mount /dev/mapper/report-diskspace "$DISKSPACE_WORK/dm"
+ diskspace_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.DiskSpace io.systemd.Metrics.List {} | tr -d '\036')"
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/dm" 'select(.object == $o) | .fields.source' | sort -u)" == "/dev/mapper/report-diskspace" ]]
+ [[ "$(echo "$diskspace_metrics" | jq -r --arg o "$DISKSPACE_WORK/dm" 'select(.object == $o) | .fields.originatingSource' | sort -u)" == "$DISKSPACE_LOOP" ]]
+
+ diskspace_cleanup
+ trap - EXIT
+}
+
+# The socket and service are only built if libmount is available
+if ! systemctl cat systemd-report-diskspace.socket &>/dev/null; then
+ echo "systemd-report-diskspace.socket is not installed, skipping io.systemd.DiskSpace tests."
+else
+ systemctl start systemd-report-diskspace.socket
+ varlinkctl info /run/systemd/report/io.systemd.DiskSpace
+ varlinkctl list-methods /run/systemd/report/io.systemd.DiskSpace
+ # varlinkctl emits a JSON-SEQ stream for --more; strip the record separators so that plain jq can consume it
+ diskspace_describe="$(varlinkctl call --more /run/systemd/report/io.systemd.DiskSpace io.systemd.Metrics.Describe {} | tr -d '\036')"
+ echo "$diskspace_describe" | jq -r 'select(.name == "io.systemd.DiskSpace.FreeBytes") | .type' | grep -wx gauge >/dev/null
+ echo "$diskspace_describe" | jq -r 'select(.name == "io.systemd.DiskSpace.SizeBytes") | .type' | grep -wx gauge >/dev/null
+
+ diskspace_mount_tests
+
+ "$REPORT" describe io.systemd.DiskSpace
+ "$REPORT" metrics io.systemd.DiskSpace
+fi
+
# test io.systemd.Basic Metrics
# ensure the socket is running, as some distros don't enable it by default
systemctl start systemd-report-basic.socket
diff --git a/tools/command_ignorelist b/tools/command_ignorelist
index e4c06b1c5c..f4b5207f55 100644
--- a/tools/command_ignorelist
+++ b/tools/command_ignorelist
@@ -588,3 +588,8 @@ systemd.network.xml ./refsect1[title="[ModemManager] Section Options"]/variablel
systemd-report-hwmon@.service.xml ./refsect1[title="Description"]/variablelist/varlistentry[term="chip"]
systemd-report-hwmon@.service.xml ./refsect1[title="Description"]/variablelist/varlistentry[term="sensor"]
systemd-report-hwmon@.service.xml ./refsect1[title="Description"]/variablelist/varlistentry[term="label"]
+systemd-report-diskspace@.service.xml ./refsect1[title="Description"]/variablelist[1]/varlistentry[term="io.systemd.DiskSpace.SizeBytes"]
+systemd-report-diskspace@.service.xml ./refsect1[title="Description"]/variablelist[1]/varlistentry[term="io.systemd.DiskSpace.FreeBytes"]
+systemd-report-diskspace@.service.xml ./refsect1[title="Description"]/variablelist[2]/varlistentry[term="source"]
+systemd-report-diskspace@.service.xml ./refsect1[title="Description"]/variablelist[2]/varlistentry[term="originatingSource"]
+systemd-report-diskspace@.service.xml ./refsect1[title="Description"]/variablelist[2]/varlistentry[term="fileSystemType"]
diff --git a/units/meson.build b/units/meson.build
index eba7a53215..55610bfb27 100644
--- a/units/meson.build
+++ b/units/meson.build
@@ -794,6 +794,14 @@ units = [
},
{ 'file' : 'systemd-report-cgroup.socket' },
{ 'file' : 'systemd-report-cgroup@.service.in' },
+ {
+ 'file' : 'systemd-report-diskspace.socket',
+ 'conditions' : ['HAVE_LIBMOUNT'],
+ },
+ {
+ 'file' : 'systemd-report-diskspace@.service.in',
+ 'conditions' : ['HAVE_LIBMOUNT'],
+ },
{ 'file' : 'systemd-report-files.socket' },
{ 'file' : 'systemd-report-files@.service.in' },
{ 'file' : 'systemd-report-hwmon.socket' },
diff --git a/units/systemd-report-diskspace.socket b/units/systemd-report-diskspace.socket
new file mode 100644
index 0000000000..eb7caf083b
--- /dev/null
+++ b/units/systemd-report-diskspace.socket
@@ -0,0 +1,29 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# This file is part of systemd.
+#
+# systemd is free software; you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation; either version 2.1 of the License, or
+# (at your option) any later version.
+
+[Unit]
+Description=Disk Space Report Varlink Socket
+Documentation=man:systemd-report-diskspace@.service(8)
+DefaultDependencies=no
+Before=sockets.target shutdown.target
+Conflicts=shutdown.target
+
+[Socket]
+ListenStream=/run/systemd/report/io.systemd.DiskSpace
+FileDescriptorName=varlink
+SocketMode=0666
+Accept=yes
+MaxConnectionsPerSource=16
+RemoveOnStop=yes
+XAttrEntryPoint=user.varlink=entrypoint
+XAttrListen=user.varlink=listen
+XAttrAccept=user.varlink=server
+
+[Install]
+WantedBy=sockets.target
diff --git a/units/systemd-report-diskspace@.service.in b/units/systemd-report-diskspace@.service.in
new file mode 100644
index 0000000000..d5efec5206
--- /dev/null
+++ b/units/systemd-report-diskspace@.service.in
@@ -0,0 +1,43 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# This file is part of systemd.
+#
+# systemd is free software; you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation; either version 2.1 of the License, or
+# (at your option) any later version.
+
+[Unit]
+Description=Disk Space Report Service
+Documentation=man:systemd-report-diskspace@.service(8)
+DefaultDependencies=no
+Conflicts=shutdown.target
+Before=shutdown.target
+
+[Service]
+# Note that DynamicUser=, ProtectSystem=, ProtectHome= and PrivateTmp= are deliberately not used here: they
+# make the host's file systems appear read-only, or shadow them with private mounts, in the service's mount
+# namespace, and hence would hide from this service exactly what it is supposed to report on.
+CapabilityBoundingSet=
+DeviceAllow=
+IPAddressDeny=any
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+NoNewPrivileges=yes
+PrivateDevices=yes
+PrivateIPC=yes
+PrivateNetwork=yes
+ProtectControlGroups=yes
+ProtectHostname=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+RestrictAddressFamilies=AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RuntimeMaxSec=1min
+SystemCallArchitectures=native
+SystemCallErrorNumber=EPERM
+SystemCallFilter=@system-service
+ExecStart={{LIBEXECDIR}}/systemd-report-diskspace