baker.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  1. from getpass import getpass
  2. from hashlib import md5
  3. from itertools import chain
  4. from collections import ChainMap, OrderedDict, defaultdict
  5. import argparse
  6. import io
  7. import logging
  8. import os
  9. import posixpath
  10. import subprocess
  11. import sys
  12. import threading
  13. import paramiko
  14. import yaml
  15. try:
  16. import keyring
  17. except ImportError:
  18. keyring = None
  19. __version__ = '0.0'
  20. log_fmt = '%(levelname)s:%(asctime).19s: %(message)s'
  21. logger = logging.getLogger('baker')
  22. logger.setLevel(logging.INFO)
  23. log_handler = logging.StreamHandler()
  24. log_handler.setLevel(logging.INFO)
  25. log_handler.setFormatter(logging.Formatter(log_fmt))
  26. logger.addHandler(log_handler)
  27. TAB = '\n '
  28. class BakerException(Exception):
  29. pass
  30. class FmtException(BakerException):
  31. pass
  32. class ExecutionException(BakerException):
  33. pass
  34. class RemoteException(ExecutionException):
  35. pass
  36. class LocalException(ExecutionException):
  37. pass
  38. def enable_logging_color():
  39. try:
  40. import colorama
  41. except ImportError:
  42. return
  43. colorama.init()
  44. MAGENTA = colorama.Fore.MAGENTA
  45. RED = colorama.Fore.RED
  46. RESET = colorama.Style.RESET_ALL
  47. # We define custom handler ..
  48. class Handler(logging.StreamHandler):
  49. def format(self, record):
  50. if record.levelname == 'INFO':
  51. record.msg = MAGENTA + record.msg + RESET
  52. elif record.levelname in ('WARNING', 'ERROR', 'CRITICAL'):
  53. record.msg = RED + record.msg + RESET
  54. return super(Handler, self).format(record)
  55. # .. and plug it
  56. logger.removeHandler(log_handler)
  57. handler = Handler()
  58. handler.setFormatter(logging.Formatter(log_fmt))
  59. logger.addHandler(handler)
  60. logger.propagate = 0
  61. def yaml_load(stream):
  62. class OrderedLoader(yaml.Loader):
  63. pass
  64. def construct_mapping(loader, node):
  65. loader.flatten_mapping(node)
  66. return OrderedDict(loader.construct_pairs(node))
  67. OrderedLoader.add_constructor(
  68. yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
  69. construct_mapping)
  70. return yaml.load(stream, OrderedLoader)
  71. def edits(word):
  72. yield word
  73. splits = ((word[:i], word[i:]) for i in range(len(word) + 1))
  74. for left, right in splits:
  75. if right:
  76. yield left + right[1:]
  77. def gen_candidates(wordlist):
  78. candidates = defaultdict(set)
  79. for word in wordlist:
  80. for ed1 in edits(word):
  81. for ed2 in edits(ed1):
  82. candidates[ed2].add(word)
  83. return candidates
  84. def spell(candidates, word):
  85. matches = set(chain.from_iterable(
  86. candidates[ed] for ed in edits(word) if ed in candidates
  87. ))
  88. return matches
  89. def spellcheck(objdict, word):
  90. if word in objdict:
  91. return
  92. candidates = objdict.get('_candidates')
  93. if not candidates:
  94. candidates = gen_candidates(list(objdict))
  95. objdict._candidates = candidates
  96. msg = '"%s" not found in %s' % (word, objdict._path)
  97. matches = spell(candidates, word)
  98. if matches:
  99. msg += ', try: %s' % ' or '.join(matches)
  100. raise BakerException(msg)
  101. class ObjectDict(dict):
  102. """
  103. Simple objet sub-class that allows to transform a dict into an
  104. object, like: `ObjectDict({'ham': 'spam'}).ham == 'spam'`
  105. """
  106. _meta = {}
  107. def __getattr__(self, key):
  108. if key.startswith('_'):
  109. return ObjectDict._meta[id(self), key]
  110. if key in self:
  111. return self[key]
  112. else:
  113. return None
  114. def __setattr__(self, key, value):
  115. if key.startswith('_'):
  116. ObjectDict._meta[id(self), key] = value
  117. else:
  118. self[key] = value
  119. class Node:
  120. @staticmethod
  121. def fail(path, kind):
  122. msg = 'Error while parsing config: expecting "%s" while parsing "%s"'
  123. raise BakerException(msg % (kind, '->'.join(path)))
  124. @classmethod
  125. def parse(cls, cfg, path=tuple()):
  126. children = getattr(cls, '_children', None)
  127. type_name = children and type(children).__name__ \
  128. or ' or '.join((c.__name__ for c in cls._type))
  129. res = None
  130. if type_name == 'dict':
  131. if not isinstance(cfg, dict):
  132. cls.fail(path, type_name)
  133. res = ObjectDict()
  134. if '*' in children:
  135. assert len(children) == 1, "Don't mix '*' and other keys"
  136. child_class = children['*']
  137. for name, value in cfg.items():
  138. res[name] = child_class.parse(value, path + (name,))
  139. else:
  140. # Enforce known pre-defined
  141. for key in cfg:
  142. if key not in children:
  143. path = ' -> '.join(path)
  144. if path:
  145. msg = 'Attribute "%s" not understood in %s' % (
  146. key, path)
  147. else:
  148. msg = 'Top-level attribute "%s" not understood' % (
  149. key)
  150. candidates = gen_candidates(children.keys())
  151. matches = spell(candidates, key)
  152. if matches:
  153. msg += ', try: %s' % ' or '.join(matches)
  154. raise BakerException(msg)
  155. for name, child_class in children.items():
  156. if name not in cfg:
  157. continue
  158. res[name] = child_class.parse(cfg.pop(name), path + (name,))
  159. elif type_name == 'list':
  160. if not isinstance(cfg, list):
  161. cls.fail(path, type_name)
  162. child_class = children[0]
  163. res = [child_class.parse(c, path+ ('[]',)) for c in cfg]
  164. else:
  165. if not isinstance(cfg, cls._type):
  166. cls.fail(path, type_name)
  167. res = cfg
  168. return cls.setup(res, path)
  169. @classmethod
  170. def setup(cls, values, path):
  171. if isinstance(values, ObjectDict):
  172. values._path = '->'.join(path)
  173. return values
  174. class Atom(Node):
  175. _type = (str, bool)
  176. class AtomList(Node):
  177. _children = [Atom]
  178. class Hosts(Node):
  179. _children = [Atom]
  180. class Auth(Node):
  181. _children = {'*': Atom}
  182. class EnvNode(Node):
  183. _children = {'*': Atom}
  184. class HostGroup(Node):
  185. _children = {
  186. 'hosts': Hosts,
  187. }
  188. class Network(Node):
  189. _children = {
  190. '*': HostGroup,
  191. }
  192. class Multi(Node):
  193. _children = {
  194. 'task': Atom,
  195. 'export': Atom,
  196. 'network': Atom,
  197. }
  198. class MultiList(Node):
  199. _children = [Multi]
  200. class Task(Node):
  201. _children = {
  202. 'desc': Atom,
  203. 'local': Atom,
  204. 'python': Atom,
  205. 'once': Atom,
  206. 'run': Atom,
  207. 'sudo': Atom,
  208. 'send': Atom,
  209. 'to': Atom,
  210. 'assert': Atom,
  211. 'env': EnvNode,
  212. 'multi': MultiList,
  213. }
  214. @classmethod
  215. def setup(cls, values, path):
  216. values['name'] = path and path[-1] or ''
  217. if 'desc' not in values:
  218. values['desc'] = values.get('name', '')
  219. super().setup(values, path)
  220. return values
  221. class TaskGroup(Node):
  222. _children = {
  223. '*': Task,
  224. }
  225. class LoadNode(Node):
  226. _children = {
  227. 'file': Atom,
  228. 'as': Atom,
  229. }
  230. class LoadList(Node):
  231. _children = [LoadNode]
  232. class ConfigRoot(Node):
  233. _children = {
  234. 'networks': Network,
  235. 'tasks': TaskGroup,
  236. 'auth': Auth,
  237. 'env': EnvNode,
  238. 'load': LoadList,
  239. }
  240. # Multi can also accept any task attribute:
  241. Multi._children.update(Task._children)
  242. class Env(ChainMap):
  243. def __init__(self, *dicts):
  244. return super().__init__(*filter(lambda x: x is not None, dicts))
  245. def fmt(self, string):
  246. try:
  247. return string.format(**self)
  248. except KeyError as exc:
  249. msg = 'Unable to format "%s" (missing: "%s")'% (string, exc.args[0])
  250. candidates = gen_candidates(self.keys())
  251. key = exc.args[0]
  252. matches = spell(candidates, key)
  253. if matches:
  254. msg += ', try: %s' % ' or '.join(matches)
  255. raise FmtException(msg )
  256. except IndexError as exc:
  257. msg = 'Unable to format "%s", positional argument not supported'
  258. raise FmtException(msg)
  259. def get_passphrase(key_path):
  260. service = 'SSH private key'
  261. csum = md5(open(key_path, 'rb').read()).digest().hex()
  262. ssh_pass = keyring.get_password(service, csum)
  263. if not ssh_pass:
  264. ssh_pass = getpass('Password for %s: ' % key_path)
  265. keyring.set_password(service, csum, ssh_pass)
  266. return ssh_pass
  267. def get_password(host):
  268. service = 'SSH password'
  269. ssh_pass = keyring.get_password(service, host)
  270. if not ssh_pass:
  271. ssh_pass = getpass('Password for %s: ' % host)
  272. keyring.set_password(service, host, ssh_pass)
  273. return ssh_pass
  274. def get_sudo_passwd():
  275. service = "Sudo password"
  276. passwd = keyring.get_password(service, '-')
  277. if not passwd:
  278. passwd = getpass('Sudo password:')
  279. keyring.set_password(service, '-', passwd)
  280. return passwd
  281. CONNECTION_CACHE = {}
  282. def connect(host, auth):
  283. if host in CONNECTION_CACHE:
  284. return CONNECTION_CACHE[host]
  285. private_key_file = password = None
  286. if auth and auth.get('ssh_private_key'):
  287. private_key_file = auth.ssh_private_key
  288. if not os.path.exists(auth.ssh_private_key):
  289. msg = 'Private key file "%s" not found' % auth.ssh_private_key
  290. raise BakerException(msg)
  291. password = get_passphrase(auth.ssh_private_key)
  292. else:
  293. password = get_password(host)
  294. username, hostname = host.split('@', 1)
  295. client = paramiko.SSHClient()
  296. client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
  297. client.connect(hostname, username=username, password=password,
  298. key_filename=private_key_file,
  299. )
  300. CONNECTION_CACHE[host] = client
  301. return client
  302. def run_local(cmd, env, cli):
  303. # Run local task
  304. cmd = env.fmt(cmd)
  305. logger.info(env.fmt('{task_desc}'))
  306. if cli.dry_run:
  307. logger.info('[dry-run] ' + cmd)
  308. return None
  309. logger.debug(TAB + TAB.join(cmd.splitlines()))
  310. process = subprocess.Popen(
  311. cmd, shell=True,
  312. stdout=subprocess.PIPE,
  313. stderr=subprocess.STDOUT,
  314. env=env,
  315. )
  316. stdout, stderr = process.communicate()
  317. success = process.returncode == 0
  318. if stdout:
  319. logger.debug(TAB + TAB.join(stdout.decode().splitlines()))
  320. if not success:
  321. raise LocalException(stdout, stderr)
  322. return ObjectDict(stdout=stdout, stderr=stderr)
  323. def run_python(task, env, cli):
  324. # Execute a piece of python localy
  325. code = task.python
  326. logger.info(env.fmt('{task_desc}'))
  327. if cli.dry_run:
  328. logger.info('[dry-run] ' + code)
  329. return None
  330. logger.debug(TAB + TAB.join(code.splitlines()))
  331. cmd = 'python -c "import sys;exec(sys.stdin.read())"'
  332. if task.sudo:
  333. cmd = 'sudo -- ' + cmd
  334. process = subprocess.Popen(
  335. cmd,
  336. stdout=subprocess.PIPE,
  337. stderr=subprocess.PIPE,
  338. stdin=subprocess.PIPE,
  339. env=env,
  340. )
  341. # Plug io
  342. out_buff = io.StringIO()
  343. err_buff = io.StringIO()
  344. log_stream(process.stdout, out_buff)
  345. log_stream(process.stderr, err_buff)
  346. process.stdin.write(code.encode())
  347. process.stdin.flush()
  348. process.stdin.close()
  349. success = process.wait() == 0
  350. process.stdout.close()
  351. process.stderr.close()
  352. out = out_buff.getvalue()
  353. if out:
  354. logger.debug(TAB + TAB.join(out.splitlines()))
  355. if not success:
  356. raise LocalException(out + err_buff.getvalue())
  357. return ObjectDict(stdout=out, stderr=err_buff.getvalue())
  358. def log_stream(stream, buff):
  359. def _log():
  360. try:
  361. for chunk in iter(lambda: stream.readline(2048), ""):
  362. if isinstance(chunk, bytes):
  363. chunk = chunk.decode()
  364. buff.write(chunk)
  365. except ValueError:
  366. # read raises a ValueError on closed stream
  367. pass
  368. t = threading.Thread(target=_log)
  369. t.start()
  370. return t
  371. def run_helper(client, cmd, env=None, in_buff=None, sudo=False):
  372. chan = client.get_transport().open_session()
  373. if env:
  374. chan.update_environment(env)
  375. stdin = chan.makefile('wb')
  376. stdout = chan.makefile('r')
  377. stderr = chan.makefile_stderr('r')
  378. out_buff = io.StringIO()
  379. err_buff = io.StringIO()
  380. out_thread = log_stream(stdout, out_buff)
  381. err_thread = log_stream(stderr, err_buff)
  382. if sudo:
  383. assert not in_buff, 'in_buff and sudo can not be combined'
  384. if isinstance(sudo, str):
  385. sudo_cmd = 'sudo -u %s -s' % sudo
  386. else:
  387. sudo_cmd = 'sudo -s'
  388. chan.exec_command(sudo_cmd)
  389. in_buff = cmd
  390. else:
  391. chan.exec_command(cmd)
  392. if in_buff:
  393. # XXX use a real buff (not a simple str) ?
  394. stdin.write(in_buff)
  395. stdin.flush()
  396. stdin.close()
  397. chan.shutdown_write()
  398. success = chan.recv_exit_status() == 0
  399. out_thread.join()
  400. err_thread.join()
  401. if not success:
  402. raise RemoteException(out_buff.getvalue() + err_buff.getvalue())
  403. res = ObjectDict(
  404. stdout = out_buff.getvalue(),
  405. stderr = err_buff.getvalue(),
  406. )
  407. return res
  408. def run_remote(task, host, env, cli):
  409. res = None
  410. host = env.fmt(host)
  411. env.update({
  412. 'host': host,
  413. })
  414. if cli.dry_run:
  415. client = None
  416. else:
  417. client = connect(host, cli.cfg.auth)
  418. if task.run:
  419. cmd = env.fmt(task.run)
  420. prefix = ''
  421. if task.sudo:
  422. if task.sudo is True:
  423. prefix = '[sudo] '
  424. else:
  425. prefix = '[sudo as %s] ' % task.sudo
  426. msg = prefix + '{host}: {task_desc}'
  427. logger.info(env.fmt(msg))
  428. logger.debug(TAB + TAB.join(cmd.splitlines()))
  429. if cli.dry_run:
  430. logger.info('[dry-run] ' + cmd)
  431. else:
  432. res = run_helper(client, cmd, env=env, sudo=task.sudo)
  433. elif task.send:
  434. local_path = env.fmt(task.send)
  435. remote_path = env.fmt(task.to)
  436. logger.info(f'[send] {local_path} -> {host}:{remote_path}')
  437. if cli.dry_run:
  438. logger.info('[dry-run]')
  439. return
  440. else:
  441. with client.open_sftp() as sftp:
  442. if os.path.isfile(local_path):
  443. sftp.put(os.path.abspath(local_path), remote_path)
  444. else:
  445. for root, subdirs, files in os.walk(local_path):
  446. rel_dir = os.path.relpath(root, local_path)
  447. rem_dir = posixpath.join(remote_path, rel_dir)
  448. run_helper(client, 'mkdir -p {}'.format(rem_dir))
  449. for f in files:
  450. rel_f = os.path.join(root, f)
  451. rem_file = posixpath.join(rem_dir, f)
  452. sftp.put(os.path.abspath(rel_f), rem_file)
  453. else:
  454. raise BakerException('Unable to run task "%s"' % task.name)
  455. if res and res.stdout:
  456. logger.debug(TAB + TAB.join(res.stdout.splitlines()))
  457. return res
  458. def run_task(task, host, cli, parent_env=None):
  459. '''
  460. Execute one task on one host (or locally)
  461. '''
  462. # Prepare environment
  463. env = Env(
  464. # Env from parent task
  465. parent_env,
  466. # Env on the task itself
  467. task.get('env'),
  468. # Top-level env
  469. cli.cfg.get('env'),
  470. # OS env
  471. os.environ,
  472. ).new_child()
  473. env.update({
  474. 'task_desc': env.fmt(task.desc),
  475. 'task_name': task.name,
  476. 'host': host or '',
  477. })
  478. if task.local:
  479. res = run_local(task.local, env, cli)
  480. elif task.python:
  481. res = run_python(task, env, cli)
  482. else:
  483. res = run_remote(task, host, env, cli)
  484. if task.get('assert'):
  485. env.update({
  486. 'stdout': res.stdout.strip(),
  487. 'stderr': res.stderr.strip(),
  488. })
  489. assert_ = env.fmt(task['assert'])
  490. ok = eval(assert_, dict(env))
  491. if ok:
  492. logger.info('Assert ok')
  493. else:
  494. raise BakerException('Assert "%s" failed!' % assert_)
  495. return res
  496. def run_batch(task, hosts, cli, env=None):
  497. '''
  498. Run one task on a list of hosts
  499. '''
  500. out = None
  501. export_env = {}
  502. env = Env(export_env, task.get('env'), env)
  503. if task.get('multi'):
  504. parent_sudo = task.sudo
  505. for multi in task.multi:
  506. task_name = multi.task
  507. if task:
  508. # _cfg contain "local" config wrt the task
  509. siblings = task._cfg.tasks
  510. spellcheck(siblings, task_name)
  511. sub_task = siblings[task_name]
  512. sudo = multi.sudo or sub_task.sudo or parent_sudo
  513. else:
  514. # reify a task out of attributes
  515. sub_task = Task.parse(multi)
  516. sudo = sub_task.sudo or parent_sudo
  517. sub_task.sudo = sudo
  518. network = multi.get('network')
  519. if network:
  520. spellcheck(cli.cfg.networks, network)
  521. hosts = cli.cfg.networks[network].hosts
  522. child_env = Env(multi.get('env', {}), env)
  523. for k, v in child_env.items():
  524. # env wrap-around!
  525. child_env[k] = child_env.fmt(child_env[k])
  526. out = run_batch(sub_task, hosts, cli, child_env)
  527. out = out.decode() if isinstance(out, bytes) else out
  528. export_env['_'] = out
  529. if multi.export:
  530. export_env[multi.export] = out
  531. else:
  532. res = None
  533. if task.once and (task.local or task.python):
  534. res = run_task(task, None, cli, env)
  535. else:
  536. for host in hosts:
  537. res = run_task(task, host, cli, env)
  538. if task.once:
  539. break
  540. out = res and res.stdout.strip() or ''
  541. return out
  542. def abort(msg):
  543. logger.error(msg)
  544. sys.exit(1)
  545. def load_cfg(path, prefix=None):
  546. load_sections = ('networks', 'tasks', 'auth', 'env')
  547. if os.path.isfile(path):
  548. logger.info('Load config %s' % path)
  549. cfg = yaml_load(open(path))
  550. cfg = ConfigRoot.parse(cfg)
  551. else:
  552. raise BakerException('Config file "%s" not found' % path)
  553. # Define useful defaults
  554. cfg.networks = cfg.networks or ObjectDict()
  555. cfg.tasks = cfg.tasks or ObjectDict()
  556. # Create backrefs between tasks to the local config
  557. if cfg.get('tasks'):
  558. items = cfg['tasks'].items()
  559. for k, v in items:
  560. v._cfg = ObjectDict(cfg.copy())
  561. if prefix:
  562. key_fn = lambda x: '/'.join(prefix + [x])
  563. # Apply prefix
  564. for section in load_sections:
  565. if not cfg.get(section):
  566. continue
  567. items = cfg[section].items()
  568. cfg[section] = {key_fn(k): v for k, v in items}
  569. # Recursive load
  570. if cfg.load:
  571. cfg_path = os.path.dirname(path)
  572. for item in cfg.load:
  573. if item.get('as'):
  574. child_prefix = item['as']
  575. else:
  576. child_prefix, _ = os.path.splitext(item.file)
  577. child_path = os.path.join(cfg_path, item.file)
  578. child_cfg = load_cfg(child_path, child_prefix.split('/'))
  579. for section in load_sections:
  580. cfg[section].update(child_cfg.get(section, {}))
  581. return cfg
  582. def load_cli(args=None):
  583. parser = argparse.ArgumentParser()
  584. parser.add_argument('names', nargs='*',
  585. help='Hosts and commands to run them on')
  586. parser.add_argument('-c', '--config', default='bk.yaml',
  587. help='Config file')
  588. parser.add_argument('-R', '--run', nargs='*', default=[],
  589. help='Run remote task')
  590. parser.add_argument('-L', '--run-local', nargs='*', default=[],
  591. help='Run local task')
  592. parser.add_argument('-P', '--run-python', nargs='*', default=[],
  593. help='Run python task')
  594. parser.add_argument('-d', '--dry-run', action='store_true',
  595. help='Do not run actual tasks, just print them')
  596. parser.add_argument('-e', '--env', nargs='*', default=[],
  597. help='Add value to execution environment '
  598. '(ex: -e foo=bar "name=John Doe")')
  599. parser.add_argument('-s', '--sudo', default='auto',
  600. help='Enable sudo (auto|yes|no')
  601. parser.add_argument('-v', '--verbose', action='count',
  602. default=0, help='Increase verbosity')
  603. parser.add_argument('-q', '--quiet', action='count',
  604. default=0, help='Decrease verbosity')
  605. parser.add_argument('-n', '--no-color', action='store_true',
  606. help='Disable colored logs')
  607. cli = parser.parse_args(args=args)
  608. cli = ObjectDict(vars(cli))
  609. # Load config
  610. cfg = load_cfg(cli.config)
  611. cli.cfg = cfg
  612. cli.update(get_hosts_and_tasks(cli, cfg))
  613. # Transformt env string into dict
  614. cli.env = dict(e.split('=') for e in cli.env)
  615. return cli
  616. def get_hosts_and_tasks(cli, cfg):
  617. # Make sure we don't have overlap between hosts and tasks
  618. items = list(cfg.networks) + list(cfg.tasks)
  619. msg = 'Name collision between tasks and networks'
  620. assert len(set(items)) == len(items), msg
  621. # Build task list
  622. tasks = []
  623. networks = []
  624. for name in cli.names:
  625. if name in cfg.networks:
  626. host = cfg.networks[name]
  627. networks.append(host)
  628. elif name in cfg.tasks:
  629. task = cfg.tasks[name]
  630. tasks.append(task)
  631. else:
  632. msg = 'Name "%s" not understood' % name
  633. matches = spell(cfg.networks, name) | spell(cfg.tasks, name)
  634. if matches:
  635. msg += ', try: %s' % ' or '.join(matches)
  636. raise BakerException (msg)
  637. # Collect custom tasks from cli
  638. customs = []
  639. for cli_key in ('run', 'run_local', 'run_python'):
  640. cmd_key = cli_key.rsplit('_', 1)[-1]
  641. customs.extend('%s: %s' % (cmd_key, ck) for ck in cli[cli_key])
  642. for custom_task in customs:
  643. task = Task.parse(yaml_load(custom_task))
  644. task.desc = 'Custom command'
  645. tasks.append(task)
  646. hosts = list(chain.from_iterable(n.hosts for n in networks))
  647. return dict(hosts=hosts, tasks=tasks)
  648. def main():
  649. cli = None
  650. try:
  651. cli = load_cli()
  652. if not cli.no_color:
  653. enable_logging_color()
  654. cli.verbose = max(0, 1 + cli.verbose - cli.quiet)
  655. level = ['WARNING', 'INFO', 'DEBUG'][min(cli.verbose, 2)]
  656. log_handler.setLevel(level)
  657. logger.setLevel(level)
  658. for task in cli.tasks:
  659. run_batch(task, cli.hosts, cli, cli.env)
  660. except BakerException as e:
  661. if cli and cli.verbose > 2:
  662. raise
  663. abort(str(e))
  664. if __name__ == '__main__':
  665. main()