diff --git a/releasenotes/notes/adds-ansible-actions-4da45efa8a98cade.yaml b/releasenotes/notes/adds-ansible-actions-4da45efa8a98cade.yaml new file mode 100644 index 000000000..860562b51 --- /dev/null +++ b/releasenotes/notes/adds-ansible-actions-4da45efa8a98cade.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Adds actions for calling ansible and ansible playbook executables from a + workflow. +deprecations: + - | + The actions for calling ansible and ansible playbook executables from a + workflow will be removed in the Queens release as they are intended to + be migrated to the mistral-extra project. diff --git a/setup.cfg b/setup.cfg index 030468b33..a5e2ef6b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -110,4 +110,6 @@ mistral.actions = tripleo.validations.run_validation = tripleo_common.actions.validations:RunValidationAction tripleo.validations.verify_profiles = tripleo_common.actions.validations:VerifyProfilesAction # deprecated for pike release, will be removed in queens + tripleo.ansible = tripleo_common.actions.ansible:AnsibleAction + tripleo.ansible-playbook = tripleo_common.actions.ansible:AnsiblePlaybookAction tripleo.templates.upload_default = tripleo_common.actions.templates:UploadTemplatesAction diff --git a/tripleo_common/actions/ansible.py b/tripleo_common/actions/ansible.py new file mode 100644 index 000000000..76b788c0c --- /dev/null +++ b/tripleo_common/actions/ansible.py @@ -0,0 +1,308 @@ +# Copyright 2017 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import json +import os +import six +import tempfile + +import yaml + +from mistral_lib import actions +from oslo_concurrency import processutils + + +def _write_data(data, suffix=''): + temp_data = tempfile.NamedTemporaryFile(suffix=suffix) + temp_data.write(data) + temp_data.flush() + return temp_data + + +class AnsibleAction(actions.Action): + """Executes ansible module""" + + def __init__(self, **kwargs): + self._kwargs_for_run = kwargs + self.hosts = self._kwargs_for_run.pop('hosts', None) + self.module = self._kwargs_for_run.pop('module', None) + self.module_args = self._kwargs_for_run.pop('module_args', None) + if self.module_args: + self.module_args = json.dumps(self.module_args) + self.limit_hosts = self._kwargs_for_run.pop('limit_hosts', None) + self.remote_user = self._kwargs_for_run.pop('remote_user', None) + self.become = self._kwargs_for_run.pop('become', None) + self.become_user = self._kwargs_for_run.pop('become_user', None) + self.extra_vars = self._kwargs_for_run.pop('extra_vars', None) + if self.extra_vars: + self.extra_vars = json.dumps(self.extra_vars) + self._inventory = self._kwargs_for_run.pop('inventory', None) + self.verbosity = self._kwargs_for_run.pop('verbosity', 5) + self._ssh_private_key = self._kwargs_for_run.pop( + 'ssh_private_key', None) + self.forks = self._kwargs_for_run.pop('forks', None) + self.timeout = self._kwargs_for_run.pop('timeout', None) + self.ssh_extra_args = self._kwargs_for_run.pop('ssh_extra_args', None) + if self.ssh_extra_args: + self.ssh_extra_args = json.dumps(self.ssh_extra_args) + self.ssh_common_args = self._kwargs_for_run.pop( + 'ssh_common_args', None) + if self.ssh_common_args: + self.ssh_common_args = json.dumps(self.ssh_common_args) + + @property + def inventory(self): + if not self._inventory: + return None + + # NOTE(flaper87): if it's a path, use it + if (isinstance(self._inventory, six.string_types) and + os.path.exists(self._inventory)): + return open(self._inventory) + else: + self._inventory = yaml.safe_dump(self._inventory) + + # NOTE(flaper87): + # We could probably catch parse errors here + # but if we do, they won't be propagated and + # we should not move forward with the action + # if the inventory generation failed + return _write_data(self._inventory, suffix='.yaml') + + @property + def ssh_private_key(self): + if not self._ssh_private_key: + return None + + # NOTE(flaper87): if it's a path, use it + if (isinstance(self._ssh_private_key, six.string_types) and + os.path.exists(self._ssh_private_key)): + return open(self._ssh_private_key) + + # NOTE(flaper87): + # We could probably catch parse errors here + # but if we do, they won't be propagated and + # we should not move forward with the action + # if the playbook generation failed + return _write_data(self._ssh_private_key) + + def run(self, context): + + if 0 < self.verbosity < 6: + verbosity_option = '-' + ('v' * self.verbosity) + command = ['ansible', self.hosts, verbosity_option, ] + else: + command = ['ansible-playbook', self.hosts, ] + + if self.module: + command.extend(['--module-name', self.module]) + + if self.module_args: + command.extend(['--args', self.module_args]) + + if self.limit_hosts: + command.extend(['--limit', self.limit_hosts]) + + if self.remote_user: + command.extend(['--user', self.remote_user]) + + if self.become: + command.extend(['--become']) + + if self.become_user: + command.extend(['--become-user', self.become_user]) + + if self.extra_vars: + command.extend(['--extra-vars', self.extra_vars]) + + if self.forks: + command.extend(['--forks', self.forks]) + + if self.ssh_common_args: + command.extend(['--ssh-common-args', self.ssh_common_args]) + + if self.ssh_extra_args: + command.extend(['--ssh-extra-args', self.ssh_extra_args]) + + if self.timeout: + command.extend(['--timeout', self.timeout]) + + inventory_file = self.inventory + if inventory_file: + command.extend(['--inventory-file', inventory_file.name]) + + ssh_priv_key_file = self.ssh_private_key + if ssh_priv_key_file: + command.extend(['--private-key', ssh_priv_key_file.name]) + + try: + stderr, stdout = processutils.execute( + *command, log_errors=processutils.LogErrors.ALL) + return {"stderr": stderr, "stdout": stdout} + finally: + # NOTE(flaper87): Close the file + # this is important as it'll also cleanup + # temporary files + if inventory_file: + inventory_file.close() + + if ssh_priv_key_file: + ssh_priv_key_file.close() + + +class AnsiblePlaybookAction(actions.Action): + """Executes ansible playbook""" + + def __init__(self, **kwargs): + self._kwargs_for_run = kwargs + self._playbook = self._kwargs_for_run.pop('playbook', None) + self.limit_hosts = self._kwargs_for_run.pop('limit_hosts', None) + self.remote_user = self._kwargs_for_run.pop('remote_user', None) + self.become = self._kwargs_for_run.pop('become', None) + self.become_user = self._kwargs_for_run.pop('become_user', None) + self.extra_vars = self._kwargs_for_run.pop('extra_vars', None) + if self.extra_vars: + self.extra_vars = json.dumps(self.extra_vars) + self._inventory = self._kwargs_for_run.pop('inventory', None) + self.verbosity = self._kwargs_for_run.pop('verbosity', 5) + self._ssh_private_key = self._kwargs_for_run.pop( + 'ssh_private_key', None) + self.flush_cache = self._kwargs_for_run.pop('flush_cache', None) + self.forks = self._kwargs_for_run.pop('forks', None) + self.timeout = self._kwargs_for_run.pop('timeout', None) + self.ssh_extra_args = self._kwargs_for_run.pop('ssh_extra_args', None) + if self.ssh_extra_args: + self.ssh_extra_args = json.dumps(self.ssh_extra_args) + self.ssh_common_args = self._kwargs_for_run.pop( + 'ssh_common_args', None) + if self.ssh_common_args: + self.ssh_common_args = json.dumps(self.ssh_common_args) + + @property + def inventory(self): + if not self._inventory: + return None + + # NOTE(flaper87): if it's a path, use it + if (isinstance(self._inventory, six.string_types) and + os.path.exists(self._inventory)): + return open(self._inventory) + else: + self._inventory = yaml.safe_dump(self._inventory) + + # NOTE(flaper87): + # We could probably catch parse errors here + # but if we do, they won't be propagated and + # we should not move forward with the action + # if the inventory generation failed + return _write_data(self._inventory, suffix='.yaml') + + @property + def playbook(self): + if not self._playbook: + return None + + # NOTE(flaper87): if it's a path, use it + if (isinstance(self._playbook, six.string_types) and + os.path.exists(self._playbook)): + return open(self._playbook) + else: + self._playbook = yaml.safe_dump(self._playbook) + + # NOTE(flaper87): + # We could probably catch parse errors here + # but if we do, they won't be propagated and + # we should not move forward with the action + # if the playbook generation failed + return _write_data(self._playbook, suffix='.yaml') + + @property + def ssh_private_key(self): + if not self._ssh_private_key: + return None + + # NOTE(flaper87): if it's a path, use it + if (isinstance(self._ssh_private_key, six.string_types) and + os.path.exists(self._ssh_private_key)): + return open(self._ssh_private_key) + + # NOTE(flaper87): + # We could probably catch parse errors here + # but if we do, they won't be propagated and + # we should not move forward with the action + # if the playbook generation failed + return _write_data(self._ssh_private_key) + + def run(self, context): + playbook_file = self.playbook + if 0 < self.verbosity < 6: + verbosity_option = '-' + ('v' * self.verbosity) + command = ['ansible-playbook', verbosity_option, + playbook_file.name] + else: + command = ['ansible-playbook', playbook_file.name] + + if self.limit_hosts: + command.extend(['--limit', self.limit_hosts]) + + if self.remote_user: + command.extend(['--user', self.remote_user]) + + if self.become: + command.extend(['--become']) + + if self.become_user: + command.extend(['--become-user', self.become_user]) + + if self.extra_vars: + command.extend(['--extra-vars', self.extra_vars]) + + if self.flush_cache: + command.extend(['--flush-cache']) + + if self.forks: + command.extend(['--forks', self.forks]) + + if self.ssh_common_args: + command.extend(['--ssh-common-args', self.ssh_common_args]) + + if self.ssh_extra_args: + command.extend(['--ssh-extra-args', self.ssh_extra_args]) + + if self.timeout: + command.extend(['--timeout', self.timeout]) + + inventory_file = self.inventory + if inventory_file: + command.extend(['--inventory-file', inventory_file.name]) + + ssh_priv_key_file = self.ssh_private_key + if ssh_priv_key_file: + command.extend(['--private-key', ssh_priv_key_file.name]) + + try: + stderr, stdout = processutils.execute( + *command, log_errors=processutils.LogErrors.ALL) + return {"stderr": stderr, "stdout": stdout} + finally: + # NOTE(flaper87): Close the file + # this is important as it'll also cleanup + # temporary files + if inventory_file: + inventory_file.close() + + if ssh_priv_key_file: + ssh_priv_key_file.close() + + playbook_file.close() diff --git a/tripleo_common/tests/actions/test_ansible.py b/tripleo_common/tests/actions/test_ansible.py new file mode 100644 index 000000000..00bcbb7cc --- /dev/null +++ b/tripleo_common/tests/actions/test_ansible.py @@ -0,0 +1,88 @@ +# Copyright 2017 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import json +import mock + +from oslo_concurrency import processutils + +from tripleo_common.actions import ansible +from tripleo_common.tests import base + + +class AnsibleActionTest(base.TestCase): + + def setUp(self): + super(AnsibleActionTest, self).setUp() + + self.hosts = "127.0.0.2" + self.module = "foo" + self.remote_user = 'fido' + self.become = True + self.become_user = 'root' + self.ctx = mock.MagicMock() + + @mock.patch("oslo_concurrency.processutils.execute") + def test_run(self, mock_execute): + + mock_execute.return_value = ('', '') + + action = ansible.AnsibleAction( + hosts=self.hosts, module=self.module, remote_user=self.remote_user, + become=self.become, become_user=self.become_user) + action.run(self.ctx) + + mock_execute.assert_called_once_with( + 'ansible', self.hosts, '-vvvvv', '--module-name', + self.module, '--user', self.remote_user, '--become', + '--become-user', self.become_user, + log_errors=processutils.LogErrors.ALL + ) + + +class AnsiblePlaybookActionTest(base.TestCase): + + def setUp(self): + super(AnsiblePlaybookActionTest, self).setUp() + + self.playbook = "myplaybook" + self.limit_hosts = None + self.remote_user = 'fido' + self.become = True + self.become_user = 'root' + self.extra_vars = {"var1": True, "var2": 0} + self.verbosity = 1 + self.ctx = mock.MagicMock() + + @mock.patch("tripleo_common.actions.ansible._write_data") + @mock.patch("oslo_concurrency.processutils.execute") + def test_run(self, mock_execute, mock_temp): + + mock_execute.return_value = ('', '') + mock_file = mock.MagicMock() + mock_file.name = self.playbook + mock_temp.return_value = mock_file + + action = ansible.AnsiblePlaybookAction( + playbook=self.playbook, limit_hosts=self.limit_hosts, + remote_user=self.remote_user, become=self.become, + become_user=self.become_user, extra_vars=self.extra_vars, + verbosity=self.verbosity) + action.run(self.ctx) + + mock_execute.assert_called_once_with( + 'ansible-playbook', '-v', self.playbook, '--user', + self.remote_user, '--become', '--become-user', self.become_user, + '--extra-vars', json.dumps(self.extra_vars), + log_errors=processutils.LogErrors.ALL)