diff --git a/qubesadmin/tools/qvm_template.py b/qubesadmin/tools/qvm_template.py index dc68ed49..87e9e915 100644 --- a/qubesadmin/tools/qvm_template.py +++ b/qubesadmin/tools/qvm_template.py @@ -21,6 +21,8 @@ import qubesadmin import qubesadmin.tools +import qubesadmin.tools.qvm_kill +import qubesadmin.tools.qvm_remove import rpm import tqdm import xdg.BaseDirectory @@ -49,9 +51,12 @@ def parser_gen() -> argparse.ArgumentParser: subparsers = parser_main.add_subparsers(dest='operation', required=True, description='Command to run.') - def parser_add_command(cmd, help_str, add_help=True): - return subparsers.add_parser(cmd, formatter_class=formatter, - help=help_str, description=help_str, add_help=add_help) + def parser_add_command(cmd, help_str): + return subparsers.add_parser( + cmd, + formatter_class=formatter, + help=help_str, + description=help_str) # qrexec/DNF related parser_main.add_argument('--repo-files', action='append', @@ -130,9 +135,14 @@ def parser_add_command(cmd, help_str, add_help=True): parser_search.add_argument('templates', nargs='*', metavar='PATTERN') # qvm-template remove parser_remove = parser_add_command('remove', - help_str='Remove installed templates.', - add_help=False) # Forward --help to qvm-remove - _ = parser_remove # unused + help_str='Remove installed templates.') + parser_remove.add_argument('--disassoc', action='store_true', + help='Also disassociate VMs from the templates to be removed.') + parser_remove.add_argument('templates', nargs='*', metavar='TEMPLATE') + # qvm-template purge + parser_purge = parser_add_command('purge', + help_str='Remove installed templates and associated VMs.') + parser_purge.add_argument('templates', nargs='*', metavar='TEMPLATE') # qvm-template clean parser_clean = parser_add_command('clean', help_str='Remove cached data.') @@ -282,6 +292,19 @@ def get_managed_template_vm(app: qubesadmin.app.QubesBase, name: str parser.error("Template '%s' is not managed by qvm-template." % name) return vm +def confirm_action(msg: str, affected: typing.List[str]) -> None: + """Confirm user action.""" + print(msg) + for name in affected: + print(' ' + name) + + confirm = '' + while confirm != 'y': + confirm = input('Are you sure? [y/N] ').lower() + if confirm == 'n': + print('Operation cancelled.') + sys.exit(1) + def qrexec_popen( args: argparse.Namespace, app: qubesadmin.app.QubesBase, @@ -816,15 +839,9 @@ def verify(rpmfile, reponame): for name in dl_list: override_tpls.append(name) - print('This will override changes made in the following VMs:', - file=sys.stderr) - for name in override_tpls: - print(' %s' % name, file=sys.stderr) - confirm = '' - while confirm != 'y': - confirm = input('Are you sure? [y/N] ').lower() - if confirm == 'n': - sys.exit(1) + confirm_action( + 'This will override changes made in the following VMs:', + override_tpls) download(args, app, path_override=args.cachedir, dl_list=dl_list, suffix=UNVERIFIED_SUFFIX, @@ -1118,21 +1135,83 @@ def gen_header(needles): print('===', cur_header, '===') print(query_res[idx].name, ':', query_res[idx].summary) -def remove(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None: +def remove( + args: argparse.Namespace, + app: qubesadmin.app.QubesBase, + disassoc: bool = False, + purge: bool = False, + dummy: str = 'dummy' + ) -> None: """Command that remove templates. :param args: Arguments received by the application. :param app: Qubes application object + :param disassoc: Whether to disassociate VMs from the templates + :param purge: Whether to remove VMs based on the templates + :param dummy: Name of dummy VM if disassoc is used """ - _ = args, app # unused - - # Remove 'remove' entry from the args... - operation_idx = sys.argv.index('remove') - argv = sys.argv[1:operation_idx] + sys.argv[operation_idx+1:] + # NOTE: While QubesArgumentParser provide similar functionality + # it does not seem to work as a parent parser + for tpl in args.templates: + if tpl not in app.domains: + parser.error("no such domain: '%s'" % tpl) + + remove_list = args.templates + if purge: + # Not disassociating first may result in dependency ordering issues + disassoc = True + # Remove recursively via BFS + remove_set = set(remove_list) # visited + idx = 0 + while idx < len(remove_list): + tpl = remove_list[idx] + idx += 1 + vm = app.domains[tpl] + for holder, prop in qubesadmin.utils.vm_dependencies(app, vm): + if holder is not None and holder.name not in remove_set: + remove_list.append(holder.name) + remove_set.add(holder.name) + + if not args.yes: + repeat = 3 if purge else 1 + for _ in range(repeat): + confirm_action( + 'This will completely remove the selected VM(s)...', + remove_list) + + if disassoc: + # Remove the dummy afterwards if we're purging + remove_dummy = purge + # Create dummy template; handle name collisions + orig_dummy = dummy + cnt = 1 + while dummy in app.domains \ + and not app.domains[dummy].features.get('template-dummy', 0): + dummy = '%s-%d' % (orig_dummy, cnt) + cnt += 1 + if dummy not in app.domains: + dummy_vm = app.add_new_vm('TemplateVM', dummy, 'red') + else: + dummy_vm = app.domains[dummy] + + for tpl in remove_list: + vm = app.domains[tpl] + for holder, prop in qubesadmin.utils.vm_dependencies(app, vm): + if holder: + setattr(holder, prop, dummy_vm) + holder.template = dummy_vm + print("Property '%s' of '%s' set to '%s'." % ( + prop, holder.name, dummy), file=sys.stderr) + else: + print("Global property '%s' set to ''." % prop, + file=sys.stderr) + setattr(app, prop, '') + if remove_dummy: + remove_list.append(dummy) - # ...then pass the args to qvm-remove - # Use exec so stdio can be shared easily - os.execvp('qvm-remove', ['qvm-remove'] + argv) + if disassoc or purge: + qubesadmin.tools.qvm_kill.main(['--'] + remove_list, app) + qubesadmin.tools.qvm_remove.main(['--force', '--'] + remove_list, app) def clean(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None: """Command that cleans the local package cache. @@ -1214,15 +1293,7 @@ def main(args: typing.Optional[typing.Sequence[str]] = None, :return: Return code of the application """ - p_args, unk_args = parser.parse_known_args(args) - if p_args.operation != 'remove' and unk_args: - p_args = parser.parse_args(args) # this should result in an error - assert False and 'This line should not be executed.' - # FIXME: Currently doing things this way as we have to forward - # arguments to qvm-remove. While argparse.REMAINDER should be able to - # solve this, there's a bug (issue 17050) that prevents it from working - # on inputs where the first argument is an option, like 'qvm-template - # remove --help'. The bug should be fixed in Python 3.9. + p_args = parser.parse_args(args) # If the user specified other repo files... if len(p_args.repo_files) > 1: @@ -1255,7 +1326,9 @@ def main(args: typing.Optional[typing.Sequence[str]] = None, elif p_args.operation == 'search': search(p_args, app) elif p_args.operation == 'remove': - remove(p_args, app) + remove(p_args, app, disassoc=p_args.disassoc) + elif p_args.operation == 'purge': + remove(p_args, app, purge=True) elif p_args.operation == 'clean': clean(p_args, app) elif p_args.operation == 'repolist':