diff --git a/op-mode-definitions/system-image.xml.in b/op-mode-definitions/system-image.xml.in index 57aeb7bb4..463b985d6 100644 --- a/op-mode-definitions/system-image.xml.in +++ b/op-mode-definitions/system-image.xml.in @@ -1,189 +1,201 @@ <?xml version="1.0" encoding="UTF-8"?> <interfaceDefinition> <node name="add"> <properties> <help>Add an object</help> </properties> <children> <node name="system"> <properties> <help>Add item to a system facility</help> </properties> <children> <tagNode name="image"> <properties> <help>Add a new image to the system</help> <completionHelp> <list>/path/to/vyos-image.iso "http://example.com/vyos-image.iso"</list> </completionHelp> </properties> <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}"</command> <children> <tagNode name="vrf"> <properties> <help>Download image via specified VRF</help> <completionHelp> <path>vrf name</path> </completionHelp> </properties> <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --vrf "${6}"</command> <children> <tagNode name="username"> <properties> <help>Username for authentication</help> </properties> <children> <tagNode name="password"> <properties> <help>Password to use with authentication</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --vrf "${6}" --username "${8}" --password "${10}"</command> </tagNode> </children> </tagNode> </children> </tagNode> <tagNode name="username"> <properties> <help>Username for authentication</help> </properties> <children> <tagNode name="password"> <properties> <help>Password to use with authentication</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --username "${6}" --password "${8}"</command> </tagNode> </children> </tagNode> </children> </tagNode> </children> </node> </children> </node> <node name="set"> <properties> <help>Install a new system</help> </properties> <children> <node name="system"> <properties> <help>Set system operational parameters</help> </properties> <children> <node name="image"> <properties> <help>Set system image parameters</help> </properties> <children> + <node name="default-boot"> + <properties> + <help>Set default image to boot.</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action set</command> + </node> <tagNode name="default-boot"> <properties> <help>Set default image to boot.</help> <completionHelp> <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script> </completionHelp> </properties> <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action set --image_name "${5}"</command> </tagNode> </children> </node> </children> </node> </children> </node> <node name="install"> <properties> <help>Install a new system</help> </properties> <children> <node name="image"> <properties> <help>Install new system image to hard drive</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action install</command> </node> </children> </node> <node name="delete"> <properties> <help>Delete an object</help> </properties> <children> <node name="system"> <properties> <help>Delete system objects</help> </properties> <children> + <node name="image"> + <properties> + <help>Remove an installed image from the system</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action delete</command> + </node> <tagNode name="image"> <properties> <help>Remove an installed image from the system</help> <completionHelp> <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script> </completionHelp> </properties> <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action delete --image_name "${4}"</command> </tagNode> </children> </node> </children> </node> <node name="rename"> <properties> <help>Rename an object</help> </properties> <children> <node name="system"> <properties> <help>Rename a system object</help> </properties> <children> <tagNode name="image"> <properties> <help>System image to rename</help> <completionHelp> <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script> </completionHelp> </properties> <children> <tagNode name="to"> <properties> <help>A new name for an image</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action rename --image_name "${4}" --image_new_name "${6}"</command> </tagNode> </children> </tagNode> </children> </node> </children> </node> <node name="show"> <properties> <help>Rename an object</help> </properties> <children> <node name="system"> <properties> <help>Show system information</help> </properties> <children> <node name="image"> <properties> <help>Show installed VyOS images</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_info.py show_images_summary</command> <children> <node name="details"> <properties> <help>Show details about installed VyOS images</help> </properties> <command>sudo ${vyos_op_scripts_dir}/image_info.py show_images_details</command> </node> </children> </node> </children> </node> </children> </node> </interfaceDefinition> diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 8790cbaac..e34a1ba32 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -1,74 +1,93 @@ # 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/>. def print_error(str='', end='\n'): """ Print `str` to stderr, terminated with `end`. Used for warnings and out-of-band messages to avoid mangling precious stdout output. """ import sys sys.stderr.write(str) sys.stderr.write(end) sys.stderr.flush() def ask_input(question, default='', numeric_only=False, valid_responses=[]): question_out = question if default: question_out += f' (Default: {default})' response = '' while True: response = input(question_out + ' ').strip() if not response and default: return default if numeric_only: if not response.isnumeric(): print("Invalid value, try again.") continue response = int(response) if valid_responses and response not in valid_responses: print("Invalid value, try again.") continue break return response def ask_yes_no(question, default=False) -> bool: """Ask a yes/no question via input() and return their answer.""" from sys import stdout default_msg = "[Y/n]" if default else "[y/N]" while True: try: stdout.write("%s %s " % (question, default_msg)) c = input().lower() if c == '': return default elif c in ("y", "ye", "yes"): return True elif c in ("n", "no"): return False else: stdout.write("Please respond with yes/y or no/n\n") except EOFError: stdout.write("\nPlease respond with yes/y or no/n\n") def is_interactive(): """Try to determine if the routine was called from an interactive shell.""" import os, sys return os.getenv('TERM', default=False) and sys.stderr.isatty() and sys.stdout.isatty() def is_dumb_terminal(): """Check if the current TTY is dumb, so that we can disable advanced terminal features.""" import os return os.getenv('TERM') in ['vt100', 'dumb'] + +def select_entry(l: list, list_msg: str = '', prompt_msg: str = '') -> str: + """Select an entry from a list + + Args: + l (list): a list of entries + list_msg (str): a message to print before listing the entries + prompt_msg (str): a message to print as prompt for selection + + Returns: + str: a selected entry + """ + en = list(enumerate(l, 1)) + print(list_msg) + for i, e in en: + print(f'\t{i}: {e}') + select = ask_input(prompt_msg, numeric_only=True, + valid_responses=range(1, len(l)+1)) + return next(filter(lambda x: x[0] == select, en))[1] diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index 55fd5c07d..de53c4cf0 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -1,187 +1,207 @@ #!/usr/bin/env python3 # # Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # # VyOS is free software: you can redistribute it and/or modify it under the # terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # VyOS 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 # VyOS. If not, see <https://www.gnu.org/licenses/>. from argparse import ArgumentParser, Namespace from pathlib import Path from shutil import rmtree from sys import exit +from typing import Optional from vyos.system import disk, grub, image, compat -from vyos.utils.io import ask_yes_no +from vyos.utils.io import ask_yes_no, select_entry + +SET_IMAGE_LIST_MSG: str = 'The following images are available:' +SET_IMAGE_PROMPT_MSG: str = 'Select an image to set as default:' +DELETE_IMAGE_LIST_MSG: str = 'The following images are installed:' +DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:' +MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first' +MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' @compat.grub_cfg_update -def delete_image(image_name: str) -> None: +def delete_image(image_name: Optional[str] = None, + prompt: bool = True) -> None: """Remove installed image files and boot entry Args: image_name (str): a name of image to delete """ + available_images: list[str] = grub.version_list() + if image_name is None: + if not prompt: + exit('An image name is required for delete action') + else: + image_name = select_entry(available_images, + DELETE_IMAGE_LIST_MSG, + DELETE_IMAGE_PROMPT_MSG) if image_name == image.get_running_image(): - exit('Currently running image cannot be deleted') + exit(MSG_DELETE_IMAGE_RUNNING) if image_name == image.get_default_image(): - exit('Default image cannot be deleted') - available_images: list[str] = grub.version_list() + exit(MSG_DELETE_IMAGE_DEFAULT) if image_name not in available_images: exit(f'The image "{image_name}" cannot be found') presistence_storage: str = disk.find_persistence() if not presistence_storage: exit('Persistence storage cannot be found') if not ask_yes_no(f'Do you really want to delete the image {image_name}?', default=False): exit() # remove files and menu entry version_path: Path = Path(f'{presistence_storage}/boot/{image_name}') try: rmtree(version_path) grub.version_del(image_name, presistence_storage) print(f'The image "{image_name}" was successfully deleted') except Exception as err: exit(f'Unable to remove the image "{image_name}": {err}') @compat.grub_cfg_update -def set_image(image_name: str) -> None: +def set_image(image_name: Optional[str] = None, + prompt: bool = True) -> None: """Set default boot image Args: image_name (str): an image name """ + available_images: list[str] = grub.version_list() + if image_name is None: + if not prompt: + exit('An image name is required for set action') + else: + image_name = select_entry(available_images, + SET_IMAGE_LIST_MSG, + SET_IMAGE_PROMPT_MSG) if image_name == image.get_default_image(): exit(f'The image "{image_name}" already configured as default') - available_images: list[str] = grub.version_list() if image_name not in available_images: exit(f'The image "{image_name}" cannot be found') presistence_storage: str = disk.find_persistence() if not presistence_storage: exit('Persistence storage cannot be found') # set default boot image try: grub.set_default(image_name, presistence_storage) print(f'The image "{image_name}" is now default boot image') except Exception as err: exit(f'Unable to set default image "{image_name}": {err}') @compat.grub_cfg_update def rename_image(name_old: str, name_new: str) -> None: """Rename installed image Args: name_old (str): old name name_new (str): new name """ if name_old == image.get_running_image(): exit('Currently running image cannot be renamed') available_images: list[str] = grub.version_list() if name_old not in available_images: exit(f'The image "{name_old}" cannot be found') if name_new in available_images: exit(f'The image "{name_new}" already exists') if not image.validate_name(name_new): exit(f'The image name "{name_new}" is not allowed') presistence_storage: str = disk.find_persistence() if not presistence_storage: exit('Persistence storage cannot be found') if not ask_yes_no( f'Do you really want to rename the image {name_old} ' f'to the {name_new}?', default=False): exit() try: # replace default boot item if name_old == image.get_default_image(): grub.set_default(name_new, presistence_storage) # rename files and dirs old_path: Path = Path(f'{presistence_storage}/boot/{name_old}') new_path: Path = Path(f'{presistence_storage}/boot/{name_new}') old_path.rename(new_path) # replace boot item grub.version_del(name_old, presistence_storage) grub.version_add(name_new, presistence_storage) print(f'The image "{name_old}" was renamed to "{name_new}"') except Exception as err: exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}') def list_images() -> None: """Print list of available images for CLI hints""" images_list: list[str] = grub.version_list() for image_name in images_list: print(image_name) def parse_arguments() -> Namespace: """Parse arguments Returns: Namespace: a namespace with parsed arguments """ parser: ArgumentParser = ArgumentParser(description='Manage system images') parser.add_argument('--action', choices=['delete', 'set', 'rename', 'list'], required=True, help='action to perform with an image') parser.add_argument( '--image_name', help= 'a name of an image to add, delete, install, rename, or set as default') parser.add_argument('--image_new_name', help='a new name for image') args: Namespace = parser.parse_args() # Validate arguments - if args.action == 'delete' and not args.image_name: - exit('An image name is required for delete action') - if args.action == 'set' and not args.image_name: - exit('An image name is required for set action') if args.action == 'rename' and (not args.image_name or not args.image_new_name): exit('Both old and new image names are required for rename action') return args if __name__ == '__main__': try: args: Namespace = parse_arguments() if args.action == 'delete': delete_image(args.image_name) if args.action == 'set': set_image(args.image_name) if args.action == 'rename': rename_image(args.image_name, args.image_new_name) if args.action == 'list': list_images() exit() except KeyboardInterrupt: print('Stopped by Ctrl+C') exit() except Exception as err: exit(f'{err}')