Repository: StackStorm/st2

Issue:
Unable to pause workflow
When trying to pause a workflow from the CLI i get the following error:

```
$ st2 execution pause 5a859f78032fb6065d52359c
ERROR: 404 Client Error: Not Found
MESSAGE: Unable to identify resource with id "['5a859f78032fb6065d52359c']". for url: http://127.0.0.1:9101/executions/['5a859f78032fb6065d52359c']
```

Looks like the ID is being parsed as a list and inserted into the URL as the string representation of a list with a single element.


Discussion:
Found the issue.

Looks like the `id` parameter is being added to `argparse` with `nargs='+'`: https://github.com/StackStorm/st2/blob/master/st2client/st2client/commands/action.py#L1338

This turns it into a list, causing the URL to be generated incorrectly.

Instead we need to process the IDs in a loop like how it's handled in the cancel command: https://github.com/StackStorm/st2/blob/master/st2client/st2client/commands/action.py#L1231-L1244

Resolving patch:
diff --git a/st2client/st2client/commands/action.py b/st2client/st2client/commands/action.py
--- a/st2client/st2client/commands/action.py
+++ b/st2client/st2client/commands/action.py
@@ -1335,27 +1335,36 @@ def __init__(self, resource, *args, **kwargs):
             resource.get_plural_display_name().lower(),
             *args, **kwargs)
 
-        self.parser.add_argument('id', nargs='+',
-                                 metavar='id',
+        self.parser.add_argument('ids',
+                                 nargs='+',
                                  help='ID of action execution to pause.')
 
         self._add_common_options()
 
     @add_auth_token_to_kwargs_from_cli
     def run(self, args, **kwargs):
-        return self.manager.pause(args.id)
+        responses = []
+        for execution_id in args.ids:
+            try:
+                response = self.manager.pause(execution_id)
+                responses.append([execution_id, response])
+            except resource.ResourceNotFoundError:
+                self.print_not_found(args.ids)
+                raise ResourceNotFoundError('Execution with id %s not found.' % (execution_id))
+
+        return responses
 
     @add_auth_token_to_kwargs_from_cli
     def run_and_print(self, args, **kwargs):
-        try:
-            execution = self.run(args, **kwargs)
+        responses = self.run(args, **kwargs)
 
-            if not args.json and not args.yaml:
-                # Include elapsed time for running executions
-                execution = format_execution_status(execution)
-        except resource.ResourceNotFoundError:
-            self.print_not_found(args.id)
-            raise ResourceNotFoundError('Execution  with id %s not found.' % (args.id))
+        for execution_id, response in responses:
+            self._print_result(args, execution_id, response, **kwargs)
+
+    def _print_result(self, args, execution_id, execution, **kwargs):
+        if not args.json and not args.yaml:
+            # Include elapsed time for running executions
+            execution = format_execution_status(execution)
         return self._print_execution_details(execution=execution, args=args, **kwargs)
 
 
@@ -1369,27 +1378,36 @@ def __init__(self, resource, *args, **kwargs):
             resource.get_plural_display_name().lower(),
             *args, **kwargs)
 
-        self.parser.add_argument('id', nargs='+',
-                                 metavar='id',
+        self.parser.add_argument('ids',
+                                 nargs='+',
                                  help='ID of action execution to resume.')
 
         self._add_common_options()
 
     @add_auth_token_to_kwargs_from_cli
     def run(self, args, **kwargs):
-        return self.manager.resume(args.id)
+        responses = []
+        for execution_id in args.ids:
+            try:
+                response = self.manager.resume(execution_id)
+                responses.append([execution_id, response])
+            except resource.ResourceNotFoundError:
+                self.print_not_found(execution_id)
+                raise ResourceNotFoundError('Execution with id %s not found.' % (execution_id))
+
+        return responses
 
     @add_auth_token_to_kwargs_from_cli
     def run_and_print(self, args, **kwargs):
-        try:
-            execution = self.run(args, **kwargs)
+        responses = self.run(args, **kwargs)
 
-            if not args.json and not args.yaml:
-                # Include elapsed time for running executions
-                execution = format_execution_status(execution)
-        except resource.ResourceNotFoundError:
-            self.print_not_found(args.id)
-            raise ResourceNotFoundError('Execution %s not found.' % (args.id))
+        for execution_id, response in responses:
+            self._print_result(args, response, **kwargs)
+
+    def _print_result(self, args, execution, **kwargs):
+        if not args.json and not args.yaml:
+            # Include elapsed time for running executions
+            execution = format_execution_status(execution)
         return self._print_execution_details(execution=execution, args=args, **kwargs)
 
 


Test patch:
diff --git a/st2client/tests/unit/test_action.py b/st2client/tests/unit/test_action.py
--- a/st2client/tests/unit/test_action.py
+++ b/st2client/tests/unit/test_action.py
@@ -446,3 +446,94 @@ def test_param_value_with_equal_sign(self):
         expected = {'action': 'mockety.mock2', 'user': None,
                     'parameters': {'key': 'foo=bar&ponies=unicorns'}}
         httpclient.HTTPClient.post.assert_called_with('/executions', expected)
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'delete',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_cancel_single_execution(self):
+        self.shell.run(['execution', 'cancel', '123'])
+        httpclient.HTTPClient.delete.assert_called_with('/executions/123')
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'delete',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_cancel_multiple_executions(self):
+        self.shell.run(['execution', 'cancel', '123', '456', '789'])
+        calls = [mock.call('/executions/123'),
+                 mock.call('/executions/456'),
+                 mock.call('/executions/789')]
+        httpclient.HTTPClient.delete.assert_has_calls(calls)
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'put',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_pause_single_execution(self):
+        self.shell.run(['execution', 'pause', '123'])
+        expected = {'status': 'pausing'}
+        httpclient.HTTPClient.put.assert_called_with('/executions/123', expected)
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'put',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_pause_multiple_executions(self):
+        self.shell.run(['execution', 'pause', '123', '456', '789'])
+        expected = {'status': 'pausing'}
+        calls = [mock.call('/executions/123', expected),
+                 mock.call('/executions/456', expected),
+                 mock.call('/executions/789', expected)]
+        httpclient.HTTPClient.put.assert_has_calls(calls)
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'put',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_resume_single_execution(self):
+        self.shell.run(['execution', 'resume', '123'])
+        expected = {'status': 'resuming'}
+        httpclient.HTTPClient.put.assert_called_with('/executions/123', expected)
+
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_ref_or_id',
+        mock.MagicMock(side_effect=get_by_ref))
+    @mock.patch.object(
+        models.ResourceManager, 'get_by_name',
+        mock.MagicMock(side_effect=get_by_name))
+    @mock.patch.object(
+        httpclient.HTTPClient, 'put',
+        mock.MagicMock(return_value=base.FakeResponse(json.dumps(LIVE_ACTION), 200, 'OK')))
+    def test_resume_multiple_executions(self):
+        self.shell.run(['execution', 'resume', '123', '456', '789'])
+        expected = {'status': 'resuming'}
+        calls = [mock.call('/executions/123', expected),
+                 mock.call('/executions/456', expected),
+                 mock.call('/executions/789', expected)]
+        httpclient.HTTPClient.put.assert_has_calls(calls)
diff --git a/st2client/tests/unit/test_shell.py b/st2client/tests/unit/test_shell.py
--- a/st2client/tests/unit/test_shell.py
+++ b/st2client/tests/unit/test_shell.py
@@ -219,8 +219,11 @@ def test_action_execution(self):
             ['execution', 're-run', '123', '--tasks', 'x', 'y', 'z', '--no-reset', 'x'],
             ['execution', 're-run', '123', 'a=1', 'b=x', 'c=True'],
             ['execution', 'cancel', '123'],
+            ['execution', 'cancel', '123', '456'],
             ['execution', 'pause', '123'],
-            ['execution', 'resume', '123']
+            ['execution', 'pause', '123', '456'],
+            ['execution', 'resume', '123'],
+            ['execution', 'resume', '123', '456']
         ]
         self._validate_parser(args_list)