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