1#!/usr/bin/env python3
2
3r"""
4See class prolog below for details.
5"""
6
7import json
8import re
9import sys
10from json.decoder import JSONDecodeError
11
12import func_args as fa
13import gen_print as gp
14from redfish.rest.v1 import InvalidCredentialsError
15from redfish_plus import redfish_plus
16from robot.libraries.BuiltIn import BuiltIn
17
18MTLS_ENABLED = BuiltIn().get_variable_value("${MTLS_ENABLED}")
19
20
21class bmc_redfish(redfish_plus):
22    r"""
23    bmc_redfish is a child class of redfish_plus that is designed to provide
24    benefits specifically for using redfish to communicate with an OpenBMC.
25
26    See the prologs of the methods below for details.
27    """
28
29    def __init__(self, *args, **kwargs):
30        r"""
31        Do BMC-related redfish initialization.
32
33        Presently, older versions of BMC code may not support redfish
34        requests.  This can lead to unsightly error text being printed out for
35        programs that may use lib/bmc_redfish_resource.robot even though they
36        don't necessarily intend to make redfish requests.
37
38        This class method will make an attempt to tolerate this situation.  At
39        some future point, when all BMCs can be expected to support redfish,
40        this class method may be considered for deletion.  If it is deleted,
41        the self.__inited__ test code in the login() class method below should
42        likewise be deleted.
43        """
44        self.__inited__ = False
45        try:
46            if MTLS_ENABLED == "True":
47                self.__inited__ = True
48            else:
49                super(bmc_redfish, self).__init__(*args, **kwargs)
50                self.__inited__ = True
51        except ValueError as get_exception:
52            except_type, except_value, except_traceback = sys.exc_info()
53            regex = r"The HTTP status code was not valid:[\r\n]+status:[ ]+502"
54            result = re.match(regex, str(except_value), flags=re.MULTILINE)
55            if not result:
56                gp.lprint_var(except_type)
57                gp.lprint_varx("except_value", str(except_value))
58                raise (get_exception)
59        BuiltIn().set_global_variable("${REDFISH_SUPPORTED}", self.__inited__)
60        BuiltIn().set_global_variable("${REDFISH_REST_SUPPORTED}", True)
61
62    def login(self, *args, **kwargs):
63        r"""
64        Assign BMC default values for username, password and auth arguments
65        and call parent class login method.
66
67        Description of argument(s):
68        args                        See parent class method prolog for details.
69        kwargs                      See parent class method prolog for details.
70        """
71
72        if MTLS_ENABLED == "True":
73            return None
74        if not self.__inited__:
75            message = "bmc_redfish.__init__() was never successfully run.  It "
76            message += "is likely that the target BMC firmware code level "
77            message += "does not support redfish.\n"
78            raise ValueError(message)
79        # Assign default values for username, password, auth where necessary.
80        openbmc_username = BuiltIn().get_variable_value("${OPENBMC_USERNAME}")
81        openbmc_password = BuiltIn().get_variable_value("${OPENBMC_PASSWORD}")
82        username, args, kwargs = fa.pop_arg(openbmc_username, *args, **kwargs)
83        password, args, kwargs = fa.pop_arg(openbmc_password, *args, **kwargs)
84        auth, args, kwargs = fa.pop_arg("session", *args, **kwargs)
85
86        try:
87            super(bmc_redfish, self).login(
88                username, password, auth, *args, **kwargs
89            )
90        # Handle InvalidCredentialsError.
91        # (raise redfish.rest.v1.InvalidCredentialsError if not [200, 201, 202, 204])
92        except InvalidCredentialsError:
93            except_type, except_value, except_traceback = sys.exc_info()
94            BuiltIn().log_to_console(str(except_type))
95            BuiltIn().log_to_console(str(except_value))
96            e_message = "Re-try login due to exception and "
97            e_message += "it is likely error response from server side."
98            BuiltIn().log_to_console(e_message)
99            super(bmc_redfish, self).login(
100                username, password, auth, *args, **kwargs
101            )
102        # Handle JSONDecodeError and others.
103        except JSONDecodeError:
104            except_type, except_value, except_traceback = sys.exc_info()
105            BuiltIn().log_to_console(str(except_type))
106            BuiltIn().log_to_console(str(except_value))
107            e_message = "Re-try login due to JSONDecodeError exception and "
108            e_message += "it is likely error response from server side."
109            BuiltIn().log_to_console(e_message)
110            super(bmc_redfish, self).login(
111                username, password, auth, *args, **kwargs
112            )
113        except ValueError:
114            except_type, except_value, except_traceback = sys.exc_info()
115            BuiltIn().log_to_console(str(except_type))
116            BuiltIn().log_to_console(str(except_value))
117            e_message = "Unexpected exception."
118            BuiltIn().log_to_console(e_message)
119
120    def logout(self):
121        if MTLS_ENABLED == "True":
122            return None
123        else:
124            super(bmc_redfish, self).logout()
125
126    def get_properties(self, *args, **kwargs):
127        r"""
128        Return dictionary of attributes for a given path.
129
130        The difference between calling this function and calling get()
131        directly is that this function returns ONLY the dictionary portion of
132        the response object.
133
134        Example robot code:
135
136        ${properties}=  Get Properties  /redfish/v1/Systems/system/
137        Rprint Vars  properties
138
139        Output:
140
141        properties:
142          [PowerState]:      Off
143          [Processors]:
144            [@odata.id]:     /redfish/v1/Systems/system/Processors
145          [SerialNumber]:    1234567
146          ...
147
148        Description of argument(s):
149        args                        See parent class get() prolog for details.
150        kwargs                      See parent class get() prolog for details.
151        """
152
153        resp = self.get(*args, **kwargs)
154        return resp.dict if hasattr(resp, "dict") else {}
155
156    def get_attribute(self, path, attribute, default=None, *args, **kwargs):
157        r"""
158        Get and return the named attribute from the properties for a given
159        path.
160
161        This method has the following advantages over calling get_properties
162        directly:
163        - The caller can specify a default value to be returned if the
164          attribute does not exist.
165
166        Example robot code:
167
168        ${attribute}=  Get Attribute  /redfish/v1/AccountService
169        ...  MaxPasswordLength  default=600
170        Rprint Vars  attribute
171
172        Output:
173
174        attribute:           31
175
176        Description of argument(s):
177        path                        The path (e.g.
178                                    "/redfish/v1/AccountService").
179        attribute                   The name of the attribute to be retrieved
180                                    (e.g. "MaxPasswordLength").
181        default                     The default value to be returned if the
182                                    attribute does not exist (e.g. "").
183        args                        See parent class get() prolog for details.
184        kwargs                      See parent class get() prolog for details.
185        """
186
187        return self.get_properties(path, *args, **kwargs).get(
188            attribute, default
189        )
190
191    def get_session_info(self):
192        r"""
193        Get and return session info as a tuple consisting of session_key and
194        session_location.
195        """
196
197        return self.get_session_key(), self.get_session_location()
198
199    def enumerate(
200        self, resource_path, return_json=1, include_dead_resources=False
201    ):
202        r"""
203        Perform a GET enumerate request and return available resource paths.
204
205        Description of argument(s):
206        resource_path               URI resource absolute path (e.g. "/redfish/v1/SessionService/Sessions").
207        return_json                 Indicates whether the result should be returned as a json string or as a
208                                    dictionary.
209        include_dead_resources      Check and return a list of dead/broken URI resources.
210        """
211
212        gp.qprint_executing(style=gp.func_line_style_short)
213        # Set quiet variable to keep subordinate get() calls quiet.
214        quiet = 1
215
216        self.__result = {}
217        # Variable to hold the pending list of resources for which enumeration is yet to be obtained.
218        self.__pending_enumeration = set()
219        self.__pending_enumeration.add(resource_path)
220
221        # Variable having resources for which enumeration is completed.
222        enumerated_resources = set()
223        dead_resources = {}
224        resources_to_be_enumerated = (resource_path,)
225        while resources_to_be_enumerated:
226            for resource in resources_to_be_enumerated:
227                # JsonSchemas, SessionService or URLs containing # are not required in enumeration.
228                # Example: '/redfish/v1/JsonSchemas/' and sub resources.
229                #          '/redfish/v1/SessionService'
230                #          '/redfish/v1/Managers/bmc#/Oem'
231                if (
232                    ("JsonSchemas" in resource)
233                    or ("SessionService" in resource)
234                    or ("#" in resource)
235                ):
236                    continue
237
238                self._rest_response_ = self.get(
239                    resource, valid_status_codes=[200, 404, 500]
240                )
241                # Enumeration is done for available resources ignoring the ones for which response is not
242                # obtained.
243                if self._rest_response_.status != 200:
244                    if include_dead_resources:
245                        try:
246                            dead_resources[self._rest_response_.status].append(
247                                resource
248                            )
249                        except KeyError:
250                            dead_resources[self._rest_response_.status] = [
251                                resource
252                            ]
253                    continue
254                self.walk_nested_dict(self._rest_response_.dict, url=resource)
255
256            enumerated_resources.update(set(resources_to_be_enumerated))
257            resources_to_be_enumerated = tuple(
258                self.__pending_enumeration - enumerated_resources
259            )
260
261        if return_json:
262            if include_dead_resources:
263                return (
264                    json.dumps(
265                        self.__result,
266                        sort_keys=True,
267                        indent=4,
268                        separators=(",", ": "),
269                    ),
270                    dead_resources,
271                )
272            else:
273                return json.dumps(
274                    self.__result,
275                    sort_keys=True,
276                    indent=4,
277                    separators=(",", ": "),
278                )
279        else:
280            if include_dead_resources:
281                return self.__result, dead_resources
282            else:
283                return self.__result
284
285    def walk_nested_dict(self, data, url=""):
286        r"""
287        Parse through the nested dictionary and get the resource id paths.
288
289        Description of argument(s):
290        data                        Nested dictionary data from response message.
291        url                         Resource for which the response is obtained in data.
292        """
293        url = url.rstrip("/")
294
295        for key, value in data.items():
296            # Recursion if nested dictionary found.
297            if isinstance(value, dict):
298                self.walk_nested_dict(value)
299            else:
300                # Value contains a list of dictionaries having member data.
301                if "Members" == key:
302                    if isinstance(value, list):
303                        for memberDict in value:
304                            self.__pending_enumeration.add(
305                                memberDict["@odata.id"]
306                            )
307                if "@odata.id" == key:
308                    value = value.rstrip("/")
309                    # Data for the given url.
310                    if value == url:
311                        self.__result[url] = data
312                    # Data still needs to be looked up,
313                    else:
314                        self.__pending_enumeration.add(value)
315
316    def get_members_list(self, resource_path, filter=None):
317        r"""
318        Return members list in a given URL.
319
320        Description of argument(s):
321        resource_path    URI resource absolute path (e.g. "/redfish/v1/AccountService/Accounts").
322        filter           strings or regex
323
324        /redfish/v1/AccountService/Accounts/
325        {
326            "@odata.id": "/redfish/v1/AccountService/Accounts",
327            "@odata.type": "#ManagerAccountCollection.ManagerAccountCollection",
328            "Description": "BMC User Accounts",
329            "Members": [
330                {
331                    "@odata.id": "/redfish/v1/AccountService/Accounts/root"
332                },
333                {
334                    "@odata.id": "/redfish/v1/AccountService/Accounts/admin"
335                }
336           ],
337           "Members@odata.count": 2,
338           "Name": "Accounts Collection"
339        }
340
341        Return list of members if no filter is applied as:
342        ['/redfish/v1/AccountService/Accounts/root', "/redfish/v1/AccountService/Accounts/admin"]
343
344        Return list of members if filter (e.g "root") is applied as:
345        ['/redfish/v1/AccountService/Accounts/root']
346
347
348        Calling from robot code:
349           ${resp}=  Redfish.Get Members List  /redfish/v1/AccountService/Accounts
350           ${resp}=  Redfish.Get Members List  /redfish/v1/AccountService/Accounts  filter=root
351        """
352
353        member_list = []
354        self._rest_response_ = self.get(
355            resource_path, valid_status_codes=[200]
356        )
357
358        try:
359            for member in self._rest_response_.dict["Members"]:
360                member_list.append(member["@odata.id"])
361        except KeyError:
362            # Non Members child objects at the top level, ignore.
363            pass
364
365        # Filter elements in the list and return matched elements.
366        if filter is not None:
367            regex = ".*/" + filter + "[^/]*$"
368            return [x for x in member_list if re.match(regex, x)]
369
370        return member_list
371