From 6bc8f689b008f5d4246faa4a15d090700d19fee6 Mon Sep 17 00:00:00 2001
From: Lin Yang <lin.a.yang@intel.com>
Date: Wed, 1 Feb 2017 18:17:29 -0800
Subject: [PATCH] Support post action to composed node

Added api entry v1/node/<uuid>/action to allow user to post reset
action to composed node. The available reset type contains
Power On/Off, Force Shutdown/Restart, etc, which may vary because of
different podm.

Partially-Implements blueprint node-action
Closes-Bug: #1659932

Change-Id: Idb23bdde02318c70e046e92dd1d474cba54c645e
---
 .../mockup/node-post-action-request.json      |  2 +-
 valence/api/route.py                          |  3 +
 valence/api/v1/nodes.py                       |  8 ++
 valence/controller/nodes.py                   | 25 ++++++
 valence/redfish/redfish.py                    | 69 ++++++++++++++++
 valence/tests/unit/controller/test_nodes.py   | 13 +++
 valence/tests/unit/redfish/test_redfish.py    | 80 +++++++++++++++++++
 7 files changed, 199 insertions(+), 1 deletion(-)

diff --git a/api-ref/source/mockup/node-post-action-request.json b/api-ref/source/mockup/node-post-action-request.json
index 674cbb6..ff9a539 100644
--- a/api-ref/source/mockup/node-post-action-request.json
+++ b/api-ref/source/mockup/node-post-action-request.json
@@ -1,5 +1,5 @@
 {
   "Reset": {
-    "type": "On"
+    "Type": "On"
   }
 }
diff --git a/valence/api/route.py b/valence/api/route.py
index 57a633e..587b0c6 100644
--- a/valence/api/route.py
+++ b/valence/api/route.py
@@ -70,6 +70,9 @@ api.add_resource(v1_nodes.Nodes, '/v1/nodes', endpoint='nodes')
 api.add_resource(v1_nodes.Node,
                  '/v1/nodes/<string:node_uuid>',
                  endpoint='node')
+api.add_resource(v1_nodes.NodeAction,
+                 '/v1/nodes/<string:node_uuid>/action',
+                 endpoint='node_action')
 api.add_resource(v1_nodes.NodesStorage,
                  '/v1/nodes/<string:nodeid>/storages',
                  endpoint='nodes_storages')
diff --git a/valence/api/v1/nodes.py b/valence/api/v1/nodes.py
index dd93e3e..79bdb4a 100644
--- a/valence/api/v1/nodes.py
+++ b/valence/api/v1/nodes.py
@@ -44,6 +44,14 @@ class Node(Resource):
             http_client.OK, nodes.Node.delete_composed_node(node_uuid))
 
 
+class NodeAction(Resource):
+
+    def post(self, node_uuid):
+        return utils.make_response(
+            http_client.OK,
+            nodes.Node.node_action(node_uuid, request.get_json()))
+
+
 class NodesStorage(Resource):
 
     def get(self, nodeid):
diff --git a/valence/controller/nodes.py b/valence/controller/nodes.py
index f075c2a..9754718 100644
--- a/valence/controller/nodes.py
+++ b/valence/controller/nodes.py
@@ -97,3 +97,28 @@ class Node(object):
         """
         return [cls._show_node_brief_info(node_info.as_dict())
                 for node_info in db_api.Connection.list_composed_nodes()]
+
+    @classmethod
+    def node_action(cls, node_uuid, request_body):
+        """Post action to a composed node
+
+        param node_uuid: uuid of composed node
+        param request_body: parameter of node action
+        return: message of this deletion
+        """
+
+        # Get node detail from db, and map node uuid to index
+        index = db_api.Connection.get_composed_node_by_uuid(node_uuid).index
+
+        # TODO(lin.yang): should validate request body whether follow specifc
+        # format, like
+        #   {
+        #     "Reset": {
+        #       "Type": "On"
+        #     }
+        #   }
+        # Should rework this part after basic validation framework for api
+        # input is done.
+        # https://review.openstack.org/#/c/422547/
+
+        return redfish.node_action(index, request_body)
diff --git a/valence/redfish/redfish.py b/valence/redfish/redfish.py
index fb42c64..f1fcb97 100644
--- a/valence/redfish/redfish.py
+++ b/valence/redfish/redfish.py
@@ -529,3 +529,72 @@ def list_nodes():
         nodes.append(get_node_by_id(node_index, show_detail=False))
 
     return nodes
+
+
+def reset_node(nodeid, request):
+    nodes_url = get_base_resource_url("Nodes")
+    node_url = os.path.normpath("/".join([nodes_url, nodeid]))
+    resp = send_request(node_url)
+
+    if resp.status_code != http_client.OK:
+        # Raise exception if don't find node
+        raise exception.RedfishException(resp.json(),
+                                         status_code=resp.status_code)
+
+    node = resp.json()
+
+    action_type = request.get("Reset", {}).get("Type")
+    allowable_actions = node["Actions"]["#ComposedNode.Reset"][
+        "ResetType@DMTF.AllowableValues"]
+
+    if not action_type:
+        raise exception.BadRequest(
+            detail="The content of node action request is malformed. Please "
+                   "refer to Valence api specification to correct it.")
+    if allowable_actions and action_type not in allowable_actions:
+        raise exception.BadRequest(
+            detail="Action type '{0}' is not in allowable action list "
+                   "{1}.".format(action_type, allowable_actions))
+
+    target_url = node["Actions"]["#ComposedNode.Reset"]["target"]
+
+    action_resp = send_request(target_url, 'POST',
+                               headers={'Content-type': 'application/json'},
+                               json={"ResetType": action_type})
+
+    if action_resp.status_code != http_client.NO_CONTENT:
+        raise exception.RedfishException(action_resp.json(),
+                                         status_code=action_resp.status_code)
+    else:
+        # Reset node successfully
+        LOG.debug("Post action '{0}' to node {1} successfully."
+                  .format(action_type, target_url))
+        return exception.confirmation(
+            confirm_code="Reset Composed Node",
+            confirm_detail="This composed node has been set to '{0}' "
+                           "successfully.".format(action_type))
+
+
+def node_action(nodeid, request):
+    # Only support one action in single request
+    if len(list(request.keys())) != 1:
+        raise exception.BadRequest(
+            detail="No action found or multiple actions in one single request."
+                   " Please refer to Valence api specification to correct the"
+                   " content of node action request.")
+
+    action = list(request.keys())[0]
+
+    # Podm support two kinds of action for composed node, assemble and reset.
+    # Because valence assemble node by default when compose node, so only need
+    # to support "Reset" action here. In case podm new version support more
+    # actions, use "functions" dict to drive the workflow.
+    functions = {"Reset": reset_node}
+
+    if action not in functions:
+        raise exception.BadRequest(
+            detail="This node action '{0}' is unsupported. Please refer to "
+                   "Valence api specification to correct this content of node "
+                   "action request.".format(action))
+
+    return functions[action](nodeid, request)
diff --git a/valence/tests/unit/controller/test_nodes.py b/valence/tests/unit/controller/test_nodes.py
index bc488f3..2b7ec56 100644
--- a/valence/tests/unit/controller/test_nodes.py
+++ b/valence/tests/unit/controller/test_nodes.py
@@ -115,3 +115,16 @@ class TestAPINodes(unittest.TestCase):
         result = nodes.Node.list_composed_nodes()
 
         self.assertEqual(expected, result)
+
+    @mock.patch("valence.redfish.redfish.node_action")
+    @mock.patch("valence.db.api.Connection.get_composed_node_by_uuid")
+    def test_node_action(
+            self, mock_db_get_composed_node, mock_node_action):
+        """Test reset composed node status"""
+        action = {"Reset": {"Type": "On"}}
+        mock_db_model = mock.MagicMock()
+        mock_db_model.index = "1"
+        mock_db_get_composed_node.return_value = mock_db_model
+
+        nodes.Node.node_action("fake_uuid", action)
+        mock_node_action.assert_called_once_with("1", action)
diff --git a/valence/tests/unit/redfish/test_redfish.py b/valence/tests/unit/redfish/test_redfish.py
index 2c94ab6..aae0f2b 100644
--- a/valence/tests/unit/redfish/test_redfish.py
+++ b/valence/tests/unit/redfish/test_redfish.py
@@ -422,3 +422,83 @@ class TestRedfish(TestCase):
 
         mock_get_node_by_id.assert_called_with("1", show_detail=False)
         self.assertEqual(["node1_detail"], result)
+
+    @mock.patch('valence.redfish.redfish.send_request')
+    @mock.patch('valence.redfish.redfish.get_base_resource_url')
+    def test_reset_node_malformed_request(self, mock_get_url, mock_request):
+        """Test reset node with malformed request content"""
+        mock_get_url.return_value = '/redfish/v1/Nodes'
+        mock_request.return_value = fakes.mock_request_get(
+            fakes.fake_node_detail(), http_client.OK)
+
+        with self.assertRaises(exception.BadRequest) as context:
+            redfish.reset_node("1", {"fake_request": "fake_value"})
+
+        self.assertTrue("The content of node action request is malformed. "
+                        "Please refer to Valence api specification to correct "
+                        "it." in str(context.exception.detail))
+
+    @mock.patch('valence.redfish.redfish.send_request')
+    @mock.patch('valence.redfish.redfish.get_base_resource_url')
+    def test_reset_node_wrong_request(self, mock_get_url, mock_request):
+        """Test reset node with wrong action type"""
+        mock_get_url.return_value = '/redfish/v1/Nodes'
+        mock_request.return_value = fakes.mock_request_get(
+            fakes.fake_node_detail(), http_client.OK)
+
+        with self.assertRaises(exception.BadRequest) as context:
+            redfish.reset_node("1", {"Reset": {"Type": "wrong_action"}})
+
+        self.assertTrue("Action type 'wrong_action' is not in allowable action"
+                        " list" in str(context.exception.detail))
+
+    @mock.patch('valence.redfish.redfish.send_request')
+    @mock.patch('valence.redfish.redfish.get_base_resource_url')
+    def test_reset_node_success(self, mock_get_url, mock_request):
+        """Test successfully reset node status"""
+        mock_get_url.return_value = '/redfish/v1/Nodes'
+        fake_node_detail = fakes.mock_request_get(
+            fakes.fake_node_detail(), http_client.OK)
+        fake_node_action_resp = fakes.mock_request_get(
+            {}, http_client.NO_CONTENT)
+        mock_request.side_effect = [fake_node_detail, fake_node_action_resp]
+
+        result = redfish.reset_node("1", {"Reset": {"Type": "On"}})
+        expected = exception.confirmation(
+            confirm_code="Reset Composed Node",
+            confirm_detail="This composed node has been set to 'On' "
+                           "successfully.")
+
+        self.assertEqual(expected, result)
+
+    @mock.patch('valence.redfish.redfish.reset_node')
+    def test_node_action_malformed_request(self, mock_reset_node):
+        """Test post node_action with malformed request"""
+
+        # Unsupported multiple action
+        with self.assertRaises(exception.BadRequest) as context:
+            redfish.node_action(
+                "1", {"Reset": {"Type": "On"}, "Assemble": {}})
+        self.assertTrue("No action found or multiple actions in one single "
+                        "request. Please refer to Valence api specification "
+                        "to correct the content of node action request."
+                        in str(context.exception.detail))
+        mock_reset_node.assert_not_called()
+
+        # Unsupported action
+        with self.assertRaises(exception.BadRequest) as context:
+            redfish.node_action(
+                "1", {"Assemble": {}})
+        self.assertTrue("This node action 'Assemble' is unsupported. Please "
+                        "refer to Valence api specification to correct this "
+                        "content of node action request."
+                        in str(context.exception.detail))
+        mock_reset_node.assert_not_called()
+
+    @mock.patch('valence.redfish.redfish.reset_node')
+    def test_node_action_success(self, mock_reset_node):
+        """Test post node_action success"""
+
+        redfish.node_action("1", {"Reset": {"Type": "On"}})
+
+        mock_reset_node.assert_called_once_with("1", {"Reset": {"Type": "On"}})