Initial commit of qvm-template.
Refer to <https://gist.github.com/WillyPillow/61ee5f48b7c5b7cc90c9fd2ec5c1b20d> for previous revisions.
This commit is contained in:
		
							parent
							
								
									bab8e699d7
								
							
						
					
					
						commit
						b634c7c785
					
				
							
								
								
									
										414
									
								
								qubesadmin/tools/qvm_template.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										414
									
								
								qubesadmin/tools/qvm_template.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,414 @@ | ||||
| #!/usr/bin/env python3 | ||||
| 
 | ||||
| import argparse | ||||
| import datetime | ||||
| import os | ||||
| import shutil | ||||
| import subprocess | ||||
| import sys | ||||
| import tempfile | ||||
| import time | ||||
| 
 | ||||
| import dnf | ||||
| import qubesadmin | ||||
| import rpm | ||||
| import xdg.BaseDirectory | ||||
| 
 | ||||
| PATH_PREFIX = '/var/lib/qubes/vm-templates' | ||||
| TEMP_DIR = '/var/tmp' | ||||
| PACKAGE_NAME_PREFIX = 'qubes-template-' | ||||
| CACHE_DIR = os.path.join(xdg.BaseDirectory.xdg_cache_home, 'qvm-template') | ||||
| 
 | ||||
| def qubes_release(): | ||||
|     try: | ||||
|         os_id = subprocess.check_output(['lsb_release', '-si'], | ||||
|             encoding='UTF-8').strip() | ||||
|         if os_id == 'Qubes': | ||||
|             return subprocess.check_output(['lsb_release', '-sr'], | ||||
|                 encoding='UTF-8').strip() | ||||
|     except: | ||||
|         pass | ||||
|     with open('/usr/share/qubes/marker-vm', 'r') as f: | ||||
|         # Get last line (in the format `x.x`) | ||||
|         return f.readlines[-1].strip() | ||||
| 
 | ||||
| parser = argparse.ArgumentParser(description='Qubes Template Manager') | ||||
| parser.add_argument('operation', type=str)  | ||||
| parser.add_argument('templates', nargs='*') | ||||
| 
 | ||||
| # qrexec related | ||||
| parser.add_argument('--repo-files', action='append', | ||||
|     default=['/etc/yum.repos.d/qubes-templates.repo'], | ||||
|     help='Specify files containing DNF repository configuration.') | ||||
| parser.add_argument('--updatevm', default='sys-firewall', | ||||
|     help='Specify VM to download updates from.') | ||||
| # DNF-related options | ||||
| parser.add_argument('--enablerepo', action='append', | ||||
|     help='Enable additional repositories.') | ||||
| parser.add_argument('--disablerepo', action='append', | ||||
|     help='Disable certain repositories.') | ||||
| parser.add_argument('--repoid', action='append', | ||||
|     help='Enable just specific repositories.') | ||||
| parser.add_argument('--releasever', default=qubes_release(), | ||||
|     help='Override distro release version.') | ||||
| parser.add_argument('--cachedir', default=CACHE_DIR, | ||||
|     help='Override cache directory.') | ||||
| # qvm-template install | ||||
| parser.add_argument('--nogpgcheck', action='store_true', | ||||
|     help='Disable signature checks.') | ||||
| parser.add_argument('--allow-pv', action='store_true', | ||||
|     help='Allow setting virt_mode to pv in configuration file.') | ||||
| # qvm-template download | ||||
| parser.add_argument('--downloaddir', default='.', | ||||
|     help='Override download directory.') | ||||
| # qvm-template list | ||||
| parser.add_argument('--all', action='store_true') | ||||
| parser.add_argument('--installed', action='store_true') | ||||
| parser.add_argument('--available', action='store_true') | ||||
| parser.add_argument('--extras', action='store_true') | ||||
| parser.add_argument('--upgrades', action='store_true') | ||||
| 
 | ||||
| # NOTE: Verifying RPMs this way is prone to TOCTOU. This is okay for local | ||||
| # files, but may create problems if multiple instances of `qvm-template` are | ||||
| # downloading the same file, so a lock is needed in that case. | ||||
| def verify_rpm(path, nogpgcheck=False, transaction_set=None): | ||||
|     if transaction_set is None: | ||||
|         transaction_set = rpm.TransactionSet() | ||||
|     with open(path, 'rb') as f: | ||||
|         try: | ||||
|             hdr = transaction_set.hdrFromFdno(f) | ||||
|             if hdr[rpm.RPMTAG_SIGSIZE] is None \ | ||||
|                     and hdr[rpm.RPMTAG_SIGPGP] is None \ | ||||
|                     and hdr[rpm.RPMTAG_SIGGPG] is None: | ||||
|                 return nogpgcheck | ||||
|         except rpm.error as e: | ||||
|             if str(e) == 'public key not trusted' \ | ||||
|                     or str(e) == 'public key not available': | ||||
|                 return nogpgcheck | ||||
|             return False | ||||
|     return True | ||||
| 
 | ||||
| def get_package_hdr(path, transaction_set=None): | ||||
|     if transaction_set is None: | ||||
|         transaction_set = rpm.TransactionSet() | ||||
|     with open(path, 'rb') as f: | ||||
|         hdr = transaction_set.hdrFromFdno(f) | ||||
|         return hdr | ||||
| 
 | ||||
| def extract_rpm(name, path, target): | ||||
|     with open(path, 'rb') as in_file: | ||||
|         rpm2cpio = subprocess.Popen(['rpm2cpio', path], stdout=subprocess.PIPE) | ||||
|         # `-D` is GNUism | ||||
|         cpio = subprocess.Popen([ | ||||
|                 'cpio', | ||||
|                 '-idm', | ||||
|                 '-D', | ||||
|                 target, | ||||
|                 '.%s/%s/*' % (PATH_PREFIX, name) | ||||
|             ], stdin=rpm2cpio.stdout, stdout=subprocess.DEVNULL) | ||||
|         return rpm2cpio.wait() == 0 and cpio.wait() == 0 | ||||
| 
 | ||||
| def parse_config(path): | ||||
|     with open(path, 'r') as f: | ||||
|         return dict(line.rstrip('\n').split('=', 1) for line in f) | ||||
| 
 | ||||
| def install(args, app): | ||||
|     # TODO: Lock, mentioned in the note above | ||||
|     transaction_set = rpm.TransactionSet() | ||||
| 
 | ||||
|     rpm_list = [] | ||||
|     for template in args.templates: | ||||
|         if template.endswith('.rpm'): | ||||
|             if not os.path.exists(template): | ||||
|                 parser.error('RPM file \'%s\' not found.' % template) | ||||
|             rpm_list.append(template) | ||||
| 
 | ||||
|     os.makedirs(args.cachedir, exist_ok=True) | ||||
| 
 | ||||
|     dl_list = get_dl_list(args, app) | ||||
|     dl_list_copy = dl_list.copy() | ||||
|     # Verify that the templates are not yet installed | ||||
|     for name, ver in dl_list.items(): | ||||
|         if name in app.domains: | ||||
|             print(('Template \'%s\' already installed, skipping...' | ||||
|                 ' (You may want to use the {reinstall,upgrade,downgrade}' | ||||
|                 ' operations.)') % name, file=sys.stderr) | ||||
|             del dl_list_copy[name] | ||||
|         else: | ||||
|             version_str = build_version_str(ver) | ||||
|             target_file = \ | ||||
|                 '%s%s-%s.rpm' % (PACKAGE_NAME_PREFIX, name, version_str) | ||||
|             rpm_list.append(os.path.join(args.cachedir, target_file)) | ||||
|     dl_list = dl_list_copy | ||||
| 
 | ||||
|     download(args, app, path_override=args.cachedir, dl_list=dl_list) | ||||
| 
 | ||||
|     for rpmfile in rpm_list: | ||||
|         if not verify_rpm(rpmfile, args.nogpgcheck, transaction_set): | ||||
|             parser.error('Package \'%s\' verification failed.' % template) | ||||
| 
 | ||||
|     for rpmfile in rpm_list: | ||||
|         with tempfile.TemporaryDirectory(dir=TEMP_DIR) as target: | ||||
|             package_hdr = get_package_hdr(rpmfile) | ||||
|             package_name = package_hdr[rpm.RPMTAG_NAME] | ||||
|             if not package_name.startswith(PACKAGE_NAME_PREFIX): | ||||
|                 parser.error( | ||||
|                     'Illegal package name for package \'%s\'.' % rpmfile) | ||||
|             # Remove prefix to get the real template name | ||||
|             name = package_name[len(PACKAGE_NAME_PREFIX):] | ||||
| 
 | ||||
|             # Another check for already-downloaded RPMs | ||||
|             if name in app.domains: | ||||
|                 print(('Template \'%s\' already installed, skipping...' | ||||
|                     ' (You may want to use the {reinstall,upgrade,downgrade}' | ||||
|                     ' operations.)') % name, file=sys.stderr) | ||||
|                 continue | ||||
| 
 | ||||
|             print('Installing template \'%s\'...' % name, file=sys.stderr) | ||||
|             extract_rpm(name, rpmfile, target) | ||||
|             cmdline = [ | ||||
|                 'qvm-template-postprocess', | ||||
|                 '--keep-source', | ||||
|                 '--really', | ||||
|                 '--no-installed-by-rpm', | ||||
|             ] | ||||
|             if args.allow_pv: | ||||
|                 cmdline.append('--allow-pv') | ||||
|             subprocess.check_call(cmdline + [ | ||||
|                 'post-install', | ||||
|                 name, | ||||
|                 target + PATH_PREFIX + '/' + name]) | ||||
| 
 | ||||
|             app.domains.refresh_cache(force=True) | ||||
|             tpl = app.domains[name] | ||||
| 
 | ||||
|             tpl.features['template-epoch'] = \ | ||||
|                 package_hdr[rpm.RPMTAG_EPOCHNUM] | ||||
|             tpl.features['template-version'] = \ | ||||
|                 package_hdr[rpm.RPMTAG_VERSION] | ||||
|             tpl.features['template-release'] = \ | ||||
|                 package_hdr[rpm.RPMTAG_RELEASE] | ||||
|             tpl.features['template-install-date'] = \ | ||||
|                 str(datetime.datetime.today()) | ||||
|             tpl.features['template-name'] = name | ||||
|             # TODO: Store source repo | ||||
| 
 | ||||
| def qrexec_popen(args, app, service, stdout=subprocess.PIPE, encoding='UTF-8'): | ||||
|     if args.updatevm: | ||||
|         from_vm = shutil.which('qrexec-client-vm') is not None | ||||
|         if from_vm: | ||||
|             return subprocess.Popen( | ||||
|                 ['qrexec-client-vm', args.updatevm, service], | ||||
|                 stdin=subprocess.PIPE, | ||||
|                 stdout=stdout, | ||||
|                 stderr=subprocess.PIPE, | ||||
|                 encoding=encoding) | ||||
|         else: | ||||
|             return subprocess.Popen([ | ||||
|                     'qrexec-client', | ||||
|                     '-d', | ||||
|                     args.updatevm, | ||||
|                     'user:/etc/qubes-rpc/%s' % service | ||||
|                 ], | ||||
|                 stdin=subprocess.PIPE, | ||||
|                 stdout=stdout, | ||||
|                 stderr=subprocess.PIPE, | ||||
|                 encoding=encoding) | ||||
|     else: | ||||
|         return subprocess.Popen([ | ||||
|                 '/etc/qubes-rpc/%s' % service, | ||||
|             ], | ||||
|             stdin=subprocess.PIPE, | ||||
|             stdout=stdout, | ||||
|             stderr=subprocess.PIPE, | ||||
|             encoding=encoding) | ||||
| 
 | ||||
| def qrexec_payload(args, app, spec): | ||||
|     payload = '' | ||||
|     for r in args.enablerepo if args.enablerepo else []: | ||||
|         payload += '--enablerepo=%s\n' % r | ||||
|     for r in args.disablerepo if args.disablerepo else []: | ||||
|         payload += '--disablerepo=%s\n' % r | ||||
|     for r in args.repoid if args.repoid else []: | ||||
|         payload += '--repoid=%s\n' % r | ||||
|     payload += '--releasever=%s\n' % args.releasever | ||||
|     payload += spec + '\n' | ||||
|     payload += '---\n' | ||||
|     for fn in args.repo_files: | ||||
|         with open(fn, 'r') as f: | ||||
|             payload += f.read() + '\n' | ||||
|     return payload | ||||
| 
 | ||||
| def qrexec_repoquery(args, app, spec='*'): | ||||
|     proc = qrexec_popen(args, app, 'qubes.TemplateSearch') | ||||
|     payload = qrexec_payload(args, app, spec) | ||||
|     stdout, stderr = proc.communicate(payload) | ||||
|     if proc.wait() != 0: | ||||
|         return None | ||||
|     result = [] | ||||
|     for line in stdout.strip().split('\n'): | ||||
|         entry = line.split(':') | ||||
|         if not entry[0].startswith(PACKAGE_NAME_PREFIX): | ||||
|             continue | ||||
|         entry[0] = entry[0][len(PACKAGE_NAME_PREFIX):] | ||||
|         result.append(entry) | ||||
|     return result | ||||
| 
 | ||||
| def qrexec_download(args, app, spec, path): | ||||
|     with open(path, 'wb') as f: | ||||
|         proc = qrexec_popen(args, app, 'qubes.TemplateDownload', | ||||
|             stdout=f, encoding=None) | ||||
|         payload = qrexec_payload(args, app, spec) | ||||
|         proc.stdin.write(payload.encode('UTF-8')) | ||||
|         proc.stdin.close() | ||||
|         while True: | ||||
|             c = proc.stderr.read(1) | ||||
|             if not c: | ||||
|                 break | ||||
|             # Write raw byte w/o decoding | ||||
|             sys.stdout.buffer.write(c) | ||||
|             sys.stdout.flush() | ||||
|         if proc.wait() != 0: | ||||
|             return False | ||||
|         return True | ||||
| 
 | ||||
| def build_version_str(evr): | ||||
|     return '%s:%s-%s' % evr | ||||
| 
 | ||||
| def pretty_print_table(table): | ||||
|     if len(table) != 0: | ||||
|         widths = [] | ||||
|         for i in range(len(table[0])): | ||||
|             widths.append(max(len(s[i]) for s in table)) | ||||
|         for row in sorted(table): | ||||
|             cols = ['{key:{width}s}'.format( | ||||
|                 key=row[i], width=widths[i]) for i in range(len(row))] | ||||
|             print(' '.join(cols)) | ||||
| 
 | ||||
| def do_list(args, app): | ||||
|     # TODO: Check local template name actually matches to account for renames | ||||
|     # TODO: Also display repo like `dnf list` | ||||
|     tpl_list = [] | ||||
| 
 | ||||
|     if not (args.installed or args.available or args.extras or args.upgrades): | ||||
|         args.all = True | ||||
| 
 | ||||
|     if args.installed or args.all: | ||||
|         for vm in app.domains: | ||||
|             if 'template-install-date' in vm.features: | ||||
|                 version_str = build_version_str(( | ||||
|                     vm.features['template-epoch'], | ||||
|                     vm.features['template-version'], | ||||
|                     vm.features['template-release'])) | ||||
|                 tpl_list.append((vm.name, version_str)) | ||||
| 
 | ||||
|     if args.available or args.all: | ||||
|         query_res = qrexec_repoquery(args, app) | ||||
|         for name, epoch, version, release, reponame, dlsize, summary \ | ||||
|                 in query_res: | ||||
|             version_str = build_version_str((epoch, version, release)) | ||||
|             tpl_list.append((name, version_str)) | ||||
| 
 | ||||
|     if args.extras: | ||||
|         query_res = qrexec_repoquery(args, app) | ||||
|         remote = set() | ||||
|         for name, epoch, version, release, reponame, dlsize, summary \ | ||||
|                 in query_res: | ||||
|             remote.add(name) | ||||
|         for vm in app.domains: | ||||
|             if 'template-name' in vm.features and \ | ||||
|                     vm.features['template-name'] not in remote: | ||||
|                 version_str = build_version_str(( | ||||
|                     vm.features['template-epoch'], | ||||
|                     vm.features['template-version'], | ||||
|                     vm.features['template-release'])) | ||||
|                 tpl_list.append((vm.name, version_str)) | ||||
| 
 | ||||
|     if args.upgrades: | ||||
|         query_res = qrexec_repoquery(args, app) | ||||
|         local = {} | ||||
|         for vm in app.domains: | ||||
|             if 'template-name' in vm.features: | ||||
|                 local[vm.features['template-name']] = ( | ||||
|                     vm.features['template-epoch'], | ||||
|                     vm.features['template-version'], | ||||
|                     vm.features['template-release']) | ||||
|         for name, epoch, version, release, reponame, dlsize, summary \ | ||||
|                 in query_res: | ||||
|             if name in local: | ||||
|                 if rpm.labelCompare(local[name], (epoch, version, release)) < 0: | ||||
|                     version_str = build_version_str((epoch, version, release)) | ||||
|                     tpl_list.append((name, version_str)) | ||||
| 
 | ||||
|     pretty_print_table(tpl_list) | ||||
| 
 | ||||
| def get_dl_list(args, app): | ||||
|     candid = {} | ||||
|     for template in args.templates: | ||||
|         # Skip local RPMs | ||||
|         if template.endswith('.rpm'): | ||||
|             continue | ||||
|         query_res = qrexec_repoquery(args, app, PACKAGE_NAME_PREFIX + template) | ||||
|         if len(query_res) == 0: | ||||
|             parser.error('Package \'%s\' not found.' % template) | ||||
|             # TODO: Better error handling | ||||
|             sys.exit(1) | ||||
|         # We only select one (latest) package for each distinct package name | ||||
|         for name, epoch, version, release, reponame, dlsize, summary \ | ||||
|                 in query_res: | ||||
|             ver = (epoch, version, release) | ||||
|             if name not in candid or rpm.labelCompare(candid[name], ver) < 0: | ||||
|                 candid[name] = ver | ||||
|     return candid | ||||
| 
 | ||||
| def download(args, app, path_override=None, dl_list=None): | ||||
|     if dl_list is None: | ||||
|         dl_list = get_dl_list(args, app) | ||||
| 
 | ||||
|     path = path_override if path_override != None else args.downloaddir | ||||
|     for name, ver in dl_list.items(): | ||||
|         version_str = build_version_str(ver) | ||||
|         spec = PACKAGE_NAME_PREFIX + name + '-' + version_str | ||||
|         target = os.path.join(path, '%s.rpm' % spec) | ||||
|         if os.path.exists(target): | ||||
|             print('\'%s\' already exists, skipping...' % target, | ||||
|                 file=sys.stderr) | ||||
|         else: | ||||
|             print('Downloading \'%s\'...' % spec, file=sys.stderr) | ||||
|             ret = qrexec_download(args, app, spec, target) | ||||
|             if not ret: | ||||
|                 # TODO: Retry? | ||||
|                 print('\'%s\' download failed.' % spec, file=sys.stderr) | ||||
|                 sys.exit(1) | ||||
| 
 | ||||
| def remove(args, app): | ||||
|     # Use exec so stdio can be shared easily | ||||
|     os.execvp('qvm-remove', ['qvm-remove'] + args.templates) | ||||
| 
 | ||||
| def clean(args, app): | ||||
|     # TODO: More fine-grained options | ||||
|     shutil.rmtree(args.cachedir) | ||||
| 
 | ||||
| def main(args=None, app=None): | ||||
|     args = parser.parse_args(args) | ||||
| 
 | ||||
|     if app is None: | ||||
|         app = qubesadmin.Qubes() | ||||
| 
 | ||||
|     if args.operation == 'install': | ||||
|        install(args, app)  | ||||
|     elif args.operation == 'list': | ||||
|        do_list(args, app) | ||||
|     elif args.operation == 'download': | ||||
|        download(args, app) | ||||
|     elif args.operation == 'remove': | ||||
|         remove(args, app) | ||||
|     elif args.operation == 'clean': | ||||
|         clean(args, app) | ||||
|     else: | ||||
|        parser.error('Operation \'%s\' not supported.' % args.operation) | ||||
| 
 | ||||
|     return 0 | ||||
| 
 | ||||
| if __name__ == '__main__': | ||||
|     sys.exit(main()) | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 WillyPillow
						WillyPillow