qvm_template.py 61 KB

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