From 8702496bfb0205764569782a9a2ebd11fd80e5e8 Mon Sep 17 00:00:00 2001 From: Ludwig Nussel Date: Thu, 8 Dec 2022 16:27:31 +0100 Subject: [PATCH] bootctl: unlink and cleanup functions The unlink command removes an entry from the ESP including referenced files that are not referenced in other entries. That is useful eg to have multiple entries that use the same kernel with different options. The cleanup command removes all files that are not referenced by any entry. --- man/bootctl.xml | 23 ++ meson.build | 1 + shell-completion/bash/bootctl | 6 +- shell-completion/zsh/_bootctl | 7 + src/boot/bootctl-status.c | 267 ++++++++++++++++++- src/boot/bootctl-status.h | 1 + src/boot/bootctl.c | 17 +- src/boot/bootctl.h | 1 + src/kernel-install/90-loaderentry.install.in | 2 +- src/kernel-install/test-kernel-install.sh | 32 +++ src/test/meson.build | 1 + 11 files changed, 351 insertions(+), 7 deletions(-) diff --git a/man/bootctl.xml b/man/bootctl.xml index 0d796bedc19..ada8c03332c 100644 --- a/man/bootctl.xml +++ b/man/bootctl.xml @@ -153,6 +153,21 @@ disables the timeout while always showing the menu. When an empty string ("") is specified the bootloader will revert to its default menu timeout. + + + ID + + Removes a boot loader entry including the files it refers to. Takes a single boot + loader entry ID string or a glob pattern as argument. Referenced files such as kernel or initrd are + only removed if no other entry refers to them. + + + + + + Removes files from the ESP and XBOOTLDR partitions that belong to the entry token but + are not referenced in any boot loader entries. + @@ -409,6 +424,14 @@ the firmware's boot option menu. + + + Dry run for and . + + In dry run mode, the unlink and cleanup operations only print the files that would get deleted + without actually deleting them. + + diff --git a/meson.build b/meson.build index d1f3a00691b..965b237d23a 100644 --- a/meson.build +++ b/meson.build @@ -4051,6 +4051,7 @@ public_programs += exe if want_tests != 'false' and want_kernel_install test('test-kernel-install', test_kernel_install_sh, + env : test_env, args : [exe.full_path(), loaderentry_install]) endif diff --git a/shell-completion/bash/bootctl b/shell-completion/bash/bootctl index 0b7cef7871b..8d8b507ea95 100644 --- a/shell-completion/bash/bootctl +++ b/shell-completion/bash/bootctl @@ -31,7 +31,7 @@ _bootctl() { local i verb comps local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]} local -A OPTS=( - [STANDALONE]='-h --help -p --print-esp-path -x --print-boot-path --version --no-variables --no-pager --graceful' + [STANDALONE]='-h --help -p --print-esp-path -x --print-boot-path --version --no-variables --no-pager --graceful --dry-run' [ARG]='--esp-path --boot-path --make-machine-id-directory --root --image --install-source' ) @@ -67,8 +67,8 @@ _bootctl() { local -A VERBS=( # systemd-efi-options takes an argument, but it is free-form, so we cannot complete it - [STANDALONE]='help status install update remove is-installed random-seed systemd-efi-options list set-timeout set-timeout-oneshot' - [BOOTENTRY]='set-default set-oneshot' + [STANDALONE]='help status install update remove is-installed random-seed systemd-efi-options list set-timeout set-timeout-oneshot cleanup' + [BOOTENTRY]='set-default set-oneshot unlink' [BOOLEAN]='reboot-to-firmware' ) diff --git a/shell-completion/zsh/_bootctl b/shell-completion/zsh/_bootctl index 8634e8b9bc0..83d910cda15 100644 --- a/shell-completion/zsh/_bootctl +++ b/shell-completion/zsh/_bootctl @@ -24,6 +24,10 @@ _bootctl_set-oneshot() { _bootctl_comp_ids } +_bootctl_unlink() { + _bootctl_comp_ids +} + _bootctl_reboot-to-firmware() { local -a _completions _completions=( yes no ) @@ -48,6 +52,8 @@ _bootctl_reboot-to-firmware() { "set-oneshot:Set the default boot loader entry only for the next boot" "set-timeout:Set the menu timeout" "set-timeout-oneshot:Set the menu timeout for the next boot only" + "unlink:Remove boot loader entry" + "cleanup:Remove files in ESP not referenced in any boot entry" ) if (( CURRENT == 1 )); then _describe -t commands 'bootctl command' _bootctl_cmds || compadd "$@" @@ -73,6 +79,7 @@ _arguments \ '--no-variables[Do not touch EFI variables]' \ '--no-pager[Do not pipe output into a pager]' \ '--graceful[Do not fail when locating ESP or writing fails]' \ + '--dry-run[Dry run (unlink and cleanup)]' \ '--root=[Operate under the specified directory]:PATH' \ '--image=[Operate on the specified image]:PATH' \ '--install-source[Where to pick files when using --root=/--image=]:options:(image host auto)' \ diff --git a/src/boot/bootctl-status.c b/src/boot/bootctl-status.c index 0adb354bdb8..8077a8a0055 100644 --- a/src/boot/bootctl-status.c +++ b/src/boot/bootctl-status.c @@ -18,6 +18,7 @@ #include "find-esp.h" #include "path-util.h" #include "pretty-print.h" +#include "recurse-dir.h" #include "terminal-util.h" #include "tpm2-util.h" @@ -503,6 +504,250 @@ int verb_status(int argc, char *argv[], void *userdata) { return r; } +static int ref_file(Hashmap *known_files, const char *fn, int increment) { + char *k = NULL; + int n, r; + + assert(known_files); + + /* just gracefully ignore this. This way the caller doesn't + have to verify whether the bootloader entry is relevant */ + if (!fn) + return 0; + + n = PTR_TO_INT(hashmap_get2(known_files, fn, (void**)&k)); + n += increment; + + assert(n >= 0); + + if (n == 0) { + (void) hashmap_remove(known_files, fn); + free(k); + } else if (!k) { + _cleanup_free_ char *t = NULL; + + t = strdup(fn); + if (!t) + return -ENOMEM; + r = hashmap_put(known_files, t, INT_TO_PTR(n)); + if (r < 0) + return r; + TAKE_PTR(t); + } else { + r = hashmap_update(known_files, fn, INT_TO_PTR(n)); + if (r < 0) + return r; + } + + return n; +} + +static void deref_unlink_file(Hashmap *known_files, const char *fn, const char *root) { + _cleanup_free_ char *path = NULL; + int r; + + /* just gracefully ignore this. This way the caller doesn't + have to verify whether the bootloader entry is relevant */ + if (!fn || !root) + return; + + r = ref_file(known_files, fn, -1); + if (r < 0) + return (void) log_warning_errno(r, "Failed to deref \"%s\", ignoring: %m", fn); + if (r > 0) + return; + + if (arg_dry_run) { + r = chase_symlinks_and_access(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, F_OK, &path, NULL); + if (r < 0) + log_info("Unable to determine whether \"%s\" exists, ignoring: %m", fn); + else + log_info("Would remove %s", path); + return; + } + + r = chase_symlinks_and_unlink(fn, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, 0, &path); + if (r >= 0) + log_info("Removed %s", path); + else if (r != -ENOENT) + return (void) log_warning_errno(r, "Failed to remove \"%s\", ignoring: %m", path ?: fn); + + _cleanup_free_ char *d = NULL; + if (path_extract_directory(fn, &d) >= 0 && !path_equal(d, "/")) { + r = chase_symlinks_and_unlink(d, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, AT_REMOVEDIR, NULL); + if (r < 0 && !IN_SET(r, -ENOTEMPTY, -ENOENT)) + log_warning_errno(r, "Failed to remove directoy \"%s\", ignoring: %m", d); + } +} + +static int count_known_files(const BootConfig *config, const char* root, Hashmap **ret_known_files) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + int r = 0; + + assert(config); + assert(ret_known_files); + + known_files = hashmap_new(&path_hash_ops); + if (!known_files) + return -ENOMEM; + + for (size_t i = 0; i < config->n_entries; i++) { + const BootEntry *e = config->entries + i; + + if (!path_equal(e->root, root)) + continue; + + r = ref_file(known_files, e->kernel, +1); + if (r < 0) + return r; + r = ref_file(known_files, e->efi, +1); + if (r < 0) + return r; + STRV_FOREACH(s, e->initrd) { + r = ref_file(known_files, *s, +1); + if (r < 0) + return r; + } + r = ref_file(known_files, e->device_tree, +1); + if (r < 0) + return r; + STRV_FOREACH(s, e->device_tree_overlay) { + r = ref_file(known_files, *s, +1); + if (r < 0) + return r; + } + } + + *ret_known_files = TAKE_PTR(known_files); + + return 0; +} + +static int boot_config_find_in(const BootConfig *config, const char *root, const char *id) { + assert(config); + + if (!root || !id) + return -1; + + for (size_t i = 0; i < config->n_entries; i++) + if (path_equal(config->entries[i].root, root) + && fnmatch(id, config->entries[i].id, FNM_CASEFOLD) == 0) + return i; + + return -1; +} + +static int unlink_entry(const BootConfig *config, const char *root, const char *id) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + const BootEntry *e = NULL; + int r; + + assert(config); + + r = count_known_files(config, root, &known_files); + if (r < 0) + return log_error_errno(r, "Failed to count files in %s: %m", root); + + r = boot_config_find_in(config, root, id); + if (r < 0) + return -ENOENT; + + if (r == config->default_entry) + log_warning("%s is the default boot entry", id); + if (r == config->selected_entry) + log_warning("%s is the selected boot entry", id); + + e = &config->entries[r]; + + deref_unlink_file(known_files, e->kernel, e->root); + deref_unlink_file(known_files, e->efi, e->root); + STRV_FOREACH(s, e->initrd) + deref_unlink_file(known_files, *s, e->root); + deref_unlink_file(known_files, e->device_tree, e->root); + STRV_FOREACH(s, e->device_tree_overlay) + deref_unlink_file(known_files, *s, e->root); + + if (arg_dry_run) + log_info("Would remove %s", e->path); + else { + r = chase_symlinks_and_unlink(e->path, root, CHASE_PROHIBIT_SYMLINKS, 0, NULL); + if (r < 0) + return log_error_errno(r, "Failed to remove \"%s\": %m", e->path); + + log_info("Removed %s", e->path); + } + + return 0; +} + +static int list_remove_orphaned_file( + RecurseDirEvent event, + const char *path, + int dir_fd, + int inode_fd, + const struct dirent *de, + const struct statx *sx, + void *userdata) { + Hashmap *known_files = userdata; + + assert(path); + assert(known_files); + + if (event != RECURSE_DIR_ENTRY) + return RECURSE_DIR_CONTINUE; + + if (hashmap_get(known_files, path)) + return RECURSE_DIR_CONTINUE; /* keep! */ + + if (arg_dry_run) + log_info("Would remove %s", path); + else if (unlinkat(dir_fd, de->d_name, 0) < 0) + log_warning_errno(errno, "Failed to remove \"%s\", ignoring: %m", path); + else + log_info("Removed %s", path); + + return RECURSE_DIR_CONTINUE; +} + +static int cleanup_orphaned_files( + const BootConfig *config, + const char *root) { + _cleanup_(hashmap_free_free_keyp) Hashmap *known_files = NULL; + _cleanup_free_ char *full = NULL, *p = NULL; + _cleanup_close_ int dir_fd = -1; + int r = -1; + + assert(config); + assert(root); + + log_info("Cleaning %s", root); + + r = settle_entry_token(); + if (r < 0) + return r; + + r = count_known_files(config, root, &known_files); + if (r < 0) + return log_error_errno(r, "Failed to count files in %s: %m", root); + + dir_fd = chase_symlinks_and_open(arg_entry_token, root, CHASE_PREFIX_ROOT|CHASE_PROHIBIT_SYMLINKS, + O_DIRECTORY|O_CLOEXEC, &full); + if (dir_fd == -ENOENT) + return 0; + if (dir_fd < 0) + return log_error_errno(dir_fd, "Failed to open '%s/%s': %m", root, arg_entry_token); + + p = path_join("/", arg_entry_token); + if (!p) + return log_oom(); + + r = recurse_dir(dir_fd, p, 0, UINT_MAX, RECURSE_DIR_SORT, list_remove_orphaned_file, known_files); + if (r < 0) + return log_error_errno(r, "Failed to cleanup %s: %m", full); + + return r; +} + int verb_list(int argc, char *argv[], void *userdata) { _cleanup_(boot_config_free) BootConfig config = BOOT_CONFIG_NULL; dev_t esp_devid = 0, xbootldr_devid = 0; @@ -534,6 +779,24 @@ int verb_list(int argc, char *argv[], void *userdata) { return 0; } - pager_open(arg_pager_flags); - return show_boot_entries(&config, arg_json_format_flags); + if (streq(argv[0], "list")) { + pager_open(arg_pager_flags); + return show_boot_entries(&config, arg_json_format_flags); + } else if (streq(argv[0], "cleanup")) { + if (arg_xbootldr_path && xbootldr_devid != esp_devid) + cleanup_orphaned_files(&config, arg_xbootldr_path); + return cleanup_orphaned_files(&config, arg_esp_path); + } else { + assert(streq(argv[0], "unlink")); + if (arg_xbootldr_path && xbootldr_devid != esp_devid) { + r = unlink_entry(&config, arg_xbootldr_path, argv[1]); + if (r == 0 || r != -ENOENT) + return r; + } + return unlink_entry(&config, arg_esp_path, argv[1]); + } +} + +int verb_unlink(int argc, char *argv[], void *userdata) { + return verb_list(argc, argv, userdata); } diff --git a/src/boot/bootctl-status.h b/src/boot/bootctl-status.h index 0b57c86f917..f7998a3303e 100644 --- a/src/boot/bootctl-status.h +++ b/src/boot/bootctl-status.h @@ -2,3 +2,4 @@ int verb_status(int argc, char *argv[], void *userdata); int verb_list(int argc, char *argv[], void *userdata); +int verb_unlink(int argc, char *argv[], void *userdata); diff --git a/src/boot/bootctl.c b/src/boot/bootctl.c index 7dd1a2f5f29..e910a72042b 100644 --- a/src/boot/bootctl.c +++ b/src/boot/bootctl.c @@ -48,6 +48,7 @@ char *arg_root = NULL; char *arg_image = NULL; InstallSource arg_install_source = ARG_INSTALL_SOURCE_AUTO; char *arg_efi_boot_option_description = NULL; +bool arg_dry_run = false; STATIC_DESTRUCTOR_REGISTER(arg_esp_path, freep); STATIC_DESTRUCTOR_REGISTER(arg_xbootldr_path, freep); @@ -145,6 +146,8 @@ static int help(int argc, char *argv[], void *userdata) { " set-timeout SECONDS Set the menu timeout\n" " set-timeout-oneshot SECONDS\n" " Set the menu timeout for the next boot only\n" + " unlink ID Remove boot loader entry\n" + " cleanup Remove files in ESP not referenced in any boot entry\n" "\n%3$ssystemd-boot Commands:%4$s\n" " install Install systemd-boot to the ESP and EFI variables\n" " update Update systemd-boot in the ESP and EFI variables\n" @@ -179,6 +182,7 @@ static int help(int argc, char *argv[], void *userdata) { " Install all supported EFI architectures\n" " --efi-boot-option-description=DESCRIPTION\n" " Description of the entry in the boot option list\n" + " --dry-run Dry run (unlink and cleanup)\n" "\nSee the %2$s for details.\n", program_invocation_short_name, link, @@ -206,6 +210,7 @@ static int parse_argv(int argc, char *argv[]) { ARG_JSON, ARG_ARCH_ALL, ARG_EFI_BOOT_OPTION_DESCRIPTION, + ARG_DRY_RUN, }; static const struct option options[] = { @@ -230,6 +235,7 @@ static int parse_argv(int argc, char *argv[]) { { "json", required_argument, NULL, ARG_JSON }, { "all-architectures", no_argument, NULL, ARG_ARCH_ALL }, { "efi-boot-option-description", required_argument, NULL, ARG_EFI_BOOT_OPTION_DESCRIPTION }, + { "dry-run", no_argument, NULL, ARG_DRY_RUN }, {} }; @@ -379,6 +385,10 @@ static int parse_argv(int argc, char *argv[]) { return r; break; + case ARG_DRY_RUN: + arg_dry_run = true; + break; + case '?': return -EINVAL; @@ -387,7 +397,7 @@ static int parse_argv(int argc, char *argv[]) { } if ((arg_root || arg_image) && argv[optind] && !STR_IN_SET(argv[optind], "status", "list", - "install", "update", "remove", "is-installed", "random-seed")) + "install", "update", "remove", "is-installed", "random-seed", "unlink", "cleanup")) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Options --root= and --image= are not supported with verb %s.", argv[optind]); @@ -398,6 +408,9 @@ static int parse_argv(int argc, char *argv[]) { if (arg_install_source != ARG_INSTALL_SOURCE_AUTO && !arg_root && !arg_image) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--install-from-host is only supported with --root= or --image=."); + if (arg_dry_run && argv[optind] && !STR_IN_SET(argv[optind], "unlink", "cleanup")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--dry is only supported with --unlink or --cleanup"); + return 1; } @@ -412,6 +425,8 @@ static int bootctl_main(int argc, char *argv[]) { { "kernel-identify", 2, 2, 0, verb_kernel_identify }, { "kernel-inspect", 2, 2, 0, verb_kernel_inspect }, { "list", VERB_ANY, 1, 0, verb_list }, + { "unlink", 2, 2, 0, verb_unlink }, + { "cleanup", VERB_ANY, 1, 0, verb_list }, { "set-default", 2, 2, 0, verb_set_efivar }, { "set-oneshot", 2, 2, 0, verb_set_efivar }, { "set-timeout", 2, 2, 0, verb_set_efivar }, diff --git a/src/boot/bootctl.h b/src/boot/bootctl.h index 5a14faf1a47..311b954c2c8 100644 --- a/src/boot/bootctl.h +++ b/src/boot/bootctl.h @@ -39,6 +39,7 @@ extern char *arg_root; extern char *arg_image; extern InstallSource arg_install_source; extern char *arg_efi_boot_option_description; +extern bool arg_dry_run; static inline const char *arg_dollar_boot_path(void) { /* $BOOT shall be the XBOOTLDR partition if it exists, and otherwise the ESP */ diff --git a/src/kernel-install/90-loaderentry.install.in b/src/kernel-install/90-loaderentry.install.in index 0992c641f03..e8e8cf37c36 100755 --- a/src/kernel-install/90-loaderentry.install.in +++ b/src/kernel-install/90-loaderentry.install.in @@ -32,7 +32,7 @@ MACHINE_ID="$KERNEL_INSTALL_MACHINE_ID" ENTRY_TOKEN="$KERNEL_INSTALL_ENTRY_TOKEN" BOOT_ROOT="$KERNEL_INSTALL_BOOT_ROOT" -BOOT_MNT="$(stat -c %m "$BOOT_ROOT")" +[ -n "$BOOT_MNT" ] || BOOT_MNT="$(stat -c %m "$BOOT_ROOT")" if [ "$BOOT_MNT" = '/' ]; then ENTRY_DIR="$ENTRY_DIR_ABS" else diff --git a/src/kernel-install/test-kernel-install.sh b/src/kernel-install/test-kernel-install.sh index 2e440636680..f16bb9f50f9 100755 --- a/src/kernel-install/test-kernel-install.sh +++ b/src/kernel-install/test-kernel-install.sh @@ -9,6 +9,8 @@ plugin="${2:?}" D="$(mktemp --tmpdir --directory "test-kernel-install.XXXXXXXXXX")" +export _KERNEL_INSTALL_BOOTCTL="$PROJECT_BUILD_ROOT/bootctl" + # shellcheck disable=SC2064 trap "rm -rf '$D'" EXIT INT QUIT PIPE mkdir -p "$D/boot" @@ -31,6 +33,7 @@ EOF export KERNEL_INSTALL_CONF_ROOT="$D/sources" export KERNEL_INSTALL_PLUGINS="$plugin" export BOOT_ROOT="$D/boot" +export BOOT_MNT="$D/boot" export MACHINE_ID='3e0484f3634a418b8e6a39e8828b03e3' "$kernel_install" -v add 1.1.1 "$D/sources/linux" "$D/sources/initrd" @@ -82,3 +85,32 @@ grep -qE '^initrd .*/the-token/1.1.1/initrd' "$entry" grep -qE 'image' "$BOOT_ROOT/the-token/1.1.1/linux" grep -qE 'initrd' "$BOOT_ROOT/the-token/1.1.1/initrd" + +if test -x "$_KERNEL_INSTALL_BOOTCTL"; then + echo "Testing bootctl" + e2="${entry%+*}_2.conf" + cp "$entry" "$e2" + export SYSTEMD_ESP_PATH=/ + + # create file that is not referenced. Check if cleanup removes + # it but leaves the rest alone + :> "$BOOT_ROOT/the-token/1.1.2/initrd" + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" cleanup + test ! -e "$BOOT_ROOT/the-token/1.1.2/initrd" + test -e "$BOOT_ROOT/the-token/1.1.2/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/initrd" + # now remove duplicated entry and make sure files are left over + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" unlink "${e2##*/}" + test -e "$BOOT_ROOT/the-token/1.1.1/linux" + test -e "$BOOT_ROOT/the-token/1.1.1/initrd" + test -e "$entry" + test ! -e "$e2" + # remove last entry referencing those files + entry_id="${entry##*/}" + entry_id="${entry_id%+*}.conf" + "$_KERNEL_INSTALL_BOOTCTL" --root="$BOOT_ROOT" unlink "$entry_id" + test ! -e "$entry" + test ! -e "$BOOT_ROOT/the-token/1.1.1/linux" + test ! -e "$BOOT_ROOT/the-token/1.1.1/initrd" +fi diff --git a/src/test/meson.build b/src/test/meson.build index 3aa98e1ffdf..8dfc97c9e8a 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -15,6 +15,7 @@ path = run_command(sh, '-c', 'echo "$PATH"', check: true).stdout().strip() test_env = environment() test_env.set('SYSTEMD_LANGUAGE_FALLBACK_MAP', language_fallback_map) test_env.set('PATH', project_build_root + ':' + path) +test_env.set('PROJECT_BUILD_ROOT', project_build_root) ############################################################