From b76e4c808c954dcf498b510aaa9c8d6c91850991 Mon Sep 17 00:00:00 2001 From: erkin Date: Mon, 20 Nov 2023 08:01:59 +0300 Subject: op-mode: T4038: Python rewrite of image tools --- op-mode-definitions/file.xml.in | 86 +++++++++ python/vyos/remote.py | 6 +- schema/op-mode-definition.rnc | 8 +- schema/op-mode-definition.rng | 5 + scripts/build-command-op-templates | 7 + src/completion/list_images.py | 44 +++++ src/op_mode/file.py | 383 +++++++++++++++++++++++++++++++++++++ 7 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 op-mode-definitions/file.xml.in create mode 100755 src/completion/list_images.py create mode 100755 src/op_mode/file.py diff --git a/op-mode-definitions/file.xml.in b/op-mode-definitions/file.xml.in new file mode 100644 index 000000000..549b9ad92 --- /dev/null +++ b/op-mode-definitions/file.xml.in @@ -0,0 +1,86 @@ + + + + + + + Show the contents of a file, a directory or an image + + + sudo ${vyos_op_scripts_dir}/file.py --show $3 + + + + + + Copy an object + + + + + Copy a file or a directory + + + + + + Destination path + + + sudo ${vyos_op_scripts_dir}/file.py --copy $3 $5 + + + + + + + + + Delete an object + + + + + Delete a local file, possibly from an image + + + sudo ${vyos_op_scripts_dir}/file.py --delete $3 + + + + + + Clone an object + + + + + Clone a system object + + + + + Clone the current system configuration to an image + + + + + sudo ${vyos_op_scripts_dir}/file.py --clone $4 + + + + Clone system configuration from an image to another one + + running + + + + sudo ${vyos_op_scripts_dir}/file.py --clone-from $6 $4 + + + + + + + + diff --git a/python/vyos/remote.py b/python/vyos/remote.py index 4be477d24..b7c9ed176 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -50,7 +50,7 @@ class InteractivePolicy(MissingHostKeyPolicy): def missing_host_key(self, client, hostname, key): print_error(f"Host '{hostname}' not found in known hosts.") print_error('Fingerprint: ' + key.get_fingerprint().hex()) - if is_interactive() and ask_yes_no('Do you wish to continue?'): + if sys.stdin.isatty() and ask_yes_no('Do you wish to continue?'): if client._host_keys_filename\ and ask_yes_no('Do you wish to permanently add this host/key pair to known hosts?'): client._host_keys.add(hostname, key.get_name(), key) @@ -330,8 +330,10 @@ def download(local_path, urlstring, progressbar=False, check_space=False, urlc(urlstring, progressbar, check_space, source_host, source_port, timeout).download(local_path) except Exception as err: print_error(f'Unable to download "{urlstring}": {err}') + sys.exit(1) except KeyboardInterrupt: print_error('\nDownload aborted by user.') + sys.exit(1) def upload(local_path, urlstring, progressbar=False, source_host='', source_port=0, timeout=10.0): @@ -340,8 +342,10 @@ def upload(local_path, urlstring, progressbar=False, urlc(urlstring, progressbar, source_host, source_port, timeout).upload(local_path) except Exception as err: print_error(f'Unable to upload "{urlstring}": {err}') + sys.exit(1) except KeyboardInterrupt: print_error('\nUpload aborted by user.') + sys.exit(1) def get_remote_config(urlstring, source_host='', source_port=0): """ diff --git a/schema/op-mode-definition.rnc b/schema/op-mode-definition.rnc index cbe51e6dc..ad41700b9 100644 --- a/schema/op-mode-definition.rnc +++ b/schema/op-mode-definition.rnc @@ -95,13 +95,15 @@ command = element command # completionHelp tags contain information about allowed values of a node that is used for generating # tab completion in the CLI frontend and drop-down lists in GUI frontends -# It is only meaninful for leaf nodes +# It is only meaningful for leaf nodes # Allowed values can be given as a fixed list of values (e.g. foo bar baz), # as a configuration path (e.g. interfaces ethernet), -# or as a path to a script file that generates the list (e.g. +# as a path to a script file that generates the list (e.g. , +# or to enable built-in image path completion (). completionHelp = element completionHelp { (element list { text })* & (element path { text })* & - (element script { text })* + (element script { text })* & + (element imagePath { empty })? } diff --git a/schema/op-mode-definition.rng b/schema/op-mode-definition.rng index 900f41e27..a255aeb73 100644 --- a/schema/op-mode-definition.rng +++ b/schema/op-mode-definition.rng @@ -162,6 +162,11 @@ + + + + + diff --git a/scripts/build-command-op-templates b/scripts/build-command-op-templates index b008596dc..46ad634b9 100755 --- a/scripts/build-command-op-templates +++ b/scripts/build-command-op-templates @@ -100,6 +100,7 @@ def get_properties(p): scripts = c.findall("script") paths = c.findall("path") lists = c.findall("list") + comptype = c.find("imagePath") # Current backend doesn't support multiple allowed: tags # so we get to emulate it @@ -110,8 +111,12 @@ def get_properties(p): comp_exprs.append("/bin/cli-shell-api listActiveNodes {0} | sed -e \"s/'//g\" && echo".format(i.text)) for i in scripts: comp_exprs.append("{0}".format(i.text)) + if comptype is not None: + props["comp_type"] = "imagefiles" + comp_exprs.append("echo -n \"\"") comp_help = " && ".join(comp_exprs) props["comp_help"] = comp_help + except: props["comp_help"] = [] @@ -127,6 +132,8 @@ def make_node_def(props, command): help = props["help"] help = fill(help, width=64, subsequent_indent='\t\t\t') node_def += f'help: {help}\n' + if "comp_type" in props: + node_def += f'comptype: {props["comp_type"]}\n' if "comp_help" in props: node_def += f'allowed: {props["comp_help"]}\n' if command is not None: 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 . + +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/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 +# +# 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 . + +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 or ~/. +A remote path is ://. +An image path is :. + +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) -- cgit v1.2.3