From b8305b17995ea28ae0902473f82d7cf3e347438b Mon Sep 17 00:00:00 2001
From: Kevin Tibi <kevintibi@hotmail.com>
Date: Mon, 6 Aug 2018 12:32:23 +0200
Subject: [PATCH] Add commands to run command on hosts

Change-Id: I019fc3f5c59c383215febb958f9c4cf8c8b8e4a6
Story: 2003312
Task: 24270
---
 ansible/host-command-run.yml                  |  6 ++
 doc/source/administration/overcloud.rst       | 14 ++++
 doc/source/administration/seed.rst            | 18 +++++
 kayobe/cli/commands.py                        | 59 +++++++++++++++
 kayobe/tests/unit/cli/test_commands.py        | 72 +++++++++++++++++++
 kayobe/tests/unit/test_utils.py               |  5 ++
 kayobe/utils.py                               | 16 +++++
 .../host-run-command-eb98cb077d546551.yaml    |  8 +++
 setup.cfg                                     |  3 +
 9 files changed, 201 insertions(+)
 create mode 100644 ansible/host-command-run.yml
 create mode 100644 releasenotes/notes/host-run-command-eb98cb077d546551.yaml

diff --git a/ansible/host-command-run.yml b/ansible/host-command-run.yml
new file mode 100644
index 00000000..580cd746
--- /dev/null
+++ b/ansible/host-command-run.yml
@@ -0,0 +1,6 @@
+---
+- name: Run a command
+  hosts: seed-hypervisor:seed:overcloud
+  tasks:
+    - name: Run a command
+      shell: "{{ host_command_to_run }}"
diff --git a/doc/source/administration/overcloud.rst b/doc/source/administration/overcloud.rst
index 3a02aaa1..2a3336ce 100644
--- a/doc/source/administration/overcloud.rst
+++ b/doc/source/administration/overcloud.rst
@@ -21,6 +21,20 @@ To only install updates that have been marked security related::
 Note that these commands do not affect packages installed in containers, only
 those installed on the host.
 
+Running Commands
+================
+
+It is possible to run a command on the overcloud hosts::
+
+    (kayobe) $ kayobe overcloud host command run --command "<command>"
+
+For example::
+
+    (kayobe) $ kayobe overcloud host command run --command "service docker restart"
+
+To execute the command with root privileges, add the ``--become`` argument.
+Adding the ``--verbose`` argument allows the output of the command to be seen.
+
 Reconfiguring Containerised Services
 ====================================
 
diff --git a/doc/source/administration/seed.rst b/doc/source/administration/seed.rst
index 9d99e3bb..41ad8e8e 100644
--- a/doc/source/administration/seed.rst
+++ b/doc/source/administration/seed.rst
@@ -122,3 +122,21 @@ Finally, start the Ironic and Ironic Inspector services again::
 
     docker exec -it bifrost_deploy \
     systemctl start ironic-api ironic-conductor ironic-inspector
+
+Running Commands
+================
+
+It is possible to run a command on the seed host::
+
+    (kayobe) $ kayobe seed host command run --command "<command>"
+
+For example::
+
+    (kayobe) $ kayobe seed host command run --command "service docker restart"
+
+Commands can also be run on the seed hypervisor host, if one is in use::
+
+    (kayobe) $ kayobe seed hypervisor host command run --command "<command>"
+
+To execute the command with root privileges, add the ``--become`` argument.
+Adding the ``--verbose`` argument allows the output of the command to be seen.
diff --git a/kayobe/cli/commands.py b/kayobe/cli/commands.py
index 4596af69..9f91dd26 100644
--- a/kayobe/cli/commands.py
+++ b/kayobe/cli/commands.py
@@ -326,6 +326,27 @@ class SeedHypervisorHostConfigure(KollaAnsibleMixin, KayobeAnsibleMixin,
                                   limit="seed-hypervisor")
 
 
+class SeedHypervisorHostCommandRun(KayobeAnsibleMixin, VaultMixin, Command):
+    """Run command on the seed hypervisor host."""
+
+    def get_parser(self, prog_name):
+        parser = super(SeedHypervisorHostCommandRun, self).get_parser(
+            prog_name)
+        group = parser.add_argument_group("Host Command Run")
+        group.add_argument("--command", required=True,
+                           help="Command to run (required).")
+        return parser
+
+    def take_action(self, parsed_args):
+        self.app.LOG.debug("Run command on seed hypervisor host")
+        extra_vars = {
+            "host_command_to_run": utils.escape_jinja(parsed_args.command)}
+        playbooks = _build_playbook_list("host-command-run")
+        self.run_kayobe_playbooks(parsed_args, playbooks,
+                                  limit="seed-hypervisor",
+                                  extra_vars=extra_vars)
+
+
 class SeedHypervisorHostUpgrade(KayobeAnsibleMixin, VaultMixin, Command):
     """Upgrade the seed hypervisor host services.
 
@@ -503,6 +524,25 @@ class SeedHostPackageUpdate(KayobeAnsibleMixin, VaultMixin, Command):
                                   extra_vars=extra_vars)
 
 
+class SeedHostCommandRun(KayobeAnsibleMixin, VaultMixin, Command):
+    """Run command on the seed host."""
+
+    def get_parser(self, prog_name):
+        parser = super(SeedHostCommandRun, self).get_parser(prog_name)
+        group = parser.add_argument_group("Host Command Run")
+        group.add_argument("--command", required=True,
+                           help="Command to run (required).")
+        return parser
+
+    def take_action(self, parsed_args):
+        self.app.LOG.debug("Run command on seed host")
+        extra_vars = {
+            "host_command_to_run": utils.escape_jinja(parsed_args.command)}
+        playbooks = _build_playbook_list("host-command-run")
+        self.run_kayobe_playbooks(parsed_args, playbooks, limit="seed",
+                                  extra_vars=extra_vars)
+
+
 class SeedHostUpgrade(KollaAnsibleMixin, KayobeAnsibleMixin, VaultMixin,
                       Command):
     """Upgrade the seed host services.
@@ -877,6 +917,25 @@ class OvercloudHostPackageUpdate(KayobeAnsibleMixin, VaultMixin, Command):
                                   extra_vars=extra_vars)
 
 
+class OvercloudHostCommandRun(KayobeAnsibleMixin, VaultMixin, Command):
+    """Run command on the overcloud host."""
+
+    def get_parser(self, prog_name):
+        parser = super(OvercloudHostCommandRun, self).get_parser(prog_name)
+        group = parser.add_argument_group("Host Command Run")
+        group.add_argument("--command", required=True,
+                           help="Command to run (required).")
+        return parser
+
+    def take_action(self, parsed_args):
+        self.app.LOG.debug("Run command on overcloud host")
+        extra_vars = {
+            "host_command_to_run": utils.escape_jinja(parsed_args.command)}
+        playbooks = _build_playbook_list("host-command-run")
+        self.run_kayobe_playbooks(parsed_args, playbooks, limit="overcloud",
+                                  extra_vars=extra_vars)
+
+
 class OvercloudHostUpgrade(KayobeAnsibleMixin, VaultMixin, Command):
     """Upgrade the overcloud host services.
 
diff --git a/kayobe/tests/unit/cli/test_commands.py b/kayobe/tests/unit/cli/test_commands.py
index c3d45635..8f89257a 100644
--- a/kayobe/tests/unit/cli/test_commands.py
+++ b/kayobe/tests/unit/cli/test_commands.py
@@ -273,6 +273,30 @@ class TestCase(unittest.TestCase):
         ]
         self.assertEqual(expected_calls, mock_run.call_args_list)
 
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    def test_seed_hypervisor_host_command_run(self, mock_run):
+        command = commands.SeedHypervisorHostCommandRun(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--command", "ls -a"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [
+                    utils.get_data_files_path("ansible",
+                                              "host-command-run.yml"),
+                ],
+                limit="seed-hypervisor",
+                extra_vars={
+                    "host_command_to_run": utils.escape_jinja("ls -a")},
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
     def test_seed_hypervisor_host_upgrade(self, mock_run):
@@ -485,6 +509,30 @@ class TestCase(unittest.TestCase):
         ]
         self.assertEqual(expected_calls, mock_kolla_run.call_args_list)
 
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    def test_seed_host_command_run(self, mock_run):
+        command = commands.SeedHostCommandRun(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--command", "ls -a"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [
+                    utils.get_data_files_path("ansible",
+                                              "host-command-run.yml"),
+                ],
+                limit="seed",
+                extra_vars={
+                    "host_command_to_run": utils.escape_jinja("ls -a")},
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
     def test_seed_host_package_update_all(self, mock_run):
@@ -1059,6 +1107,30 @@ class TestCase(unittest.TestCase):
         ]
         self.assertEqual(expected_calls, mock_kolla_run.call_args_list)
 
+    @mock.patch.object(commands.KayobeAnsibleMixin,
+                       "run_kayobe_playbooks")
+    def test_overcloud_host_command_run(self, mock_run):
+        command = commands.OvercloudHostCommandRun(TestApp(), [])
+        parser = command.get_parser("test")
+        parsed_args = parser.parse_args(["--command", "ls -a"])
+
+        result = command.run(parsed_args)
+        self.assertEqual(0, result)
+
+        expected_calls = [
+            mock.call(
+                mock.ANY,
+                [
+                    utils.get_data_files_path("ansible",
+                                              "host-command-run.yml"),
+                ],
+                limit="overcloud",
+                extra_vars={
+                    "host_command_to_run": utils.escape_jinja("ls -a")},
+            ),
+        ]
+        self.assertEqual(expected_calls, mock_run.call_args_list)
+
     @mock.patch.object(commands.KayobeAnsibleMixin,
                        "run_kayobe_playbooks")
     def test_overcloud_host_package_update_all(self, mock_run):
diff --git a/kayobe/tests/unit/test_utils.py b/kayobe/tests/unit/test_utils.py
index 4f5029ba..47f998b4 100644
--- a/kayobe/tests/unit/test_utils.py
+++ b/kayobe/tests/unit/test_utils.py
@@ -123,3 +123,8 @@ key2: value2
 
     def test_quote_and_escape_non_string(self):
         self.assertEqual(True, utils.quote_and_escape(True))
+
+    def test_escape_jinja(self):
+        value = "string to escape"
+        expected = "{{'c3RyaW5nIHRvIGVzY2FwZQ==' | b64decode }}"
+        self.assertEqual(expected, utils.escape_jinja(value))
diff --git a/kayobe/utils.py b/kayobe/utils.py
index 20302b82..4bed03d8 100644
--- a/kayobe/utils.py
+++ b/kayobe/utils.py
@@ -12,6 +12,7 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import base64
 import glob
 import logging
 import os
@@ -165,3 +166,18 @@ def quote_and_escape(value):
     if not isinstance(value, six.string_types):
         return value
     return "'" + value.replace("'", "'\\''") + "'"
+
+
+def escape_jinja(string):
+    """Escapes a string so that jinja template variables are not expanded
+
+    :param string: the string to escape
+    :return: the escaped string
+    """
+    # We base64 encode the string to avoid the need to escape characters.
+    # This is because ansible has some parsing quirks that makes it fairly
+    # hard to escape stuff in generic way.
+    # See: https://github.com/ansible/ansible/issues/10464
+
+    b64_value = base64.b64encode(string.encode())
+    return ''.join(('{{', "'", b64_value.decode(), "' | b64decode ", '}}'))
diff --git a/releasenotes/notes/host-run-command-eb98cb077d546551.yaml b/releasenotes/notes/host-run-command-eb98cb077d546551.yaml
new file mode 100644
index 00000000..8771e682
--- /dev/null
+++ b/releasenotes/notes/host-run-command-eb98cb077d546551.yaml
@@ -0,0 +1,8 @@
+---
+features:
+  - |
+    Add commands to run commands on seed hypervisor, seed and overcloud hosts:
+
+    ``kayobe seed hypervisor host command run --command <command>``
+    ``kayobe seed host command run --command <command>``
+    ``kayobe overcloud host command run --command <command>``
diff --git a/setup.cfg b/setup.cfg
index cc1c4bf7..872582e1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -58,6 +58,7 @@ kayobe.cli=
     overcloud_hardware_inspect = kayobe.cli.commands:OvercloudHardwareInspect
     overcloud_host_configure = kayobe.cli.commands:OvercloudHostConfigure
     overcloud_host_package_update = kayobe.cli.commands:OvercloudHostPackageUpdate
+    overcloud_host_command_run = kayobe.cli.commands:OvercloudHostCommandRun
     overcloud_host_upgrade = kayobe.cli.commands:OvercloudHostUpgrade
     overcloud_introspection_data_save = kayobe.cli.commands:OvercloudIntrospectionDataSave
     overcloud_inventory_discover = kayobe.cli.commands:OvercloudInventoryDiscover
@@ -75,8 +76,10 @@ kayobe.cli=
     seed_deployment_image_build = kayobe.cli.commands:SeedDeploymentImageBuild
     seed_host_configure = kayobe.cli.commands:SeedHostConfigure
     seed_host_package_update = kayobe.cli.commands:SeedHostPackageUpdate
+    seed_host_command_run = kayobe.cli.commands:SeedHostCommandRun
     seed_host_upgrade = kayobe.cli.commands:SeedHostUpgrade
     seed_hypervisor_host_configure = kayobe.cli.commands:SeedHypervisorHostConfigure
+    seed_hypervisor_host_command_run = kayobe.cli.commands:SeedHypervisorHostCommandRun
     seed_hypervisor_host_upgrade = kayobe.cli.commands:SeedHypervisorHostUpgrade
     seed_service_deploy = kayobe.cli.commands:SeedServiceDeploy
     seed_service_upgrade = kayobe.cli.commands:SeedServiceUpgrade
-- 
GitLab