A Python library for code common to TripleO CLI and TripleO UI.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

604 lines
23 KiB

  1. # Copyright 2017 Red Hat, Inc.
  2. # All Rights Reserved.
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  5. # not use this file except in compliance with the License. You may obtain
  6. # a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  12. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  13. # License for the specific language governing permissions and limitations
  14. # under the License.
  15. import json
  16. import multiprocessing
  17. import os
  18. import shutil
  19. import six
  20. from six.moves import configparser
  21. import subprocess
  22. import tempfile
  23. import time
  24. import yaml
  25. from mistral_lib import actions
  26. from oslo_concurrency import processutils
  27. from tripleo_common.actions import base
  28. from tripleo_common.inventory import TripleoInventory
  29. def write_default_ansible_cfg(work_dir,
  30. base_ansible_cfg='/etc/ansible/ansible.cfg'):
  31. ansible_config_path = os.path.join(work_dir, 'ansible.cfg')
  32. shutil.copy(base_ansible_cfg, ansible_config_path)
  33. callbacks_whitelist = ','.join(['profile_tasks'])
  34. config = configparser.ConfigParser()
  35. config.read(ansible_config_path)
  36. config.set('defaults', 'retry_files_enabled', 'False')
  37. config.set('defaults', 'log_path',
  38. os.path.join(work_dir, 'ansible.log'))
  39. config.set('defaults', 'callback_whitelist', callbacks_whitelist)
  40. # suppress tasks if all hosts skip, was previously full_skip callback
  41. config.set('defaults', 'display_skipped_hosts', 'no')
  42. config.set('defaults', 'forks', str(multiprocessing.cpu_count() * 10))
  43. config.set('defaults', 'timeout', '30')
  44. config.set('defaults', 'gather_timeout', '30')
  45. # Setup fact cache to improve playbook execution speed
  46. config.set('defaults', 'gathering', 'smart')
  47. config.set('defaults', 'fact_caching', 'jsonfile')
  48. config.set('defaults', 'fact_caching_connection',
  49. '/var/lib/mistral/ansible_fact_cache')
  50. # Expire facts in the fact cache after 7200s (2h)
  51. config.set('defaults', 'fact_caching_timeout', '7200')
  52. # mistral user has no home dir set, so no place to save a known hosts file
  53. config.set('ssh_connection', 'ssh_args',
  54. '-o UserKnownHostsFile=/dev/null '
  55. '-o StrictHostKeyChecking=no '
  56. '-o ControlMaster=auto '
  57. '-o ControlPersist=30m '
  58. '-o ServerAliveInterval=5 '
  59. '-o ServerAliveCountMax=5')
  60. config.set('ssh_connection', 'control_path_dir',
  61. os.path.join(work_dir, 'ansible-ssh'))
  62. config.set('ssh_connection', 'retries', '8')
  63. config.set('ssh_connection', 'pipelining', 'True')
  64. with open(ansible_config_path, 'w') as configfile:
  65. config.write(configfile)
  66. return ansible_config_path
  67. class AnsibleAction(actions.Action):
  68. """Executes ansible module"""
  69. def __init__(self, **kwargs):
  70. self._kwargs_for_run = kwargs
  71. self.hosts = self._kwargs_for_run.pop('hosts', None)
  72. self.module = self._kwargs_for_run.pop('module', None)
  73. self.module_args = self._kwargs_for_run.pop('module_args', None)
  74. if self.module_args and isinstance(self.module_args, dict):
  75. self.module_args = json.dumps(self.module_args)
  76. self.limit_hosts = self._kwargs_for_run.pop('limit_hosts', None)
  77. self.remote_user = self._kwargs_for_run.pop('remote_user', None)
  78. self.become = self._kwargs_for_run.pop('become', None)
  79. self.become_user = self._kwargs_for_run.pop('become_user', None)
  80. self.extra_vars = self._kwargs_for_run.pop('extra_vars', None)
  81. if self.extra_vars:
  82. self.extra_vars = json.dumps(self.extra_vars)
  83. self._inventory = self._kwargs_for_run.pop('inventory', None)
  84. self.verbosity = self._kwargs_for_run.pop('verbosity', 5)
  85. self._ssh_private_key = self._kwargs_for_run.pop(
  86. 'ssh_private_key', None)
  87. self.forks = self._kwargs_for_run.pop('forks', None)
  88. self.timeout = self._kwargs_for_run.pop('timeout', None)
  89. self.ssh_extra_args = self._kwargs_for_run.pop('ssh_extra_args', None)
  90. if self.ssh_extra_args:
  91. self.ssh_extra_args = json.dumps(self.ssh_extra_args)
  92. self.ssh_common_args = self._kwargs_for_run.pop(
  93. 'ssh_common_args', None)
  94. if self.ssh_common_args:
  95. self.ssh_common_args = json.dumps(self.ssh_common_args)
  96. self.use_openstack_credentials = self._kwargs_for_run.pop(
  97. 'use_openstack_credentials', False)
  98. self.extra_env_variables = self._kwargs_for_run.pop(
  99. 'extra_env_variables', None)
  100. self._work_dir = None
  101. @property
  102. def work_dir(self):
  103. if self._work_dir:
  104. return self._work_dir
  105. self._work_dir = tempfile.mkdtemp(prefix='ansible-mistral-action')
  106. return self._work_dir
  107. @property
  108. def inventory(self):
  109. if not self._inventory:
  110. return None
  111. # NOTE(flaper87): if it's a path, use it
  112. if (isinstance(self._inventory, six.string_types) and
  113. os.path.exists(self._inventory)):
  114. return self._inventory
  115. elif not isinstance(self._inventory, six.string_types):
  116. self._inventory = yaml.safe_dump(self._inventory)
  117. path = os.path.join(self.work_dir, 'inventory.yaml')
  118. # NOTE(flaper87):
  119. # We could probably catch parse errors here
  120. # but if we do, they won't be propagated and
  121. # we should not move forward with the action
  122. # if the inventory generation failed
  123. with open(path, 'w') as inventory:
  124. inventory.write(self._inventory)
  125. self._inventory = path
  126. return path
  127. @property
  128. def ssh_private_key(self):
  129. if not self._ssh_private_key:
  130. return None
  131. # NOTE(flaper87): if it's a path, use it
  132. if (isinstance(self._ssh_private_key, six.string_types) and
  133. os.path.exists(self._ssh_private_key)):
  134. return self._ssh_private_key
  135. path = os.path.join(self.work_dir, 'ssh_private_key')
  136. # NOTE(flaper87):
  137. # We could probably catch parse errors here
  138. # but if we do, they won't be propagated and
  139. # we should not move forward with the action
  140. # if the inventory generation failed
  141. with open(path, 'w') as ssh_key:
  142. ssh_key.write(self._ssh_private_key)
  143. os.chmod(path, 0o600)
  144. self._ssh_private_key = path
  145. return path
  146. def run(self, context):
  147. if 0 < self.verbosity < 6:
  148. verbosity_option = '-' + ('v' * self.verbosity)
  149. command = ['ansible', self.hosts, verbosity_option, ]
  150. else:
  151. command = ['ansible', self.hosts, ]
  152. if self.module:
  153. command.extend(['--module-name', self.module])
  154. if self.module_args:
  155. command.extend(['--args', self.module_args])
  156. if self.limit_hosts:
  157. command.extend(['--limit', self.limit_hosts])
  158. if self.remote_user:
  159. command.extend(['--user', self.remote_user])
  160. if self.become:
  161. command.extend(['--become'])
  162. if self.become_user:
  163. command.extend(['--become-user', self.become_user])
  164. if self.extra_vars:
  165. command.extend(['--extra-vars', self.extra_vars])
  166. if self.forks:
  167. command.extend(['--forks', self.forks])
  168. if self.ssh_common_args:
  169. command.extend(['--ssh-common-args', self.ssh_common_args])
  170. if self.ssh_extra_args:
  171. command.extend(['--ssh-extra-args', self.ssh_extra_args])
  172. if self.timeout:
  173. command.extend(['--timeout', self.timeout])
  174. if self.inventory:
  175. command.extend(['--inventory-file', self.inventory])
  176. if self.ssh_private_key:
  177. command.extend(['--private-key', self.ssh_private_key])
  178. if self.extra_env_variables:
  179. if not isinstance(self.extra_env_variables, dict):
  180. msg = "extra_env_variables must be a dict"
  181. return actions.Result(error=msg)
  182. try:
  183. ansible_config_path = write_default_ansible_cfg(self.work_dir)
  184. env_variables = {
  185. 'HOME': self.work_dir,
  186. 'ANSIBLE_LOCAL_TEMP': self.work_dir,
  187. 'ANSIBLE_CONFIG': ansible_config_path
  188. }
  189. if self.extra_env_variables:
  190. env_variables.update(self.extra_env_variables)
  191. if self.use_openstack_credentials:
  192. env_variables.update({
  193. 'OS_AUTH_URL': context.security.auth_uri,
  194. 'OS_USERNAME': context.security.user_name,
  195. 'OS_AUTH_TOKEN': context.security.auth_token,
  196. 'OS_PROJECT_NAME': context.security.project_name})
  197. stderr, stdout = processutils.execute(
  198. *command, cwd=self.work_dir,
  199. env_variables=env_variables,
  200. log_errors=processutils.LogErrors.ALL)
  201. return {"stderr": stderr, "stdout": stdout,
  202. "log_path": os.path.join(self.work_dir, 'ansible.log')}
  203. finally:
  204. # NOTE(flaper87): clean the mess if debug is disabled.
  205. if not self.verbosity:
  206. shutil.rmtree(self.work_dir)
  207. class AnsiblePlaybookAction(base.TripleOAction):
  208. """Executes ansible playbook"""
  209. def __init__(self, **kwargs):
  210. self._kwargs_for_run = kwargs
  211. self._playbook = self._kwargs_for_run.pop('playbook', None)
  212. self.limit_hosts = self._kwargs_for_run.pop('limit_hosts', None)
  213. self.module_path = self._kwargs_for_run.pop('module_path', None)
  214. self.remote_user = self._kwargs_for_run.pop('remote_user', None)
  215. self.become = self._kwargs_for_run.pop('become', None)
  216. self.become_user = self._kwargs_for_run.pop('become_user', None)
  217. self.extra_vars = self._kwargs_for_run.pop('extra_vars', None)
  218. if self.extra_vars:
  219. self.extra_vars = json.dumps(self.extra_vars)
  220. self._inventory = self._kwargs_for_run.pop('inventory', None)
  221. self.verbosity = self._kwargs_for_run.pop('verbosity', 5)
  222. self._ssh_private_key = self._kwargs_for_run.pop(
  223. 'ssh_private_key', None)
  224. self.flush_cache = self._kwargs_for_run.pop('flush_cache', None)
  225. self.forks = self._kwargs_for_run.pop('forks', None)
  226. self.timeout = self._kwargs_for_run.pop('timeout', None)
  227. self.ssh_extra_args = self._kwargs_for_run.pop('ssh_extra_args', None)
  228. if self.ssh_extra_args:
  229. self.ssh_extra_args = json.dumps(self.ssh_extra_args)
  230. self.ssh_common_args = self._kwargs_for_run.pop(
  231. 'ssh_common_args', None)
  232. if self.ssh_common_args:
  233. self.ssh_common_args = json.dumps(self.ssh_common_args)
  234. self.use_openstack_credentials = self._kwargs_for_run.pop(
  235. 'use_openstack_credentials', False)
  236. self.tags = self._kwargs_for_run.pop('tags', None)
  237. self.skip_tags = self._kwargs_for_run.pop('skip_tags', None)
  238. self.extra_env_variables = self._kwargs_for_run.pop(
  239. 'extra_env_variables', None)
  240. self.queue_name = self._kwargs_for_run.pop('queue_name', None)
  241. self.reproduce_command = self._kwargs_for_run.pop(
  242. 'reproduce_command', True)
  243. self.execution_id = self._kwargs_for_run.pop('execution_id', None)
  244. self._work_dir = self._kwargs_for_run.pop(
  245. 'work_dir', None)
  246. self.max_message_size = self._kwargs_for_run.pop(
  247. 'max_message_size', 1048576)
  248. self.trash_output = self._kwargs_for_run.pop('trash_output', False)
  249. self.profile_tasks = self._kwargs_for_run.pop('profile_tasks', True)
  250. self.profile_tasks_limit = self._kwargs_for_run.pop(
  251. 'profile_tasks_limit', 0)
  252. self.blacklisted_hostnames = self._kwargs_for_run.pop(
  253. 'blacklisted_hostnames', [])
  254. @property
  255. def work_dir(self):
  256. if self._work_dir:
  257. return self._work_dir
  258. self._work_dir = tempfile.mkdtemp(prefix='ansible-mistral-action')
  259. return self._work_dir
  260. @property
  261. def inventory(self):
  262. if not self._inventory:
  263. return None
  264. # NOTE(flaper87): if it's a path, use it
  265. if (isinstance(self._inventory, six.string_types) and
  266. os.path.exists(self._inventory)):
  267. return self._inventory
  268. elif not isinstance(self._inventory, six.string_types):
  269. self._inventory = yaml.safe_dump(self._inventory)
  270. path = os.path.join(self.work_dir, 'inventory.yaml')
  271. # NOTE(flaper87):
  272. # We could probably catch parse errors here
  273. # but if we do, they won't be propagated and
  274. # we should not move forward with the action
  275. # if the inventory generation failed
  276. with open(path, 'w') as inventory:
  277. inventory.write(self._inventory)
  278. self._inventory = path
  279. return path
  280. @property
  281. def playbook(self):
  282. if not self._playbook:
  283. return None
  284. # NOTE(flaper87): if it's a path, use it
  285. if (isinstance(self._playbook, six.string_types) and
  286. os.path.exists(self._playbook)):
  287. return self._playbook
  288. elif not isinstance(self._playbook, six.string_types):
  289. self._playbook = yaml.safe_dump(self._playbook)
  290. path = os.path.join(self.work_dir, 'playbook.yaml')
  291. # NOTE(flaper87):
  292. # We could probably catch parse errors here
  293. # but if we do, they won't be propagated and
  294. # we should not move forward with the action
  295. # if the inventory generation failed
  296. with open(path, 'w') as playbook:
  297. playbook.write(self._playbook)
  298. self._playbook = path
  299. return path
  300. @property
  301. def ssh_private_key(self):
  302. if not self._ssh_private_key:
  303. return None
  304. # NOTE(flaper87): if it's a path, use it
  305. if (isinstance(self._ssh_private_key, six.string_types) and
  306. os.path.exists(self._ssh_private_key)):
  307. return self._ssh_private_key
  308. path = os.path.join(self.work_dir, 'ssh_private_key')
  309. # NOTE(flaper87):
  310. # We could probably catch parse errors here
  311. # but if we do, they won't be propagated and
  312. # we should not move forward with the action
  313. # if the inventory generation failed
  314. with open(path, 'w') as ssh_key:
  315. ssh_key.write(self._ssh_private_key)
  316. os.chmod(path, 0o600)
  317. self._ssh_private_key = path
  318. return path
  319. def format_message(self, message):
  320. return {
  321. 'body': {
  322. 'payload': {
  323. 'message': message,
  324. 'status': 'RUNNING',
  325. 'execution': {'id': self.execution_id}}}}
  326. def post_message(self, queue, message):
  327. """Posts message to queue
  328. Breaks the message up by maximum message size if needed.
  329. """
  330. start = 0
  331. # We use 50% of the max message size to account for any overhead
  332. # due to JSON encoding plus the wrapped dict structure from
  333. # format_message.
  334. message_size = int(self.max_message_size * 0.5)
  335. while True:
  336. end = start + message_size
  337. message_part = message[start:end]
  338. start = end
  339. if not message_part:
  340. return
  341. queue.post(self.format_message(message_part))
  342. def run(self, context):
  343. if 0 < self.verbosity < 6:
  344. verbosity_option = '-' + ('v' * self.verbosity)
  345. command = ['ansible-playbook', verbosity_option,
  346. self.playbook]
  347. else:
  348. command = ['ansible-playbook', self.playbook]
  349. if self.limit_hosts:
  350. command.extend(['--limit', self.limit_hosts])
  351. if self.module_path:
  352. command.extend(['--module-path', self.module_path])
  353. if self.remote_user:
  354. command.extend(['--user', self.remote_user])
  355. if self.become:
  356. command.extend(['--become'])
  357. if self.become_user:
  358. command.extend(['--become-user', self.become_user])
  359. if self.extra_vars:
  360. command.extend(['--extra-vars', self.extra_vars])
  361. if self.flush_cache:
  362. command.extend(['--flush-cache'])
  363. if self.forks:
  364. command.extend(['--forks', self.forks])
  365. if self.ssh_common_args:
  366. command.extend(['--ssh-common-args', self.ssh_common_args])
  367. if self.ssh_extra_args:
  368. command.extend(['--ssh-extra-args', self.ssh_extra_args])
  369. if self.timeout:
  370. command.extend(['--timeout', self.timeout])
  371. if self.inventory:
  372. command.extend(['--inventory-file', self.inventory])
  373. if self.ssh_private_key:
  374. command.extend(['--private-key', self.ssh_private_key])
  375. if self.blacklisted_hostnames:
  376. host_pattern = ':'.join(
  377. ['!%s' % h for h in self.blacklisted_hostnames])
  378. command.extend(['--limit', host_pattern])
  379. if self.tags:
  380. command.extend(['--tags', self.tags])
  381. if self.skip_tags:
  382. command.extend(['--skip-tags', self.skip_tags])
  383. if self.extra_env_variables:
  384. if not isinstance(self.extra_env_variables, dict):
  385. msg = "extra_env_variables must be a dict"
  386. return actions.Result(error=msg)
  387. else:
  388. for key, value in self.extra_env_variables.items():
  389. self.extra_env_variables[key] = six.text_type(value)
  390. try:
  391. ansible_config_path = write_default_ansible_cfg(self.work_dir)
  392. env_variables = {
  393. 'HOME': self.work_dir,
  394. 'ANSIBLE_LOCAL_TEMP': self.work_dir,
  395. 'ANSIBLE_CONFIG': ansible_config_path,
  396. }
  397. if self.profile_tasks:
  398. env_variables.update({
  399. # the whitelist could be collected from multiple
  400. # arguments if we find a use case for it
  401. 'ANSIBLE_CALLBACK_WHITELIST': 'profile_tasks',
  402. 'PROFILE_TASKS_TASK_OUTPUT_LIMIT':
  403. six.text_type(self.profile_tasks_limit),
  404. })
  405. if self.extra_env_variables:
  406. env_variables.update(self.extra_env_variables)
  407. if self.use_openstack_credentials:
  408. env_variables.update({
  409. 'OS_AUTH_URL': context.auth_uri,
  410. 'OS_USERNAME': context.user_name,
  411. 'OS_AUTH_TOKEN': context.auth_token,
  412. 'OS_PROJECT_NAME': context.project_name})
  413. command = [str(c) for c in command]
  414. if self.reproduce_command:
  415. command_path = os.path.join(self.work_dir,
  416. "ansible-playbook-command.sh")
  417. with open(command_path, 'w') as f:
  418. f.write('#!/bin/bash\n')
  419. f.write('\n')
  420. for var in env_variables:
  421. f.write('%s="%s"\n' % (var, env_variables[var]))
  422. f.write('\n')
  423. f.write(' '.join(command))
  424. f.write(' "$@"')
  425. f.write('\n')
  426. os.chmod(command_path, 0o750)
  427. if self.queue_name:
  428. zaqar = self.get_messaging_client(context)
  429. queue = zaqar.queue(self.queue_name)
  430. # TODO(d0ugal): We don't have the log errors functionality
  431. # that processutils has, do we need to replicate that somehow?
  432. process = subprocess.Popen(command, stdout=subprocess.PIPE,
  433. stderr=subprocess.STDOUT,
  434. shell=False, bufsize=1,
  435. cwd=self.work_dir,
  436. env=env_variables)
  437. start = time.time()
  438. stdout = []
  439. lines = []
  440. for line in iter(process.stdout.readline, b''):
  441. lines.append(line)
  442. if not self.trash_output:
  443. stdout.append(line)
  444. if time.time() - start > 30:
  445. self.post_message(queue, ''.join(lines))
  446. lines = []
  447. start = time.time()
  448. self.post_message(queue, ''.join(lines))
  449. process.stdout.close()
  450. returncode = process.wait()
  451. # TODO(d0ugal): This bit isn't ideal - as we redirect stderr to
  452. # stdout we don't know the difference. To keep the return dict
  453. # similar there is an empty stderr. We can use the return code
  454. # to determine if there was an error.
  455. return {"stdout": "".join(stdout), "returncode": returncode,
  456. "stderr": ""}
  457. stderr, stdout = processutils.execute(
  458. *command, cwd=self.work_dir,
  459. env_variables=env_variables,
  460. log_errors=processutils.LogErrors.ALL)
  461. if self.trash_output:
  462. stdout = ""
  463. stderr = ""
  464. return {"stderr": stderr, "stdout": stdout,
  465. "log_path": os.path.join(self.work_dir, 'ansible.log')}
  466. finally:
  467. # NOTE(flaper87): clean the mess if debug is disabled.
  468. if not self.verbosity:
  469. shutil.rmtree(self.work_dir)
  470. class AnsibleGenerateInventoryAction(base.TripleOAction):
  471. """Executes tripleo-ansible-inventory to generate an inventory"""
  472. def __init__(self, **kwargs):
  473. self._kwargs_for_run = kwargs
  474. self.ansible_ssh_user = self._kwargs_for_run.pop(
  475. 'ansible_ssh_user', 'tripleo-admin')
  476. self._work_dir = self._kwargs_for_run.pop(
  477. 'work_dir', None)
  478. self.plan_name = self._kwargs_for_run.pop(
  479. 'plan_name', 'overcloud')
  480. @property
  481. def work_dir(self):
  482. if self._work_dir:
  483. return self._work_dir
  484. self._work_dir = tempfile.mkdtemp(prefix='ansible-mistral-action')
  485. return self._work_dir
  486. def run(self, context):
  487. inventory_path = os.path.join(
  488. self.work_dir, 'tripleo-ansible-inventory.yaml')
  489. inventory = TripleoInventory(
  490. session=self.get_session(context, 'heat'),
  491. hclient=self.get_orchestration_client(context),
  492. auth_url=context.security.auth_uri,
  493. cacert=context.security.auth_cacert,
  494. project_name=context.security.project_name,
  495. username=context.security.user_name,
  496. ansible_ssh_user=self.ansible_ssh_user,
  497. plan_name=self.plan_name)
  498. inventory.write_static_inventory(inventory_path)
  499. return inventory_path