qvm_template.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428
  1. #!/usr/bin/env python3
  2. '''Tool for managing VM templates.'''
  3. import argparse
  4. import collections
  5. import datetime
  6. import enum
  7. import fnmatch
  8. import functools
  9. import itertools
  10. import json
  11. import operator
  12. import os
  13. import re
  14. import shutil
  15. import subprocess
  16. import sys
  17. import tempfile
  18. import time
  19. import typing
  20. import rpm
  21. import tqdm
  22. import xdg.BaseDirectory
  23. import qubesadmin
  24. import qubesadmin.tools
  25. import qubesadmin.tools.qvm_kill
  26. import qubesadmin.tools.qvm_remove
  27. PATH_PREFIX = '/var/lib/qubes/vm-templates'
  28. TEMP_DIR = '/var/tmp'
  29. PACKAGE_NAME_PREFIX = 'qubes-template-'
  30. CACHE_DIR = os.path.join(xdg.BaseDirectory.xdg_cache_home, 'qvm-template')
  31. UNVERIFIED_SUFFIX = '.unverified'
  32. LOCK_FILE = '/var/tmp/qvm-template.lck'
  33. DATE_FMT = '%Y-%m-%d %H:%M:%S'
  34. UPDATEVM = str('global UpdateVM')
  35. def qubes_release() -> str:
  36. """Return the Qubes release."""
  37. if os.path.exists('/usr/share/qubes/marker-vm'):
  38. with open('/usr/share/qubes/marker-vm', 'r') as fd:
  39. # Get last line (in the format `x.x`)
  40. return fd.readlines()[-1].strip()
  41. with open('/etc/os-release', 'r') as fd:
  42. for line in fd:
  43. line = line.strip()
  44. if not line or line[0] == '#':
  45. continue
  46. key, val = line.split('=', 1)
  47. if key != 'VERSION_ID':
  48. continue
  49. val = val.strip('\'"') # strip possible quotes
  50. return val
  51. # Return default value instead of throwing so that it works on CI
  52. return '4.1'
  53. def parser_gen() -> argparse.ArgumentParser:
  54. """Generate argument parser for the application."""
  55. formatter = argparse.ArgumentDefaultsHelpFormatter
  56. parser_main = argparse.ArgumentParser(description='Qubes Template Manager',
  57. formatter_class=formatter)
  58. subparsers = parser_main.add_subparsers(dest='operation',
  59. description='Command to run.')
  60. def parser_add_command(cmd, help_str):
  61. return subparsers.add_parser(
  62. cmd,
  63. formatter_class=formatter,
  64. help=help_str,
  65. description=help_str)
  66. parser_main.add_argument('--repo-files', action='append',
  67. default=['/usr/share/qubes/repo-templates/qubes-templates.repo'],
  68. help=('Specify files containing DNF repository configuration.'
  69. ' Can be used more than once.'))
  70. parser_main.add_argument('--keyring',
  71. default='/usr/share/qubes/repo-templates/keys',
  72. help='Specify directory containing RPM public keys.')
  73. parser_main.add_argument('--updatevm', default=UPDATEVM,
  74. help=('Specify VM to download updates from.'
  75. ' (Set to empty string to specify the current VM.)'))
  76. parser_main.add_argument('--enablerepo', action='append', default=[],
  77. metavar='REPOID',
  78. help=('Enable additional repositories by an id or a glob.'
  79. ' Can be used more than once.'))
  80. parser_main.add_argument('--disablerepo', action='append', default=[],
  81. metavar='REPOID',
  82. help=('Disable certain repositories by an id or a glob.'
  83. ' Can be used more than once.'))
  84. parser_main.add_argument('--repoid', action='append', default=[],
  85. help=('Enable just specific repositories by an id or a glob.'
  86. ' Can be used more than once.'))
  87. parser_main.add_argument('--releasever', default=qubes_release(),
  88. help='Override Qubes release version.')
  89. parser_main.add_argument('--refresh', action='store_true',
  90. help='Set repository metadata as expired before running the command.')
  91. parser_main.add_argument('--cachedir', default=CACHE_DIR,
  92. help='Specify cache directory.')
  93. parser_main.add_argument('--yes', action='store_true',
  94. help='Assume "yes" to questions.')
  95. parser_main.add_argument('--quiet', action='store_true',
  96. help='Decrease verbosity.')
  97. # qvm-template {install,reinstall,downgrade,upgrade}
  98. parser_install = parser_add_command('install',
  99. help_str='Install template packages.')
  100. parser_install.add_argument('--pool',
  101. help='Specify pool to store created VMs in.')
  102. parser_reinstall = parser_add_command('reinstall',
  103. help_str='Reinstall template packages.')
  104. parser_downgrade = parser_add_command('downgrade',
  105. help_str='Downgrade template packages.')
  106. parser_upgrade = parser_add_command('upgrade',
  107. help_str='Upgrade template packages.')
  108. for parser_x in [parser_install, parser_reinstall,
  109. parser_downgrade, parser_upgrade]:
  110. parser_x.add_argument('--nogpgcheck', action='store_true',
  111. help='Disable signature checks.')
  112. parser_x.add_argument('--allow-pv', action='store_true',
  113. help='Allow templates that set virt_mode to pv.')
  114. parser_x.add_argument('templates', nargs='*', metavar='TEMPLATESPEC')
  115. # qvm-template download
  116. parser_download = parser_add_command('download',
  117. help_str='Download template packages.')
  118. for parser_x in [parser_install, parser_reinstall,
  119. parser_downgrade, parser_upgrade, parser_download]:
  120. parser_x.add_argument('--downloaddir', default='.',
  121. help='Specify download directory.')
  122. parser_x.add_argument('--retries', default=5, type=int,
  123. help='Specify maximum number of retries for downloads.')
  124. parser_download.add_argument('templates', nargs='*',
  125. metavar='TEMPLATESPEC')
  126. # qvm-template {list,info}
  127. parser_list = parser_add_command('list',
  128. help_str='List templates.')
  129. parser_info = parser_add_command('info',
  130. help_str='Display details about templates.')
  131. for parser_x in [parser_list, parser_info]:
  132. parser_x.add_argument('--all', action='store_true',
  133. help='Show all templates (default).')
  134. parser_x.add_argument('--installed', action='store_true',
  135. help='Show installed templates.')
  136. parser_x.add_argument('--available', action='store_true',
  137. help='Show available templates.')
  138. parser_x.add_argument('--extras', action='store_true',
  139. help=('Show extras (e.g., ones that exist'
  140. ' locally but not in repos) templates.'))
  141. parser_x.add_argument('--upgrades', action='store_true',
  142. help='Show available upgrades.')
  143. readable = parser_x.add_mutually_exclusive_group()
  144. readable.add_argument('--machine-readable', action='store_true',
  145. help='Enable machine-readable output.')
  146. readable.add_argument('--machine-readable-json', action='store_true',
  147. help='Enable machine-readable output (JSON).')
  148. parser_x.add_argument('templates', nargs='*', metavar='TEMPLATESPEC')
  149. # qvm-template search
  150. parser_search = parser_add_command('search',
  151. help_str='Search template details for the given string.')
  152. parser_search.add_argument('--all', action='store_true',
  153. help=('Search also in the template description and URL. In addition,'
  154. ' the criterion are evaluated with OR instead of AND.'))
  155. parser_search.add_argument('templates', nargs='*', metavar='PATTERN')
  156. # qvm-template remove
  157. parser_remove = parser_add_command('remove',
  158. help_str='Remove installed templates.')
  159. parser_remove.add_argument('--disassoc', action='store_true',
  160. help=('Also disassociate VMs from the templates to be removed.'
  161. ' This creates a dummy template for the VMs to link with.'))
  162. parser_remove.add_argument('templates', nargs='*', metavar='TEMPLATE')
  163. # qvm-template purge
  164. parser_purge = parser_add_command('purge',
  165. help_str='Remove installed templates and associated VMs.')
  166. parser_purge.add_argument('templates', nargs='*', metavar='TEMPLATE')
  167. # qvm-template clean
  168. parser_clean = parser_add_command('clean',
  169. help_str='Remove locally cached packages.')
  170. _ = parser_clean # unused
  171. # qvm-template repolist
  172. parser_repolist = parser_add_command('repolist',
  173. help_str='Show configured repositories.')
  174. repolim = parser_repolist.add_mutually_exclusive_group()
  175. repolim.add_argument('--all', action='store_true',
  176. help='Show all repos.')
  177. repolim.add_argument('--enabled', action='store_true',
  178. help='Show only enabled repos (default).')
  179. repolim.add_argument('--disabled', action='store_true',
  180. help='Show only disabled repos.')
  181. parser_repolist.add_argument('repos', nargs='*', metavar='REPOS')
  182. return parser_main
  183. parser = parser_gen()
  184. class TemplateState(enum.Enum):
  185. """Enum representing the state of a template."""
  186. INSTALLED = 'installed'
  187. AVAILABLE = 'available'
  188. EXTRA = 'extra'
  189. UPGRADABLE = 'upgradable'
  190. def title(self) -> str:
  191. """Return a long description of the state. Can be used as headings."""
  192. #pylint: disable=invalid-name
  193. TEMPLATE_TITLES = {
  194. TemplateState.INSTALLED: 'Installed Templates',
  195. TemplateState.AVAILABLE: 'Available Templates',
  196. TemplateState.EXTRA: 'Extra Templates',
  197. TemplateState.UPGRADABLE: 'Available Upgrades'
  198. }
  199. return TEMPLATE_TITLES[self]
  200. class VersionSelector(enum.Enum):
  201. """Enum representing how the candidate template version is chosen."""
  202. LATEST = enum.auto()
  203. """Install latest version."""
  204. REINSTALL = enum.auto()
  205. """Reinstall current version."""
  206. LATEST_LOWER = enum.auto()
  207. """Downgrade to the highest version that is lower than the current one."""
  208. LATEST_HIGHER = enum.auto()
  209. """Upgrade to the highest version that is higher than the current one."""
  210. class Template(typing.NamedTuple):
  211. """Details of a template."""
  212. name: str
  213. epoch: str
  214. version: str
  215. release: str
  216. reponame: str
  217. dlsize: int
  218. buildtime: datetime.datetime
  219. licence: str
  220. url: str
  221. summary: str
  222. description: str
  223. class DlEntry(typing.NamedTuple):
  224. """Information about a template to be downloaded."""
  225. evr: typing.Tuple[str, str, str]
  226. reponame: str
  227. dlsize: int
  228. def build_version_str(evr: typing.Tuple[str, str, str]) -> str:
  229. """Return version string described by ``evr``, which is in (epoch, version,
  230. release) format."""
  231. return '%s:%s-%s' % evr
  232. def is_match_spec(name: str, epoch: str, version: str, release: str, spec: str
  233. ) -> typing.Tuple[bool, float]:
  234. """Check whether (name, epoch, version, release) matches the spec string.
  235. For the algorithm, refer to section "NEVRA Matching" in the DNF
  236. documentation.
  237. Note that currently ``arch`` is ignored as the templates should be of
  238. ``noarch``.
  239. :return: A tuple. The first element indicates whether there is a match; the
  240. second element represents the priority of the match (lower is better)
  241. """
  242. if epoch != '0':
  243. targets = [
  244. f'{name}-{epoch}:{version}-{release}',
  245. f'{name}',
  246. f'{name}-{epoch}:{version}'
  247. ]
  248. else:
  249. targets = [
  250. f'{name}-{epoch}:{version}-{release}',
  251. f'{name}-{version}-{release}',
  252. f'{name}',
  253. f'{name}-{epoch}:{version}',
  254. f'{name}-{version}'
  255. ]
  256. for prio, target in enumerate(targets):
  257. if fnmatch.fnmatch(target, spec):
  258. return True, prio
  259. return False, float('inf')
  260. def query_local(vm: qubesadmin.vm.QubesVM) -> Template:
  261. """Return Template object associated with ``vm``.
  262. Requires the VM to be managed by qvm-template.
  263. """
  264. return Template(
  265. vm.features['template-name'],
  266. vm.features['template-epoch'],
  267. vm.features['template-version'],
  268. vm.features['template-release'],
  269. vm.features['template-reponame'],
  270. vm.get_disk_utilization(),
  271. datetime.datetime.strptime(vm.features['template-buildtime'], DATE_FMT),
  272. vm.features['template-license'],
  273. vm.features['template-url'],
  274. vm.features['template-summary'],
  275. vm.features['template-description'].replace('|', '\n'))
  276. def query_local_evr(vm: qubesadmin.vm.QubesVM) -> typing.Tuple[str, str, str]:
  277. """Return the (epoch, version, release) of ``vm``.
  278. Requires the VM to be managed by qvm-template.
  279. """
  280. return (
  281. vm.features['template-epoch'],
  282. vm.features['template-version'],
  283. vm.features['template-release'])
  284. def is_managed_template(vm: qubesadmin.vm.QubesVM) -> bool:
  285. """Return whether the VM is managed by qvm-template."""
  286. return vm.features.get('template-name', None) == vm.name
  287. def get_managed_template_vm(app: qubesadmin.app.QubesBase, name: str
  288. ) -> qubesadmin.vm.QubesVM:
  289. """Return the QubesVM object associated with the given name if it exists
  290. and is managed by qvm-template, otherwise raise a parser error."""
  291. if name not in app.domains:
  292. parser.error("Template '%s' not already installed." % name)
  293. vm = app.domains[name]
  294. if not is_managed_template(vm):
  295. parser.error("Template '%s' is not managed by qvm-template." % name)
  296. return vm
  297. def confirm_action(msg: str, affected: typing.List[str]) -> None:
  298. """Confirm user action."""
  299. print(msg)
  300. for name in affected:
  301. print(' ' + name)
  302. confirm = ''
  303. while confirm != 'y':
  304. confirm = input('Are you sure? [y/N] ').lower()
  305. if confirm == 'n':
  306. print('Operation cancelled.')
  307. sys.exit(1)
  308. def qrexec_popen(
  309. args: argparse.Namespace,
  310. app: qubesadmin.app.QubesBase,
  311. service: str,
  312. stdout: typing.Union[int, typing.IO] = subprocess.PIPE,
  313. filter_esc: bool = True) -> subprocess.Popen:
  314. """Return ``Popen`` object that communicates with the given qrexec call in
  315. ``args.updatevm``.
  316. Note that this falls back to invoking ``/etc/qubes-rpc/*`` directly if
  317. ``args.updatevm`` is empty string.
  318. :param args: Arguments received by the application. ``args.updatevm`` is
  319. used
  320. :param app: Qubes application object
  321. :param service: The qrexec call to invoke
  322. :param stdout: Where the process stdout points to. This is passed directly
  323. to ``subprocess.Popen``. Defaults to ``subprocess.PIPE``
  324. Note that stderr is always set to ``subprocess.PIPE``
  325. :param filter_esc: Whether to filter out escape sequences from
  326. stdout/stderr. Defaults to True
  327. :returns: ``Popen`` object that communicates with the given qrexec call
  328. """
  329. if args.updatevm:
  330. return app.domains[args.updatevm].run_service(
  331. service,
  332. filter_esc=filter_esc,
  333. stdout=stdout)
  334. return subprocess.Popen([
  335. '/etc/qubes-rpc/%s' % service,
  336. ],
  337. stdin=subprocess.PIPE,
  338. stdout=stdout,
  339. stderr=subprocess.PIPE)
  340. def qrexec_payload(args: argparse.Namespace, app: qubesadmin.app.QubesBase,
  341. spec: str, refresh: bool) -> str:
  342. """Return payload string for the ``qubes.Template*`` qrexec calls.
  343. :param args: Arguments received by the application. Specifically,
  344. ``args.{enablerepo,disablerepo,repoid,releasever,repo_files}`` are used
  345. :param app: Qubes application object
  346. :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
  347. DNF documentation)
  348. :param refresh: Whether to force refresh repo metadata
  349. :return: Payload string
  350. :raises: Parser error if spec equals ``---`` or input contains ``\\n``
  351. """
  352. _ = app # unused
  353. if spec == '---':
  354. parser.error("Malformed template name: argument should not be '---'.")
  355. def check_newline(string, name):
  356. if '\n' in string:
  357. parser.error(f"Malformed {name}:" +
  358. " argument should not contain '\\n'.")
  359. payload = ''
  360. for repo in args.enablerepo:
  361. check_newline(repo, '--enablerepo')
  362. payload += '--enablerepo=%s\n' % repo
  363. for repo in args.disablerepo:
  364. check_newline(repo, '--disablerepo')
  365. payload += '--disablerepo=%s\n' % repo
  366. for repo in args.repoid:
  367. check_newline(repo, '--repoid')
  368. payload += '--repoid=%s\n' % repo
  369. if refresh:
  370. payload += '--refresh\n'
  371. check_newline(args.releasever, '--releasever')
  372. payload += '--releasever=%s\n' % args.releasever
  373. check_newline(spec, 'template name')
  374. payload += spec + '\n'
  375. payload += '---\n'
  376. for path in args.repo_files:
  377. with open(path, 'r') as fd:
  378. payload += fd.read() + '\n'
  379. return payload
  380. def qrexec_repoquery(
  381. args: argparse.Namespace,
  382. app: qubesadmin.app.QubesBase,
  383. spec: str = '*',
  384. refresh: bool = False) -> typing.List[Template]:
  385. """Query template information from repositories.
  386. :param args: Arguments received by the application. Specifically,
  387. ``args.{enablerepo,disablerepo,repoid,releasever,repo_files,updatevm}``
  388. are used
  389. :param app: Qubes application object
  390. :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
  391. DNF documentation). Defaults to ``*``
  392. :param refresh: Whether to force refresh repo metadata. Defaults to False
  393. :raises ConnectionError: if the qrexec call fails
  394. :return: List of ``Template`` objects representing the result of the query
  395. """
  396. payload = qrexec_payload(args, app, spec, refresh)
  397. proc = qrexec_popen(args, app, 'qubes.TemplateSearch')
  398. stdout, stderr = proc.communicate(payload.encode('UTF-8'))
  399. stdout = stdout.decode('ASCII')
  400. if proc.wait() != 0:
  401. for line in stderr.decode('ASCII').rstrip().split('\n'):
  402. print('[Qrexec] %s' % line, file=sys.stderr)
  403. raise ConnectionError("qrexec call 'qubes.TemplateSearch' failed.")
  404. name_re = re.compile(r'^[A-Za-z0-9._+\-]*$')
  405. evr_re = re.compile(r'^[A-Za-z0-9._+~]*$')
  406. date_re = re.compile(r'^\d+-\d+-\d+ \d+:\d+$')
  407. licence_re = re.compile(r'^[A-Za-z0-9._+\-()]*$')
  408. result = []
  409. # FIXME: This breaks when \n is the first character of the description
  410. for line in stdout.split('|\n'):
  411. # Note that there's an empty entry at the end as .strip() is not used.
  412. # This is because if .strip() is used, the .split() will not work.
  413. if line == '':
  414. continue
  415. entry = line.split('|')
  416. try:
  417. # If there is an incorrect number of entries, raise an error
  418. # Unpack manually instead of stuffing into `Template` right away
  419. # so that it's easier to mutate stuff.
  420. name, epoch, version, release, reponame, dlsize, \
  421. buildtime, licence, url, summary, description = entry
  422. # Ignore packages that are not templates
  423. if not name.startswith(PACKAGE_NAME_PREFIX):
  424. continue
  425. name = name[len(PACKAGE_NAME_PREFIX):]
  426. # Check that the values make sense
  427. if not re.fullmatch(name_re, name):
  428. raise ValueError
  429. for val in [epoch, version, release]:
  430. if not re.fullmatch(evr_re, val):
  431. raise ValueError
  432. if not re.fullmatch(name_re, reponame):
  433. raise ValueError
  434. dlsize = int(dlsize)
  435. # First verify that the date does not look weird, then parse it
  436. if not re.fullmatch(date_re, buildtime):
  437. raise ValueError
  438. buildtime = datetime.datetime.strptime(buildtime, '%Y-%m-%d %H:%M')
  439. # XXX: Perhaps whitelist licenses directly?
  440. if not re.fullmatch(licence_re, licence):
  441. raise ValueError
  442. # Check name actually matches spec
  443. if not is_match_spec(PACKAGE_NAME_PREFIX + name,
  444. epoch, version, release, spec)[0]:
  445. continue
  446. result.append(Template(name, epoch, version, release, reponame,
  447. dlsize, buildtime, licence, url, summary, description))
  448. except (TypeError, ValueError):
  449. raise ConnectionError(("qrexec call 'qubes.TemplateSearch' failed:"
  450. " unexpected data format."))
  451. return result
  452. def qrexec_download(
  453. args: argparse.Namespace,
  454. app: qubesadmin.app.QubesBase,
  455. spec: str,
  456. path: str,
  457. dlsize: typing.Optional[int] = None,
  458. refresh: bool = False) -> None:
  459. """Download a template from repositories.
  460. :param args: Arguments received by the application. Specifically,
  461. ``args.{enablerepo,disablerepo,repoid,releasever,repo_files,updatevm,
  462. quiet}`` are used
  463. :param app: Qubes application object
  464. :param spec: Package spec to query (refer to ``<package-name-spec>`` in the
  465. DNF documentation)
  466. :param path: Path to place the downloaded template
  467. :param dlsize: Size of template to be downloaded. Used for the progress
  468. bar. Optional
  469. :param refresh: Whether to force refresh repo metadata. Defaults to False
  470. :raises ConnectionError: if the qrexec call fails
  471. """
  472. with open(path, 'wb') as fd:
  473. payload = qrexec_payload(args, app, spec, refresh)
  474. # Don't filter ESCs for binary files
  475. proc = qrexec_popen(args, app, 'qubes.TemplateDownload',
  476. stdout=fd, filter_esc=False)
  477. proc.stdin.write(payload.encode('UTF-8'))
  478. proc.stdin.close()
  479. with tqdm.tqdm(desc=spec, total=dlsize, unit_scale=True,
  480. unit_divisor=1000, unit='B', disable=args.quiet) as pbar:
  481. last = 0
  482. while proc.poll() is None:
  483. cur = fd.tell()
  484. pbar.update(cur - last)
  485. last = cur
  486. time.sleep(0.1)
  487. if proc.wait() != 0:
  488. raise ConnectionError(
  489. "qrexec call 'qubes.TemplateDownload' failed.")
  490. def rpm_transactionset(key_dir: str) -> rpm.transaction.TransactionSet:
  491. """Create RPM TransactionSet using the keys in the given directory."""
  492. tset = rpm.TransactionSet()
  493. kring = rpm.keyring()
  494. for name in os.listdir(key_dir):
  495. path = os.path.join(key_dir, name)
  496. if os.path.isfile(path):
  497. with open(path, 'rb') as fd:
  498. kring.addKey(rpm.pubkey(fd.read()))
  499. tset.setKeyring(kring)
  500. return tset
  501. def verify_rpm(
  502. path: str,
  503. transaction_set: rpm.transaction.TransactionSet,
  504. nogpgcheck: bool = False
  505. ) -> rpm.hdr:
  506. """Verify the digest and signature of a RPM package and return the package
  507. header.
  508. Note that verifying RPMs this way is prone to TOCTOU. This is okay for
  509. local files, but may create problems if multiple instances of
  510. **qvm-template** are downloading the same file, so a lock is needed in that
  511. case.
  512. :param path: Location of the RPM package
  513. :param transaction_set: RPM ``TransactionSet``
  514. :param nogpgcheck: Whether to allow invalid GPG signatures
  515. :return: RPM package header. If verification fails, ``None`` is returned.
  516. """
  517. with open(path, 'rb') as fd:
  518. try:
  519. hdr = transaction_set.hdrFromFdno(fd)
  520. if hdr[rpm.RPMTAG_SIGPGP] is None \
  521. and hdr[rpm.RPMTAG_SIGGPG] is None:
  522. return hdr if nogpgcheck else None
  523. except rpm.error as e:
  524. if str(e) == 'public key not trusted' \
  525. or str(e) == 'public key not available':
  526. # FIXME: This does not work
  527. # Should just tell TransactionSet not to verify sigs
  528. return hdr if nogpgcheck else None
  529. return None
  530. return hdr
  531. def extract_rpm(name: str, path: str, target: str) -> bool:
  532. """Extract a template RPM package.
  533. :param name: Name of the template
  534. :param path: Location of the RPM package
  535. :param target: Target path to extract to
  536. :return: Whether the extraction succeeded
  537. """
  538. rpm2cpio = subprocess.Popen(['rpm2cpio', path], stdout=subprocess.PIPE)
  539. # `-D` is GNUism
  540. cpio = subprocess.Popen([
  541. 'cpio',
  542. '-idm',
  543. '-D',
  544. target,
  545. '.%s/%s/*' % (PATH_PREFIX, name)
  546. ], stdin=rpm2cpio.stdout, stdout=subprocess.DEVNULL)
  547. return rpm2cpio.wait() == 0 and cpio.wait() == 0
  548. def get_dl_list(
  549. args: argparse.Namespace,
  550. app: qubesadmin.app.QubesBase,
  551. version_selector: VersionSelector = VersionSelector.LATEST
  552. ) -> typing.Dict[str, DlEntry]:
  553. """Return list of templates that needs to be downloaded.
  554. :param args: Arguments received by the application.
  555. :param app: Qubes application object
  556. :param version_selector: Specify algorithm to select the candidate version
  557. of a package. Defaults to ``VersionSelector.LATEST``
  558. :return: Dictionary that maps to ``DlEntry`` the names of templates that
  559. needs to be downloaded
  560. """
  561. full_candid: typing.Dict[str, DlEntry] = {}
  562. for template in args.templates:
  563. # This will be merged into `full_candid` later.
  564. # It is separated so that we can check whether it is empty.
  565. candid: typing.Dict[str, DlEntry] = {}
  566. # Skip local RPMs
  567. if template.endswith('.rpm'):
  568. continue
  569. query_res = qrexec_repoquery(args, app, PACKAGE_NAME_PREFIX + template)
  570. # We only select one package for each distinct package name
  571. for entry in query_res:
  572. ver = (entry.epoch, entry.version, entry.release)
  573. insert = False
  574. if version_selector == VersionSelector.LATEST:
  575. if entry.name not in candid \
  576. or rpm.labelCompare(candid[entry.name][0], ver) < 0:
  577. insert = True
  578. elif version_selector == VersionSelector.REINSTALL:
  579. vm = get_managed_template_vm(app, entry.name)
  580. cur_ver = query_local_evr(vm)
  581. if rpm.labelCompare(ver, cur_ver) == 0:
  582. insert = True
  583. elif version_selector in [VersionSelector.LATEST_LOWER,
  584. VersionSelector.LATEST_HIGHER]:
  585. vm = get_managed_template_vm(app, entry.name)
  586. cur_ver = query_local_evr(vm)
  587. cmp_res = -1 \
  588. if version_selector == VersionSelector.LATEST_LOWER \
  589. else 1
  590. if rpm.labelCompare(ver, cur_ver) == cmp_res:
  591. if entry.name not in candid \
  592. or rpm.labelCompare(candid[entry.name][0], ver) < 0:
  593. insert = True
  594. if insert:
  595. candid[entry.name] = DlEntry(ver, entry.reponame, entry.dlsize)
  596. # XXX: As it's possible to include version information in `template`,
  597. # perhaps the messages can be improved
  598. if len(candid) == 0:
  599. if version_selector == VersionSelector.LATEST:
  600. parser.error('Template \'%s\' not found.' % template)
  601. elif version_selector == VersionSelector.REINSTALL:
  602. parser.error('Same version of template \'%s\' not found.' \
  603. % template)
  604. # Copy behavior of DNF and do nothing if version not found
  605. elif version_selector == VersionSelector.LATEST_LOWER:
  606. print(("Template '%s' of lowest version"
  607. " already installed, skipping..." % template),
  608. file=sys.stderr)
  609. elif version_selector == VersionSelector.LATEST_HIGHER:
  610. print(("Template '%s' of highest version"
  611. " already installed, skipping..." % template),
  612. file=sys.stderr)
  613. # Merge & choose the template with the highest version
  614. for name, dlentry in candid.items():
  615. if name not in full_candid \
  616. or rpm.labelCompare(full_candid[name].evr, dlentry.evr) < 0:
  617. full_candid[name] = dlentry
  618. return full_candid
  619. def download(
  620. args: argparse.Namespace,
  621. app: qubesadmin.app.QubesBase,
  622. path_override: typing.Optional[str] = None,
  623. dl_list: typing.Optional[typing.Dict[str, DlEntry]] = None,
  624. suffix: str = '',
  625. version_selector: VersionSelector = VersionSelector.LATEST) -> None:
  626. """Command that downloads template packages.
  627. :param args: Arguments received by the application.
  628. :param app: Qubes application object
  629. :param path_override: Override path to store downloads. If not set or set
  630. to None, ``args.downloaddir`` is used. Optional
  631. :param dl_list: Override list of templates to download. If not set or set
  632. to None, ``get_dl_list`` is called, which generates the list from
  633. ``args``. Optional
  634. :param suffix: Suffix to add to the file name of downloaded packages. This
  635. is useful if you want to distinguish between verified and unverified
  636. packages. Defaults to an empty string
  637. :param version_selector: Specify algorithm to select the candidate version
  638. of a package. Defaults to ``VersionSelector.LATEST``
  639. """
  640. if dl_list is None:
  641. dl_list = get_dl_list(args, app, version_selector=version_selector)
  642. path = path_override if path_override is not None else args.downloaddir
  643. for name, entry in dl_list.items():
  644. version_str = build_version_str(entry.evr)
  645. spec = PACKAGE_NAME_PREFIX + name + '-' + version_str
  646. target = os.path.join(path, '%s.rpm' % spec)
  647. target_suffix = target + suffix
  648. if os.path.exists(target_suffix):
  649. print('\'%s\' already exists, skipping...' % target,
  650. file=sys.stderr)
  651. elif os.path.exists(target):
  652. print('\'%s\' already exists, skipping...' % target,
  653. file=sys.stderr)
  654. os.rename(target, target_suffix)
  655. else:
  656. print('Downloading \'%s\'...' % spec, file=sys.stderr)
  657. done = False
  658. for attempt in range(args.retries):
  659. try:
  660. qrexec_download(args, app, spec, target_suffix,
  661. entry.dlsize)
  662. done = True
  663. break
  664. except ConnectionError:
  665. os.remove(target_suffix)
  666. if attempt + 1 < args.retries:
  667. print('\'%s\' download failed, retrying...' % spec,
  668. file=sys.stderr)
  669. except:
  670. # Also remove file if interrupted by other means
  671. os.remove(target_suffix)
  672. raise
  673. if not done:
  674. print('Error: \'%s\' download failed.' % spec, file=sys.stderr)
  675. sys.exit(1)
  676. def install(
  677. args: argparse.Namespace,
  678. app: qubesadmin.app.QubesBase,
  679. version_selector: VersionSelector = VersionSelector.LATEST,
  680. override_existing: bool = False) -> None:
  681. """Command that installs template packages.
  682. This command creates a lock file to ensure that two instances are not
  683. running at the same time.
  684. :param args: Arguments received by the application.
  685. :param app: Qubes application object
  686. :param version_selector: Specify algorithm to select the candidate version
  687. of a package. Defaults to ``VersionSelector.LATEST``
  688. :param override_existing: Whether to override existing packages. Used for
  689. reinstall, upgrade, and downgrade operations
  690. """
  691. try:
  692. with open(LOCK_FILE, 'x') as _:
  693. pass
  694. except FileExistsError:
  695. parser.error(('%s already exists.'
  696. ' Perhaps another instance of qvm-template is running?')
  697. % LOCK_FILE)
  698. try:
  699. transaction_set = rpm_transactionset(args.keyring)
  700. unverified_rpm_list = [] # rpmfile, reponame
  701. verified_rpm_list = []
  702. def verify(rpmfile, reponame):
  703. """Verify package signature and version, remove "unverified"
  704. suffix, and parse package header."""
  705. if reponame != '@commandline':
  706. path = rpmfile + UNVERIFIED_SUFFIX
  707. else:
  708. path = rpmfile
  709. package_hdr = verify_rpm(path, transaction_set, args.nogpgcheck)
  710. if not package_hdr:
  711. parser.error('Package \'%s\' verification failed.' % rpmfile)
  712. package_name = package_hdr[rpm.RPMTAG_NAME]
  713. if not package_name.startswith(PACKAGE_NAME_PREFIX):
  714. parser.error(
  715. 'Illegal package name for package \'%s\'.' % rpmfile)
  716. # Remove prefix to get the real template name
  717. name = package_name[len(PACKAGE_NAME_PREFIX):]
  718. if path != rpmfile:
  719. os.rename(path, rpmfile)
  720. # Check if already installed
  721. if not override_existing and name in app.domains:
  722. print(('Template \'%s\' already installed, skipping...'
  723. ' (You may want to use the'
  724. ' {reinstall,upgrade,downgrade}'
  725. ' operations.)') % name, file=sys.stderr)
  726. return
  727. # Check if version is really what we want
  728. if override_existing:
  729. vm = get_managed_template_vm(app, name)
  730. pkg_evr = (
  731. str(package_hdr[rpm.RPMTAG_EPOCHNUM]),
  732. package_hdr[rpm.RPMTAG_VERSION],
  733. package_hdr[rpm.RPMTAG_RELEASE])
  734. vm_evr = query_local_evr(vm)
  735. cmp_res = rpm.labelCompare(pkg_evr, vm_evr)
  736. if version_selector == VersionSelector.REINSTALL \
  737. and cmp_res != 0:
  738. parser.error(
  739. 'Same version of template \'%s\' not found.' \
  740. % name)
  741. elif version_selector == VersionSelector.LATEST_LOWER \
  742. and cmp_res != -1:
  743. print(("Template '%s' of lower version"
  744. " already installed, skipping..." % name),
  745. file=sys.stderr)
  746. return
  747. elif version_selector == VersionSelector.LATEST_HIGHER \
  748. and cmp_res != 1:
  749. print(("Template '%s' of higher version"
  750. " already installed, skipping..." % name),
  751. file=sys.stderr)
  752. return
  753. verified_rpm_list.append((rpmfile, reponame, name, package_hdr))
  754. # Process local templates
  755. for template in args.templates:
  756. if template.endswith('.rpm'):
  757. if not os.path.exists(template):
  758. parser.error('RPM file \'%s\' not found.' % template)
  759. unverified_rpm_list.append((template, '@commandline'))
  760. # First verify local RPMs and extract header
  761. for rpmfile, reponame in unverified_rpm_list:
  762. verify(rpmfile, reponame)
  763. unverified_rpm_list = []
  764. os.makedirs(args.cachedir, exist_ok=True)
  765. # Get list of templates to download
  766. dl_list = get_dl_list(args, app, version_selector=version_selector)
  767. dl_list_copy = dl_list.copy()
  768. for name, entry in dl_list.items():
  769. # Should be ensured by checks in repoquery
  770. assert entry.reponame != '@commandline'
  771. # Verify that the templates to be downloaded are not yet installed
  772. # Note that we *still* have to do this again in verify() for
  773. # already-downloaded templates
  774. if not override_existing and name in app.domains:
  775. print(('Template \'%s\' already installed, skipping...'
  776. ' (You may want to use the'
  777. ' {reinstall,upgrade,downgrade}'
  778. ' operations.)') % name, file=sys.stderr)
  779. del dl_list_copy[name]
  780. else:
  781. # XXX: Perhaps this is better returned by download()
  782. version_str = build_version_str(entry.evr)
  783. target_file = \
  784. '%s%s-%s.rpm' % (PACKAGE_NAME_PREFIX, name, version_str)
  785. unverified_rpm_list.append(
  786. (os.path.join(args.cachedir, target_file), entry.reponame))
  787. dl_list = dl_list_copy
  788. # Ask the user for confirmation before we actually download stuff
  789. if override_existing and not args.yes:
  790. override_tpls = []
  791. # Local templates, already verified
  792. for _, _, name, _ in verified_rpm_list:
  793. override_tpls.append(name)
  794. # Templates not yet downloaded
  795. for name in dl_list:
  796. override_tpls.append(name)
  797. confirm_action(
  798. 'This will override changes made in the following VMs:',
  799. override_tpls)
  800. download(args, app, path_override=args.cachedir,
  801. dl_list=dl_list, suffix=UNVERIFIED_SUFFIX,
  802. version_selector=version_selector)
  803. # Verify downloaded templates
  804. for rpmfile, reponame in unverified_rpm_list:
  805. verify(rpmfile, reponame)
  806. unverified_rpm_list = []
  807. # Unpack and install
  808. for rpmfile, reponame, name, package_hdr in verified_rpm_list:
  809. with tempfile.TemporaryDirectory(dir=TEMP_DIR) as target:
  810. print('Installing template \'%s\'...' % name, file=sys.stderr)
  811. # FIXME: Handle return value
  812. extract_rpm(name, rpmfile, target)
  813. cmdline = [
  814. 'qvm-template-postprocess',
  815. '--really',
  816. '--no-installed-by-rpm',
  817. ]
  818. if args.allow_pv:
  819. cmdline.append('--allow-pv')
  820. if not override_existing and args.pool:
  821. cmdline += ['--pool', args.pool]
  822. subprocess.check_call(cmdline + [
  823. 'post-install',
  824. name,
  825. target + PATH_PREFIX + '/' + name])
  826. app.domains.refresh_cache(force=True)
  827. tpl = app.domains[name]
  828. tpl.features['template-name'] = name
  829. tpl.features['template-epoch'] = \
  830. package_hdr[rpm.RPMTAG_EPOCHNUM]
  831. tpl.features['template-version'] = \
  832. package_hdr[rpm.RPMTAG_VERSION]
  833. tpl.features['template-release'] = \
  834. package_hdr[rpm.RPMTAG_RELEASE]
  835. tpl.features['template-reponame'] = reponame
  836. tpl.features['template-buildtime'] = \
  837. datetime.datetime.fromtimestamp(
  838. int(package_hdr[rpm.RPMTAG_BUILDTIME]),
  839. tz=datetime.timezone.utc) \
  840. .strftime(DATE_FMT)
  841. tpl.features['template-installtime'] = \
  842. datetime.datetime.now(
  843. tz=datetime.timezone.utc).strftime(DATE_FMT)
  844. tpl.features['template-license'] = \
  845. package_hdr[rpm.RPMTAG_LICENSE]
  846. tpl.features['template-url'] = \
  847. package_hdr[rpm.RPMTAG_URL]
  848. tpl.features['template-summary'] = \
  849. package_hdr[rpm.RPMTAG_SUMMARY]
  850. tpl.features['template-description'] = \
  851. package_hdr[rpm.RPMTAG_DESCRIPTION].replace('\n', '|')
  852. finally:
  853. os.remove(LOCK_FILE)
  854. def list_templates(args: argparse.Namespace,
  855. app: qubesadmin.app.QubesBase, operation: str) -> None:
  856. """Command that lists templates.
  857. :param args: Arguments received by the application.
  858. :param app: Qubes application object
  859. :param operation: If set to ``list``, display a listing similar to ``dnf
  860. list``. If set to ``info``, display detailed template information
  861. similar to ``dnf info``. Otherwise, an ``AssertionError`` is raised.
  862. """
  863. tpl_list = []
  864. def append_list(data, status, install_time=None):
  865. _ = install_time # unused
  866. version_str = build_version_str(
  867. (data.epoch, data.version, data.release))
  868. tpl_list.append((status, data.name, version_str, data.reponame))
  869. def append_info(data, status, install_time=None):
  870. tpl_list.append((status, data, install_time))
  871. def list_to_human_output(tpls):
  872. outputs = []
  873. for status, grp in itertools.groupby(tpls, lambda x: x[0]):
  874. def convert(row):
  875. return row[1:]
  876. outputs.append((status, list(map(convert, grp))))
  877. return outputs
  878. def list_to_machine_output(tpls):
  879. outputs = {}
  880. for status, grp in itertools.groupby(tpls, lambda x: x[0]):
  881. def convert(row):
  882. _, name, evr, reponame = row
  883. return {'name': name, 'evr': evr, 'reponame': reponame}
  884. outputs[status.value] = list(map(convert, grp))
  885. return outputs
  886. def info_to_human_output(tpls):
  887. outputs = []
  888. for status, grp in itertools.groupby(tpls, lambda x: x[0]):
  889. output = []
  890. for _, data, install_time in grp:
  891. output.append(('Name', ':', data.name))
  892. output.append(('Epoch', ':', data.epoch))
  893. output.append(('Version', ':', data.version))
  894. output.append(('Release', ':', data.release))
  895. output.append(('Size', ':',
  896. qubesadmin.utils.size_to_human(data.dlsize)))
  897. output.append(('Repository', ':', data.reponame))
  898. output.append(('Buildtime', ':', str(data.buildtime)))
  899. if install_time:
  900. output.append(('Install time', ':', str(install_time)))
  901. output.append(('URL', ':', data.url))
  902. output.append(('License', ':', data.licence))
  903. output.append(('Summary', ':', data.summary))
  904. # Only show "Description" for the first line
  905. title = 'Description'
  906. for line in data.description.splitlines():
  907. output.append((title, ':', line))
  908. title = ''
  909. output.append((' ', ' ', ' ')) # empty line
  910. outputs.append((status, output))
  911. return outputs
  912. def info_to_machine_output(tpls, replace_newline=True):
  913. outputs = {}
  914. for status, grp in itertools.groupby(tpls, lambda x: x[0]):
  915. output = []
  916. for _, data, install_time in grp:
  917. name, epoch, version, release, reponame, dlsize, \
  918. buildtime, licence, url, summary, description = data
  919. dlsize = str(dlsize)
  920. buildtime = buildtime.strftime(DATE_FMT)
  921. install_time = install_time if install_time else ''
  922. if replace_newline:
  923. description = description.replace('\n', '|')
  924. output.append({
  925. 'name': name,
  926. 'epoch': epoch,
  927. 'version': version,
  928. 'release': release,
  929. 'reponame': reponame,
  930. 'size': dlsize,
  931. 'buildtime': buildtime,
  932. 'installtime': install_time,
  933. 'license': licence,
  934. 'url': url,
  935. 'summary': summary,
  936. 'description': description})
  937. outputs[status.value] = output
  938. return outputs
  939. if operation == 'list':
  940. append = append_list
  941. elif operation == 'info':
  942. append = append_info
  943. else:
  944. assert False and 'Unknown operation'
  945. def append_vm(vm, status):
  946. append(query_local(vm), status, vm.features['template-installtime'])
  947. def check_append(name, evr):
  948. return not args.templates or \
  949. any(is_match_spec(name, *evr, spec)[0]
  950. for spec in args.templates)
  951. if not (args.installed or args.available or args.extras or args.upgrades):
  952. args.all = True
  953. if args.all or args.available or args.extras or args.upgrades:
  954. if args.templates:
  955. query_res_set: typing.Set[Template] = set()
  956. for spec in args.templates:
  957. query_res_set |= set(qrexec_repoquery(args, app, spec))
  958. query_res = list(query_res_set)
  959. else:
  960. query_res = qrexec_repoquery(args, app)
  961. if args.installed or args.all:
  962. for vm in app.domains:
  963. if is_managed_template(vm) and \
  964. check_append(vm.name, query_local_evr(vm)):
  965. append_vm(vm, TemplateState.INSTALLED)
  966. if args.available or args.all:
  967. # Spec should already be checked by repoquery
  968. for data in query_res:
  969. append(data, TemplateState.AVAILABLE)
  970. if args.extras:
  971. remote = set()
  972. for data in query_res:
  973. remote.add(data.name)
  974. for vm in app.domains:
  975. if is_managed_template(vm) and vm.name not in remote and \
  976. check_append(vm.name, query_local_evr(vm)):
  977. append_vm(vm, TemplateState.EXTRA)
  978. if args.upgrades:
  979. local = {}
  980. for vm in app.domains:
  981. if is_managed_template(vm):
  982. local[vm.name] = query_local_evr(vm)
  983. # Spec should already be checked by repoquery
  984. for entry in query_res:
  985. evr = (entry.epoch, entry.version, entry.release)
  986. if entry.name in local:
  987. if rpm.labelCompare(local[entry.name], evr) < 0:
  988. append(entry, TemplateState.UPGRADABLE)
  989. if len(tpl_list) == 0:
  990. parser.error('No matching templates to list')
  991. if args.machine_readable:
  992. if operation == 'info':
  993. tpl_list_dict = info_to_machine_output(tpl_list)
  994. elif operation == 'list':
  995. tpl_list_dict = list_to_machine_output(tpl_list)
  996. for status, grp in tpl_list_dict.items():
  997. for line in grp:
  998. print('|'.join([status] + list(line.values())))
  999. elif args.machine_readable_json:
  1000. if operation == 'info':
  1001. tpl_list_dict = \
  1002. info_to_machine_output(tpl_list, replace_newline=False)
  1003. elif operation == 'list':
  1004. tpl_list_dict = list_to_machine_output(tpl_list)
  1005. print(json.dumps(tpl_list_dict))
  1006. else:
  1007. if operation == 'info':
  1008. tpl_list = info_to_human_output(tpl_list)
  1009. elif operation == 'list':
  1010. tpl_list = list_to_human_output(tpl_list)
  1011. for status, grp in tpl_list:
  1012. print(status.title())
  1013. qubesadmin.tools.print_table(grp)
  1014. def search(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
  1015. """Command that searches template details for given patterns.
  1016. :param args: Arguments received by the application.
  1017. :param app: Qubes application object
  1018. """
  1019. # Search in both installed and available templates
  1020. query_res = qrexec_repoquery(args, app)
  1021. for vm in app.domains:
  1022. if is_managed_template(vm):
  1023. query_res.append(query_local(vm))
  1024. # Get latest version for each template
  1025. query_res_tmp = []
  1026. for _, grp in itertools.groupby(sorted(query_res), lambda x: x[0]):
  1027. def compare(lhs, rhs):
  1028. return lhs if rpm.labelCompare(lhs[1:4], rhs[1:4]) > 0 else rhs
  1029. query_res_tmp.append(functools.reduce(compare, grp))
  1030. query_res = query_res_tmp
  1031. #pylint: disable=invalid-name
  1032. WEIGHT_NAME_EXACT = 1 << 4
  1033. WEIGHT_NAME = 1 << 3
  1034. WEIGHT_SUMMARY = 1 << 2
  1035. WEIGHT_DESCRIPTION = 1 << 1
  1036. WEIGHT_URL = 1 << 0
  1037. WEIGHT_TO_FIELD = [
  1038. (WEIGHT_NAME_EXACT, 'Name'),
  1039. (WEIGHT_NAME, 'Name'),
  1040. (WEIGHT_SUMMARY, 'Summary'),
  1041. (WEIGHT_DESCRIPTION, 'Description'),
  1042. (WEIGHT_URL, 'URL')]
  1043. search_res_by_idx: \
  1044. typing.Dict[int, typing.List[typing.Tuple[int, str, bool]]] = \
  1045. collections.defaultdict(list)
  1046. for keyword in args.templates:
  1047. for idx, entry in enumerate(query_res):
  1048. needle_types = \
  1049. [(entry.name, WEIGHT_NAME), (entry.summary, WEIGHT_SUMMARY)]
  1050. if args.all:
  1051. needle_types += [(entry.description, WEIGHT_DESCRIPTION),
  1052. (entry.url, WEIGHT_URL)]
  1053. for key, weight in needle_types:
  1054. if fnmatch.fnmatch(key, '*' + keyword + '*'):
  1055. exact = keyword == key
  1056. if exact and weight == WEIGHT_NAME:
  1057. weight = WEIGHT_NAME_EXACT
  1058. search_res_by_idx[idx].append((weight, keyword, exact))
  1059. if not args.all:
  1060. keywords = set(args.templates)
  1061. idxs = list(search_res_by_idx.keys())
  1062. for idx in idxs:
  1063. if keywords != set(x[1] for x in search_res_by_idx[idx]):
  1064. del search_res_by_idx[idx]
  1065. def key_func(x):
  1066. # ORDER BY weight DESC, list_of_needles ASC, name ASC
  1067. idx, needles = x
  1068. weight = sum(t[0] for t in needles)
  1069. name = query_res[idx][0]
  1070. return (-weight, needles, name)
  1071. search_res = sorted(search_res_by_idx.items(), key=key_func)
  1072. def gen_header(needles):
  1073. fields = []
  1074. weight_types = set(x[0] for x in needles)
  1075. for weight, field in WEIGHT_TO_FIELD:
  1076. if weight in weight_types:
  1077. fields.append(field)
  1078. exact = all(x[-1] for x in needles)
  1079. match = 'Exactly Matched' if exact else 'Matched'
  1080. keywords = sorted(list(set(x[1] for x in needles)))
  1081. return ' & '.join(fields) + ' ' + match + ': ' + ', '.join(keywords)
  1082. last_header = ''
  1083. for idx, needles in search_res:
  1084. # Print headers
  1085. cur_header = gen_header(needles)
  1086. if last_header != cur_header:
  1087. last_header = cur_header
  1088. # XXX: The style is different from that of DNF
  1089. print('===', cur_header, '===')
  1090. print(query_res[idx].name, ':', query_res[idx].summary)
  1091. def remove(
  1092. args: argparse.Namespace,
  1093. app: qubesadmin.app.QubesBase,
  1094. disassoc: bool = False,
  1095. purge: bool = False,
  1096. dummy: str = 'dummy'
  1097. ) -> None:
  1098. """Command that remove templates.
  1099. :param args: Arguments received by the application.
  1100. :param app: Qubes application object
  1101. :param disassoc: Whether to disassociate VMs from the templates
  1102. :param purge: Whether to remove VMs based on the templates
  1103. :param dummy: Name of dummy VM if disassoc is used
  1104. """
  1105. # NOTE: While QubesArgumentParser provide similar functionality
  1106. # it does not seem to work as a parent parser
  1107. for tpl in args.templates:
  1108. if tpl not in app.domains:
  1109. parser.error("no such domain: '%s'" % tpl)
  1110. remove_list = args.templates
  1111. if purge:
  1112. # Not disassociating first may result in dependency ordering issues
  1113. disassoc = True
  1114. # Remove recursively via BFS
  1115. remove_set = set(remove_list) # visited
  1116. idx = 0
  1117. while idx < len(remove_list):
  1118. tpl = remove_list[idx]
  1119. idx += 1
  1120. vm = app.domains[tpl]
  1121. for holder, prop in qubesadmin.utils.vm_dependencies(app, vm):
  1122. if holder is not None and holder.name not in remove_set:
  1123. remove_list.append(holder.name)
  1124. remove_set.add(holder.name)
  1125. if not args.yes:
  1126. repeat = 3 if purge else 1
  1127. for _ in range(repeat):
  1128. confirm_action(
  1129. 'This will completely remove the selected VM(s)...',
  1130. remove_list)
  1131. if disassoc:
  1132. # Remove the dummy afterwards if we're purging
  1133. remove_dummy = purge
  1134. # Create dummy template; handle name collisions
  1135. orig_dummy = dummy
  1136. cnt = 1
  1137. while dummy in app.domains \
  1138. and app.domains[dummy].features.get(
  1139. 'template-dummy', '0') == '0':
  1140. dummy = '%s-%d' % (orig_dummy, cnt)
  1141. cnt += 1
  1142. if dummy not in app.domains:
  1143. dummy_vm = app.add_new_vm('TemplateVM', dummy, 'red')
  1144. dummy_vm.features['template-dummy'] = 1
  1145. else:
  1146. dummy_vm = app.domains[dummy]
  1147. for tpl in remove_list:
  1148. vm = app.domains[tpl]
  1149. for holder, prop in qubesadmin.utils.vm_dependencies(app, vm):
  1150. if holder:
  1151. setattr(holder, prop, dummy_vm)
  1152. holder.template = dummy_vm
  1153. print("Property '%s' of '%s' set to '%s'." % (
  1154. prop, holder.name, dummy), file=sys.stderr)
  1155. else:
  1156. print("Global property '%s' set to ''." % prop,
  1157. file=sys.stderr)
  1158. setattr(app, prop, '')
  1159. if remove_dummy:
  1160. remove_list.append(dummy)
  1161. if disassoc or purge:
  1162. qubesadmin.tools.qvm_kill.main(['--'] + remove_list, app)
  1163. qubesadmin.tools.qvm_remove.main(['--force', '--'] + remove_list, app)
  1164. def clean(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
  1165. """Command that cleans the local package cache.
  1166. :param args: Arguments received by the application.
  1167. :param app: Qubes application object
  1168. """
  1169. # TODO: More fine-grained options?
  1170. _ = app # unused
  1171. shutil.rmtree(args.cachedir)
  1172. def repolist(args: argparse.Namespace, app: qubesadmin.app.QubesBase) -> None:
  1173. """Command that lists configured repositories.
  1174. :param args: Arguments received by the application.
  1175. :param app: Qubes application object
  1176. """
  1177. _ = app # unused
  1178. # python-dnf is not packaged on Debian
  1179. # As this is not an "essential operation", the module is imported here
  1180. # instead of top-level so that other operations still work.
  1181. try:
  1182. import dnf
  1183. except ModuleNotFoundError:
  1184. print("Error: Python module 'dnf' not found.", file=sys.stderr)
  1185. sys.exit(1)
  1186. if not args.all and not args.disabled:
  1187. args.enabled = True
  1188. with tempfile.TemporaryDirectory(dir=TEMP_DIR) as reposdir:
  1189. for idx, path in enumerate(args.repo_files):
  1190. src = os.path.abspath(path)
  1191. # Use index as file name in case of collisions
  1192. dst = os.path.join(reposdir, '%d.repo' % idx)
  1193. os.symlink(src, dst)
  1194. conf = dnf.conf.Conf()
  1195. conf.substitutions['releasever'] = args.releasever
  1196. conf.reposdir = reposdir
  1197. base = dnf.Base(conf)
  1198. base.read_all_repos()
  1199. if args.repoid:
  1200. base.repos.get_matching('*').disable()
  1201. for repo in args.repoid:
  1202. base.repos.get_matching(repo).enable()
  1203. else:
  1204. for repo in args.enablerepo:
  1205. base.repos.get_matching(repo).enable()
  1206. for repo in args.disablerepo:
  1207. base.repos.get_matching(repo).disable()
  1208. repos: typing.List[dnf.repo.Repo]
  1209. if args.repos:
  1210. repos = []
  1211. for repo in args.repos:
  1212. repos += list(base.repos.get_matching(repo))
  1213. repos = list(set(repos))
  1214. repos.sort(key=operator.attrgetter('id'))
  1215. else:
  1216. repos = list(base.repos.values())
  1217. repos.sort(key=operator.attrgetter('id'))
  1218. table = []
  1219. for repo in repos:
  1220. if args.all or (args.enabled == repo.enabled):
  1221. state = 'enabled' if repo.enabled else 'disabled'
  1222. table.append((repo.id, repo.name, state))
  1223. qubesadmin.tools.print_table(table)
  1224. def main(args: typing.Optional[typing.Sequence[str]] = None,
  1225. app: typing.Optional[qubesadmin.app.QubesBase] = None) -> int:
  1226. """Main routine of **qvm-template**.
  1227. :param args: Override arguments received by the application. Optional
  1228. :param app: Override Qubes application object. Optional
  1229. :return: Return code of the application
  1230. """
  1231. p_args = parser.parse_args(args)
  1232. if not p_args.operation:
  1233. parser.error('An operation needs to be specified.')
  1234. # If the user specified other repo files...
  1235. if len(p_args.repo_files) > 1:
  1236. # ...remove the default entry
  1237. p_args.repo_files.pop(0)
  1238. if app is None:
  1239. app = qubesadmin.Qubes()
  1240. if p_args.updatevm is UPDATEVM:
  1241. p_args.updatevm = app.updatevm
  1242. if p_args.refresh:
  1243. qrexec_repoquery(p_args, app, refresh=True)
  1244. if p_args.operation == 'download':
  1245. download(p_args, app)
  1246. elif p_args.operation == 'install':
  1247. install(p_args, app)
  1248. elif p_args.operation == 'reinstall':
  1249. install(p_args, app, version_selector=VersionSelector.REINSTALL,
  1250. override_existing=True)
  1251. elif p_args.operation == 'downgrade':
  1252. install(p_args, app, version_selector=VersionSelector.LATEST_LOWER,
  1253. override_existing=True)
  1254. elif p_args.operation == 'upgrade':
  1255. install(p_args, app, version_selector=VersionSelector.LATEST_HIGHER,
  1256. override_existing=True)
  1257. elif p_args.operation == 'list':
  1258. list_templates(p_args, app, 'list')
  1259. elif p_args.operation == 'info':
  1260. list_templates(p_args, app, 'info')
  1261. elif p_args.operation == 'search':
  1262. search(p_args, app)
  1263. elif p_args.operation == 'remove':
  1264. remove(p_args, app, disassoc=p_args.disassoc)
  1265. elif p_args.operation == 'purge':
  1266. remove(p_args, app, purge=True)
  1267. elif p_args.operation == 'clean':
  1268. clean(p_args, app)
  1269. elif p_args.operation == 'repolist':
  1270. repolist(p_args, app)
  1271. else:
  1272. parser.error('Operation \'%s\' not supported.' % p_args.operation)
  1273. return 0
  1274. if __name__ == '__main__':
  1275. sys.exit(main())