From 104970a8bd7a3b53067f6e50283183406a579f0b Mon Sep 17 00:00:00 2001 From: Simran Singh Date: Wed, 10 Dec 2025 06:14:53 +0530 Subject: [PATCH] clonesetup: add support to clone devices via /etc/clonetab Adds dm-clone device setup at boot via a new /etc/clonetab config file, following the crypttab/veritytab pattern. - Add systemd-clonesetup-generator to parse /etc/clonetab and generate units. - Add systemd-clonesetup binary to create/remove dm-clone devices via ioctl. - Add clonesetup.target for ordering dm-clone activation at boot. - Add region_size= option in clonetab to configure dm-clone hydration granularity. - Add clonetab(5) and systemd-clonesetup-generator(8) man pages. Fixes: https://github.com/systemd/systemd/issues/39500 --- docs/ENVIRONMENT.md | 4 + man/clonetab.xml | 121 ++++++ man/rules/meson.build | 6 + man/systemd-clonesetup-generator.xml | 50 +++ man/systemd-clonesetup.xml | 76 ++++ meson.build | 2 + src/clonesetup/clonesetup-generator.c | 226 ++++++++++++ src/clonesetup/clonesetup-ioctl.c | 276 ++++++++++++++ src/clonesetup/clonesetup-ioctl.h | 16 + src/clonesetup/clonesetup-util.c | 37 ++ src/clonesetup/clonesetup-util.h | 6 + src/clonesetup/clonesetup.c | 192 ++++++++++ src/clonesetup/meson.build | 19 + .../TEST-93-CLONESETUP/meson.build | 8 + test/integration-tests/meson.build | 1 + test/units/TEST-93-CLONESETUP.sh | 344 ++++++++++++++++++ units/clonesetup.target | 12 + units/meson.build | 4 + 18 files changed, 1400 insertions(+) create mode 100644 man/clonetab.xml create mode 100644 man/systemd-clonesetup-generator.xml create mode 100644 man/systemd-clonesetup.xml create mode 100644 src/clonesetup/clonesetup-generator.c create mode 100644 src/clonesetup/clonesetup-ioctl.c create mode 100644 src/clonesetup/clonesetup-ioctl.h create mode 100644 src/clonesetup/clonesetup-util.c create mode 100644 src/clonesetup/clonesetup-util.h create mode 100644 src/clonesetup/clonesetup.c create mode 100644 src/clonesetup/meson.build create mode 100644 test/integration-tests/TEST-93-CLONESETUP/meson.build create mode 100755 test/units/TEST-93-CLONESETUP.sh create mode 100644 units/clonesetup.target diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 1d2498e206..71df55896a 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -76,6 +76,10 @@ All tools: `/etc/veritytab`. Only useful for debugging. Currently only supported by `systemd-veritysetup-generator`. +* `$SYSTEMD_CLONETAB` — if set, use this path instead of + `/etc/clonetab`. Only useful for debugging. Currently only supported by + `systemd-clonesetup-generator`. + * `$SYSTEMD_DEFAULT_HOSTNAME` — override the compiled-in fallback hostname (relevant in particular for the system manager and `systemd-hostnamed`). Must be a valid hostname (either a single label or a FQDN). diff --git a/man/clonetab.xml b/man/clonetab.xml new file mode 100644 index 0000000000..aa202e57fa --- /dev/null +++ b/man/clonetab.xml @@ -0,0 +1,121 @@ + + + + + + + + clonetab + systemd + + + + clonetab + 5 + + + + clonetab + Configuration for dm-clone block devices + + + + /etc/clonetab + + + + Description + + The /etc/clonetab file describes + dm-clone block devices that are set up during system boot. + + Empty lines and lines starting with the # + character are ignored. Each of the remaining lines describes one + dm-clone device. Fields are delimited by white space. + + Each line is in the formname source-device destination-device metadata-device [options] + The first four fields are mandatory, the fifth is optional. + + The five fields of /etc/clonetab are defined as follows: + + + + The first field contains the name of the resulting dm-clone device; its + block device is set up below /dev/mapper/. + + The second field contains a path to the read-only source block device. + This is the device whose data is cloned to the destination device. Reads to regions not + yet hydrated are served directly from this device. + + The third field contains a path to the writable destination block device. + The source device's data is copied here in the background. It must be at least as + large as the source device. + + The fourth field contains a path to the metadata block device. This + small device tracks which regions of the destination have been hydrated and is managed + exclusively by dm-clone. + + The fifth field, if present, contains comma-separated key=value + options. The following option is supported: + + + + + Controls the granularity of background hydration copying — how much + data is copied at a time. Region size is specified in bytes (standard suffixes like + K, M, G are supported), and must + correspond to a power of two between 4 KiB and 1 GiB. For example, + region-size=4K or region-size=4096 sets a 4 KiB region size. + + + One region is the atomic unit dm-clone tracks: it is either fully hydrated (copied to the destination) + or not, never partially. If a copy is interrupted mid-region, that whole region is + retried from scratch on next boot. Smaller regions mean finer progress tracking; + larger regions reduce metadata overhead. Defaults to 4K. For background, see + dm-clone kernel documentation. + + + + + + + If no options are needed, the field may be omitted entirely or + - may be used as a placeholder. + + + + + At early boot and when the system manager configuration is reloaded, this file is + translated into native systemd units by + systemd-clonesetup-generator8. + + + + Examples + + + Simple clone without options + Clone a source device to a destination, using a separate metadata device: + mydevice /dev/sdb /dev/sdc /dev/sdd + + + + Clone with custom region size + Clone a source device to a destination with a custom region size of 8 KiB: + mydevice /dev/sdb /dev/sdc /dev/sdd region-size=8K + + + + + See Also + + systemd1 + systemd-clonesetup-generator8 + dmsetup8 + + + + diff --git a/man/rules/meson.build b/man/rules/meson.build index 13025b8993..bac836015b 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -15,6 +15,7 @@ manpages = [ ['bootup', '7', [], ''], ['busctl', '1', [], ''], ['capsule@.service', '5', [], ''], + ['clonetab', '5', [], ''], ['coredump.conf', '5', ['coredump.conf.d'], 'ENABLE_COREDUMP'], ['coredumpctl', '1', [], 'ENABLE_COREDUMP'], ['crypttab', '5', [], 'HAVE_LIBCRYPTSETUP'], @@ -1024,6 +1025,11 @@ manpages = [ ['systemd-cat', '1', [], ''], ['systemd-cgls', '1', [], ''], ['systemd-cgtop', '1', [], ''], + ['systemd-clonesetup', + '8', + ['systemd-clonesetup@.service'], + ''], + ['systemd-clonesetup-generator', '8', [], ''], ['systemd-coredump', '8', ['systemd-coredump.socket', 'systemd-coredump@.service'], diff --git a/man/systemd-clonesetup-generator.xml b/man/systemd-clonesetup-generator.xml new file mode 100644 index 0000000000..28c8b5ad00 --- /dev/null +++ b/man/systemd-clonesetup-generator.xml @@ -0,0 +1,50 @@ + + + + + + + + systemd-clonesetup-generator + systemd + + + + systemd-clonesetup-generator + 8 + + + + systemd-clonesetup-generator + Unit generator for /etc/clonetab + + + + /usr/lib/systemd/system-generators/systemd-clonesetup-generator + + + + Description + + systemd-clonesetup-generator is a generator that translates + /etc/clonetab into native systemd units early at boot and when + configuration of the system manager is reloaded. This will create + systemd-clonesetup@.service8 + units as necessary. + + systemd-clonesetup-generator implements + systemd.generator7. + + + + See Also + + systemd1 + clonetab5 + systemd-clonesetup@.service8 + dmsetup8 + + + + diff --git a/man/systemd-clonesetup.xml b/man/systemd-clonesetup.xml new file mode 100644 index 0000000000..eace5a701f --- /dev/null +++ b/man/systemd-clonesetup.xml @@ -0,0 +1,76 @@ + + + + + + + + systemd-clonesetup + systemd + + + + systemd-clonesetup + 8 + + + + systemd-clonesetup + systemd-clonesetup@.service + Set up and tear down dm-clone devices + + + + + systemd-clonesetup + add + NAME + SOURCE-DEVICE + DEST-DEVICE + META-DEVICE + OPTIONS + + + + systemd-clonesetup + remove + NAME + + + systemd-clonesetup@.service + + + + Description + + systemd-clonesetup is used to set up (with add) and tear down + (with remove) dm-clone devices. It is primarily used via + systemd-clonesetup@.service units generated from + /etc/clonetab by + systemd-clonesetup-generator8. + + + The positional arguments NAME, SOURCE-DEVICE, + DEST-DEVICE, META-DEVICE, and OPTIONS + have the same meaning as the fields in + clonetab5. + + Currently, the supported option in OPTIONS is + region-size=BYTES, where + BYTES is measured in bytes (standard suffixes like + K, M, G are supported). + + + + See Also + + systemd1 + clonetab5 + systemd-clonesetup-generator8 + systemd.generator7 + dmsetup8 + + + + diff --git a/meson.build b/meson.build index 3097576561..fb34109ec2 100644 --- a/meson.build +++ b/meson.build @@ -302,6 +302,7 @@ conf.set_quoted('SYSTEMD_USERWORK_PATH', libexecdir / 'syst conf.set_quoted('SYSTEMD_MOUNTWORK_PATH', libexecdir / 'systemd-mountwork') conf.set_quoted('SYSTEMD_NSRESOURCEWORK_PATH', libexecdir / 'systemd-nsresourcework') conf.set_quoted('SYSTEMD_VERITYSETUP_PATH', libexecdir / 'systemd-veritysetup') +conf.set_quoted('SYSTEMD_CLONESETUP_PATH', libexecdir / 'systemd-clonesetup') conf.set_quoted('SYSTEM_CONFIG_UNIT_DIR', pkgsysconfdir / 'system') conf.set_quoted('SYSTEM_DATA_UNIT_DIR', systemunitdir) conf.set_quoted('SYSTEM_ENV_GENERATOR_DIR', systemenvgeneratordir) @@ -2141,6 +2142,7 @@ subdir('src/bootctl') subdir('src/busctl') subdir('src/cgls') subdir('src/cgtop') +subdir('src/clonesetup') subdir('src/coredump') subdir('src/creds') subdir('src/cryptenroll') diff --git a/src/clonesetup/clonesetup-generator.c b/src/clonesetup/clonesetup-generator.c new file mode 100644 index 0000000000..77b1c96d03 --- /dev/null +++ b/src/clonesetup/clonesetup-generator.c @@ -0,0 +1,226 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "alloc-util.h" +#include "clonesetup-util.h" +#include "dropin.h" +#include "errno-util.h" +#include "fd-util.h" +#include "fileio.h" +#include "generator.h" +#include "log.h" +#include "path-util.h" +#include "specifier.h" +#include "unit-name.h" + +static const char *arg_dest = NULL; + +/* Generate unit files that call the systemd-clonesetup binary to create or remove clone devices. */ +static int generate_clone_units( + const char *clone_name, + const char *source_dev, + const char *dest_dev, + const char *metadata_dev, + const char *options) { + + /* unit files for each device */ + _cleanup_fclose_ FILE *f = NULL; + int r; + + assert(clone_name); + assert(source_dev); + assert(dest_dev); + assert(metadata_dev); + + /* Escape clone name for unit specifiers and then ExecStart/ExecStop parsing. */ + _cleanup_free_ char *clone_name_spec_escaped = NULL; + clone_name_spec_escaped = specifier_escape(clone_name); + if (!clone_name_spec_escaped) + return log_oom(); + + /* create clone_dev_path that holds path for new cloned device */ + _cleanup_free_ char *clone_dev_path = NULL, *clone_dev_path_escaped = NULL, + *clone_dev_path_unit_escaped = NULL; + clone_dev_path = path_join("/dev/mapper", clone_name); + if (!clone_dev_path) + return log_oom(); + + clone_dev_path_escaped = specifier_escape(clone_dev_path); + if (!clone_dev_path_escaped) + return log_oom(); + + r = unit_name_path_escape(clone_dev_path, &clone_dev_path_unit_escaped); + if (r < 0) + return log_error_errno(r, "Failed to escape clone device path: %m"); + + _cleanup_free_ char *e = NULL, *unit = NULL; + /* escape clone name */ + e = unit_name_escape(clone_name); + if (!e) + return log_oom(); + + /* Generate unit name for the clone service */ + r = unit_name_build("systemd-clonesetup", e, ".service", &unit); + if (r < 0) + return log_error_errno(r, "Failed to generate unit name: %m"); + + /* Generate unit names for dependencies */ + _cleanup_free_ char *source_unit = NULL, *dest_unit = NULL, *metadata_unit = NULL; + r = unit_name_from_path(source_dev, ".device", &source_unit); + if (r < 0) + return log_error_errno(r, "Failed to generate source device unit name: %m"); + + r = unit_name_from_path(dest_dev, ".device", &dest_unit); + if (r < 0) + return log_error_errno(r, "Failed to generate dest device unit name: %m"); + + r = unit_name_from_path(metadata_dev, ".device", &metadata_unit); + if (r < 0) + return log_error_errno(r, "Failed to generate metadata device unit name: %m"); + + /* Escape device paths for unit specifiers and then ExecStart parsing. */ + _cleanup_free_ char *source_spec_escaped = NULL, + *dest_spec_escaped = NULL, *metadata_spec_escaped = NULL, + *options_spec_escaped = NULL; + source_spec_escaped = specifier_escape(source_dev); + if (!source_spec_escaped) + return log_oom(); + + dest_spec_escaped = specifier_escape(dest_dev); + if (!dest_spec_escaped) + return log_oom(); + + metadata_spec_escaped = specifier_escape(metadata_dev); + if (!metadata_spec_escaped) + return log_oom(); + + if (options) { + options_spec_escaped = specifier_escape(options); + if (!options_spec_escaped) + return log_oom(); + } + + r = generator_open_unit_file(arg_dest, /* source = */ NULL, unit, &f); + if (r < 0) + return r; + + /* With DefaultDependencies=no, order after udev so backing /dev nodes are ready in early boot. + * The : exec prefix on ExecStart=/ExecStop= disables $ { } env-var expansion by the manager. */ + fprintf(f, + "[Unit]\n" + "Description=Create dm-clone device %1$s\n" + "Documentation=man:clonetab(5) man:systemd-clonesetup(8) man:systemd-clonesetup-generator(8)\n" + "DefaultDependencies=no\n" + "BindsTo=%2$s %3$s %4$s\n" + "After=%2$s %3$s %4$s systemd-udevd-kernel.socket\n" + "Before=blockdev@%5$s.target clonesetup.target shutdown.target\n" + "Wants=blockdev@%5$s.target\n" + "Conflicts=shutdown.target\n" + "\n" + "[Service]\n" + "Type=oneshot\n" + "RemainAfterExit=yes\n" + "ExecStart=:" SYSTEMD_CLONESETUP_PATH " add '%6$s' '%7$s' '%8$s' '%9$s' '%10$s'\n" + "ExecStop=:" SYSTEMD_CLONESETUP_PATH " remove '%6$s'\n" + "TimeoutSec=0\n", + clone_dev_path_escaped, + source_unit, dest_unit, metadata_unit, + clone_dev_path_unit_escaped, + clone_name_spec_escaped, source_spec_escaped, dest_spec_escaped, + metadata_spec_escaped, options_spec_escaped ?: "-"); + + r = fflush_and_check(f); + if (r < 0) + return log_error_errno(r, "Failed to write unit %s: %m", unit); + + /* symlink unit file to enable it */ + _cleanup_free_ char *dmname = NULL; + r = unit_name_from_path(clone_dev_path, ".device", &dmname); + if (r < 0) + return log_error_errno(r, "Failed to generate clone device unit name: %m"); + + r = generator_add_symlink(arg_dest, dmname, "requires", unit); + if (r < 0) + return r; + + /* Extend device timeout to allow clone service to complete */ + r = write_drop_in(arg_dest, dmname, 40, "device-timeout", + "# Automatically generated by systemd-clonesetup-generator\n\n" + "[Unit]\n" + "JobRunningTimeoutSec=infinity\n"); + if (r < 0) + log_warning_errno(r, "Failed to write device timeout drop-in: %m"); + + /* Add to clonesetup.target so it starts at boot */ + r = generator_add_symlink(arg_dest, "clonesetup.target", "requires", unit); + if (r < 0) + return r; + + return 0; +} + +static int add_clone_devices(void) { + _cleanup_fclose_ FILE *f = NULL; + unsigned clone_line = 0; + int r, ret = 0; + const char *fname; + + fname = secure_getenv("SYSTEMD_CLONETAB") ?: "/etc/clonetab"; + + r = fopen_unlocked(fname, "re", &f); + if (r < 0) { + if (r != -ENOENT) + log_error_errno(r, "Failed to open %s: %m", fname); + return 0; + } + + for (;;) { + _cleanup_free_ char *line = NULL, *src = NULL, + *name = NULL, *dst = NULL, *meta = NULL, *options = NULL; + int k; + + r = read_stripped_line(f, LONG_LINE_MAX, &line); + if (r < 0) + return log_error_errno(r, "Failed to read %s: %m", fname); + if (r == 0) + break; + + clone_line++; + + if (IN_SET(line[0], 0, '#')) + continue; + + k = sscanf(line, "%ms %ms %ms %ms %ms", &name, &src, &dst, &meta, &options); + if (k < 4 || k > 5) { + log_warning("Failed to parse %s:%u, ignoring.", fname, clone_line); + continue; + } + + log_debug("Processing %s:%u", fname, clone_line); + r = validate_fields(name, src, dst, meta, options); + if (r < 0) + continue; + RET_GATHER(ret, generate_clone_units(name, src, dst, meta, options)); + } + + return ret; +} + +/* This generator reads /etc/clonetab and for each entry, writes unit files + * (creates systemd-clonesetup@.service and clonesetup.target.requires/systemd-clonesetup@.service) + * that clonesetup.target requires, and that run systemd-clonesetup (add device at boot, + * remove it at shutdown); systemd-clonesetup (used in systemd-clonesetup@.service) is the binary that + * uses device-mapper ioctls to create and remove the dm-clone devices. + * clonesetup.target groups these units so they run together at boot. + * Boot chain: sysinit.target has clonesetup.target in sysinit.target.wants/ (see units/meson.build), + * so at boot clonesetup.target starts and pulls in these units via clonesetup.target.requires/. */ +static int run(const char *dest, const char *dest_early, const char *dest_late) { + + /* dest usually is /run/systemd/generator */ + assert_se(arg_dest = dest); + + return add_clone_devices(); +} + +DEFINE_MAIN_GENERATOR_FUNCTION(run); diff --git a/src/clonesetup/clonesetup-ioctl.c b/src/clonesetup/clonesetup-ioctl.c new file mode 100644 index 0000000000..73c52e6e99 --- /dev/null +++ b/src/clonesetup/clonesetup-ioctl.c @@ -0,0 +1,276 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include +#include +#include + +#include "sd-device.h" + +#include "clonesetup-ioctl.h" +#include "device-private.h" +#include "errno-util.h" +#include "fd-util.h" +#include "log.h" +#include "memory-util.h" +#include "string-util.h" + +/* Returns the size in bytes of the block device at dev_path. + * Loading the dm-clone table needs the source device size in sectors; sysfs + * reports size in 512-byte sectors. This reads sysfs and returns bytes so the + * caller can divide by 512 and pass the sector count to dm_clone_load_table(). */ +static int get_size(const char *dev_path, uint64_t *ret_size) { + _cleanup_(sd_device_unrefp) sd_device *dev = NULL; + uint64_t size, temp_size; + int r; + + assert(dev_path); + assert(ret_size); + + r = sd_device_new_from_devname(&dev, dev_path); + if (r < 0) + return log_error_errno(r, "Failed to create device from '%s': %m", dev_path); + + r = device_get_sysattr_u64(dev, "size", &size); + if (r < 0) + return log_error_errno(r, "Failed to get device size for '%s': %m", dev_path); + + /* sysfs 'size' is in 512-byte sectors */ + temp_size = u64_multiply_safe(size, 512); + if (temp_size == 0 && size != 0) + return log_error_errno(SYNTHETIC_ERRNO(EOVERFLOW), + "Device size overflow for '%s'", dev_path); + + *ret_size = temp_size; + return 0; +} + +/* Common helper used to run dm ioctls. */ +static int dm_ioctl_run(const char *name, uint32_t cmd, struct dm_ioctl *data, size_t data_size) { + _cleanup_close_ int fd = -EBADF; + struct dm_ioctl *dm = data; + int r; + + assert(name); + assert(data); + assert(data_size >= sizeof(struct dm_ioctl)); + + dm->version[0] = DM_VERSION_MAJOR; + dm->version[1] = DM_VERSION_MINOR; + dm->version[2] = DM_VERSION_PATCHLEVEL; + dm->data_size = data_size; + + if (strlen(name) >= sizeof_field(struct dm_ioctl, name)) + return log_error_errno(SYNTHETIC_ERRNO(ENAMETOOLONG), + "DM device name too long: %s", name); + strncpy_exact(dm->name, name, sizeof(dm->name)); + + fd = open("/dev/mapper/control", O_RDWR | O_CLOEXEC); + if (fd < 0) + return log_error_errno(errno, "Failed to open /dev/mapper/control: %m"); + + r = RET_NERRNO(ioctl(fd, cmd, dm)); + if (r < 0) { + if (r == -ENXIO && cmd == DM_DEV_REMOVE) { + log_full_errno(LOG_DEBUG, r, "Device \"%s\" is already inactive, ignoring: %m", dm->name); + return 0; + } + return log_error_errno(r, "DM ioctl failed: %m"); + } + return 0; +} + +/* First dm ioctl needed to create a device. */ +static int dm_clone_create(const char *name) { + int r; + assert(name); + + struct dm_ioctl dm = {}; + r = dm_ioctl_run(name, DM_DEV_CREATE, &dm, sizeof(dm)); + if (r < 0) { + if (r == -EEXIST) + return log_error_errno(r, "Device '/dev/mapper/%s' already exists.", name); + return log_error_errno(r, "Failed to create DM device '%s': %m", name); + } + return 0; +} + +/* Second dm ioctl needed to create a device. */ +static int dm_clone_load_table(const char *name, uint64_t size_sectors, const char *target_params) { + char *params_buf; + size_t params_len, dm_size; + _cleanup_free_ struct dm_ioctl *dm = NULL; + struct dm_target_spec *tgt; + + assert(name); + assert(target_params); + + params_len = strlen(target_params) + 1; + /* ensure that dm_size is always aligned, so it makes the buffer actually match what .next claims */ + dm_size = ALIGN8(sizeof(struct dm_ioctl)) + ALIGN8(sizeof(struct dm_target_spec) + params_len); + dm = malloc0(dm_size); + if (!dm) + return log_oom(); + *dm = (struct dm_ioctl) { + .data_start = ALIGN8(sizeof(struct dm_ioctl)), + .target_count = 1, + }; + + tgt = CAST_ALIGN_PTR(struct dm_target_spec, (uint8_t *) dm + dm->data_start); + *tgt = (struct dm_target_spec) { + .length = size_sectors, + /* Per linux/dm-ioctl.h: next is the byte offset from this dm_target_spec to the next one, + * rounded up to 8-byte alignment. With target_count == 1 next == 0 works, but set it + * correctly to avoid silent breakage if a second target is ever added. */ + .next = ALIGN8(sizeof(struct dm_target_spec) + params_len), + }; + strncpy(tgt->target_type, "clone", sizeof(tgt->target_type)); + + params_buf = (char *) tgt + ALIGN8(sizeof(struct dm_target_spec)); + memcpy(params_buf, target_params, params_len); + + return dm_ioctl_run(name, DM_TABLE_LOAD, dm, dm_size); +} + +/* Third and final dm ioctl needed to create a device. */ +static int dm_clone_activate(const char *name) { + assert(name); + + struct dm_ioctl dm = {}; + + return dm_ioctl_run(name, DM_DEV_SUSPEND, &dm, sizeof(dm)); +} + +/* Calls multiple dm ioctls to create device. */ +int dm_clone_create_device( + const char *name, + const char *source_dev, + const char *dest_dev, + const char *metadata_dev, + uint64_t region_size_bytes) { + + _cleanup_free_ char *target_params = NULL; + uint64_t src_dev_size_sectors, src_dev_size, region_size_sectors; + int r; + + assert(name); + assert(source_dev); + assert(dest_dev); + assert(metadata_dev); + + r = get_size(source_dev, &src_dev_size); + if (r < 0) + return r; + + /* The device mapper kernel API always uses 512-byte sectors, regardless of the + * physical block size of the device (all DM targets use sector_t which is 512B). + * + * get_size internally uses sysfs i.e. /sys/block//size which also reports device size in + * 512-byte sectors. Before returning, get_size multiplies the size returned by sysfs to bytes. So we + * divide the received byte size by 512 to get the sector count for the DM table. */ + assert(src_dev_size % 512 == 0); + src_dev_size_sectors = src_dev_size / 512; + + assert(region_size_bytes % 512 == 0); + region_size_sectors = region_size_bytes / 512; + + /* dm-clone target params: [options] + * region_size = region size in sectors, configurable via clonetab (default 8 = 4KB regions with + * 512-byte sectors) 1 = hydration threshold (regions to hydrate per batch) no_hydration = don't + * start automatic background hydration + * + * The DM table "target_params" string is passed directly to the kernel via ioctl(fd, DM_TABLE_LOAD, + * ...) in dm_clone_load_table as a raw byte buffer. The kernel's DM table parser + * (drivers/md/dm-table.c) simply splits the params string on whitespace, so the only constraint is + * that the paths in params - metadata_dev, dest_dev, source_dev, and region_size must not contain + * spaces, which standard /dev/ paths never do, so the below args do NOT require shell escaping */ + if (asprintf(&target_params, "%s %s %s %" PRIu64 " 1 no_hydration", + metadata_dev, dest_dev, source_dev, region_size_sectors) < 0) + return log_oom(); + + r = dm_clone_create(name); + if (r < 0) + return r; + + r = dm_clone_load_table(name, src_dev_size_sectors, target_params); + if (r < 0) + goto fail; + + r = dm_clone_activate(name); + if (r < 0) + goto fail; + + log_info("Device %s active.", name); + return 0; + +fail: + (void) dm_clone_remove_device_deferred(name); + return r; +} + +/* Calls dm ioctl to send a message to the device. dm_ioctl is the kernel's generic device mapper envelope — + * every ioctl needs it. dm_target_msg is specific to the "send a message" operation + * */ +int dm_clone_send_message(const char *name, const char *message) { + _cleanup_free_ struct dm_ioctl *dm = NULL; + struct dm_target_msg *msg; + size_t dm_size, msg_len; + + assert(name); + assert(message); + + msg_len = strlen(message) + 1; + /* need to take into account both headers in size calculation */ + dm_size = ALIGN8(sizeof(struct dm_ioctl)) + sizeof(struct dm_target_msg) + msg_len; + dm = malloc0(dm_size); + if (!dm) + return log_oom(); + *dm = (struct dm_ioctl) { + /* with ALIGN8 call below, dm_target_msg starts at ALIGN8(sizeof(struct dm_ioctl)) which is + * already aligned, so the dm_target_msg struct lands correctly */ + .data_start = ALIGN8(sizeof(struct dm_ioctl)), + }; + + msg = CAST_ALIGN_PTR(struct dm_target_msg, (uint8_t *) dm + dm->data_start); + memcpy(msg->message, message, msg_len); + + return dm_ioctl_run(name, DM_TARGET_MSG, dm, dm_size); +} + +/* Calls dm ioctl to remove a device. flags if set can be used + * for deferred remove - e.g. DM_DEFERRED_REMOVE */ +static int dm_clone_remove_device_full(const char *name, uint32_t flags) { + struct dm_ioctl dm = { + .flags = flags, + }; + + assert(name); + + return dm_ioctl_run(name, DM_DEV_REMOVE, &dm, sizeof(dm)); +} + +/* Calls dm ioctl to remove a device. */ +int dm_clone_remove_device(const char *name) { + int r; + + assert(name); + r = dm_clone_remove_device_full(name, 0); + if (r < 0) + return r; + + log_info("Device %s inactive.", name); + return 0; +} + +/* Calls dm ioctl for deferred removal i.e. DM_DEFERRED_REMOVE */ +int dm_clone_remove_device_deferred(const char *name) { + int r; + + assert(name); + r = dm_clone_remove_device_full(name, DM_DEFERRED_REMOVE); + if (r < 0) + return r; + + log_info("Device %s marked for deferred removal.", name); + return 0; +} diff --git a/src/clonesetup/clonesetup-ioctl.h b/src/clonesetup/clonesetup-ioctl.h new file mode 100644 index 0000000000..ca3a373fc6 --- /dev/null +++ b/src/clonesetup/clonesetup-ioctl.h @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include + +int dm_clone_create_device( + const char *name, + const char *source_dev, + const char *dest_dev, + const char *metadata_dev, + uint64_t region_size_bytes); + +int dm_clone_send_message(const char *name, const char *message); + +int dm_clone_remove_device(const char *name); +int dm_clone_remove_device_deferred(const char *name); diff --git a/src/clonesetup/clonesetup-util.c b/src/clonesetup/clonesetup-util.c new file mode 100644 index 0000000000..da9ea634d4 --- /dev/null +++ b/src/clonesetup/clonesetup-util.c @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "clonesetup-util.h" +#include "log.h" +#include "path-util.h" +#include "string-util.h" + +int validate_dev_path(const char *what, const char *path) { + if (!string_is_safe(path, 0) || !path_is_normalized(path) || + !path_is_absolute(path) || !path_startswith(path, "/dev/")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "Invalid %s device path '%s'.", what, path); + return 0; +} + +int validate_fields(const char *name, const char *src, const char *dst, + const char *meta, const char *options) { + int r; + + if (!filename_is_valid(name)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid clone name '%s'.", name); + + r = validate_dev_path("source", src); + if (r < 0) + return r; + r = validate_dev_path("destination", dst); + if (r < 0) + return r; + r = validate_dev_path("metadata", meta); + if (r < 0) + return r; + + if (options && !string_is_safe(options, 0)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid options '%s'.", options); + + return 0; +} diff --git a/src/clonesetup/clonesetup-util.h b/src/clonesetup/clonesetup-util.h new file mode 100644 index 0000000000..ef64edb17a --- /dev/null +++ b/src/clonesetup/clonesetup-util.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +int validate_dev_path(const char *what, const char *path); +int validate_fields(const char *name, const char *src, const char *dst, + const char *meta, const char *options); diff --git a/src/clonesetup/clonesetup.c b/src/clonesetup/clonesetup.c new file mode 100644 index 0000000000..debd10590a --- /dev/null +++ b/src/clonesetup/clonesetup.c @@ -0,0 +1,192 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include + +#include "alloc-util.h" +#include "build.h" +#include "clonesetup-ioctl.h" +#include "clonesetup-util.h" +#include "extract-word.h" +#include "format-table.h" +#include "help-util.h" +#include "log.h" +#include "main-func.h" +#include "options.h" +#include "parse-util.h" +#include "string-util.h" +#include "strv.h" /* strv_skip */ +#include "verbs.h" + +/* region-size: size of each dm-clone region in bytes. Handled internally in bytes, but must correspond to a + * power of 2 between 4K and 1G per dm-clone kernel docs. */ +#define CLONE_REGION_SIZE_DEFAULT_BYTES (UINT64_C(1) << 12) /* 4 KiB */ +#define CLONE_REGION_SIZE_MIN_BYTES (UINT64_C(1) << 12) /* 4 KiB */ +#define CLONE_REGION_SIZE_MAX_BYTES (UINT64_C(1) << 30) /* 1 GiB */ + +static int parse_clone_options(const char *options, uint64_t *ret_region_size_bytes) { + uint64_t region_size_bytes = CLONE_REGION_SIZE_DEFAULT_BYTES; + + assert(ret_region_size_bytes); + + for (;;) { + _cleanup_free_ char *word = NULL; + const char *val; + int r; + + /* extract_first_word: * + * Returns > 0 — successfully extracted a word * + * Returns 0 — no more words (end of string) * + * Returns < 0 — actual error (e.g. memory allocation failure) */ + r = extract_first_word(&options, &word, ",", EXTRACT_DONT_COALESCE_SEPARATORS); + if (r < 0) + return log_error_errno(r, "Failed to parse clone options: %m"); + if (r == 0) + break; + + /* treat - as empty — common placeholders for "no options" */ + if (streq(word, "-")) + continue; + + if ((val = startswith(word, "region-size="))) { + uint64_t r_size_bytes; + /* parse_size handles suffixes like K, M, G automatically */ + r = parse_size(val, 1024, &r_size_bytes); + if (r < 0) + log_warning_errno(r, "Failed to parse region-size= value '%s', using default.", val); + else if (!ISPOWEROF2(r_size_bytes) || r_size_bytes < CLONE_REGION_SIZE_MIN_BYTES || r_size_bytes > CLONE_REGION_SIZE_MAX_BYTES) + log_warning("region-size=%s must be a power of two between 4K and 1G, using default.", val); + else + region_size_bytes = r_size_bytes; + } else + /* currently only region-size is supported */ + log_warning("Unknown clone option '%s', ignoring.", word); + } + *ret_region_size_bytes = region_size_bytes; + return 0; +} + +/* dm-clone device creation workflow: + * 1. Create the dm-clone device + * 2. Enable background hydration */ +static int clone_device( + const char *clone_name, + const char *source_dev, + const char *dest_dev, + const char *metadata_dev, + const char *options) { + + int r; + + assert(clone_name); + assert(source_dev); + assert(dest_dev); + assert(metadata_dev); + + uint64_t region_size_bytes; + r = parse_clone_options(options, ®ion_size_bytes); + if (r < 0) + return r; + + r = dm_clone_create_device(clone_name, source_dev, dest_dev, metadata_dev, region_size_bytes); + if (r < 0) + return r; + + r = dm_clone_send_message(clone_name, "enable_hydration"); + if (r < 0) { + (void) dm_clone_remove_device_deferred(clone_name); + return r; + } + return 0; +} + +VERB(verb_add, "add", "NAME SOURCE DEST METADATA [OPTIONS]", 5, 6, 0, "Create a dm-clone device"); + +/* Arguments: systemd-clonesetup add NAME SOURCE DEST METADATA [OPTIONS] */ +static int verb_add(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + assert(argc >= 5 && argc <= 6); + + const char *name = ASSERT_PTR(argv[1]); + const char *src = ASSERT_PTR(argv[2]); + const char *dst = ASSERT_PTR(argv[3]); + const char *meta = ASSERT_PTR(argv[4]); + + const char *options = argc == 6 ? argv[5] : ""; + + r = validate_fields(name, src, dst, meta, options); + if (r < 0) + return r; + + log_debug("%s %s %s %s %s opts=%s", __func__, + name, src, dst, meta, options); + return clone_device(name, src, dst, meta, options); +} + +VERB(verb_remove, "remove", "NAME", 2, 2, 0, "Remove a dm-clone device"); + +static int verb_remove(int argc, char *argv[], uintptr_t data, void *userdata) { + const char *name = ASSERT_PTR(argv[1]); + int r; + + r = dm_clone_remove_device(name); + if (r == -EBUSY) + return dm_clone_remove_device_deferred(name); + if (r < 0) + return r; + + return 0; +} + +static int help(void) { + _cleanup_(table_unrefp) Table *options = NULL; + int r; + + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_cmdline("add NAME SOURCE DEST METADATA [OPTIONS]"); + help_cmdline("remove NAME"); + help_abstract("Add or remove a dm-clone device."); + help_section("Options"); + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-clonesetup", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + FOREACH_OPTION_OR_RETURN(c, &opts) + switch (c) { + + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + } + + return 1; +} + +/* systemd-clonesetup uses device-mapper ioctls to create and remove the + * dm-clone devices. */ +static int run(int argc, char *argv[]) { + int r; + + log_setup(); + umask(0022); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return dispatch_verb(strv_skip(argv, 1), /* userdata= */ NULL); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/clonesetup/meson.build b/src/clonesetup/meson.build new file mode 100644 index 0000000000..044f69b813 --- /dev/null +++ b/src/clonesetup/meson.build @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +systemd_clonesetup_sources = files( + 'clonesetup-ioctl.c', + 'clonesetup.c', +) + +executables += [ + libexec_template + { + 'name' : 'systemd-clonesetup', + 'sources' : systemd_clonesetup_sources, + 'export' : files('clonesetup-util.c'), + }, + generator_template + { + 'name' : 'systemd-clonesetup-generator', + 'sources' : files('clonesetup-generator.c'), + 'import' : ['systemd-clonesetup'], + }, +] diff --git a/test/integration-tests/TEST-93-CLONESETUP/meson.build b/test/integration-tests/TEST-93-CLONESETUP/meson.build new file mode 100644 index 0000000000..77370ce458 --- /dev/null +++ b/test/integration-tests/TEST-93-CLONESETUP/meson.build @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +integration_tests += [ + integration_test_template + { + 'name' : fs.name(meson.current_source_dir()), + 'vm' : true, + }, +] diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build index 3991b11e79..228d808f51 100644 --- a/test/integration-tests/meson.build +++ b/test/integration-tests/meson.build @@ -105,6 +105,7 @@ foreach dirname : [ 'TEST-90-RESTRICT-FSACCESS', 'TEST-91-LIVEUPDATE', 'TEST-92-TPM2-SWTPM', + 'TEST-93-CLONESETUP', ] subdir(dirname) endforeach diff --git a/test/units/TEST-93-CLONESETUP.sh b/test/units/TEST-93-CLONESETUP.sh new file mode 100755 index 0000000000..537c40b972 --- /dev/null +++ b/test/units/TEST-93-CLONESETUP.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eux +set -o pipefail +set -E + +# shellcheck source=test/units/test-control.sh +. "$(dirname "$0")"/test-control.sh + +CLONESETUP_BIN=/usr/lib/systemd/systemd-clonesetup +# Test clonesetup generator and systemd-clonesetup +# Disable pager usage so interactive/manual runs do not block in `less`. +export SYSTEMD_PAGER=cat +export SYSTEMD_LESS= + +create_loop_triplet() { + local workdir="${1:?}" + local src_img dst_img meta_img + local loop_src loop_dst loop_meta + + src_img="$workdir/source.img" + dst_img="$workdir/dest.img" + meta_img="$workdir/meta.img" + + truncate -s 32M "$src_img" + truncate -s 32M "$dst_img" + truncate -s 8M "$meta_img" + + loop_src="$(losetup --show --find "$src_img")" + loop_dst="$(losetup --show --find "$dst_img")" + loop_meta="$(losetup --show --find "$meta_img")" + + # Wait for udev to process new loop devices before they are consumed. + udevadm settle --timeout=60 + + printf '%s %s %s\n' "$loop_src" "$loop_dst" "$loop_meta" +} + +cleanup_loop_triplet() { + local loop_src="${1:-}" + local loop_dst="${2:-}" + local loop_meta="${3:-}" + + [[ -n "$loop_src" ]] && losetup -d "$loop_src" + [[ -n "$loop_dst" ]] && losetup -d "$loop_dst" + [[ -n "$loop_meta" ]] && losetup -d "$loop_meta" +} + +at_exit() { + set +e + + rm -f /etc/clonetab + [[ -e /tmp/clonetab.bak ]] && cp -fv /tmp/clonetab.bak /etc/clonetab + dmsetup remove testclonesetup 2>/dev/null || true + + systemctl daemon-reload +} + +at_error() { + local rc="$?" + local source="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" + local line="${BASH_LINENO[0]:-0}" + local func="${FUNCNAME[1]:-main}" + + echo "ERROR: rc=$rc at $source:$line ($func): $BASH_COMMAND" >&2 + return "$rc" +} + +trap at_exit EXIT +trap at_error ERR + +clonesetup_start_and_check() { + local volume unit + + volume="${1:?}" + unit="systemd-clonesetup@$volume.service" + + # The unit existence check should always pass + [[ "$(systemctl show -P LoadState "$unit")" == loaded ]] + systemctl list-unit-files "$unit" + + systemctl start "$unit" + # wait for udev to create /dev/mapper/ node after DM device activation + udevadm settle --timeout=10 + systemctl status "$unit" + test -e "/dev/mapper/$volume" + dmsetup status "$volume" + + systemctl stop "$unit" + # wait for udev to finish processing so the device node state is in sync + # before the API returns. + udevadm settle --timeout=10 + test ! -e "/dev/mapper/$volume" +} + +prereq() { + # Skip when kernel lacks dm-clone (CONFIG_DM_CLONE) + modprobe dm_clone 2>/dev/null || true + if [[ ! -d /sys/module/dm_clone ]]; then + echo "no dm-clone" >/skipped + exit 77 + fi + echo "Found required kernel module: dm_clone" +} + +prereq + +# Backup existing clonetab if any +[[ -e /etc/clonetab ]] && cp -fv /etc/clonetab /tmp/clonetab.bak + +# Create test clonetab +clonetab_create() { + local loop_name="${1:?}" + local loop_src="${2:?}" + local loop_dst="${3:?}" + local loop_meta="${4:?}" + local loop_options="${5:?}" + + cat >/etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab </etc/clonetab <