diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/completion/list_images.py | 44 | ||||
-rwxr-xr-x | src/conf_mode/protocols_rpki.py | 6 | ||||
-rwxr-xr-x | src/conf_mode/qos.py | 8 | ||||
-rwxr-xr-x | src/conf_mode/vpn_l2tp.py | 10 | ||||
-rwxr-xr-x | src/conf_mode/vpn_openconnect.py | 2 | ||||
-rwxr-xr-x | src/conf_mode/vrf.py | 14 | ||||
-rw-r--r-- | src/etc/sysctl.d/30-vyos-router.conf | 2 | ||||
-rwxr-xr-x | src/init/vyos-router | 4 | ||||
-rwxr-xr-x | src/migration-scripts/rpki/1-to-2 | 51 | ||||
-rwxr-xr-x | src/op_mode/file.py | 383 | ||||
-rwxr-xr-x | src/op_mode/generate_ipsec_debug_archive.py | 3 | ||||
-rwxr-xr-x | src/op_mode/image_installer.py | 5 |
12 files changed, 504 insertions, 28 deletions
diff --git a/src/completion/list_images.py b/src/completion/list_images.py new file mode 100755 index 000000000..eae29c084 --- /dev/null +++ b/src/completion/list_images.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os +import sys + +from vyos.system.image import is_live_boot +from vyos.system.image import get_running_image + + +parser = argparse.ArgumentParser(description='list available system images') +parser.add_argument('--no-running', action='store_true', + help='do not display the currently running image') + +def get_images(omit_running: bool = False) -> list[str]: + if is_live_boot(): + return [] + images = os.listdir("/lib/live/mount/persistence/boot") + if omit_running: + images.remove(get_running_image()) + if 'grub' in images: + images.remove('grub') + if 'efi' in images: + images.remove('efi') + return sorted(images) + +if __name__ == '__main__': + args = parser.parse_args() + print("\n".join(get_images(omit_running=args.no_running))) + sys.exit(0) diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index 05e876f3b..0fc14e868 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -63,11 +63,11 @@ def verify(rpki): preferences.append(preference) if 'ssh' in peer_config: - files = ['private_key_file', 'public_key_file', 'known_hosts_file'] + files = ['private_key_file', 'public_key_file'] for file in files: if file not in peer_config['ssh']: - raise ConfigError('RPKI+SSH requires username, public/private ' \ - 'keys and known-hosts file to be defined!') + raise ConfigError('RPKI+SSH requires username and public/private ' \ + 'key file to be defined!') filename = peer_config['ssh'][file] if not os.path.exists(filename): diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index 40d7a6c16..4a0b4d0c5 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -36,7 +36,7 @@ from vyos.qos import RateLimiter from vyos.qos import RoundRobin from vyos.qos import TrafficShaper from vyos.qos import TrafficShaperHFSC -from vyos.utils.process import call +from vyos.utils.process import run from vyos.utils.dict import dict_search_recursive from vyos import ConfigError from vyos import airbag @@ -205,8 +205,8 @@ def apply(qos): # Always delete "old" shapers first for interface in interfaces(): # Ignore errors (may have no qdisc) - call(f'tc qdisc del dev {interface} parent ffff:') - call(f'tc qdisc del dev {interface} root') + run(f'tc qdisc del dev {interface} parent ffff:') + run(f'tc qdisc del dev {interface} root') call_dependents() diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index fc87d9539..266381754 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -19,6 +19,7 @@ import os from sys import exit from vyos.config import Config +from vyos.configdep import call_dependents, set_dependents from vyos.configdict import get_accel_dict from vyos.template import render from vyos.utils.process import call @@ -41,6 +42,9 @@ def get_config(config=None): else: conf = Config() base = ['vpn', 'l2tp', 'remote-access'] + + set_dependents('ipsec', conf) + if not conf.exists(base): return None @@ -87,10 +91,10 @@ def apply(l2tp): for file in [l2tp_chap_secrets, l2tp_conf]: if os.path.exists(file): os.unlink(file) + else: + call('systemctl restart accel-ppp@l2tp.service') - return None - - call('systemctl restart accel-ppp@l2tp.service') + call_dependents() if __name__ == '__main__': diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index 421ac6997..08e4fc6db 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -91,7 +91,7 @@ def verify(ocserv): if not ocserv["authentication"]['radius']['server']: raise ConfigError('Openconnect authentication mode radius requires at least one RADIUS server') if "local" in ocserv["authentication"]["mode"]: - if not ocserv["authentication"]["local_users"]: + if not ocserv.get("authentication", {}).get("local_users"): raise ConfigError('openconnect mode local required at least one user') if not ocserv["authentication"]["local_users"]["username"]: raise ConfigError('openconnect mode local required at least one user') diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index f2c544aa6..a2f4956be 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -27,7 +27,6 @@ from vyos.ifconfig import Interface from vyos.template import render from vyos.template import render_to_string from vyos.utils.dict import dict_search -from vyos.utils.kernel import check_kmod from vyos.utils.network import get_interface_config from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists @@ -223,18 +222,6 @@ def apply(vrf): # Delete the VRF Kernel interface call(f'ip link delete dev {tmp}') - # Enable/Disable VRF strict mode - # When net.vrf.strict_mode=0 (default) it is possible to associate multiple - # VRF devices to the same table. Conversely, when net.vrf.strict_mode=1 a - # table can be associated to a single VRF device. - # - # A VRF table can be used by the VyOS CLI only once (ensured by verify()), - # this simply adds an additional Kernel safety net - strict_mode = '0' - # Set to 1 if any VRF is defined - if 'name' in vrf: strict_mode = '1' - sysctl_write('net.vrf.strict_mode', strict_mode) - if 'name' in vrf: # Linux routing uses rules to find tables - routing targets are then # looked up in those tables. If the lookup got a matching route, the @@ -323,7 +310,6 @@ def apply(vrf): if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index 6291be5f0..c9b8ef8fe 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -108,3 +108,5 @@ net.ipv4.tcp_congestion_control=bbr # Disable IPv6 Segment Routing packets by default net.ipv6.conf.all.seg6_enabled = 0 net.ipv6.conf.default.seg6_enabled = 0 + +net.vrf.strict_mode = 1 diff --git a/src/init/vyos-router b/src/init/vyos-router index aaecbf2a1..2b4fac5ef 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -448,6 +448,10 @@ start () restore_if_missing_postconfig_script run_postconfig_scripts + tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "protocols rpki cache") + if [ ! -z $tmp ]; then + vtysh -c "rpki start" + fi } stop() diff --git a/src/migration-scripts/rpki/1-to-2 b/src/migration-scripts/rpki/1-to-2 new file mode 100755 index 000000000..559440bba --- /dev/null +++ b/src/migration-scripts/rpki/1-to-2 @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T6011: rpki: known-hosts-file is no longer supported bxy FRR CLI, +# remove VyOS CLI node + +from sys import exit +from sys import argv +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['protocols', 'rpki'] +config = ConfigTree(config_file) + +# Nothing to do +if not config.exists(base): + exit(0) + +if config.exists(base + ['cache']): + for cache in config.list_nodes(base + ['cache']): + ssh_node = base + ['cache', cache, 'ssh'] + if config.exists(ssh_node + ['known-hosts-file']): + config.delete(ssh_node + ['known-hosts-file']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/op_mode/file.py b/src/op_mode/file.py new file mode 100755 index 000000000..bf13bed6f --- /dev/null +++ b/src/op_mode/file.py @@ -0,0 +1,383 @@ +#!/usr/bin/python3 + +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library 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. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import contextlib +import datetime +import grp +import os +import pwd +import shutil +import sys +import tempfile + +from vyos.remote import download +from vyos.remote import upload +from vyos.utils.io import ask_yes_no +from vyos.utils.io import print_error +from vyos.utils.process import cmd +from vyos.utils.process import run + + +parser = argparse.ArgumentParser(description='view, copy or remove files and directories', + formatter_class=argparse.RawDescriptionHelpFormatter) +parser.epilog = """ +TYPE is one of 'remote', 'image' and 'local'. +A local path is <path> or ~/<path>. +A remote path is <scheme>://<urn>. +An image path is <image>:<path>. + +Clone operation is between images only. +Copy operation does not support directories from remote locations. +Delete operation does not support remote paths. +""" +operations = parser.add_mutually_exclusive_group(required=True) +operations.add_argument('--show', nargs=1, help='show the contents of file PATH of type TYPE', + metavar=('PATH')) +operations.add_argument('--copy', nargs=2, help='copy SRC to DEST', + metavar=('SRC', 'DEST')) +operations.add_argument('--delete', nargs=1, help='delete file PATH', + metavar=('PATH')) +operations.add_argument('--clone', help='clone config from running image to IMG', + metavar='IMG') +operations.add_argument('--clone-from', nargs=2, help='clone config from image SRC to image DEST', + metavar=('SRC', 'DEST')) + +## Helper procedures +def fix_terminal() -> None: + """ + Reset terminal after potential breakage caused by abrupt exits. + """ + run('stty sane') + +def get_types(arg: str) -> tuple[str, str]: + """ + Determine whether the argument shows a local, image or remote path. + """ + schemes = ['http', 'https', 'ftp', 'ftps', 'sftp', 'ssh', 'scp', 'tftp'] + s = arg.split("://", 1) + if len(s) != 2: + return 'local', arg + elif s[0] in schemes: + return 'remote', arg + else: + return 'image', arg + +def zealous_copy(source: str, destination: str) -> None: + # Even shutil.copy2() doesn't preserve ownership across copies. + # So we need to resort to this. + stats = os.stat(source) + shutil.copy2(source, destination) + os.chown(destination, stats.st_uid, stats.st_gid) + +def get_file_type(path: str) -> str: + return cmd(['file', '-sb', path]) + +def print_header(string: str) -> None: + print('#' * 10, string, '#' * 10) + +def octal_to_symbolic(octal: str) -> str: + perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] + result = "" + # We discard all but the last three digits because we're only + # interested in the permission bits. + for i in octal[-3:]: + result += perms[int(i)] + return result + +def get_user_and_group(stats: os.stat_result) -> tuple[str, str]: + try: + user = pwd.getpwuid(stats.st_uid).pw_name + except (KeyError, PermissionError): + user = str(stats.st_uid) + try: + group = grp.getgrgid(stats.st_gid).gr_name + except (KeyError, PermissionError): + group = str(stats.st_gid) + return user, group + +def print_file_info(path: str) -> None: + stats = os.stat(path) + username, groupname = get_user_and_group(stats) + mtime = datetime.datetime.fromtimestamp(stats.st_mtime).strftime("%F %X") + print_header('FILE INFO') + print(f'Path:\t\t{path}') + # File type is determined through `file(1)`. + print(f'Type:\t\t{get_file_type(path)}') + # Owner user and group + print(f'Owner:\t\t{username}:{groupname}') + # Permissions are converted from raw int to octal string to symbolic string. + print(f'Permissions:\t{octal_to_symbolic(oct(stats.st_mode))}') + # Last date of modification + print(f'Modified:\t{mtime}') + +def print_file_data(path: str) -> None: + print_header('FILE DATA') + file_type = get_file_type(path) + # Human-readable files are streamed line-by-line. + if 'text' in file_type: + with open(path, 'r') as f: + for line in f: + print(line, end='') + # tcpdump files go to TShark. + elif 'pcap' in file_type or os.path.splitext(path)[1] == '.pcap': + print(cmd(['sudo', 'tshark', '-r', path])) + # All other binaries get hexdumped. + else: + print(cmd(['hexdump', '-C', path])) + +def parse_image_path(image_path: str) -> str: + """ + my-image:/foo/bar -> /lib/live/mount/persistence/boot/my-image/rw/foo/bar + """ + image_name, path = image_path.split('://', 1) + if image_name == 'running': + image_root = '/' + elif image_name == 'disk-install': + image_root = '/lib/live/mount/persistence/' + else: + image_root = os.path.join('/lib/live/mount/persistence/boot', image_name, 'rw') + if not os.path.isdir(image_root): + print_error(f'Image {image_name} not found.') + sys.exit(1) + return os.path.join(image_root, path) + + +## Show procedures +def show_locally(path: str) -> None: + """ + Display the contents of a local file or directory. + """ + location = os.path.realpath(os.path.expanduser(path)) + # Temporarily redirect stdout to a throwaway file for `less(1)` to read. + # The output could be potentially too hefty for an in-memory StringIO. + temp = tempfile.NamedTemporaryFile('w', delete=False) + try: + with contextlib.redirect_stdout(temp): + # Just a directory. Call `ls(1)` and bail. + if os.path.isdir(location): + print_header('DIRECTORY LISTING') + print('Path:\t', location) + print(cmd(['ls', '-hlFGL', '--group-directories-first', location])) + elif os.path.isfile(location): + print_file_info(location) + print() + print_file_data(location) + else: + print_error(f'File or directory {path} not found.') + sys.exit(1) + sys.stdout.flush() + # Call `less(1)` and wait for it to terminate before going forward. + cmd(['/usr/bin/less', '-X', temp.name], stdout=sys.stdout) + # The stream to the temporary file could break for any reason. + # It's much less fragile than if we streamed directly to the process stdin. + # But anything could still happen and we don't want to scare the user. + except (BrokenPipeError, EOFError, KeyboardInterrupt, OSError): + fix_terminal() + sys.exit(1) + finally: + os.remove(temp.name) + +def show(type: str, path: str) -> None: + if type == 'remote': + temp = tempfile.NamedTemporaryFile(delete=False) + download(temp.name, path) + show_locally(temp.name) + os.remove(temp.name) + elif type == 'image': + show_locally(parse_image_path(path)) + elif type == 'local': + show_locally(path) + else: + print_error(f'Unknown target for showing: {type}') + print_error('Valid types are "remote", "image" and "local".') + sys.exit(1) + + +## Copying procedures +def copy(source_type: str, source_path: str, + destination_type: str, destination_path: str) -> None: + """ + Copy a file or directory locally, remotely or to and from an image. + Directory uploads and downloads not supported. + """ + source = '' + try: + # Download to a temporary file and use that as the source. + if source_type == 'remote': + source = tempfile.NamedTemporaryFile(delete=False).name + download(source, source_path) + # Prepend the image root to the path. + elif source_type == 'image': + source = parse_image_path(source_path) + elif source_type == 'local': + source = source_path + else: + print_error(f'Unknown source type: {source_type}') + print_error(f'Valid source types are "remote", "image" and "local".') + sys.exit(1) + + # Directly upload the file. + if destination_type == 'remote': + if os.path.isdir(source): + print_error(f'Cannot upload {source}. Directory uploads not supported.') + sys.exit(1) + upload(source, destination_path) + # No need to duplicate local copy operations for image copying. + elif destination_type == 'image': + copy('local', source, 'local', parse_image_path(destination_path)) + # Try to preserve metadata when copying. + elif destination_type == 'local': + if os.path.isdir(destination_path): + destination_path = os.path.join(destination_path, os.path.basename(source)) + if os.path.isdir(source): + shutil.copytree(source, destination_path, copy_function=zealous_copy) + else: + zealous_copy(source, destination_path) + else: + print_error(f'Unknown destination type: {source_type}') + print_error(f'Valid destination types are "remote", "image" and "local".') + sys.exit(1) + except OSError: + import traceback + # We can't check for every single user error (eg copying a directory to a file) + # so we just let a curtailed stack trace provide a descriptive error. + print_error(f'Failed to copy {source_path} to {destination_path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + else: + # To prevent a duplicate message. + if destination_type != 'image': + print('Copy successful.') + finally: + # Clean up temporary file. + if source_type == 'remote': + os.remove(source) + + +## Deletion procedures +def delete_locally(path: str) -> None: + """ + Remove a local file or directory. + """ + try: + if os.path.isdir(path): + if (ask_yes_no(f'Do you want to remove {path} with all its contents?')): + shutil.rmtree(path) + print(f'Directory {path} removed.') + else: + print('Operation aborted.') + elif os.path.isfile(path): + if (ask_yes_no(f'Do you want to remove {path}?')): + os.remove(path) + print(f'File {path} removed.') + else: + print('Operation aborted.') + else: + raise OSError(f'File or directory {path} not found.') + except OSError: + import traceback + print_error(f'Failed to delete {path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + +def delete(type: str, path: str) -> None: + if type == 'local': + delete_locally(path) + elif type == 'image': + delete_locally(parse_image_path(path)) + else: + print_error(f'Unknown target for deletion: {type}') + print_error('Valid types are "image" and "local".') + sys.exit(1) + + +## Cloning procedures +def clone(source: str, destination: str) -> None: + if os.geteuid(): + print_error('Only the superuser can run this command.') + sys.exit(1) + if destination == 'running' or destination == 'disk-install': + print_error(f'Cannot clone config to {destination}.') + sys.exit(1) + # If `source` is None, then we're going to copy from the running image. + if source is None or source == 'running': + source_path = '/config' + # For the warning message only. + source = 'the current' + else: + source_path = parse_image_path(source + ':/config') + destination_path = parse_image_path(destination + ':/config') + backup_path = destination_path + '.preclone' + + if not os.path.isdir(source_path): + print_error(f'Source image {source} does not exist.') + sys.exit(1) + if not os.path.isdir(destination_path): + print_error(f'Destination image {destination} does not exist.') + sys.exit(1) + print(f'WARNING: This operation will erase /config data in image {destination}.') + print(f'/config data in {source} image will be copied over in its place.') + print(f'The existing /config data in {destination} image will be backed up to /config.preclone.') + + if ask_yes_no('Are you sure you want to continue?'): + try: + if os.path.isdir(backup_path): + print('Removing previous backup...') + shutil.rmtree(backup_path) + print('Making new backup...') + shutil.move(destination_path, backup_path) + except: + print('Something went wrong during the backup process!') + print('Cowardly refusing to proceed with cloning.') + raise + # Copy new config from image. + try: + shutil.copytree(source_path, destination_path, copy_function=zealous_copy) + except: + print('Cloning failed! Reverting to backup!') + # Delete leftover files from the botched cloning. + shutil.rmtree(destination_path, ignore_errors=True) + # Restore backup before bailing out. + shutil.copytree(backup_path, destination_path, copy_function=zealous_copy) + raise + else: + print(f'Successfully cloned config from {source} to {destination}.') + finally: + shutil.rmtree(backup_path) + else: + print('Operation aborted.') + +if __name__ == '__main__': + args = parser.parse_args() + try: + if args.show: + show(*get_types(args.show[0])) + elif args.copy: + copy(*get_types(args.copy[0]), + *get_types(args.copy[1])) + elif args.delete: + delete(*get_types(args.delete[0])) + elif args.clone_from: + clone(*args.clone_from) + elif args.clone: + # Pass None as source image to copy from local image. + clone(None, args.clone) + except KeyboardInterrupt: + print_error('Operation cancelled by user.') + sys.exit(1) + sys.exit(0) diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py index 60195d48b..ca2eeb511 100755 --- a/src/op_mode/generate_ipsec_debug_archive.py +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -24,7 +24,6 @@ from vyos.utils.process import rc_cmd # define a list of commands that needs to be executed CMD_LIST: list[str] = [ - 'ipsec status', 'swanctl -L', 'swanctl -l', 'swanctl -P', @@ -36,7 +35,7 @@ CMD_LIST: list[str] = [ 'ip route | head -100', 'ip route show table 220' ] -JOURNALCTL_CMD: str = 'journalctl -b -n 10000 /usr/lib/ipsec/charon' +JOURNALCTL_CMD: str = 'journalctl --no-hostname --boot --unit strongswan.service' # execute a command and save the output to a file def save_stdout(command: str, file: Path) -> None: diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index 501e9b804..d677c2cf8 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -599,6 +599,8 @@ def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: print('Unmounting target filesystems') for mountpoint in mounts: disk.partition_umount(mountpoint) + for mountpoint in mounts: + disk.wait_for_umount(mountpoint) if remove_items: print('Removing temporary files') for remove_item in remove_items: @@ -606,7 +608,8 @@ def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: if Path(remove_item).is_file(): Path(remove_item).unlink() if Path(remove_item).is_dir(): - rmtree(remove_item) + rmtree(remove_item, ignore_errors=True) + def cleanup_raid(details: raid.RaidDetails) -> None: efiparts = [] |