From 2916ff0a1a2482caad387767eb1bded85bb77714 Mon Sep 17 00:00:00 2001
From: Tim Rupp <caphrim007@gmail.com>
Date: Sat, 6 Jan 2018 23:23:32 -0800
Subject: [PATCH] Introduces numerous fixes for bigip command (#34550)

A bug in the parsing of single commands with commas
Token cleanup
Password argument now defaults to false
Addition of coding conventions from v3 conventions
---
 .../modules/network/f5/bigip_command.py       | 90 +++++++++++++++----
 .../modules/network/f5/test_bigip_command.py  | 60 ++++++++++---
 2 files changed, 122 insertions(+), 28 deletions(-)

diff --git a/lib/ansible/modules/network/f5/bigip_command.py b/lib/ansible/modules/network/f5/bigip_command.py
index 058bf06601a..4125d3662c1 100644
--- a/lib/ansible/modules/network/f5/bigip_command.py
+++ b/lib/ansible/modules/network/f5/bigip_command.py
@@ -79,11 +79,6 @@ options:
         - cli
     default: rest
     version_added: "2.5"
-notes:
-  - Requires the f5-sdk Python package on the host. This is as easy as pip
-    install f5-sdk.
-requirements:
-  - f5-sdk >= 2.2.3
 extends_documentation_fragment: f5
 author:
   - Tim Rupp (@caphrim007)
@@ -167,6 +162,7 @@ failed_conditions:
 import re
 import time
 
+from ansible.module_utils.basic import env_fallback
 from ansible.module_utils.f5_utils import AnsibleF5Client
 from ansible.module_utils.f5_utils import AnsibleF5Parameters
 from ansible.module_utils.f5_utils import HAS_F5SDK
@@ -183,6 +179,8 @@ from ansible.module_utils.network.common.parsing import FailedConditionsError
 from ansible.module_utils.network.common.parsing import Conditional
 from ansible.module_utils.network.common.utils import ComplexList
 from ansible.module_utils.network.common.utils import to_list
+from ansible.module_utils.six import iteritems
+from collections import defaultdict
 from collections import deque
 
 try:
@@ -194,6 +192,36 @@ except ImportError:
 class Parameters(AnsibleF5Parameters):
     returnables = ['stdout', 'stdout_lines', 'warnings']
 
+    def __init__(self, params=None):
+        self._values = defaultdict(lambda: None)
+        self._values['__warnings'] = []
+        if params:
+            self.update(params=params)
+
+    def update(self, params=None):
+        if params:
+            for k, v in iteritems(params):
+                if self.api_map is not None and k in self.api_map:
+                    map_key = self.api_map[k]
+                else:
+                    map_key = k
+
+                # Handle weird API parameters like `dns.proxy.__iter__` by
+                # using a map provided by the module developer
+                class_attr = getattr(type(self), map_key, None)
+                if isinstance(class_attr, property):
+                    # There is a mapped value for the api_map key
+                    if class_attr.fset is None:
+                        # If the mapped value does not have
+                        # an associated setter
+                        self._values[map_key] = v
+                    else:
+                        # The mapped value has a setter
+                        setattr(self, map_key, v)
+                else:
+                    # If the mapped value is not a @property
+                    self._values[map_key] = v
+
     def to_return(self):
         result = {}
         for returnable in self.returnables:
@@ -201,19 +229,28 @@ class Parameters(AnsibleF5Parameters):
         result = self._filter_params(result)
         return result
 
+    def _listify(self, item):
+        if isinstance(item, string_types):
+            result = [item]
+        else:
+            result = item
+        return result
+
     @property
     def commands(self):
-        commands = deque(self._values['commands'])
+        commands = self._listify(self._values['commands'])
+        commands = deque(commands)
         if self._values['transport'] != 'cli':
             commands.appendleft(
                 'tmsh modify cli preference pager disabled'
             )
-            commands = map(self._ensure_tmsh_prefix, list(commands))
+        commands = map(self._ensure_tmsh_prefix, list(commands))
         return list(commands)
 
     @property
     def user_commands(self):
-        return map(self._ensure_tmsh_prefix, list(self._values['commands']))
+        commands = self._listify(self._values['commands'])
+        return map(self._ensure_tmsh_prefix, commands)
 
     def _ensure_tmsh_prefix(self, cmd):
         cmd = cmd.strip()
@@ -259,12 +296,13 @@ class ModuleManager(object):
         result.update(dict(changed=changed))
         return result
 
+    def _run_commands(self, module, commands):
+        return run_commands(module, commands)
+
     def execute(self):
         warnings = list()
         changed = ('tmsh modify', 'tmsh create', 'tmsh delete')
-
         commands = self.parse_commands(warnings)
-
         wait_for = self.want.wait_for or list()
         retries = self.want.retries
 
@@ -275,7 +313,7 @@ class ModuleManager(object):
 
         while retries > 0:
             if self.client.module.params['transport'] == 'cli' and HAS_CLI_TRANSPORT:
-                responses = run_commands(self.client.module, self.want.commands)
+                responses = self._run_commands(self.client.module, commands)
             else:
                 responses = self.execute_on_device(commands)
 
@@ -327,10 +365,15 @@ class ModuleManager(object):
                     'module does not exist, then please file a bug. The command '
                     'in question is "%s..."' % item['command'][0:40]
                 )
-            if item['output'] == 'one-line' and 'one-line' not in item['command']:
+            # This needs to be removed so that the ComplexList used in to_commands
+            # will work correctly.
+            output = item.pop('output', None)
+
+            if output == 'one-line' and 'one-line' not in item['command']:
                 item['command'] += ' one-line'
-            elif item['output'] == 'text' and 'one-line' in item['command']:
+            elif output == 'text' and 'one-line' in item['command']:
                 item['command'] = item['command'].replace('one-line', '')
+
             results.append(item)
         return results
 
@@ -353,7 +396,7 @@ class ArgumentSpec(object):
         self.supports_check_mode = True
         self.argument_spec = dict(
             commands=dict(
-                type='list',
+                type='raw',
                 required=True
             ),
             wait_for=dict(
@@ -376,11 +419,26 @@ class ArgumentSpec(object):
                 type='str',
                 default='rest',
                 choices=['cli', 'rest']
+            ),
+            password=dict(
+                required=False,
+                fallback=(env_fallback, ['F5_PASSWORD']),
+                no_log=True
             )
         )
         self.f5_product_name = 'bigip'
 
 
+def cleanup_tokens(client):
+    try:
+        resource = client.api.shared.authz.tokens_s.token.load(
+            name=client.api.icrs.token
+        )
+        resource.delete()
+    except Exception:
+        pass
+
+
 def main():
     spec = ArgumentSpec()
 
@@ -396,8 +454,10 @@ def main():
     try:
         mm = ModuleManager(client)
         results = mm.exec_module()
+        cleanup_tokens(client)
         client.module.exit_json(**results)
-    except (FailedConditionsError, AttributeError) as e:
+    except F5ModuleError as e:
+        cleanup_tokens(client)
         client.module.fail_json(msg=str(e))
 
 
diff --git a/test/units/modules/network/f5/test_bigip_command.py b/test/units/modules/network/f5/test_bigip_command.py
index 39d4c2333d7..8f354916424 100644
--- a/test/units/modules/network/f5/test_bigip_command.py
+++ b/test/units/modules/network/f5/test_bigip_command.py
@@ -15,21 +15,23 @@ if sys.version_info < (2, 7):
     raise SkipTest("F5 Ansible modules require Python >= 2.7")
 
 from ansible.compat.tests import unittest
-from ansible.compat.tests.mock import patch, Mock
+from ansible.compat.tests.mock import patch
+from ansible.compat.tests.mock import Mock
 from ansible.module_utils.f5_utils import AnsibleF5Client
-from units.modules.utils import set_module_args
 
 try:
     from library.bigip_command import Parameters
     from library.bigip_command import ModuleManager
     from library.bigip_command import ArgumentSpec
     from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError
+    from test.unit.modules.utils import set_module_args
 except ImportError:
     try:
         from ansible.modules.network.f5.bigip_command import Parameters
         from ansible.modules.network.f5.bigip_command import ModuleManager
         from ansible.modules.network.f5.bigip_command import ArgumentSpec
         from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError
+        from units.modules.utils import set_module_args
     except ImportError:
         raise SkipTest("F5 Ansible modules require the f5-sdk Python library")
 
@@ -68,10 +70,6 @@ class TestParameters(unittest.TestCase):
 class TestManager(unittest.TestCase):
 
     def setUp(self):
-        self.mock_run_commands = patch('ansible.modules.network.f5.bigip_command.run_commands')
-        self.run_commands = self.mock_run_commands.start()
-        self.mock_execute_on_device = patch('ansible.modules.network.f5.bigip_command.ModuleManager.execute_on_device')
-        self.execute_on_device = self.mock_execute_on_device.start()
         self.spec = ArgumentSpec()
 
     def test_run_single_command(self, *args):
@@ -90,12 +88,14 @@ class TestManager(unittest.TestCase):
             f5_product_name=self.spec.f5_product_name
         )
         mm = ModuleManager(client)
+        mm._run_commands = Mock(return_value=[])
+        mm.execute_on_device = Mock(return_value=[])
 
         results = mm.exec_module()
 
         assert results['changed'] is False
-        self.assertEqual(self.run_commands.call_count, 0)
-        self.assertEqual(self.execute_on_device.call_count, 1)
+        assert mm._run_commands.call_count == 0
+        assert mm.execute_on_device.call_count == 1
 
     def test_run_single_modification_command(self, *args):
         set_module_args(dict(
@@ -113,12 +113,14 @@ class TestManager(unittest.TestCase):
             f5_product_name=self.spec.f5_product_name
         )
         mm = ModuleManager(client)
+        mm._run_commands = Mock(return_value=[])
+        mm.execute_on_device = Mock(return_value=[])
 
         results = mm.exec_module()
 
         assert results['changed'] is True
-        self.assertEqual(self.run_commands.call_count, 0)
-        self.assertEqual(self.execute_on_device.call_count, 1)
+        assert mm._run_commands.call_count == 0
+        assert mm.execute_on_device.call_count == 1
 
     def test_cli_command(self, *args):
         set_module_args(dict(
@@ -136,6 +138,38 @@ class TestManager(unittest.TestCase):
             f5_product_name=self.spec.f5_product_name
         )
         mm = ModuleManager(client)
-        mm.exec_module()
-        self.assertEqual(self.run_commands.call_count, 1)
-        self.assertEqual(self.execute_on_device.call_count, 0)
+        mm._run_commands = Mock(return_value=[])
+        mm.execute_on_device = Mock(return_value=[])
+
+        results = mm.exec_module()
+
+        assert results['changed'] is False
+        assert mm._run_commands.call_count == 1
+        assert mm.execute_on_device.call_count == 0
+
+    def test_command_with_commas(self, *args):
+        set_module_args(dict(
+            commands="""
+              tmsh create /auth ldap system-auth {bind-dn uid=binduser,
+              cn=users,dc=domain,dc=com bind-pw $ENCRYPTEDPW check-roles-group
+              enabled search-base-dn cn=users,dc=domain,dc=com servers add {
+              ldap.server.com } }"
+            """,
+            server='localhost',
+            user='admin',
+            password='password'
+        ))
+        client = AnsibleF5Client(
+            argument_spec=self.spec.argument_spec,
+            supports_check_mode=self.spec.supports_check_mode,
+            f5_product_name=self.spec.f5_product_name
+        )
+        mm = ModuleManager(client)
+        mm._run_commands = Mock(return_value=[])
+        mm.execute_on_device = Mock(return_value=[])
+
+        results = mm.exec_module()
+
+        assert results['changed'] is True
+        assert mm._run_commands.call_count == 0
+        assert mm.execute_on_device.call_count == 1