├── makeandinstallzip.bat ├── requirements.txt ├── tests ├── __init__.py ├── ris │ ├── __init__.py │ ├── test_rmc_helper.py │ ├── test_ris.py │ └── test_config.py ├── rest │ ├── __init__.py │ └── test_v1.py └── discovery │ ├── __init__.py │ └── test_discovery.py ├── src └── redfish │ ├── discovery │ ├── __init__.py │ └── discovery.py │ ├── messages │ ├── __init__.py │ └── messages.py │ ├── rest │ ├── __init__.py │ └── v1.py │ ├── __init__.py │ └── ris │ ├── __init__.py │ ├── sharedtypes.py │ ├── config.py │ ├── ris.py │ └── rmc_helper.py ├── .travis.yml ├── tox.ini ├── .project ├── .pydevproject ├── examples ├── discover.py ├── context_manager.py ├── quickstart.py ├── multipart_push.py ├── quickstart_rmc.py └── http_adapter.py ├── AUTHORS.md ├── .gitignore ├── LICENSE.md ├── setup.py ├── CONTRIBUTING.md ├── .github └── workflows │ └── main.yml ├── CHANGELOG.md └── README.rst /makeandinstallzip.bat: -------------------------------------------------------------------------------- 1 | python setup.py sdist --formats=zip 2 | cd dist 3 | pip install --upgrade redfish-2.0.0.zip -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jsonpatch<=1.24 ; python_version == '3.4' 2 | jsonpatch ; python_version >= '3.5' 3 | jsonpath_rw 4 | jsonpointer 5 | requests 6 | requests-toolbelt 7 | requests-unixsocket 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md -------------------------------------------------------------------------------- /tests/ris/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md -------------------------------------------------------------------------------- /tests/rest/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md -------------------------------------------------------------------------------- /tests/discovery/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md -------------------------------------------------------------------------------- /src/redfish/discovery/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | -------------------------------------------------------------------------------- /src/redfish/messages/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | from .messages import * -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: focal 2 | language: python 3 | cache: 4 | - pip 5 | python: 6 | - '3.8' 7 | - '3.9' 8 | - '3.11' 9 | before_install: 10 | - pip install -U pip 11 | - pip install -U setuptools 12 | - pip install -U wheel 13 | install: 14 | - pip install tox-travis .[devel] 15 | script: 16 | - tox 17 | -------------------------------------------------------------------------------- /src/redfish/rest/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | """ Utilities to simplify interaction with Redfish data """ 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py311 3 | 4 | [testenv] 5 | usedevelop = True 6 | install_command = pip install {opts} {packages} 7 | deps = 8 | coverage 9 | fixtures 10 | pytest 11 | -rrequirements.txt 12 | commands = 13 | pytest -v 14 | 15 | [testenv:pep8] 16 | basepython = python3 17 | deps = flake8 18 | commands = flake8 tests/ src/redfish/discovery 19 | 20 | [travis] 21 | python = 3.11: py311 22 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | redfish 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /${PROJECT_DIR_NAME}/src 5 | 6 | python 2.7 7 | Default 8 | 9 | -------------------------------------------------------------------------------- /examples/discover.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import redfish 7 | 8 | # Invoke the discovery routine for SSDP and print the responses 9 | services = redfish.discover_ssdp() 10 | for service in services: 11 | print( '{}: {}'.format(service, services[service])) 12 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Original Contribution: 2 | 3 | * [Jack Garcia](//github.com/lumbajack) - HPE - Hewlett Packard Enterprise Restful API Group 4 | * [Matthew Kocurek](//github.com/Yergidy) - HPE - Hewlett Packard Enterprise Restful API Group 5 | * [Prithvi Subrahmanya](//github.com/PrithviBS) - HPE - Hewlett Packard Enterprise Restful API Group 6 | 7 | # Other Key Contributions: 8 | 9 | * For a list of people who have contributed to the codebase, see [GitHub's list of contributors](https://github.com/DMTF/python-redfish-library/contributors).* 10 | -------------------------------------------------------------------------------- /examples/context_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import sys 7 | import redfish 8 | 9 | # When running remotely connect using the address, account name, 10 | # and password to send https requests 11 | login_host = "https://192.168.1.100" 12 | login_account = "admin" 13 | login_password = "password" 14 | 15 | ## Create a REDFISH object 16 | with redfish.redfish_client(base_url=login_host, username=login_account, password=login_password) as REDFISH_OBJ: 17 | # Do a GET on a given path 18 | response = REDFISH_OBJ.get("/redfish/v1/systems/1", None) 19 | 20 | # Print out the response 21 | sys.stdout.write("%s\n" % response) 22 | -------------------------------------------------------------------------------- /src/redfish/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | """ Redfish restful library """ 7 | 8 | __all__ = ['rest', 'ris', 'discovery', 'messages'] 9 | __version__ = "3.3.4" 10 | 11 | from redfish.rest.v1 import redfish_client 12 | from redfish.rest.v1 import AuthMethod 13 | from redfish.discovery.discovery import discover_ssdp 14 | from redfish.messages import * 15 | import logging 16 | 17 | def redfish_logger(file_name, log_format, log_level=logging.ERROR): 18 | formatter = logging.Formatter(log_format) 19 | fh = logging.FileHandler(file_name) 20 | fh.setFormatter(formatter) 21 | logger = logging.getLogger(__name__) 22 | logger.addHandler(fh) 23 | logger.setLevel(log_level) 24 | return logger 25 | -------------------------------------------------------------------------------- /src/redfish/ris/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """ 8 | RIS implementation 9 | """ 10 | 11 | from .sharedtypes import ( 12 | JSONEncoder 13 | ) 14 | 15 | from .ris import ( 16 | RisMonolithMemberBase, 17 | RisMonolithMember_v1_0_0, 18 | RisMonolith_v1_0_0, 19 | RisMonolith, 20 | ) 21 | 22 | from .rmc_helper import ( 23 | UndefinedClientError, 24 | InstanceNotFoundError, 25 | CurrentlyLoggedInError, 26 | NothingSelectedError, 27 | NothingSelectedSetError, 28 | InvalidSelectionError, 29 | SessionExpired, 30 | RmcClient, 31 | RmcConfig, 32 | RmcCacheManager, 33 | RmcFileCacheManager, 34 | ) 35 | 36 | from .rmc import ( 37 | RmcApp 38 | ) 39 | -------------------------------------------------------------------------------- /tests/ris/test_rmc_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import unittest 7 | try: 8 | from unittest import mock 9 | except ImportError: 10 | import mock 11 | 12 | from redfish.ris import rmc_helper 13 | 14 | 15 | class RmcHelper(unittest.TestCase): 16 | def setUp(self): 17 | super(RmcHelper, self).setUp() 18 | 19 | @mock.patch('redfish.rest.v1.HttpClient') 20 | def test_get_cache_dirname(self, mock_http_client): 21 | url = 'http://example.com' 22 | helper = rmc_helper.RmcClient(url=url, username='oper', password='xyz') 23 | mock_http_client.return_value.get_base_url.return_value = url 24 | dir_name = helper.get_cache_dirname() 25 | self.assertEqual(dir_name, 'example.com/') 26 | 27 | 28 | if __name__ == '__main__': 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /tests/discovery/test_discovery.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- encoding: utf-8 -*- 7 | import unittest 8 | 9 | from redfish.discovery.discovery import FakeSocket 10 | from redfish.discovery.discovery import sanitize 11 | 12 | from io import BytesIO 13 | 14 | 15 | class TestFakeSocket(unittest.TestCase): 16 | def test_init(self): 17 | fake = FakeSocket(b"foo") 18 | self.assertTrue(isinstance(fake, FakeSocket)) 19 | self.assertTrue(isinstance(fake._file, BytesIO)) 20 | 21 | 22 | class TestDiscover(unittest.TestCase): 23 | def test_sanitize(self): 24 | self.assertEqual(sanitize(257, 1, 255), 255) 25 | self.assertEqual(sanitize(0, 1, 255), 1) 26 | self.assertEqual(sanitize(0, 1), 1) 27 | self.assertEqual(sanitize(2000, 1), 2000) 28 | self.assertEqual(sanitize(-1, 1), 1) 29 | -------------------------------------------------------------------------------- /examples/quickstart.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import sys 7 | import redfish 8 | 9 | # When running remotely connect using the address, account name, 10 | # and password to send https requests 11 | login_host = "https://192.168.1.100" 12 | login_account = "admin" 13 | login_password = "password" 14 | 15 | ## Create a REDFISH object 16 | REDFISH_OBJ = redfish.redfish_client(base_url=login_host, username=login_account, 17 | password=login_password, default_prefix='/redfish/v1') 18 | 19 | # Login into the server and create a session 20 | REDFISH_OBJ.login(auth="session") 21 | 22 | # Do a GET on a given path 23 | response = REDFISH_OBJ.get("/redfish/v1/systems/1", None) 24 | 25 | # Print out the response 26 | sys.stdout.write("%s\n" % response) 27 | 28 | # Logout of the current session 29 | REDFISH_OBJ.logout() -------------------------------------------------------------------------------- /tests/ris/test_ris.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- encoding: utf-8 -*- 7 | import unittest 8 | 9 | from redfish.ris import RisMonolithMember_v1_0_0 10 | from redfish.ris import RisMonolithMemberBase 11 | from redfish.ris.sharedtypes import Dictable 12 | 13 | 14 | class TestRisMonolithMemberBase(unittest.TestCase): 15 | def test_init(self): 16 | RisMonolithMemberBase() 17 | self.assertTrue(issubclass(RisMonolithMemberBase, Dictable)) 18 | 19 | 20 | class TestRisMonolithMember_v1_0_0(unittest.TestCase): 21 | def test_init(self): 22 | with self.assertRaises(TypeError): 23 | RisMonolithMember_v1_0_0() 24 | 25 | RisMonolithMember_v1_0_0("test") 26 | self.assertTrue( 27 | issubclass(RisMonolithMember_v1_0_0, RisMonolithMemberBase) 28 | ) 29 | self.assertTrue(issubclass(RisMonolithMember_v1_0_0, Dictable)) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *__pycache__* 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # pyenv 34 | .python-version 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # Editors 63 | *.swa 64 | *.swp 65 | *~ 66 | -------------------------------------------------------------------------------- /src/redfish/ris/sharedtypes.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """ Shared types used in this module """ 8 | 9 | #---------Imports--------- 10 | 11 | import logging 12 | import jsonpatch 13 | from redfish.rest.v1 import JSONEncoder 14 | 15 | #---------End of imports--------- 16 | 17 | #---------Debug logger--------- 18 | 19 | LOGGER = logging.getLogger(__name__) 20 | 21 | #---------End of debug logger--------- 22 | 23 | class JSONEncoder(JSONEncoder): 24 | """Custom JSONEncoder that understands our types""" 25 | def default(self, obj): 26 | """Set defaults 27 | 28 | :param obj: json object. 29 | :type obj: str. 30 | 31 | """ 32 | if isinstance(obj, Dictable): 33 | return obj.to_dict() 34 | elif isinstance(obj, jsonpatch.JsonPatch): 35 | return obj.patch 36 | return super(JSONEncoder, self).default(obj) 37 | 38 | class Dictable(object): 39 | """A base class which adds the to_dict method used during json encoding""" 40 | def to_dict(self): 41 | """Overridable funciton""" 42 | raise RuntimeError("You must override this method in your derived" 43 | " class") 44 | 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016-2024, Contributing Member(s) of Distributed Management Task 4 | Force, Inc.. All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation and/or 14 | other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its contributors 17 | may be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | from setuptools import setup, find_packages 7 | from codecs import open 8 | from os import path 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 12 | long_description = f.read() 13 | 14 | setup(name='redfish', 15 | version='3.3.4', 16 | description='Redfish Python Library', 17 | long_description=long_description, 18 | long_description_content_type='text/x-rst', 19 | author = 'DMTF, https://www.dmtf.org/standards/feedback', 20 | license='BSD 3-clause "New" or "Revised License"', 21 | classifiers=[ 22 | 'Development Status :: 5 - Production/Stable', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Programming Language :: Python :: 3', 25 | 'Topic :: Communications' 26 | ], 27 | keywords='Redfish', 28 | url='https://github.com/DMTF/python-redfish-library', 29 | packages=find_packages('src'), 30 | package_dir={'': 'src'}, 31 | install_requires=[ 32 | 'jsonpath_rw', 33 | 'jsonpointer', 34 | "requests", 35 | 'requests_toolbelt', 36 | 'requests-unixsocket' 37 | ], 38 | extras_require={ 39 | ':python_version == "3.4"': [ 40 | 'jsonpatch<=1.24' 41 | ], 42 | ':python_version >= "3.5"': [ 43 | 'jsonpatch' 44 | ] 45 | }) 46 | -------------------------------------------------------------------------------- /examples/multipart_push.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import sys 7 | import json 8 | import redfish 9 | 10 | # When running remotely connect using the address, account name, 11 | # and password to send https requests 12 | login_host = "https://192.168.1.100" 13 | login_account = "admin" 14 | login_password = "password" 15 | 16 | ## Create a REDFISH object 17 | REDFISH_OBJ = redfish.redfish_client(base_url=login_host, username=login_account, 18 | password=login_password, default_prefix='/redfish/v1') 19 | 20 | # Login into the server and create a session 21 | REDFISH_OBJ.login(auth="session") 22 | 23 | # Format parts of the Update 24 | headers = {'Content-Type': 'multipart/form-data'} 25 | body = {} 26 | body['UpdateParameters'] = (None, json.dumps({'Targets': ['/redfish/v1/Managers/1'], 'Oem': {}}), 'application/json') 27 | body['UpdateFile'] = ('flash.bin', open('flash.bin', 'rb'), 'application/octet-stream') 28 | 29 | # The "OemXXX" part is optional in the specification 30 | # Must be formatted as 3-tuple: 31 | # ('filename' or None, content, content-type), 32 | body['OemXXX'] = (None, '{"test": "value"}', 'application/json') 33 | body['OemXXX'] = ('extra.bin', open('extra.txt', 'rb'), 'application/octet-stream') 34 | body['OemXXX'] = ('optional.txt', open('optional.txt', 'r').read(), 'text/plain') 35 | 36 | # Perform the POST operation 37 | response = REDFISH_OBJ.post('/redfish/v1/upload', body=body, headers=headers) 38 | 39 | # Print out the response 40 | sys.stdout.write("%s\n" % response) 41 | 42 | # Logout of the current session 43 | REDFISH_OBJ.logout() 44 | -------------------------------------------------------------------------------- /examples/quickstart_rmc.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | import os 7 | import sys 8 | import json 9 | import logging 10 | 11 | from redfish import redfish_logger 12 | from redfish.ris import RmcApp, JSONEncoder 13 | 14 | # Config logger used by Restful library 15 | LOGGERFILE = "RedfishApiExamples.log" 16 | LOGGERFORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 17 | LOGGER = redfish_logger(LOGGERFILE, LOGGERFORMAT, logging.ERROR) 18 | LOGGER.info("Redfish API examples") 19 | 20 | # When running remotely connect using the address, account name, 21 | # and password to send https requests 22 | login_host = "https://192.168.1.100" 23 | login_account = "admin" 24 | login_password = "password" 25 | 26 | # Creating RMC object 27 | RMCOBJ = RmcApp([]) 28 | 29 | # Create cache directory 30 | config_dir = r'C:\DATA\redfish' 31 | RMCOBJ.config.set_cachedir(os.path.join(config_dir, 'cache')) 32 | cachedir = RMCOBJ.config.get_cachedir() 33 | 34 | # If current cache exist try to log it out 35 | if os.path.isdir(cachedir): 36 | RMCOBJ.logout 37 | 38 | 39 | # Login into the server and create a session 40 | RMCOBJ.login(username=login_account, password=login_password, \ 41 | base_url=login_host) 42 | 43 | # Select ComputerSystems 44 | RMCOBJ.select(['ComputerSystem.']) 45 | 46 | # Get selected type 47 | response = RMCOBJ.get() 48 | 49 | # Print out the response 50 | for item in response: 51 | sys.stdout.write(json.dumps(item, indent=2, cls=JSONEncoder)) 52 | sys.stdout.write('\n') 53 | 54 | # Logout of the current session 55 | RMCOBJ.logout() 56 | -------------------------------------------------------------------------------- /tests/ris/test_config.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- encoding: utf-8 -*- 7 | import tempfile 8 | import textwrap 9 | import unittest 10 | 11 | from redfish.ris.config import AutoConfigParser 12 | 13 | 14 | CONFIG = textwrap.dedent( 15 | """ 16 | [DEFAULT] 17 | ServerAliveInterval = 45 18 | Compression = yes 19 | CompressionLevel = 9 20 | ForwardX11 = yes 21 | 22 | [bitbucket.org] 23 | User = hg 24 | 25 | [topsecret.server.com] 26 | Port = 50022 27 | ForwardX11 = no 28 | """ 29 | ) 30 | 31 | 32 | class TestAutoConfigParser(unittest.TestCase): 33 | def test_init(self): 34 | acp = AutoConfigParser() 35 | self.assertEqual(acp._configfile, None) 36 | with tempfile.TemporaryDirectory() as tmpdirname: 37 | cfgfile = "{tmpdir}/config.ini".format(tmpdir=tmpdirname) 38 | with open(cfgfile, "w+") as config: 39 | config.write(CONFIG) 40 | acp = AutoConfigParser(cfgfile) 41 | self.assertEqual(acp._configfile, cfgfile) 42 | 43 | def test_load(self): 44 | with tempfile.TemporaryDirectory() as tmpdirname: 45 | cfgfile = "{tmpdir}/config.ini".format(tmpdir=tmpdirname) 46 | with open(cfgfile, "w+") as config: 47 | config.write(CONFIG) 48 | acp = AutoConfigParser() 49 | acp.load(cfgfile) 50 | 51 | def test_save(self): 52 | with tempfile.TemporaryDirectory() as tmpdirname: 53 | cfgfile = "{tmpdir}/config.ini".format(tmpdir=tmpdirname) 54 | with open(cfgfile, "w+") as config: 55 | config.write(CONFIG) 56 | acp = AutoConfigParser(cfgfile) 57 | acp.load() 58 | acp.save() 59 | dump = "{tmpdir}/config2.ini".format(tmpdir=tmpdirname) 60 | acp.save(dump) 61 | -------------------------------------------------------------------------------- /examples/http_adapter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | from pathlib import Path 4 | from typing import Optional 5 | import ssl 6 | from requests.adapters import HTTPAdapter 7 | from urllib3.poolmanager import PoolManager 8 | 9 | from redfish import redfish_client 10 | 11 | 12 | def make_dhe_compatible_context( 13 | cafile: Optional[Path] = None, 14 | *, 15 | seclevel: int = 1, 16 | verify: bool = True, 17 | tls12_only: bool = True, 18 | ) -> ssl.SSLContext: 19 | """ 20 | Build an SSLContext that accepts legacy DHE handshakes (small DH groups). 21 | - seclevel=1 usually permits 1024-bit DH. Use 0 only as a last resort. 22 | - If verify=True and the server is self-signed, pass its PEM as `cafile`. 23 | - DHE is TLS<=1.2; set tls12_only=True to pin TLS 1.2. 24 | """ 25 | ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 26 | ctx.set_ciphers(f"DEFAULT:@SECLEVEL={seclevel}:DHE") 27 | ctx.options |= ssl.OP_NO_COMPRESSION 28 | if tls12_only: 29 | ctx.minimum_version = ssl.TLSVersion.TLSv1_2 30 | ctx.maximum_version = ssl.TLSVersion.TLSv1_2 31 | 32 | if verify: 33 | if cafile: 34 | ctx.load_verify_locations(str(cafile)) 35 | else: 36 | ctx.load_default_certs() 37 | else: 38 | ctx.check_hostname = False 39 | ctx.verify_mode = ssl.CERT_NONE 40 | return ctx 41 | 42 | class SSLContextAdapter(HTTPAdapter): 43 | """requests adapter that injects a custom ssl_context into urllib3.""" 44 | def __init__(self, ssl_context: ssl.SSLContext, **kwargs): 45 | self._ssl_context = ssl_context 46 | super().__init__(**kwargs) 47 | 48 | def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): 49 | pool_kwargs["ssl_context"] = self._ssl_context 50 | self.poolmanager = PoolManager( 51 | num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs 52 | ) 53 | 54 | def proxy_manager_for(self, proxy, **proxy_kwargs): 55 | proxy_kwargs["ssl_context"] = self._ssl_context 56 | return super().proxy_manager_for(proxy, **proxy_kwargs) 57 | 58 | 59 | # Test dhe adapter 60 | ctx = make_dhe_compatible_context(seclevel=0, verify=False) 61 | adapter = SSLContextAdapter(ctx) 62 | parser = argparse.ArgumentParser( ) 63 | parser.add_argument('url',help="Server with DHE encryption") 64 | args = parser.parse_args() 65 | 66 | client = redfish_client(args.url,https_adapter=adapter) 67 | 68 | 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Overview 4 | 5 | This repository is maintained by the [DMTF](https://www.dmtf.org/ "https://www.dmtf.org/"). All contributions are reviewed and approved by members of the organization. 6 | 7 | ## Submitting Issues 8 | 9 | Bugs, feature requests, and questions are all submitted in the "Issues" section for the project. DMTF members are responsible for triaging and addressing issues. 10 | 11 | ## Contribution Process 12 | 13 | 1. Fork the repository. 14 | 2. Make and commit changes. 15 | 3. Make a pull request. 16 | 17 | All contributions must adhere to the BSD 3-Clause License described in the LICENSE.md file, and the [Developer Certificate of Origin](#developer-certificate-of-origin). 18 | 19 | Pull requests are reviewed and approved by DMTF members. 20 | 21 | ## Developer Certificate of Origin 22 | 23 | All contributions must adhere to the [Developer Certificate of Origin (DCO)](http://developercertificate.org "http://developercertificate.org"). 24 | 25 | The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer adds a "Signed-off-by" statement and thereby agrees to the DCO. This can be added by using the `--signoff` parameter with `git commit`. 26 | 27 | Full text of the DCO: 28 | 29 | ``` 30 | Developer Certificate of Origin 31 | Version 1.1 32 | 33 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 34 | 35 | Everyone is permitted to copy and distribute verbatim copies of this 36 | license document, but changing it is not allowed. 37 | 38 | 39 | Developer's Certificate of Origin 1.1 40 | 41 | By making a contribution to this project, I certify that: 42 | 43 | (a) The contribution was created in whole or in part by me and I 44 | have the right to submit it under the open source license 45 | indicated in the file; or 46 | 47 | (b) The contribution is based upon previous work that, to the best 48 | of my knowledge, is covered under an appropriate open source 49 | license and I have the right under that license to submit that 50 | work with modifications, whether created in whole or in part 51 | by me, under the same open source license (unless I am 52 | permitted to submit under a different license), as indicated 53 | in the file; or 54 | 55 | (c) The contribution was provided directly to me by some other 56 | person who certified (a), (b) or (c) and I have not modified 57 | it. 58 | 59 | (d) I understand and agree that this project and the contribution 60 | are public and that a record of the contribution (including all 61 | personal information I submit with it, including my sign-off) is 62 | maintained indefinitely and may be redistributed consistent with 63 | this project or the open source license(s) involved. 64 | ``` 65 | -------------------------------------------------------------------------------- /tests/rest/test_v1.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- encoding: utf-8 -*- 7 | import json 8 | import unittest 9 | from unittest import mock 10 | 11 | from redfish.rest.v1 import HttpClient, RetriesExhaustedError, redfish_client 12 | 13 | 14 | class TestRedFishClient(unittest.TestCase): 15 | def setUp(self) -> None: 16 | self.base_url = "http://foo.bar" 17 | self.username = "rstallman" 18 | self.password = "123456" 19 | self.default_prefix = "/custom/redfish/v1/" 20 | self.sessionkey = "fg687glgkf56vlgkf" 21 | self.capath = "/path/to/the/dir" 22 | self.cafile = "filename.test" 23 | self.timeout = 666 24 | self.max_retry = 42 25 | 26 | def test_redfish_client(self) -> None: 27 | # NOTE(hberaud) the client try to connect when we initialize the 28 | # http client object so we need to catch the retries exception first. 29 | # In a second time we need to mock the six.http_client to simulate 30 | # server responses and do some other tests 31 | with self.assertRaises(RetriesExhaustedError): 32 | client = redfish_client(base_url=self.base_url) 33 | # Check the object type 34 | self.assertTrue(isinstance(client, HttpClient)) 35 | # Check the object attributes values. 36 | # Here we check if the client object is properly initialized 37 | self.assertEqual(client.base_url, self.base_url) 38 | self.assertEqual(client.username, self.username) 39 | self.assertEqual(client.password, self.password) 40 | self.assertEqual(client.default_prefix, self.default_prefix) 41 | self.assertEqual(client.sessionkey, self.sessionkey) 42 | self.assertEqual(client.capath, self.capath) 43 | self.assertEqual(client.cafile, self.cafile) 44 | self.assertEqual(client.timeout, self.timeout) 45 | self.assertEqual(client.max_retry, self.max_retry) 46 | 47 | def test_redfish_client_no_root_resp(self) -> None: 48 | client = redfish_client(base_url=self.base_url, check_connectivity=False) 49 | self.assertIsNone(getattr(client, "root_resp", None)) 50 | 51 | @mock.patch("requests.Session.request") 52 | def test_redfish_client_root_object_initialized_after_login( 53 | self, mocked_request: mock.Mock 54 | ) -> None: 55 | dummy_root_data = '{"Links": {"Sessions": {"@data.id": "/redfish/v1/SessionService/Sessions"}}}' 56 | dummy_session_response = ( 57 | '{"@odata.type": "#Session.v1_1_2.Session", ' 58 | '"@odata.id": "/redfish/v1/SessionService/Sessions/1", ' 59 | '"Id": "1", "Name": "User Session", "Description": "Manager User Session", ' 60 | '"UserName": "user", "Oem": {}}' 61 | ) 62 | root_resp = mock.Mock(content=dummy_root_data, status_code=200) 63 | auth_resp = mock.Mock( 64 | content=dummy_session_response, 65 | status_code=200, 66 | headers={"location": "fake", "x-auth-token": "fake"}, 67 | ) 68 | mocked_request.side_effect = [ 69 | root_resp, 70 | auth_resp, 71 | ] 72 | client = redfish_client(base_url=self.base_url, check_connectivity=False) 73 | client.login() 74 | 75 | self.assertEqual(client.root, json.loads(dummy_root_data)) 76 | 77 | 78 | if __name__ == "__main__": 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release and Publish 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version number' 7 | required: true 8 | changes_1: 9 | description: 'Change entry' 10 | required: true 11 | changes_2: 12 | description: 'Change entry' 13 | required: false 14 | changes_3: 15 | description: 'Change entry' 16 | required: false 17 | changes_4: 18 | description: 'Change entry' 19 | required: false 20 | changes_5: 21 | description: 'Change entry' 22 | required: false 23 | changes_6: 24 | description: 'Change entry' 25 | required: false 26 | changes_7: 27 | description: 'Change entry' 28 | required: false 29 | changes_8: 30 | description: 'Change entry' 31 | required: false 32 | jobs: 33 | release_build: 34 | name: Build the release 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | with: 39 | token: ${{secrets.GITHUB_TOKEN}} 40 | - name: Set up Python 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: '3.x' 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install setuptools wheel twine 48 | - name: Build the changelog text 49 | run: | 50 | echo 'CHANGES<> $GITHUB_ENV 51 | echo "## [${{github.event.inputs.version}}] - $(date +'%Y-%m-%d')" >> $GITHUB_ENV 52 | echo "- ${{github.event.inputs.changes_1}}" >> $GITHUB_ENV 53 | if [[ -n "${{github.event.inputs.changes_2}}" ]]; then echo "- ${{github.event.inputs.changes_2}}" >> $GITHUB_ENV; fi 54 | if [[ -n "${{github.event.inputs.changes_3}}" ]]; then echo "- ${{github.event.inputs.changes_3}}" >> $GITHUB_ENV; fi 55 | if [[ -n "${{github.event.inputs.changes_4}}" ]]; then echo "- ${{github.event.inputs.changes_4}}" >> $GITHUB_ENV; fi 56 | if [[ -n "${{github.event.inputs.changes_5}}" ]]; then echo "- ${{github.event.inputs.changes_5}}" >> $GITHUB_ENV; fi 57 | if [[ -n "${{github.event.inputs.changes_6}}" ]]; then echo "- ${{github.event.inputs.changes_6}}" >> $GITHUB_ENV; fi 58 | if [[ -n "${{github.event.inputs.changes_7}}" ]]; then echo "- ${{github.event.inputs.changes_7}}" >> $GITHUB_ENV; fi 59 | if [[ -n "${{github.event.inputs.changes_8}}" ]]; then echo "- ${{github.event.inputs.changes_8}}" >> $GITHUB_ENV; fi 60 | echo "" >> $GITHUB_ENV 61 | echo 'EOF' >> $GITHUB_ENV 62 | - name: Update version numbers 63 | run: | 64 | sed -i -E 's/ version=.+,/ version='\'${{github.event.inputs.version}}\'',/' setup.py 65 | sed -i -E 's/__version__ = .+/__version__ = "'${{github.event.inputs.version}}'"/' src/redfish/__init__.py 66 | - name: Update the changelog 67 | run: | 68 | ex CHANGELOG.md <" 78 | git add CHANGELOG.md setup.py src/redfish/__init__.py 79 | git commit -s -m "${{github.event.inputs.version}} versioning" 80 | git push origin main 81 | - name: Make the release 82 | env: 83 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 84 | run: | 85 | gh release create ${{github.event.inputs.version}} -t ${{github.event.inputs.version}} -n "Changes since last release:"$'\n\n'"$CHANGES" 86 | - name: Build the distribution 87 | run: | 88 | python setup.py sdist bdist_wheel 89 | - name: Upload to pypi 90 | uses: pypa/gh-action-pypi-publish@release/v1 91 | with: 92 | password: ${{ secrets.PYPI_API_TOKEN }} 93 | -------------------------------------------------------------------------------- /src/redfish/discovery/discovery.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """Discovers Redfish services""" 8 | 9 | import http.client 10 | import re 11 | import socket 12 | 13 | from io import BytesIO 14 | 15 | 16 | class FakeSocket: 17 | """Helper class to force raw data into an HTTP Response structure""" 18 | 19 | def __init__(self, response_str): 20 | self._file = BytesIO(response_str) 21 | 22 | def makefile(self, *args, **kwargs): 23 | return self._file 24 | 25 | 26 | def sanitize(number, minimum, maximum=None): 27 | """ Sanity check a given number. 28 | 29 | :param number: the number to check 30 | :param minimum: the minimum acceptable number 31 | :param maximum: the maximum acceptable number (optional) 32 | 33 | if maximum is not given sanitize return the given value superior 34 | at minimum 35 | 36 | :returns: an integer who respect the given allowed minimum and maximum 37 | """ 38 | if number < minimum: 39 | number = minimum 40 | elif maximum is not None and number > maximum: 41 | number = maximum 42 | return number 43 | 44 | 45 | def discover_ssdp(port=1900, ttl=2, response_time=3, iface=None, protocol="ipv4", address=None): 46 | """Discovers Redfish services via SSDP 47 | 48 | :param port: the port to use for the SSDP request 49 | :type port: int 50 | :param ttl: the time-to-live value for the request 51 | :type ttl: int 52 | :param response_time: the number of seconds in which a service can respond 53 | :type response_time: int 54 | :param iface: the interface to use for the request; None for all 55 | :type iface: string 56 | :param protocol: the type of protocol to use for the request; either 'ipv4' or 'ipv6' 57 | :type protocol: string 58 | :param address: the address to use for the request; None for all 59 | :type address: string 60 | 61 | :returns: a set of discovery data 62 | """ 63 | # Sanity check the inputs 64 | valid_protocols = ("ipv4", "ipv6") 65 | if protocol not in valid_protocols: 66 | raise ValueError("Invalid protocol type. Expected one of: {}".format(valid_protocols)) 67 | ttl = sanitize(ttl, minimum=1, maximum=255) 68 | response_time = sanitize(response_time, minimum=1) 69 | 70 | if protocol == "ipv6": 71 | mcast_ip = "ff02::c" 72 | mcast_connection = (mcast_ip, port, 0, 0) 73 | af_type = socket.AF_INET6 74 | elif protocol == "ipv4": 75 | mcast_ip = "239.255.255.250" 76 | mcast_connection = (mcast_ip, port) 77 | af_type = socket.AF_INET 78 | 79 | # Initialize the multicast data 80 | msearch_str = ( 81 | "M-SEARCH * HTTP/1.1\r\n" 82 | "Host: {}:{}\r\n" 83 | 'Man: "ssdp:discover"\r\n' 84 | "ST: urn:dmtf-org:service:redfish-rest:1\r\n" 85 | "MX: {}\r\n\r\n" 86 | ).format(mcast_ip, port, response_time) 87 | socket.setdefaulttimeout(response_time + 2) 88 | 89 | # Set up the socket and send the request 90 | sock = socket.socket(af_type, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 91 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 92 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) 93 | if address: 94 | sock.bind((address, 0)) 95 | if iface: 96 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, str(iface+"\0").encode("utf-8")) 97 | sock.sendto(bytearray(msearch_str, "utf-8"), mcast_connection) 98 | 99 | # On the same socket, wait for responses 100 | discovered_services = {} 101 | pattern = re.compile( 102 | r"^uuid:([a-f0-9\-]*)::urn:dmtf-org:service:redfish-rest:1(:\d+)?$") # noqa 103 | while True: 104 | try: 105 | response = http.client.HTTPResponse(FakeSocket(sock.recv(1024))) 106 | response.begin() 107 | uuid_search = pattern.search(response.getheader("USN").lower()) 108 | if uuid_search: 109 | discovered_services[uuid_search.group(1)] = response.getheader( 110 | "AL" 111 | ) 112 | except socket.timeout: 113 | # We hit the timeout; done waiting for responses 114 | break 115 | 116 | sock.close() 117 | return discovered_services 118 | -------------------------------------------------------------------------------- /src/redfish/ris/config.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """Module for working with global configuration options.""" 8 | 9 | #---------Imports--------- 10 | 11 | import os 12 | import re 13 | import logging 14 | import configparser 15 | 16 | #---------End of imports--------- 17 | 18 | 19 | #---------Debug logger--------- 20 | 21 | LOGGER = logging.getLogger(__name__) 22 | 23 | #---------End of debug logger--------- 24 | 25 | class AutoConfigParser(object): 26 | """Auto configuration parser""" 27 | # properties starting with _ac__ are automatically 28 | # serialized to config file 29 | _config_pattern = re.compile(r'_ac__(?P.*)') 30 | 31 | def __init__(self, filename=None): 32 | """Initialize AutoConfigParser 33 | 34 | :param filename: file name to be used for config loading. 35 | :type filename: str. 36 | 37 | """ 38 | self._sectionname = 'globals' 39 | self._configfile = filename 40 | 41 | def _get_ac_keys(self): 42 | """Retrieve parse option keys""" 43 | result = [] 44 | for key in self.__dict__: 45 | match = AutoConfigParser._config_pattern.search(key) 46 | if match: 47 | result.append(match.group('confkey')) 48 | return result 49 | 50 | def _get(self, key): 51 | """Retrieve parse option key 52 | 53 | :param key: key to retrieve. 54 | :type key: str. 55 | 56 | """ 57 | ackey = '_ac__%s' % key.replace('-', '_') 58 | if ackey in self.__dict__: 59 | return self.__dict__[ackey] 60 | return None 61 | 62 | def _set(self, key, value): 63 | """Set parse option key 64 | 65 | :param key: key to be set. 66 | :type key: str. 67 | :param value: value to be given to key. 68 | :type value: str. 69 | 70 | """ 71 | ackey = '_ac__%s' % key.replace('-', '_') 72 | if ackey in self.__dict__: 73 | self.__dict__[ackey] = value 74 | return None 75 | 76 | def load(self, filename=None): 77 | """Load configuration settings from the file filename, if filename""" 78 | """ is None then the value from get_configfile() is used 79 | 80 | :param filename: file name to be used for config loading. 81 | :type filename: str. 82 | 83 | """ 84 | fname = self.get_configfile() 85 | if filename is not None and len(filename) > 0: 86 | fname = filename 87 | 88 | if fname is None or not os.path.isfile(fname): 89 | return 90 | 91 | try: 92 | config = configparser.RawConfigParser() 93 | config.read(fname) 94 | for key in self._get_ac_keys(): 95 | configval = None 96 | try: 97 | configval = config.get(self._sectionname, key) 98 | except configparser.NoOptionError: 99 | # also try with - instead of _ 100 | try: 101 | configval = config.get(self._sectionname, 102 | key.replace('_', '-')) 103 | except configparser.NoOptionError: 104 | pass 105 | 106 | if configval is not None and len(configval) > 0: 107 | ackey = '_ac__%s' % key 108 | self.__dict__[ackey] = configval 109 | except configparser.NoOptionError: 110 | pass 111 | except configparser.NoSectionError: 112 | pass 113 | 114 | def save(self, filename=None): 115 | """Save configuration settings from the file filename, if filename""" 116 | """ is None then the value from get_configfile() is used 117 | 118 | :param filename: file name to be used for config saving. 119 | :type filename: str. 120 | 121 | """ 122 | fname = self.get_configfile() 123 | if filename is not None and len(filename) > 0: 124 | fname = filename 125 | 126 | if fname is None or len(fname) == 0: 127 | return 128 | 129 | config = configparser.RawConfigParser() 130 | try: 131 | config.add_section(self._sectionname) 132 | except configparser.DuplicateSectionError: 133 | pass # ignored 134 | 135 | for key in self._get_ac_keys(): 136 | ackey = '_ac__%s' % key 137 | config.set(self._sectionname, key, str(self.__dict__[ackey])) 138 | 139 | fileh = open(self._configfile, 'w') 140 | config.write(fileh) 141 | fileh.close() 142 | 143 | def get_configfile(self): 144 | """ The current configuration file location""" 145 | return self._configfile 146 | 147 | -------------------------------------------------------------------------------- /src/redfish/messages/messages.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | # Copyright Notice: 3 | # Copyright 2019-2020 DMTF. All rights reserved. 4 | # License: BSD 3-Clause License. For full text see link: https://github.com/DMTF/Redfish-Tacklebox/blob/main/LICENSE.md 5 | 6 | """ 7 | Messages Module 8 | 9 | File : messages.py 10 | 11 | Brief : This file contains the definitions and functionalities for interacting 12 | with Messages for a given Redfish service 13 | """ 14 | import re 15 | 16 | class RedfishOperationFailedError( Exception ): 17 | """ 18 | Raised when an operation has failed (HTTP Status >= 400) 19 | """ 20 | pass 21 | 22 | class RedfishPasswordChangeRequiredError( Exception ): 23 | """ 24 | Raised when password change required 25 | """ 26 | def __str__(self): 27 | return "\n{}\nURL: {}\n".format( str(self.args[0]), str(self.args[1]) ) 28 | 29 | def get_messages_detail( response ): 30 | """ 31 | Builds messages detail dict in the payload 32 | 33 | Args: 34 | response: The response to parser 35 | 36 | Returns: 37 | The dict containing messages_detail 38 | messages_detail["status"]: http status code 39 | messages_detail["successful"]: response successful (http status code < 400) 40 | messages_detail["code"]: redfish message response code field 41 | messages_detail["@Message.ExtendedInfo"]: redfish message response code field 42 | """ 43 | 44 | messages_detail = {} 45 | messages_detail["status"] = response.status 46 | messages_detail["text"] = response.text 47 | messages_detail["successful"] = False 48 | messages_detail["@Message.ExtendedInfo"] = [] 49 | 50 | if response.status >= 400: 51 | messages_detail["successful"] = False 52 | else: 53 | messages_detail["successful"] = True 54 | 55 | try: 56 | message_body = response.dict 57 | messages_detail["body"] = response.dict 58 | 59 | if not "@Message.ExtendedInfo" in message_body: 60 | message_body = response.dict["error"] 61 | check_message_field = True 62 | if "@Message.ExtendedInfo" in message_body: 63 | messages_detail["@Message.ExtendedInfo"] = message_body["@Message.ExtendedInfo"] 64 | for index in range(len(messages_detail["@Message.ExtendedInfo"])): 65 | messages_item = messages_detail["@Message.ExtendedInfo"][index] 66 | if not "MessageId" in messages_item: 67 | messages_item["MessageId"] = "" 68 | if not "Message" in messages_item: 69 | messages_item["Message"] = "" 70 | messages_detail["@Message.ExtendedInfo"][index] = messages_item 71 | check_message_field = False 72 | 73 | if check_message_field is True: 74 | messages_detail["@Message.ExtendedInfo"] = [] 75 | messages_item = {} 76 | if "code" in message_body: 77 | messages_item["MessageId"] = message_body["code"] 78 | else: 79 | messages_item["MessageId"] = "" 80 | if "message" in message_body: 81 | messages_item["Message"] = message_body["message"] 82 | else: 83 | messages_item["Message"] = "" 84 | messages_detail["@Message.ExtendedInfo"].insert(0, messages_item) 85 | except: 86 | messages_detail["@Message.ExtendedInfo"] = [] 87 | messages_detail["body"] = {} 88 | 89 | return messages_detail 90 | 91 | def search_message(response, message_registry_group, message_registry_id): 92 | """ 93 | search message in the payload 94 | 95 | Args: 96 | response: The response to parser 97 | message_registry_group: target message_registry_group 98 | message_registry_id: target message_registry_id 99 | Returns: 100 | The dict containing target message detail 101 | """ 102 | if isinstance(response, dict) and "@Message.ExtendedInfo" in response: 103 | messages_detail = response 104 | else: 105 | messages_detail = get_messages_detail(response) 106 | 107 | message_registry_id_search = "^" + message_registry_group + r"\.[0-9]+\.[0-9]+\." + message_registry_id +"$" 108 | 109 | for messages_item in messages_detail["@Message.ExtendedInfo"]: 110 | if "MessageId" in messages_item: 111 | resault = re.search(message_registry_id_search, messages_item["MessageId"]) 112 | if resault: 113 | return messages_item 114 | return None 115 | 116 | def get_error_messages( response ): 117 | """ 118 | Builds a string based on the error messages in the payload 119 | 120 | Args: 121 | response: The response to print 122 | 123 | Returns: 124 | The string containing error messages 125 | """ 126 | 127 | # Pull out the error payload and the messages 128 | 129 | out_string = "" 130 | try: 131 | if isinstance(response, dict) and "@Message.ExtendedInfo" in response: 132 | messages_detail = response 133 | else: 134 | messages_detail = get_messages_detail(response) 135 | 136 | if "@Message.ExtendedInfo" in messages_detail: 137 | for message in messages_detail["@Message.ExtendedInfo"]: 138 | if "Message" in message: 139 | out_string = out_string + "\n" + message["MessageId"] + ": " + message["Message"] 140 | else: 141 | out_string = out_string + "\n" + message["MessageId"] 142 | out_string = out_string + "\n" 143 | except: 144 | # No response body 145 | out_string = "" 146 | 147 | return out_string 148 | 149 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [3.3.4] - 2025-08-29 4 | - Added option to allow for a user to specify an HTTPS adapter when building the client object 5 | 6 | ## [3.3.3] - 2025-07-25 7 | - Fixed formatting of error message string construction 8 | 9 | ## [3.3.2] - 2025-07-07 10 | - Fixed issue where headers were being propagated across different class instances 11 | 12 | ## [3.3.1] - 2025-03-28 13 | - Fixed bug in recent workaround logic for services not returning the 'Location' header to not print the workaround warning for failed login attempts 14 | 15 | ## [3.3.0] - 2025-03-21 16 | - Added workaround and warnings for session login when the service incorrectly does not provide the session location in the 'Location' response header 17 | - Minor typo fix in exception message for login failures 18 | 19 | ## [3.2.9] - 2025-03-07 20 | - Added optional 'headers' argument to the 'login' method 21 | - Clarified exception message when raising 'ServerDownOrUnreachableError' 22 | 23 | ## [3.2.8] - 2025-01-24 24 | - Updated 'dict' handling for responses without a body and 500 responses with a non-JSON body to use an empty dictionary 25 | 26 | ## [3.2.7] - 2024-12-24 27 | - Added JSON formatting of responses to debug logs 28 | 29 | ## [3.2.6] - 2024-11-15 30 | - Added workaround for services incorrectly responding with 401 when accessing the service root 31 | 32 | ## [3.2.5] - 2024-09-13 33 | - Added new 'check_connectivity' option when creating the client object 34 | 35 | ## [3.2.4] - 2024-08-09 36 | - No code changes; releasing again for PyPI publication 37 | 38 | ## [3.2.3] - 2024-08-09 39 | - Removed clearing of username and password to allow for sessions to be re-established 40 | 41 | ## [3.2.2] - 2024-01-19 42 | - Minor changes to fix Python 3.12 warnings with usage of raw strings 43 | 44 | ## [3.2.1] - 2023-08-04 45 | - Added 'timeout' and 'max_retry' parameters to all REST methods 46 | - Added exception to the method when a response contains a message indicating a password change is required 47 | 48 | ## [3.2.0] - 2023-07-27 49 | - Adding missing newline to M-SEARCH requests 50 | - Fixed the inspection of the USN response header from M-SEARCH requests to allow for a multi-digit minor version 51 | 52 | ## [3.1.9] - 2023-01-13 53 | - Improved usage of the ServerDownOrUnreachableError exception to not lose the original message 54 | 55 | ## [3.1.8] - 2022-12-02 56 | - Added request headers to debug log output 57 | - Added redacting of 'Password' properties from request bodies from debug logs 58 | 59 | ## [3.1.7] - 2022-09-09 60 | - Added handling for extracting error information when a session could not be created 61 | 62 | ## [3.1.6] - 2022-05-12 63 | - Fixed issue where the 'read' method on response objects always return strings 64 | - Modified query parameter encoding to not percent-encode characters allowed in query strings per RFC3986 65 | 66 | ## [3.1.5] - 2022-04-01 67 | - Added methods for specifying proxies directly with a new 'proxies' parameter 68 | 69 | ## [3.1.4] - 2022-03-25 70 | - Removed enforcement of trailing '/' in the 'default_prefix' 71 | 72 | ## [3.1.3] - 2022-03-21 73 | - Added support for Unix sockets 74 | 75 | ## [3.1.2] - 2022-03-10 76 | - Corrected usage of header storage and retrieval for static response objects 77 | 78 | ## [3.1.1] - 2022-01-18 79 | - Corrected 'import' statements to support Python 3.10 80 | 81 | ## [3.1.0] - 2022-01-10 82 | - Updated library to leverage 'requests' in favor of 'http.client' 83 | 84 | ## [3.0.3] - 2021-10-15 85 | - Added support for performing multi-part HTTP POST requests 86 | 87 | ## [3.0.2] - 2021-08-30 88 | - Added support for prepending 'https://' when the provided URI of the service does not contain a scheme 89 | 90 | ## [3.0.1] - 2021-06-04 91 | - Provided additional handling for HTTP 301 and 302 redirects 92 | - Changed session creation to not follow redirects in order to ensure the session token and location are not lost 93 | - Enhanced invalid JSON response handling to better highlight a service error 94 | 95 | ## [3.0.0] - 2021-02-20 96 | - Removed Python2 support 97 | 98 | ## [2.2.0] - 2021-02-15 99 | - Added support for `NO_PROXY` environment variable 100 | 101 | ## [2.1.9] - 2020-12-04 102 | - Added handling for HTTP 303 responses as part of redirect handling 103 | 104 | ## [2.1.8] - 2020-08-10 105 | - Added option to SSDP discover to bind to a specified address 106 | - Added ability to override built-in HTTP headers 107 | - Fixed issue where the location of a session was not being tracked properly for HTTP connections 108 | 109 | ## [2.1.7] - 2020-07-06 110 | - Added support for setting the 'Content-Type' header to 'application/octet-stream' when binary data is provided in a request 111 | 112 | ## [2.1.6] - 2020-06-12 113 | - Added support for leveraging the 'HTTP_PROXY' and 'HTTPS_PROXY' environment variables to set up proxy information 114 | 115 | ## [2.1.5] - 2020-02-03 116 | - Removed urlparse2 dependency 117 | - Updated jsonpatch requirements; jsonpatch 1.25 dropped Python 3.4 support 118 | 119 | ## [2.1.4] - 2020-01-10 120 | - Added fallback to using '/redfish/v1/SessionService/Sessions' if the service root does not contains the 'Links/Sessions' property for login 121 | - Added Python version checks to use time.perf_counter() in favor of time.clock() 122 | 123 | ## [2.1.3] - 2019-10-11 124 | - Added IPv6 support to SSDP discovery 125 | - Enhanced handling of poorly formatted URIs to not throw an exception 126 | 127 | ## [2.1.2] - 2019-09-16 128 | - Fixed usage of capath and cafile when setting them to None 129 | 130 | ## [2.1.1] - 2019-08-16 131 | - Added option in SSDP discovery to specify a particular interface 132 | - Added sanitization to the Base URL to remove trailing slashes 133 | 134 | ## [2.1.0] - 2019-07-12 135 | - Changed default authentication to be Session based 136 | - Removed unnecessary closing of sockets 137 | 138 | ## [2.0.9] - 2019-06-28 139 | - Added various unit tests and other cleanup 140 | - Added example for how to use the 'with' statement to perform automatically log out of a service 141 | - Made change to include the original trace when RetriesExhaustedError is encountered 142 | 143 | ## [2.0.8] - 2019-05-17 144 | - Added helper functions for Task processing 145 | 146 | ## [2.0.7] - 2019-02-08 147 | - Added optional timeout and max retry arguments 148 | 149 | ## [2.0.6] - 2019-01-11 150 | - Removed usage of setting the Content-Type header to application/x-www-form-urlencoded for PUT, POST, and PATCH methods 151 | 152 | ## [2.0.5] - 2018-11-30 153 | - Fixed handling of gzip content encoding 154 | 155 | ## [2.0.4] - 2018-10-26 156 | - Added discovery module with SSDP support 157 | 158 | ## [2.0.3] - 2018-10-19 159 | - Fixed handling of other successful HTTP responses (201, 202, and 204) 160 | - Added support for being able to check the certificate of a service 161 | 162 | ## [2.0.2] - 2018-09-07 163 | - Added handling for bad or dummy delete requests when logging out of a service 164 | 165 | ## [2.0.1] - 2018-05-25 166 | - Adjusting setup.py to contain correct information 167 | 168 | ## [2.0.0] - 2017-07-28 169 | - Python 3 Compatible Release 170 | 171 | ## [1.0.0] - 2017-01-12 172 | - Initial Public Release -- supports Redfish 1.0 features 173 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-redfish-library 2 | ====================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/redfish.svg?maxAge=2592000 5 | :target: https://pypi.python.org/pypi/redfish 6 | .. image:: https://img.shields.io/github/release/DMTF/python-redfish-library.svg?maxAge=2592000 7 | :target: https://github.com/DMTF/python-redfish-library/releases 8 | .. image:: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg 9 | :target: https://raw.githubusercontent.com/DMTF/python-redfish-library/main/LICENSE 10 | .. image:: https://img.shields.io/pypi/pyversions/redfish.svg?maxAge=2592000 11 | :target: https://pypi.python.org/pypi/redfish 12 | 13 | .. contents:: :depth: 1 14 | 15 | Description 16 | ----------- 17 | 18 | As of version 3.0.0, Python2 is no longer supported. If Python2 is required, ``redfish<3.0.0`` can be specified in a requirements file. 19 | 20 | REST (Representational State Transfer) is a web based software architectural style consisting of a set of constraints that focuses on a system's resources. The Redfish library performs GET, POST, PUT, PATCH and DELETE HTTP operations on resources within a Redfish service. Go to the `wiki <../../wiki>`_ for more details. 21 | 22 | Installing 23 | ---------- 24 | 25 | .. code-block:: console 26 | 27 | pip install redfish 28 | 29 | Building from zip file source 30 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 31 | 32 | .. code-block:: console 33 | 34 | python setup.py sdist --formats=zip (this will produce a .zip file) 35 | cd dist 36 | pip install redfish-x.x.x.zip 37 | 38 | Requirements 39 | ------------ 40 | 41 | Ensure the system does not have the OpenStack "python-redfish" module installed on the target system. This module is using a conflicting package name that this library already uses. The module in question can be found here: https://pypi.org/project/python-redfish/ 42 | 43 | Required external packages: 44 | 45 | .. code-block:: console 46 | 47 | jsonpatch<=1.24 ; python_version == '3.4' 48 | jsonpatch ; python_version >= '3.5' 49 | jsonpath_rw 50 | jsonpointer 51 | requests 52 | requests-toolbelt 53 | requests-unixsocket 54 | 55 | If installing from GitHub, you may install the external packages by running: 56 | 57 | .. code-block:: console 58 | 59 | pip install -r requirements.txt 60 | 61 | Usage 62 | ---------- 63 | 64 | A set of examples is provided under the examples directory of this project. In addition to the directives present in this paragraph, you will find valuable implementation tips and tricks in those examples. 65 | 66 | Import the relevant Python module 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | For a Redfish conformant application import the relevant Python module. 70 | 71 | For Redfish conformant application: 72 | 73 | .. code-block:: python 74 | 75 | import redfish 76 | 77 | Create a Redfish object 78 | ~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | The Redfish object contains three required parameters: 81 | 82 | * ``base_url``: The address of the Redfish service (with scheme). Example: ``https://192.168.1.100``. For Unix sockets, use the scheme ``http+unix://``, followed by the percent-encoded filepath to the socket. 83 | * ``username``: The username for authentication. 84 | * ``password``: The password for authentication. 85 | 86 | There are several optional parameters: 87 | 88 | * ``default_prefix``: The path to the Redfish service root. This is only used for initial connection and authentication with the service. The default value is ``/redfish/v1/``. 89 | * ``sessionkey``: The session key to use with subsequent requests. This can be used to bypass the login step. The default value is ``None``. 90 | * ``cafile``: The file path to the CA certificate that issued the Redfish service's certificate. The default value is ``None``. 91 | * ``timeout``: The number of seconds to wait for a response before closing the connection. The default value is ``None``. 92 | * ``max_retry``: The number of retries to perform an operation before giving up. The default value is ``10``. 93 | * ``proxies``: A dictionary containing protocol to proxy URL mappings. The default value is ``None``. See `Using proxies`_. 94 | * ``check_connectivity``: A boolean value to determine whether the client immediately attempts a connection to the base_url. The default is ``True``. 95 | 96 | To create a Redfish object, call the ``redfish_client`` method: 97 | 98 | .. code-block:: python 99 | 100 | REDFISH_OBJ = redfish.redfish_client(base_url=login_host, username=login_account, \ 101 | password=login_password, default_prefix='/redfish/v1/') 102 | 103 | Login to the service 104 | ~~~~~~~~~~~~~~~~~~~~ 105 | 106 | After creating the REDFISH_OBJ, perform the ``login`` operation to authenticate with the service. The ``auth`` parameter allows you to specify the login method. Possible values are: 107 | 108 | * ``session``: Creates a Redfish session with a session token. 109 | * ``basic``: Uses HTTP Basic authentication for all requests. 110 | 111 | .. code-block:: python 112 | 113 | REDFISH_OBJ.login(auth="session") 114 | 115 | Perform a GET operation 116 | ~~~~~~~~~~~~~~~~~~~~~~~ 117 | 118 | A simple GET operation can be performed to obtain the data present in any valid path. 119 | An example of GET operation on the path "/redfish/v1/Systems/1" is shown below: 120 | 121 | .. code-block:: python 122 | 123 | response = REDFISH_OBJ.get("/redfish/v1/Systems/1") 124 | 125 | Perform a POST operation 126 | ~~~~~~~~~~~~~~~~~~~~~~~~ 127 | 128 | A POST operation can be performed to create a resource or perform an action. 129 | An example of a POST operation on the path "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" is shown below: 130 | 131 | .. code-block:: python 132 | 133 | body = {"ResetType": "GracefulShutdown"} 134 | response = REDFISH_OBJ.post("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", body=body) 135 | 136 | Notes about HTTP methods and arguments 137 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 138 | 139 | The previous sections showed example GET and POST requests. The following is a list of the different methods supported: 140 | 141 | * ``get``: Performs an HTTP GET operation to retrieve a resource from a URI. 142 | * ``head``: Performs an HTTP HEAD operation to retrieve response headers from a URI, but no body. 143 | * ``post``: Performs an HTTP POST operation to perform an action or create a new resource. 144 | * ``put``: Performs an HTTP PUT operation to replace an existing resource. 145 | * ``patch``: Performs an HTTP PATCH operation to update an existing resource. 146 | * ``delete``: Performs an HTTP DELETE operation to remove a resource. 147 | 148 | Each of the previous methods allows for the following arguments: 149 | 150 | * ``path``: **Required**. String. The URI in which to invoke the operation. 151 | 152 | - Example: ``"/redfish/v1/Systems/1"`` 153 | 154 | * ``args``: Dictionary. Query parameters to supply with the request. 155 | 156 | - The key-value pairs in the dictionary are the query parameter name and the query parameter value to supply. 157 | - Example: ``{"$select": "Reading,Status"}`` 158 | 159 | * ``body``: Dictionary, List, Bytes, or String. The request body to provide with the request. 160 | 161 | - Not supported for ``get``, ``head``, or ``delete`` methods. 162 | - The data type supplied will dictate the encoding. 163 | - A dictionary is the most common usage, which results in a JSON body. 164 | - Example: ``{"ResetType": "GracefulShutdown"}`` 165 | - A list is used to supply multipart forms, which is useful for multipart HTTP push updates. 166 | - Bytes is used to supply an octet stream. 167 | - A string is used to supply an unstructed body, which may be used in some OEM cases. 168 | 169 | * ``headers``: Dictionary. Additional HTTP headers to supply with the request. 170 | 171 | - The key-value pairs in the dictionary are the HTTP header name and the HTTP header value to supply. 172 | - Example: ``{"If-Match": etag_value}`` 173 | 174 | * ``timeout``: Number. The number of seconds to wait for a response before closing the connection for this request. 175 | 176 | - Overrides the timeout value specified when the Redfish object is created for this request. 177 | - This can be useful when a particular URI is known to take a long time to respond, such as with firmware updates. 178 | - The default value is ``None``, which indicates the object-defined timeout is used. 179 | 180 | * ``max_retry``: Number. The number of retries to perform an operation before giving up for this request. 181 | 182 | - Overrides the max retry value specified when the Redfish object is created for this request. 183 | - This can be useful when a particular URI is known to take multiple retries. 184 | - The default value is ``None``, which indicates the object-defined max retry count is used. 185 | 186 | Working with tasks 187 | ~~~~~~~~~~~~~~~~~~ 188 | 189 | POST, PATCH, PUT, and DELETE operations may result in a task, describing an operation with a duration greater than the span of a single request. 190 | The action message object that ``is_processing`` will return a task that can be accessed reviewed when polled with monitor. 191 | An example of a POST operation with a possible task is shown below. 192 | 193 | .. code-block:: python 194 | 195 | body = {"ResetType": "GracefulShutdown"} 196 | response = REDFISH_OBJ.post("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", body=body) 197 | if(response.is_processing): 198 | task = response.monitor(REDFISH_OBJ) 199 | 200 | while(task.is_processing): 201 | retry_time = task.retry_after 202 | task_status = task.dict['TaskState'] 203 | time.sleep(retry_time if retry_time else 5) 204 | task = response.monitor(REDFISH_OBJ) 205 | 206 | Logout the created session 207 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 208 | 209 | Ensure you perform a ``logout`` operation when done interacting with the Redfish service. If this step isn't performed, the session will remain active until the Redfish service decides to close it. 210 | 211 | .. code-block:: python 212 | 213 | REDFISH_OBJ.logout() 214 | 215 | The ``logout`` operation deletes the current sesssion from the service. The ``redfish_client`` object destructor includes a logout statement. 216 | 217 | Using proxies 218 | ~~~~~~~~~~~~~ 219 | 220 | There are two methods for using proxies: configuring environment variables or directly providing proxy information. 221 | 222 | Environment variables 223 | ^^^^^^^^^^^^^^^^^^^^^ 224 | 225 | You can use a proxy by specifying the ``HTTP_PROXY`` and ``HTTPS_PROXY`` environment variables. Hosts to be excluded from the proxy can be specified using the NO_PROXY environment variable. 226 | 227 | .. code-block:: shell 228 | 229 | export HTTP_PROXY="http://192.168.1.10:8888" 230 | export HTTPS_PROXY="http://192.168.1.10:8888" 231 | 232 | Directly provided 233 | ^^^^^^^^^^^^^^^^^ 234 | 235 | You can use a proxy by building a dictionary containing the proxy information and providing it to the ``proxies`` argument when creating the ``redfish_client`` object. 236 | The key-value pairs of the dictionary contain the protocol and the proxy URL for the protocol. 237 | 238 | .. code-block:: python 239 | 240 | proxies = { 241 | 'http': 'http://192.168.1.10:8888', 242 | 'https': 'http://192.168.1.10:8888', 243 | } 244 | REDFISH_OBJ = redfish.redfish_client(base_url=login_host, username=login_account, \ 245 | password=login_password, proxies=proxies) 246 | 247 | SOCKS proxy support 248 | ^^^^^^^^^^^^^^^^^^^ 249 | 250 | An additional package is required to use SOCKS proxies. 251 | 252 | .. code-block:: console 253 | 254 | pip install -U requests[socks] 255 | 256 | Once installed, the proxy can be configured using environment variables or directly provided like any other proxy. 257 | For example: 258 | 259 | .. code-block:: shell 260 | 261 | export HTTP_PROXY="socks5h://localhost:8123" 262 | export HTTPS_PROXY="socks5h://localhost:8123" 263 | 264 | Release Process 265 | --------------- 266 | 267 | 1. Go to the "Actions" page 268 | 2. Select the "Release and Publish" workflow 269 | 3. Click "Run workflow" 270 | 4. Fill out the form 271 | 5. Click "Run workflow" 272 | 273 | Copyright and License 274 | --------------------- 275 | 276 | Copyright Notice: 277 | Copyright 2016-2022 DMTF. All rights reserved. 278 | License: BSD 3-Clause License. For full text see link: `https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md `_ 279 | -------------------------------------------------------------------------------- /src/redfish/ris/ris.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """RIS implementation""" 8 | 9 | # ---------Imports--------- 10 | 11 | import abc 12 | import logging 13 | import sys 14 | import threading 15 | from queue import Queue 16 | from collections import OrderedDict 17 | 18 | import jsonpath_rw 19 | 20 | import redfish.rest.v1 21 | from redfish.ris.sharedtypes import Dictable 22 | 23 | from urllib.parse import urlparse, urlunparse 24 | 25 | 26 | # ---------End of imports--------- 27 | 28 | # ---------Debug logger--------- 29 | 30 | LOGGER = logging.getLogger(__name__) 31 | 32 | # ---------End of debug logger--------- 33 | 34 | 35 | class SessionExpiredRis(Exception): 36 | """Raised when session has expired""" 37 | 38 | pass 39 | 40 | 41 | class RisMonolithMemberBase(Dictable): 42 | """RIS monolith member base class""" 43 | __metaclass__ = abc.ABCMeta 44 | 45 | pass 46 | 47 | 48 | class RisMonolithMember_v1_0_0(RisMonolithMemberBase): 49 | """Wrapper around RestResponse that adds the monolith data""" 50 | 51 | def __init__(self, restresp): 52 | self._resp = restresp 53 | self._patches = list() 54 | self._type = None 55 | self._typestring = "@odata.type" 56 | 57 | def _get_type(self): 58 | """Return type from monolith""" 59 | if self._typestring in self._resp.dict: 60 | return self._resp.dict[self._typestring] 61 | elif "type" in self._resp.dict: 62 | return self._resp.dict["type"] 63 | return None 64 | 65 | type = property(_get_type, None) 66 | 67 | def _get_maj_type(self): 68 | """Return maj type from monolith""" 69 | if self.type: 70 | return self.type[:-4] 71 | return None 72 | 73 | maj_type = property(_get_maj_type, None) 74 | 75 | def _get_resp(self): 76 | """Return resp from monolith""" 77 | return self._resp 78 | 79 | resp = property(_get_resp, None) 80 | 81 | def _get_patches(self): 82 | """Return patches from monolith""" 83 | return self._patches 84 | 85 | patches = property(_get_patches, None) 86 | 87 | def to_dict(self): 88 | """Convert monolith to dict""" 89 | result = OrderedDict() 90 | if self.type: 91 | result["Type"] = self.type 92 | 93 | if (self.maj_type == "Collection.1" 94 | and "MemberType" in self._resp.dict): 95 | result["MemberType"] = self._resp.dict["MemberType"] 96 | 97 | result["links"] = OrderedDict() 98 | result["links"]["href"] = "" 99 | headers = dict() 100 | 101 | for header in self._resp.getheaders(): 102 | headers[header[0]] = header[1] 103 | 104 | result["Headers"] = headers 105 | 106 | if "etag" in headers: 107 | result["ETag"] = headers["etag"] 108 | 109 | result["OriginalUri"] = self._resp.request.path 110 | result["Content"] = self._resp.dict 111 | result["Patches"] = self._patches 112 | 113 | return result 114 | 115 | def load_from_dict(self, src): 116 | """Load variables from dict monolith""" 117 | """ 118 | 119 | :param src: source to load from 120 | :type src: dict 121 | 122 | """ 123 | if "Type" in src: 124 | self._type = src["Type"] 125 | restreq = redfish.rest.v1.RestRequest(method="GET", 126 | path=src["OriginalUri"]) 127 | 128 | src["restreq"] = restreq 129 | self._resp = redfish.rest.v1.StaticRestResponse(**src) 130 | self._patches = src["Patches"] 131 | 132 | def _reducer(self, indict, breadcrumbs=None, outdict=OrderedDict()): 133 | """Monolith reducer 134 | 135 | :param indict: input dictionary. 136 | :type indict: dict. 137 | :param breadcrumbs: breadcrumbs from previous operations. 138 | :type breadcrumbs: dict. 139 | :param outdict: expected output format. 140 | :type outdict: dictionary type. 141 | :returns: returns outdict 142 | 143 | """ 144 | if breadcrumbs is None: 145 | breadcrumbs = [] 146 | 147 | if isinstance(indict, dict): 148 | for key, val in list(indict.items()): 149 | breadcrumbs.append(key) # push 150 | 151 | if isinstance(val, dict): 152 | self._reducer(val, breadcrumbs, outdict) 153 | elif isinstance(val, list) or isinstance(val, tuple): 154 | for i in range(0, len(val)): 155 | breadcrumbs.append("%s" % i) # push 156 | self._reducer(val[i], breadcrumbs, outdict) 157 | 158 | del breadcrumbs[-1] # pop 159 | elif isinstance(val, tuple): 160 | self._reducer(val, breadcrumbs, outdict) 161 | else: 162 | self._reducer(val, breadcrumbs, outdict) 163 | 164 | del breadcrumbs[-1] # pop 165 | else: 166 | outkey = "/".join(breadcrumbs) 167 | outdict[outkey] = indict 168 | 169 | return outdict 170 | 171 | def _jsonpath_reducer(self, 172 | indict, 173 | breadcrumbs=None, 174 | outdict=OrderedDict()): 175 | """JSON Path Reducer 176 | 177 | :param indict: input dictionary. 178 | :type indict: dict. 179 | :param breadcrumbs: breadcrumbs from previous operations. 180 | :type breadcrumbs: dict. 181 | :param outdict: expected output format. 182 | :type outdict: dictionary type. 183 | :returns: returns outdict 184 | 185 | """ 186 | if breadcrumbs is None: 187 | breadcrumbs = [] 188 | 189 | if isinstance(indict, dict): 190 | for key, val in list(indict.items()): 191 | breadcrumbs.append(key) # push 192 | 193 | if isinstance(val, dict): 194 | self._reducer(val, breadcrumbs, outdict) 195 | elif isinstance(val, list) or isinstance(val, tuple): 196 | for i in range(0, len(val)): 197 | breadcrumbs.append("[%s]" % i) # push 198 | self._reducer(val[i], breadcrumbs, outdict) 199 | 200 | del breadcrumbs[-1] # pop 201 | elif isinstance(val, tuple): 202 | self._reducer(val, breadcrumbs, outdict) 203 | else: 204 | self._reducer(val, breadcrumbs, outdict) 205 | 206 | del breadcrumbs[-1] # pop 207 | else: 208 | outkey = ".".join(breadcrumbs) 209 | outkey = outkey.replace(".[", "[") 210 | outdict[outkey] = indict 211 | 212 | return outdict 213 | 214 | def reduce(self): 215 | """Returns a "flatten" dict with nested data represented in""" 216 | """ JSONpath notation""" 217 | result = OrderedDict() 218 | 219 | if self.type: 220 | result["Type"] = self.type 221 | 222 | if (self.maj_type == "Collection.1" 223 | and "MemberType" in self._resp.dict): 224 | result["MemberType"] = self._resp.dict["MemberType"] 225 | 226 | self._reducer(self._resp.dict) 227 | result["OriginalUri"] = self._resp.request.path 228 | result["Content"] = self._reducer(self._resp.dict) 229 | 230 | return result 231 | 232 | 233 | class RisMonolith_v1_0_0(Dictable): 234 | """Monolithic cache of RIS data""" 235 | 236 | def __init__(self, client): 237 | """Initialize RisMonolith 238 | 239 | :param client: client to utilize 240 | :type client: RmcClient object 241 | 242 | """ 243 | self._client = client 244 | self.name = "Monolithic output of RIS Service" 245 | self.types = OrderedDict() 246 | self._visited_urls = list() 247 | self._current_location = "/" # "root" 248 | self.queue = Queue() 249 | self._type = None 250 | self._name = None 251 | self.progress = 0 252 | self.reload = False 253 | 254 | self._typestring = "@odata.type" 255 | self._hrefstring = "@odata.id" 256 | 257 | def _get_type(self): 258 | """Return monolith version type""" 259 | return "Monolith.1.0.0" 260 | 261 | type = property(_get_type, None) 262 | 263 | def update_progress(self): 264 | """Simple function to increment the dot progress""" 265 | if self.progress % 6 == 0: 266 | sys.stdout.write(".") 267 | 268 | def get_visited_urls(self): 269 | """Return the visited URLS""" 270 | return self._visited_urls 271 | 272 | def set_visited_urls(self, visited_urls): 273 | """Set visited URLS to given list.""" 274 | self._visited_urls = visited_urls 275 | 276 | def load( 277 | self, 278 | path=None, 279 | includelogs=False, 280 | skipinit=False, 281 | skipcrawl=False, 282 | loadtype="href", 283 | loadcomplete=False, 284 | ): 285 | """Walk entire RIS model and cache all responses in self. 286 | 287 | :param path: path to start load from. 288 | :type path: str. 289 | :param includelogs: flag to determine if logs should be downloaded also. 290 | :type includelogs: boolean. 291 | :param skipinit: flag to determine if first run of load. 292 | :type skipinit: boolean. 293 | :param skipcrawl: flag to determine if load should traverse found links. 294 | :type skipcrawl: boolean. 295 | :param loadtype: flag to determine if load is meant for only href items. 296 | :type loadtype: str. 297 | :param loadcomplete: flag to download the entire monolith 298 | :type loadcomplete: boolean 299 | 300 | """ # noqa 301 | if not skipinit: 302 | if LOGGER.getEffectiveLevel() == 40: 303 | sys.stdout.write("Discovering data...") 304 | else: 305 | LOGGER.info("Discovering data...") 306 | self.name = self.name + " at %s" % self._client.base_url 307 | 308 | if not self.types: 309 | self.types = OrderedDict() 310 | 311 | if not threading.active_count() >= 6: 312 | for _ in range(5): 313 | workhand = SuperDuperWorker(self.queue) 314 | workhand.setDaemon(True) 315 | workhand.start() 316 | 317 | selectivepath = path 318 | if not selectivepath: 319 | selectivepath = self._client._rest_client.default_prefix 320 | 321 | self._load( 322 | selectivepath, 323 | skipcrawl=skipcrawl, 324 | includelogs=includelogs, 325 | skipinit=skipinit, 326 | loadtype=loadtype, 327 | loadcomplete=loadcomplete, 328 | ) 329 | self.queue.join() 330 | 331 | if not skipinit: 332 | if LOGGER.getEffectiveLevel() == 40: 333 | sys.stdout.write("Done\n") 334 | else: 335 | LOGGER.info("Done\n") 336 | 337 | def _load( 338 | self, 339 | path, 340 | skipcrawl=False, 341 | originaluri=None, 342 | includelogs=False, 343 | skipinit=False, 344 | loadtype="href", 345 | loadcomplete=False, 346 | ): 347 | """Helper function to main load function. 348 | 349 | :param path: path to start load from. 350 | :type path: str. 351 | :param skipcrawl: flag to determine if load should traverse found links. 352 | :type skipcrawl: boolean. 353 | :param originaluri: variable to assist in determining originating path. 354 | :type originaluri: str. 355 | :param includelogs: flag to determine if logs should be downloaded also. 356 | :type includelogs: boolean. 357 | :param skipinit: flag to determine if first run of load. 358 | :type skipinit: boolean. 359 | :param loadtype: flag to determine if load is meant for only href items. 360 | :type loadtype: str. 361 | :param loadcomplete: flag to download the entire monolith 362 | :type loadcomplete: boolean 363 | 364 | """ # noqa 365 | if path.endswith("?page=1"): 366 | return 367 | elif not includelogs: 368 | if "/Logs/" in path: 369 | return 370 | 371 | # catch any exceptions during URL parsing or GET requests 372 | try: 373 | # remove fragments 374 | newpath = urlparse(path) 375 | newpath = list(newpath[:]) 376 | newpath[-1] = "" 377 | 378 | path = urlunparse(tuple(newpath)) 379 | 380 | LOGGER.debug("_loading %s", path) 381 | 382 | if not self.reload: 383 | if path.lower() in self._visited_urls: 384 | return 385 | 386 | resp = self._client.get(path) 387 | 388 | if resp.status != 200: 389 | path = path + "/" 390 | resp = self._client.get(path) 391 | 392 | if resp.status == 401: 393 | raise SessionExpiredRis("Invalid session. Please logout and " 394 | "log back in or include credentials.") 395 | elif resp.status != 200: 396 | return 397 | except SessionExpiredRis: 398 | raise 399 | except Exception as e: 400 | cause = e.__cause__ if e.__cause__ else e 401 | LOGGER.error("Resource '{}' skipped due to exception: {}" 402 | .format(path, repr(cause))) 403 | return 404 | 405 | self.queue.put((resp, path, skipinit, self)) 406 | 407 | if loadtype == "href": 408 | # follow all the href attributes 409 | jsonpath_expr = jsonpath_rw.parse("$..'@odata.id'") 410 | matches = jsonpath_expr.find(resp.dict) 411 | 412 | if "links" in resp.dict and "NextPage" in resp.dict["links"]: 413 | if originaluri: 414 | next_link_uri = ( 415 | originaluri + "?page=" + 416 | str(resp.dict["links"]["NextPage"]["page"])) 417 | href = "%s" % next_link_uri 418 | 419 | self._load( 420 | href, 421 | originaluri=originaluri, 422 | includelogs=includelogs, 423 | skipcrawl=skipcrawl, 424 | skipinit=skipinit, 425 | ) 426 | else: 427 | next_link_uri = ( 428 | path + "?page=" + 429 | str(resp.dict["links"]["NextPage"]["page"])) 430 | 431 | href = "%s" % next_link_uri 432 | self._load( 433 | href, 434 | originaluri=path, 435 | includelogs=includelogs, 436 | skipcrawl=skipcrawl, 437 | skipinit=skipinit, 438 | ) 439 | 440 | if not skipcrawl: 441 | for match in matches: 442 | if (str(match.full_path) == "Registries.@odata.id" or str( 443 | match.full_path) == "JsonSchemas.@odata.id"): 444 | continue 445 | 446 | if match.value == path: 447 | continue 448 | 449 | href = "%s" % match.value 450 | self._load( 451 | href, 452 | skipcrawl=skipcrawl, 453 | originaluri=originaluri, 454 | includelogs=includelogs, 455 | skipinit=skipinit, 456 | ) 457 | 458 | if loadcomplete: 459 | for match in matches: 460 | self._load( 461 | match.value, 462 | skipcrawl=skipcrawl, 463 | originaluri=originaluri, 464 | includelogs=includelogs, 465 | skipinit=skipinit, 466 | ) 467 | 468 | def branch_worker(self, resp, path, skipinit): 469 | """Helper for load function, creates threaded worker 470 | 471 | :param resp: response received. 472 | :type resp: str. 473 | :param path: path correlating to the response. 474 | :type path: str. 475 | :param skipinit: flag to determine if progress bar should be updated. 476 | :type skipinit: boolean. 477 | 478 | """ 479 | self._visited_urls.append(path.lower()) 480 | 481 | member = RisMonolithMember_v1_0_0(resp) 482 | if not member.type: 483 | return 484 | 485 | self.update_member(member) 486 | 487 | if not skipinit: 488 | self.progress += 1 489 | if LOGGER.getEffectiveLevel() == 40: 490 | self.update_progress() 491 | 492 | def update_member(self, member): 493 | """Adds member to this monolith. If the member already exists the""" 494 | """ data is updated in place. 495 | 496 | :param member: Ris monolith member object made by branch worker. 497 | :type member: RisMonolithMember_v1_0_0. 498 | 499 | """ 500 | if member.maj_type not in self.types: 501 | self.types[member.maj_type] = OrderedDict() 502 | self.types[member.maj_type]["Instances"] = list() 503 | 504 | found = False 505 | 506 | for indices in range(len(self.types[member.maj_type]["Instances"])): 507 | inst = self.types[member.maj_type]["Instances"][indices] 508 | 509 | if inst.resp.request.path == member.resp.request.path: 510 | self.types[member.maj_type]["Instances"][indices] = member 511 | self.types[ 512 | member.maj_type]["Instances"][indices].patches.extend( 513 | [patch for patch in inst.patches]) 514 | 515 | found = True 516 | break 517 | 518 | if not found: 519 | self.types[member.maj_type]["Instances"].append(member) 520 | 521 | def load_from_dict(self, src): 522 | """Load data to monolith from dict 523 | 524 | :param src: data receive from rest operation. 525 | :type src: str. 526 | 527 | """ 528 | self._type = src["Type"] 529 | self._name = src["Name"] 530 | self.types = OrderedDict() 531 | 532 | for typ in src["Types"]: 533 | for inst in typ["Instances"]: 534 | member = RisMonolithMember_v1_0_0(None) 535 | member.load_from_dict(inst) 536 | self.update_member(member) 537 | 538 | return 539 | 540 | def to_dict(self): 541 | """Convert data to monolith from dict""" 542 | result = OrderedDict() 543 | result["Type"] = self.type 544 | result["Name"] = self.name 545 | types_list = list() 546 | 547 | for typ in list(self.types.keys()): 548 | type_entry = OrderedDict() 549 | type_entry["Type"] = typ 550 | type_entry["Instances"] = list() 551 | 552 | for inst in self.types[typ]["Instances"]: 553 | type_entry["Instances"].append(inst.to_dict()) 554 | 555 | types_list.append(type_entry) 556 | 557 | result["Types"] = types_list 558 | return result 559 | 560 | def reduce(self): 561 | """Reduce monolith data""" 562 | result = OrderedDict() 563 | result["Type"] = self.type 564 | result["Name"] = self.name 565 | types_list = list() 566 | 567 | for typ in list(self.types.keys()): 568 | type_entry = OrderedDict() 569 | type_entry["Type"] = typ 570 | 571 | for inst in self.types[typ]["Instances"]: 572 | type_entry["Instances"] = inst.reduce() 573 | 574 | types_list.append(type_entry) 575 | 576 | result["Types"] = types_list 577 | return result 578 | 579 | def _jsonpath2jsonpointer(self, instr): 580 | """Convert json path to json pointer 581 | 582 | :param instr: input path to be converted to pointer. 583 | :type instr: str. 584 | 585 | """ 586 | outstr = instr.replace(".[", "[") 587 | outstr = outstr.replace("[", "/") 588 | outstr = outstr.replace("]", "/") 589 | 590 | if outstr.endswith("/"): 591 | outstr = outstr[:-1] 592 | 593 | return outstr 594 | 595 | def _get_current_location(self): 596 | """Return current location""" 597 | return self._current_location 598 | 599 | def _set_current_location(self, newval): 600 | """Set current location""" 601 | self._current_location = newval 602 | 603 | location = property(_get_current_location, _set_current_location) 604 | 605 | def list(self, lspath=None): 606 | """Function for list command 607 | 608 | :param lspath: path list. 609 | :type lspath: list. 610 | 611 | """ 612 | results = list() 613 | path_parts = ["Types"] # Types is always assumed 614 | 615 | if isinstance(lspath, list) and len(lspath) > 0: 616 | lspath = lspath[0] 617 | path_parts.extend(lspath.split("/")) 618 | elif not lspath: 619 | lspath = "/" 620 | else: 621 | path_parts.extend(lspath.split("/")) 622 | 623 | currpos = self.to_dict() 624 | for path_part in path_parts: 625 | if not path_part: 626 | continue 627 | 628 | if isinstance(currpos, RisMonolithMember_v1_0_0): 629 | break 630 | elif isinstance(currpos, dict) and path_part in currpos: 631 | currpos = currpos[path_part] 632 | elif isinstance(currpos, list): 633 | for positem in currpos: 634 | if "Type" in positem and path_part == positem["Type"]: 635 | currpos = positem 636 | break 637 | 638 | results.append(currpos) 639 | 640 | return results 641 | 642 | def killthreads(self): 643 | """Function to kill threads on logout""" 644 | threads = [] 645 | for thread in threading.enumerate(): 646 | if isinstance(thread, SuperDuperWorker): 647 | self.queue.put(("KILL", "KILL", "KILL", "KILL")) 648 | threads.append(thread) 649 | 650 | for thread in threads: 651 | thread.join() 652 | 653 | 654 | class RisMonolith(RisMonolith_v1_0_0): 655 | """Latest implementation of RisMonolith""" 656 | 657 | def __init__(self, client): 658 | """Initialize Latest RisMonolith 659 | 660 | :param client: client to utilize 661 | :type client: RmcClient object 662 | 663 | """ 664 | super(RisMonolith, self).__init__(client) 665 | 666 | 667 | class SuperDuperWorker(threading.Thread): 668 | """Recursive worker implementation""" 669 | 670 | def __init__(self, queue): 671 | """Initialize SuperDuperWorker 672 | 673 | :param queue: queue for worker 674 | :type queue: Queue object 675 | 676 | """ 677 | threading.Thread.__init__(self) 678 | self.queue = queue 679 | 680 | def run(self): 681 | """Thread creator""" 682 | while True: 683 | (resp, path, skipinit, thobj) = self.queue.get() 684 | if (resp == "KILL" and path == "KILL" and skipinit == "KILL" 685 | and thobj == "KILL"): 686 | break 687 | thobj.branch_worker(resp, path, skipinit) 688 | self.queue.task_done() 689 | -------------------------------------------------------------------------------- /src/redfish/ris/rmc_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """RMC helper implementation""" 8 | 9 | #---------Imports--------- 10 | 11 | import os 12 | import json 13 | import errno 14 | import logging 15 | import hashlib 16 | import redfish.rest 17 | 18 | from urllib.parse import urlparse 19 | 20 | from .ris import (RisMonolith) 21 | from .sharedtypes import (JSONEncoder) 22 | from .config import (AutoConfigParser) 23 | 24 | #---------End of imports--------- 25 | 26 | #---------Debug logger--------- 27 | 28 | LOGGER = logging.getLogger(__name__) 29 | 30 | #---------End of debug logger--------- 31 | 32 | class RdmcError(Exception): 33 | """Base class for all RDMC Exceptions""" 34 | errcode = 1 35 | def __init__(self, message): 36 | Exception.__init__(self, message) 37 | 38 | class InvalidCommandLineError(RdmcError): 39 | """Raised when user enter incorrect command line arguments""" 40 | pass 41 | 42 | class FailureDuringCommitError(RdmcError): 43 | """Raised when there is an error while updating firmware""" 44 | pass 45 | 46 | class UndefinedClientError(Exception): 47 | """Raised when there are no clients active (usually when user hasn't logged in""" 48 | pass 49 | 50 | class InstanceNotFoundError(Exception): 51 | """Raised when attempting to select an instance that does not exist""" 52 | pass 53 | 54 | class CurrentlyLoggedInError(Exception): 55 | """Raised when attempting to select an instance that does not exist""" 56 | pass 57 | 58 | class NothingSelectedError(Exception): 59 | """Raised when attempting to access an object without first selecting it""" 60 | pass 61 | 62 | class NothingSelectedSetError(Exception): 63 | """Raised when attempting to access an object 64 | without first selecting it""" 65 | pass 66 | 67 | class InvalidSelectionError(Exception): 68 | """Raised when selection argument fails to match anything""" 69 | pass 70 | 71 | class SessionExpired(Exception): 72 | """Raised when the session has expired""" 73 | pass 74 | 75 | class LoadSkipSettingError(Exception): 76 | """Raised when one or more settings are absent in given server""" 77 | pass 78 | 79 | class InvalidPathError(Exception): 80 | """Raised when requested path is not found""" 81 | pass 82 | 83 | class RmcClient(object): 84 | """RMC client base class""" 85 | def __init__(self, url=None, username=None, password=None, sessionkey=None, 86 | proxies=None): 87 | """Initialized RmcClient 88 | :param url: redfish host name or IP address. 89 | :type url: str. 90 | :param username: user name required to login to server. 91 | :type: str. 92 | :param password: password credentials required to login. 93 | :type password: str. 94 | :param sessionkey: session key credential for current login 95 | :type sessionkey: str 96 | :param proxies: Dictionary containing protocol to proxy URL mappings 97 | :type proxies: dict 98 | 99 | """ 100 | self._rest_client = redfish.rest.v1.redfish_client( 101 | base_url=url, username=username, password=password, 102 | sessionkey=sessionkey, proxies=proxies) 103 | 104 | self._get_cache = dict() 105 | self._monolith = RisMonolith(self) 106 | self._selector = None 107 | self._filter_attr = None 108 | self._filter_value = None 109 | 110 | def get_username(self): 111 | """The rest client's current user name""" 112 | return self._rest_client.get_username() 113 | 114 | def set_username(self, username): 115 | """Sets the rest client's user name 116 | 117 | :param username: user name to set for login 118 | :type username: str 119 | 120 | """ 121 | self._rest_client.set_username(username) 122 | 123 | def get_password(self): 124 | """The rest client's current password""" 125 | return self._rest_client.get_password() 126 | 127 | def set_password(self, password): 128 | """Sets the rest clients's password 129 | 130 | :param password: password to set for login 131 | :type password: str 132 | 133 | """ 134 | self._rest_client.set_password(password) 135 | 136 | def get_session_key(self): 137 | """The rest client's current session key""" 138 | return self._rest_client.get_session_key() 139 | 140 | def get_session_location(self): 141 | """The rest client's current session location""" 142 | return self._rest_client.get_session_location() 143 | 144 | def get_authorization_key(self): 145 | """The rest client's current authorization key""" 146 | return self._rest_client.get_authorization_key() 147 | 148 | def get_base_url(self): 149 | """The rest client's current base URL""" 150 | return self._rest_client.get_base_url() 151 | base_url = property(get_base_url, None) 152 | 153 | def get_cache_dirname(self): 154 | """The rest client's current base URL converted to path""" 155 | parts = urlparse(self.get_base_url()) 156 | pathstr = '%s/%s' % (parts.netloc, parts.path) 157 | return pathstr.replace('//', '/') 158 | 159 | def get_monolith(self): 160 | """The rest client's current monolith""" 161 | return self._monolith 162 | monolith = property(get_monolith, None) 163 | 164 | def _get_selector(self): 165 | """The rest client's current selector""" 166 | return self._selector 167 | 168 | def _set_selector(self, selectorval): 169 | """Sets the rest client's selector 170 | 171 | :param selectorval: the type selection for the set operation. 172 | :type selectorval: str 173 | 174 | """ 175 | self._selector = selectorval 176 | selector = property(_get_selector, _set_selector) 177 | 178 | def _get_filter_attr(self): 179 | """The rest client's current filter""" 180 | return self._filter_attr 181 | 182 | def _set_filter_attr(self, filterattr): 183 | """Sets the rest client's filter 184 | 185 | :param filterattr: the type selection for the select operation. 186 | :type filterattr: str. 187 | 188 | """ 189 | self._filter_attr = filterattr 190 | filter_attr = property(_get_filter_attr, _set_filter_attr) 191 | 192 | def _get_filter_value(self): 193 | """The rest client's current filter value""" 194 | return self._filter_value 195 | 196 | def _set_filter_value(self, filterval): 197 | """Sets the rest client's filter value 198 | 199 | :param filterval: value for the property to be modified. 200 | :type filterval: str. 201 | 202 | """ 203 | self._filter_value = filterval 204 | filter_value = property(_get_filter_value, _set_filter_value) 205 | 206 | def login(self): 207 | """Login using rest client""" 208 | self._rest_client.login(auth="session") 209 | 210 | def logout(self): 211 | """Logout using rest client""" 212 | self._rest_client.logout() 213 | 214 | def get(self, path, args=None, uncache=False, headers=None): 215 | """Perform a GET request on path argument. 216 | 217 | :param path: The URL to perform a GET request on. 218 | :type path: str. 219 | :param args: GET arguments. 220 | :type args: str. 221 | :returns: a RestResponse object containing the response data. 222 | :rtype: redfish.rest.v1.RestResponse. 223 | :param uncache: flag to not store the data downloaded into cache. 224 | :type uncache: boolean. 225 | :param headers: dict of headers to be appended. 226 | :type headers: dict. 227 | 228 | """ 229 | resp = self._rest_client.get(path=path, args=args, headers=headers) 230 | 231 | if uncache is False: 232 | self._get_cache[path] = resp 233 | 234 | return resp 235 | 236 | def head(self, path, args=None, headers=None): 237 | """Perform a HEAD request on path argument. 238 | 239 | :param path: The URL to perform a GET request on. 240 | :type path: str. 241 | :param args: GET arguments. 242 | :type args: str. 243 | :param headers: dict of headers to be appended. 244 | :type headers: dict. 245 | :returns: a RestResponse object containing the response data. 246 | :rtype: redfish.rest.v1.RestResponse. 247 | 248 | """ 249 | resp = self._rest_client.head(path=path, args=args, headers=headers) 250 | return resp 251 | 252 | def set(self, path, args=None, body=None, headers=None): 253 | """Perform a PATCH request on path argument. 254 | 255 | :param path: The URL to perform a PATCH request on. 256 | :type path: str. 257 | :param args: GET arguments. 258 | :type args: str. 259 | :param body: contents of the PATCH request. 260 | :type body: str. 261 | :param headers: list of headers to be appended. 262 | :type headers: list. 263 | :returns: a RestResponse object containing the response data. 264 | :rtype: redfish.rest.v1.RestResponse. 265 | 266 | """ 267 | resp = self._rest_client.get(path=path, args=args) 268 | self._get_cache[path] = resp 269 | 270 | return self._rest_client.patch(path=path, args=args, body=body, 271 | headers=headers) 272 | 273 | def toolpost(self, path, args=None, body=None, headers=None): 274 | """Perform a POST request on path argument. 275 | 276 | :param path: The URL to perform a POST request on. 277 | :type path: str. 278 | :param args: POST arguments. 279 | :type args: str. 280 | :param body: contents of the POST request. 281 | :type body: str. 282 | :param headers: list of headers to be appended. 283 | :type headers: list. 284 | :returns: a RestResponse object containing the response data. 285 | :rtype: redfish.rest.v1.RestResponse. 286 | 287 | """ 288 | resp = self._rest_client.get(path=path, args=args) 289 | self._get_cache[path] = resp 290 | 291 | return self._rest_client.post(path=path, args=args, body=body, 292 | headers=headers) 293 | 294 | def toolput(self, path, args=None, body=None, headers=None): 295 | """ 296 | Perform a PUT request on path argument. 297 | 298 | :param path: The URL to perform a PUT request on. 299 | :type path: str. 300 | :param args: PUT arguments. 301 | :type args: str. 302 | :param body: contents of the PUT request. 303 | :type body: str. 304 | :param headers: list of headers to be appended. 305 | :type headers: list. 306 | :returns: a RestResponse object containing the response data. 307 | :rtype: redfish.rest.v1.RestResponse. 308 | """ 309 | resp = self._rest_client.get(path=path, args=args) 310 | self._get_cache[path] = resp 311 | 312 | return self._rest_client.put(path=path, args=args, body=body, 313 | headers=headers) 314 | 315 | def tooldelete(self, path, args=None, headers=None): 316 | """Perform a PUT request on path argument. 317 | 318 | :param path: The URL to perform a DELETE request on. 319 | :type path: str. 320 | :param args: DELETE arguments. 321 | :type args: str. 322 | :param headers: list of headers to be appended. 323 | :type headers: list. 324 | :returns: a RestResponse object containing the response data. 325 | :rtype: redfish.rest.v1.RestResponse. 326 | 327 | """ 328 | resp = self._rest_client.get(path=path, args=args) 329 | self._get_cache[path] = resp 330 | 331 | return self._rest_client.delete(path=path, args=args, headers=headers) 332 | 333 | class RmcConfig(AutoConfigParser): 334 | """RMC config object""" 335 | def __init__(self, filename=None): 336 | """Initialize RmcConfig 337 | 338 | :param filename: file name to be used for Rmcconfig loading. 339 | :type filename: str 340 | 341 | """ 342 | AutoConfigParser.__init__(self, filename=filename) 343 | self._sectionname = 'redfish' 344 | self._configfile = filename 345 | self._ac__logdir = os.getcwd() 346 | self._ac__cache = True 347 | self._ac__url = '' 348 | self._ac__username = '' 349 | self._ac__password = '' 350 | self._ac__commit = '' 351 | self._ac__format = '' 352 | self._ac__cachedir = '' 353 | self._ac__savefile = '' 354 | self._ac__loadfile = '' 355 | 356 | def get_configfile(self): 357 | """The current configuration file""" 358 | return self._configfile 359 | 360 | def set_configfile(self, config_file): 361 | """Set the current configuration file 362 | 363 | :param config_file: file name to be used for Rmcconfig loading. 364 | :type config_file: str 365 | 366 | """ 367 | self._configfile = config_file 368 | 369 | def get_logdir(self): 370 | """Get the current log directory""" 371 | return self._get('logdir') 372 | 373 | def set_logdir(self, value): 374 | """Set the current log directory 375 | 376 | :param value: current working directory for logging 377 | :type value: str 378 | 379 | """ 380 | return self._set('logdir', value) 381 | 382 | def get_cache(self): 383 | """Get the config file cache status""" 384 | if isinstance(self._get('cache'), bool): 385 | return self._get('cache') 386 | else: 387 | return self._get('cache').lower() in ("yes", "true", "t", "1") 388 | 389 | def set_cache(self, value): 390 | """Get the config file cache status 391 | 392 | :param value: status of config file cache 393 | :type value: bool 394 | 395 | """ 396 | return self._set('cache', value) 397 | 398 | def get_url(self): 399 | """Get the config file URL""" 400 | return self._get('url') 401 | 402 | def set_url(self, value): 403 | """Set the config file URL 404 | 405 | :param value: URL path for the config file 406 | :type value: str 407 | 408 | """ 409 | return self._set('url', value) 410 | 411 | def get_username(self): 412 | """Get the config file user name""" 413 | return self._get('username') 414 | 415 | def set_username(self, value): 416 | """Set the config file user name 417 | 418 | :param value: user name for config file 419 | :type value: str 420 | 421 | """ 422 | return self._set('username', value) 423 | 424 | def get_password(self): 425 | """Get the config file password""" 426 | return self._get('password') 427 | 428 | def set_password(self, value): 429 | """Set the config file password 430 | 431 | :param value: password for config file 432 | :type value: str 433 | 434 | """ 435 | return self._set('password', value) 436 | 437 | def get_commit(self): 438 | """Get the config file commit status""" 439 | return self._get('commit') 440 | 441 | def set_commit(self, value): 442 | """Set the config file commit status 443 | 444 | :param value: commit status 445 | :type value: str 446 | 447 | """ 448 | return self._set('commit', value) 449 | 450 | def get_format(self): 451 | """Get the config file default format""" 452 | return self._get('format') 453 | 454 | def set_format(self, value): 455 | """Set the config file default format 456 | 457 | :param value: set the config file format 458 | :type value: str 459 | 460 | """ 461 | return self._set('format', value) 462 | 463 | def get_cachedir(self): 464 | """Get the config file cache directory""" 465 | return self._get('cachedir') 466 | 467 | def set_cachedir(self, value): 468 | """Set the config file cache directory 469 | 470 | :param value: config file cache directory 471 | :type value: str 472 | 473 | """ 474 | return self._set('cachedir', value) 475 | 476 | def get_defaultsavefilename(self): 477 | """Get the config file default save name""" 478 | return self._get('savefile') 479 | 480 | def set_defaultsavefilename(self, value): 481 | """Set the config file default save name 482 | 483 | :param value: config file save name 484 | :type value: str 485 | 486 | """ 487 | return self._set('savefile', value) 488 | 489 | def get_defaultloadfilename(self): 490 | """Get the config file default load name""" 491 | return self._get('loadfile') 492 | 493 | def set_defaultloadfilename(self, value): 494 | """Set the config file default load name 495 | 496 | :param value: name of config file to load by default 497 | :type value: str 498 | 499 | """ 500 | return self._set('loadfile', value) 501 | 502 | class RmcCacheManager(object): 503 | """Manages caching/uncaching of data for RmcApp""" 504 | def __init__(self, rmc): 505 | """Initialize RmcCacheManager 506 | 507 | :param rmc: RmcApp to be managed 508 | :type rmc: RmcApp object 509 | 510 | """ 511 | self._rmc = rmc 512 | 513 | class RmcFileCacheManager(RmcCacheManager): 514 | """RMC file cache manager""" 515 | def __init__(self, rmc): 516 | super(RmcFileCacheManager, self).__init__(rmc) 517 | 518 | def logout_del_function(self, url=None): 519 | """Helper function for logging out a specific URL 520 | 521 | :param url: The URL to perform a logout request on. 522 | :type url: str. 523 | 524 | """ 525 | cachedir = self._rmc.config.get_cachedir() 526 | indexfn = '%s/index' % cachedir 527 | sessionlocs = [] 528 | 529 | if os.path.isfile(indexfn): 530 | try: 531 | indexfh = open(indexfn, 'r') 532 | index_cache = json.load(indexfh) 533 | indexfh.close() 534 | 535 | for index in index_cache: 536 | if url: 537 | if url in index['url']: 538 | os.remove(os.path.join(cachedir, index['href'])) 539 | break 540 | else: 541 | if os.path.isfile(cachedir + '/' + index['href']): 542 | monolith = open(cachedir + '/' + index['href'], 'r') 543 | data = json.load(monolith) 544 | monolith.close() 545 | for item in data: 546 | if 'login' in item and 'session_location' in \ 547 | item['login']: 548 | loc = item['login']['session_location'].\ 549 | split(item['login']['url'])[-1] 550 | sesurl = item['login']['url'] 551 | sessionlocs.append((loc, sesurl, 552 | item['login']['session_key'])) 553 | 554 | os.remove(os.path.join(cachedir, index['href'])) 555 | except BaseException as excp: 556 | self._rmc.warn('Unable to read cache data %s' % excp) 557 | 558 | return sessionlocs 559 | 560 | def uncache_rmc(self): 561 | """Simple monolith uncache function""" 562 | cachedir = self._rmc.config.get_cachedir() 563 | indexfn = '%s/index' % cachedir 564 | 565 | if os.path.isfile(indexfn): 566 | try: 567 | indexfh = open(indexfn, 'r') 568 | index_cache = json.load(indexfh) 569 | indexfh.close() 570 | 571 | for index in index_cache: 572 | clientfn = index['href'] 573 | self._uncache_client(clientfn) 574 | except BaseException as excp: 575 | self._rmc.warn('Unable to read cache data %s' % excp) 576 | 577 | def _uncache_client(self, cachefn): 578 | """Complex monolith uncache function 579 | 580 | :param cachefn: The cache file name. 581 | :type cachefn: str. 582 | 583 | """ 584 | cachedir = self._rmc.config.get_cachedir() 585 | clientsfn = '%s/%s' % (cachedir, cachefn) 586 | 587 | if os.path.isfile(clientsfn): 588 | try: 589 | clientsfh = open(clientsfn, 'r') 590 | clients_cache = json.load(clientsfh) 591 | clientsfh.close() 592 | 593 | for client in clients_cache: 594 | if 'login' not in client: 595 | continue 596 | 597 | login_data = client['login'] 598 | if 'url' not in login_data: 599 | continue 600 | 601 | rmc_client = RmcClient( 602 | username=login_data.get('username', 'Administrator'), 603 | password=login_data.get('password', None), 604 | url=login_data.get('url', None), 605 | sessionkey=login_data.get('session_key', None)) 606 | 607 | rmc_client._rest_client.set_authorization_key( 608 | login_data.get('authorization_key')) 609 | rmc_client._rest_client.set_session_key( 610 | login_data.get('session_key')) 611 | rmc_client._rest_client.set_session_location( 612 | login_data.get('session_location')) 613 | 614 | if 'selector' in client: 615 | rmc_client.selector = client['selector'] 616 | 617 | if 'filter_attr' in client: 618 | rmc_client.filter_attr = client['filter_attr'] 619 | 620 | if 'filter_value' in client: 621 | rmc_client.filter_value = client['filter_value'] 622 | 623 | getdata = client['get'] 624 | rmc_client._get_cache = dict() 625 | 626 | for key in list(getdata.keys()): 627 | restreq = redfish.rest.v1.RestRequest( 628 | method='GET', path=key) 629 | 630 | getdata[key]['restreq'] = restreq 631 | rmc_client._get_cache[key] = ( 632 | redfish.rest.v1.StaticRestResponse( 633 | **getdata[key])) 634 | 635 | rmc_client._monolith = RisMonolith(rmc_client) 636 | rmc_client._monolith.load_from_dict(client['monolith']) 637 | self._rmc._rmc_clients.append(rmc_client) 638 | except BaseException as excp: 639 | self._rmc.warn('Unable to read cache data %s' % excp) 640 | 641 | def cache_rmc(self): 642 | """Caching function for monolith""" 643 | if not self._rmc.config.get_cache(): 644 | return 645 | 646 | cachedir = self._rmc.config.get_cachedir() 647 | if not os.path.isdir(cachedir): 648 | try: 649 | os.makedirs(cachedir) 650 | except OSError as ex: 651 | if ex.errno == errno.EEXIST: 652 | pass 653 | else: 654 | raise 655 | 656 | index_map = dict() 657 | index_cache = list() 658 | 659 | for client in self._rmc._rmc_clients: 660 | md5 = hashlib.md5() 661 | md5.update(client.get_base_url().encode('utf-8')) 662 | md5str = md5.hexdigest() 663 | 664 | index_map[client.get_base_url()] = md5str 665 | index_data = dict(url=client.get_base_url(), href='%s' % md5str,) 666 | index_cache.append(index_data) 667 | 668 | indexfh = open('%s/index' % cachedir, 'w') 669 | json.dump(index_cache, indexfh, indent=2, cls=JSONEncoder) 670 | indexfh.close() 671 | 672 | clients_cache = list() 673 | 674 | if self._rmc._rmc_clients: 675 | for client in self._rmc._rmc_clients: 676 | login_data = dict( 677 | username=client.get_username(), 678 | password=client.get_password(), url=client.get_base_url(), 679 | session_key=client.get_session_key(), 680 | session_location=client.get_session_location(), 681 | authorization_key=client.get_authorization_key()) 682 | 683 | clients_cache.append( 684 | dict(selector=client.selector, login=login_data, 685 | filter_attr=client._filter_attr, 686 | filter_value=client._filter_value, 687 | monolith=client.monolith, get=client._get_cache)) 688 | 689 | clientsfh = open('%s/%s' % (cachedir, 690 | index_map[client.get_base_url()]), 'w') 691 | 692 | json.dump(clients_cache, clientsfh, indent=2, cls=JSONEncoder) 693 | clientsfh.close() 694 | -------------------------------------------------------------------------------- /src/redfish/rest/v1.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2016-2021 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md 5 | 6 | # -*- coding: utf-8 -*- 7 | """Helper module for working with REST technology.""" 8 | 9 | #---------Imports--------- 10 | 11 | import sys 12 | import time 13 | import gzip 14 | import json 15 | import base64 16 | import logging 17 | import warnings 18 | import re 19 | import requests 20 | import requests_unixsocket 21 | from redfish.messages import * 22 | 23 | from collections import (OrderedDict) 24 | 25 | from urllib.parse import urlparse, urlencode, quote 26 | from io import StringIO 27 | 28 | from requests_toolbelt import MultipartEncoder 29 | 30 | # Many services come with self-signed certificates and will remain as such; need to suppress warnings for this 31 | from requests.packages.urllib3.exceptions import InsecureRequestWarning 32 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) 33 | 34 | #---------End of imports--------- 35 | 36 | #---------Debug logger--------- 37 | 38 | LOGGER = logging.getLogger(__name__) 39 | 40 | #---------End of debug logger--------- 41 | 42 | class RetriesExhaustedError(Exception): 43 | """Raised when retry attempts have been exhausted.""" 44 | pass 45 | 46 | class InvalidCredentialsError(Exception): 47 | """Raised when invalid credentials have been provided.""" 48 | pass 49 | 50 | class SessionCreationError(Exception): 51 | """Raised when a session could not be created.""" 52 | pass 53 | 54 | class ServerDownOrUnreachableError(Exception): 55 | """Raised when server is unreachable.""" 56 | def __init__(self,message,*,response=None): 57 | super().__init__(message) 58 | self.response = response 59 | 60 | class DecompressResponseError(Exception): 61 | """Raised when decompressing response failed.""" 62 | pass 63 | 64 | class JsonDecodingError(Exception): 65 | """Raised when the JSON response data is malformed.""" 66 | pass 67 | 68 | class BadRequestError(Exception): 69 | """Raised when bad request made to server.""" 70 | pass 71 | 72 | class RisObject(dict): 73 | """Converts a JSON/Rest dict into a object so you can use .property 74 | notation""" 75 | __getattr__ = dict.__getitem__ 76 | 77 | def __init__(self, d): 78 | """Initialize RisObject 79 | 80 | :param d: dictionary to be parsed 81 | :type d: dict 82 | 83 | """ 84 | super(RisObject, self).__init__() 85 | self.update(**dict((k, self.parse(value)) \ 86 | for k, value in d.items())) 87 | 88 | @classmethod 89 | def parse(cls, value): 90 | """Parse for RIS value 91 | 92 | :param cls: class referenced from class method 93 | :type cls: RisObject 94 | :param value: value to be parsed 95 | :type value: data type 96 | :returns: returns parsed value 97 | 98 | """ 99 | if isinstance(value, dict): 100 | return cls(value) 101 | elif isinstance(value, list): 102 | return [cls.parse(i) for i in value] 103 | else: 104 | return value 105 | 106 | class RestRequest(object): 107 | """Holder for Request information""" 108 | def __init__(self, path, method='GET', body=''): 109 | """Initialize RestRequest 110 | 111 | :param path: path within tree 112 | :type path: str 113 | :param method: method to be implemented 114 | :type method: str 115 | :param body: body payload for the rest call 116 | :type body: dict 117 | 118 | """ 119 | self._path = path 120 | self._body = body 121 | self._method = method 122 | 123 | def _get_path(self): 124 | """Return object path""" 125 | return self._path 126 | 127 | path = property(_get_path, None) 128 | 129 | def _get_method(self): 130 | """Return object method""" 131 | return self._method 132 | 133 | method = property(_get_method, None) 134 | 135 | def _get_body(self): 136 | """Return object body""" 137 | return self._body 138 | 139 | body = property(_get_body, None) 140 | 141 | def __str__(self): 142 | """Format string""" 143 | strvars = dict(body=self.body, method=self.method, path=self.path) 144 | 145 | # set None to '' for strings 146 | if not strvars['body']: 147 | strvars['body'] = '' 148 | 149 | try: 150 | strvars['body'] = str(str(self._body)) 151 | except BaseException: 152 | strvars['body'] = '' 153 | 154 | return "%(method)s %(path)s\n\n%(body)s" % strvars 155 | 156 | class RestResponse(object): 157 | """Returned by Rest requests""" 158 | def __init__(self, rest_request, http_response): 159 | """Initialize RestResponse 160 | 161 | :params rest_request: Holder for request information 162 | :type rest_request: RestRequest object 163 | :params http_response: Response from HTTP 164 | :type http_response: requests.Response 165 | 166 | """ 167 | self._read = None 168 | self._status = None 169 | self._session_key = None 170 | self._session_location = None 171 | self._task_location = None 172 | self._rest_request = rest_request 173 | self._http_response = http_response 174 | 175 | if http_response is not None: 176 | self._read = http_response.content 177 | self._status = http_response.status_code 178 | 179 | @property 180 | def read(self): 181 | """Property for accessing raw content as an array of bytes (unless overridden) 182 | 183 | TODO: Need to review usage elsewhwere; by default _read is an array of bytes, but applying a new value with a 184 | setter routine will make it a string. We might want to consider deprecating the setters. 185 | """ 186 | return self._read 187 | 188 | @read.setter 189 | def read(self, read): 190 | """Property for setting _read 191 | 192 | :param read: The data to set to read. 193 | :type read: str. 194 | 195 | """ 196 | if read is not None: 197 | if isinstance(read, dict): 198 | read = json.dumps(read, indent=4) 199 | self._read = read 200 | 201 | def getheaders(self): 202 | """Property for accessing the headers""" 203 | 204 | # Backwards compatibility: requests simply uses a dictionary, but older versions of this library returned a list of tuples 205 | headers = [] 206 | for header in self._http_response.headers: 207 | headers.append((header, self._http_response.headers[header])) 208 | return headers 209 | 210 | def getheader(self, name): 211 | """Property for accessing an individual header 212 | 213 | :param name: The header name to retrieve. 214 | :type name: str. 215 | :returns: returns a header from HTTP response 216 | 217 | """ 218 | return self._http_response.headers.get(name.lower(), None) 219 | 220 | def json(self, newdict): 221 | """Property for setting JSON data 222 | 223 | :param newdict: The string data to set as JSON data. 224 | :type newdict: str. 225 | 226 | """ 227 | self._read = json.dumps(newdict, indent=4) 228 | 229 | @property 230 | def text(self): 231 | """Property for accessing the data as an unparsed string""" 232 | if isinstance(self.read, str): 233 | value = self.read 234 | else: 235 | value = self.read.decode("utf-8", "ignore") 236 | return value 237 | 238 | @text.setter 239 | def text(self, value): 240 | """Property for setting text unparsed data 241 | 242 | :param value: The unparsed data to set as text. 243 | :type value: str. 244 | 245 | """ 246 | self.read = value 247 | 248 | @property 249 | def dict(self): 250 | """Property for accessing the data as an dict""" 251 | try: 252 | if len(self.text) == 0: 253 | # No response body; return empty dict instead to avoid exceptions 254 | # No response bodies can be valid in many cases (especially 4XX and 5XX responses) 255 | return {} 256 | return json.loads(self.text) 257 | except: 258 | if self.status == 500: 259 | # Make an allowance for 500 status codes 260 | # Depending on the reason for the error, it's possible the web server may not be able to support Redfish handling 261 | return {} 262 | str = "Service responded with invalid JSON at URI {}\n{}".format( 263 | self._rest_request.path, self.text) 264 | LOGGER.error(str) 265 | raise JsonDecodingError(str) from None 266 | 267 | @property 268 | def obj(self): 269 | """Property for accessing the data as an object""" 270 | return RisObject.parse(self.dict) 271 | 272 | @property 273 | def status(self): 274 | """Property for accessing the status code""" 275 | return self._status 276 | 277 | @property 278 | def session_key(self): 279 | """Property for accessing the saved session key""" 280 | if self._session_key: 281 | return self._session_key 282 | 283 | self._session_key = self.getheader('x-auth-token') 284 | return self._session_key 285 | 286 | @property 287 | def session_location(self): 288 | """Property for accessing the saved session location""" 289 | if self._session_location: 290 | return self._session_location 291 | if self.status not in [200, 201, 202, 204]: 292 | return None 293 | 294 | self._session_location = self.getheader('location') 295 | if self._session_location is None: 296 | warnings.warn("Service incorrectly did not provide the 'Location' response header when creating a session; attempting to " 297 | "get the session location from the response body. Contact your vendor.") 298 | self._session_location = self.dict["@odata.id"] 299 | return self._session_location 300 | 301 | @property 302 | def task_location(self): 303 | """Return if we're a PATCH/POST in with a task link """ 304 | if self._task_location: 305 | return self._task_location 306 | 307 | self._task_location = self.getheader('location') 308 | return self._task_location 309 | 310 | @property 311 | def is_processing(self): 312 | """Check if we're a PATCH/POST in progress """ 313 | return self.status == 202 314 | 315 | @property 316 | def retry_after(self): 317 | """Retry After header""" 318 | retry_after = self.getheader('retry-after') 319 | if retry_after is not None: 320 | # Convert to int for ease of use by callers 321 | try: 322 | retry_after = int(retry_after) 323 | except: 324 | retry_after = 5 325 | return retry_after 326 | 327 | def monitor(self, context): 328 | """Function to process Task, used on an action or POST/PATCH that returns 202""" 329 | my_href = self.task_location 330 | if self.is_processing: 331 | if my_href: 332 | my_content = context.get(my_href, None) 333 | return my_content 334 | elif my_href is None: 335 | raise ValueError('We are processing a 202, but provide no location') 336 | return self 337 | 338 | @property 339 | def request(self): 340 | """Property for accessing the saved http request""" 341 | return self._rest_request 342 | 343 | def __str__(self): 344 | """Class string formatter""" 345 | headerstr = '' 346 | for header in self.getheaders(): 347 | headerstr += '%s %s\n' % (header[0], header[1]) 348 | 349 | return "%(status)s\n%(headerstr)s\n\n%(body)s" % \ 350 | {'status': self.status, 'headerstr': headerstr, 351 | 'body': self.text} 352 | 353 | class JSONEncoder(json.JSONEncoder): 354 | """JSON Encoder class""" 355 | def default(self, obj): 356 | """Set defaults in JSON encoder class 357 | 358 | :param obj: object to be encoded into JSON. 359 | :type obj: RestResponse object. 360 | :returns: returns a JSON ordered dict 361 | 362 | """ 363 | if isinstance(obj, RestResponse): 364 | jsondict = OrderedDict() 365 | jsondict['Status'] = obj.status 366 | jsondict['Headers'] = list() 367 | 368 | for hdr in obj.getheaders(): 369 | headerd = dict() 370 | headerd[hdr[0]] = hdr[1] 371 | jsondict['Headers'].append(headerd) 372 | 373 | if obj.text: 374 | jsondict['Content'] = obj.dict 375 | 376 | return jsondict 377 | 378 | return json.JSONEncoder.default(self, obj) 379 | 380 | class JSONDecoder(json.JSONDecoder): 381 | """Custom JSONDecoder that understands our types""" 382 | def decode(self, json_string): 383 | """Decode JSON string 384 | 385 | :param json_string: The JSON string to be decoded into usable data. 386 | :type json_string: str. 387 | :returns: returns a parsed dict 388 | 389 | """ 390 | parsed_dict = super(JSONDecoder, self).decode(json_string) 391 | return parsed_dict 392 | 393 | class StaticRestResponse(RestResponse): 394 | """A RestResponse object used when data is being cached.""" 395 | def __init__(self, **kwargs): 396 | restreq = None 397 | 398 | if 'restreq' in kwargs: 399 | restreq = kwargs['restreq'] 400 | 401 | super(StaticRestResponse, self).__init__(restreq, None) 402 | 403 | if 'Status' in kwargs: 404 | self._status = kwargs['Status'] 405 | 406 | if 'Headers' in kwargs: 407 | self._headers = kwargs['Headers'] 408 | 409 | if 'session_key' in kwargs: 410 | self._session_key = kwargs['session_key'] 411 | 412 | if 'session_location' in kwargs: 413 | self._session_location = kwargs['session_location'] 414 | 415 | if 'Content' in kwargs: 416 | content = kwargs['Content'] 417 | 418 | if isinstance(content, str): 419 | self._read = content 420 | else: 421 | self._read = json.dumps(content) 422 | else: 423 | self._read = '' 424 | 425 | def getheaders(self): 426 | """Function for accessing the headers""" 427 | returnlist = list() 428 | 429 | if isinstance(self._headers, dict): 430 | for key, value in self._headers.items(): 431 | returnlist.append((key, value)) 432 | else: 433 | for item in self._headers: 434 | returnlist.append(item) 435 | 436 | return returnlist 437 | 438 | def getheader(self, name): 439 | """Property for accessing an individual header 440 | 441 | :param name: The header name to retrieve. 442 | :type name: str. 443 | :returns: returns a header from HTTP response 444 | """ 445 | returnheader = None 446 | 447 | if isinstance(self._headers, dict): 448 | for key, value in self._headers.items(): 449 | if key.lower() == name.lower(): 450 | returnheader = self._headers[key] 451 | break 452 | else: 453 | for item in self._headers: 454 | if item[0].lower() == name.lower(): 455 | returnheader = item[1] 456 | break 457 | 458 | return returnheader 459 | 460 | class AuthMethod(object): 461 | """AUTH Method class""" 462 | BASIC = 'basic' 463 | SESSION = 'session' 464 | 465 | class RestClientBase(object): 466 | """Base class for RestClients""" 467 | 468 | def __init__(self, base_url, username=None, password=None, 469 | default_prefix='/redfish/v1/', sessionkey=None, 470 | capath=None, cafile=None, timeout=None, 471 | max_retry=None, proxies=None, check_connectivity=True, 472 | https_adapter = None): 473 | """Initialization of the base class RestClientBase 474 | 475 | :param base_url: The URL of the remote system 476 | :type base_url: str 477 | :param username: The user name used for authentication 478 | :type username: str 479 | :param password: The password used for authentication 480 | :type password: str 481 | :param default_prefix: The default root point 482 | :type default_prefix: str 483 | :param sessionkey: session key for the current login of base_url 484 | :type sessionkey: str 485 | :param capath: Path to a directory containing CA certificates 486 | :type capath: str 487 | :param cafile: Path to a file of CA certs 488 | :type cafile: str 489 | :param timeout: Timeout in seconds for the initial connection 490 | :type timeout: int 491 | :param max_retry: Number of times a request will retry after a timeout 492 | :type max_retry: int 493 | :param proxies: Dictionary containing protocol to proxy URL mappings 494 | :type proxies: dict 495 | :param check_connectivity: A boolean to determine whether the client immediately checks for 496 | connectivity to the base_url or not. 497 | :type check_connectivity: bool 498 | :type https_adapter: requests.adpaters.HTTPAdapter 499 | """ 500 | 501 | self.__base_url = base_url.rstrip('/') 502 | self.__username = username 503 | self.__password = password 504 | self.__session_key = sessionkey 505 | self.__authorization_key = None 506 | self.__session_location = None 507 | if self.__base_url.startswith('http+unix://'): 508 | self._session = requests_unixsocket.Session() 509 | else: 510 | self._session = requests.Session() 511 | if https_adapter: 512 | self._session.mount('https://',https_adapter) 513 | self._timeout = timeout 514 | self._max_retry = max_retry if max_retry is not None else 10 515 | self._proxies = proxies 516 | self.login_url = None 517 | self.default_prefix = default_prefix 518 | self.capath = capath 519 | self.cafile = cafile 520 | 521 | if check_connectivity: 522 | self.get_root_object() 523 | 524 | def __enter__(self): 525 | self.login() 526 | return self 527 | 528 | def __exit__(self, exc_type, exc_value, exc_traceback): 529 | self.logout() 530 | 531 | def get_username(self): 532 | """Return used user name""" 533 | return self.__username 534 | 535 | def set_username(self, username): 536 | """Set user name 537 | 538 | :param username: The user name to be set. 539 | :type username: str 540 | 541 | """ 542 | self.__username = username 543 | 544 | def get_password(self): 545 | """Return used password""" 546 | return self.__password 547 | 548 | def set_password(self, password): 549 | """Set password 550 | 551 | :param password: The password to be set. 552 | :type password: str 553 | 554 | """ 555 | self.__password = password 556 | 557 | def get_base_url(self): 558 | """Return used URL""" 559 | return self.__base_url 560 | 561 | def set_base_url(self, url): 562 | """Set based URL 563 | 564 | :param url: The URL to be set. 565 | :type url: str 566 | 567 | """ 568 | self.__base_url = url.rstrip('/') 569 | 570 | def get_session_key(self): 571 | """Return session key""" 572 | return self.__session_key 573 | 574 | def set_session_key(self, session_key): 575 | """Set session key 576 | 577 | :param session_key: The session_key to be set. 578 | :type session_key: str 579 | 580 | """ 581 | self.__session_key = session_key 582 | 583 | def get_session_location(self): 584 | """Return session location""" 585 | return self.__session_location 586 | 587 | def set_session_location(self, session_location): 588 | """Set session location 589 | 590 | :param session_location: The session_location to be set. 591 | :type session_location: str 592 | 593 | """ 594 | self.__session_location = session_location 595 | 596 | def get_authorization_key(self): 597 | """Return authorization key""" 598 | return self.__authorization_key 599 | 600 | def set_authorization_key(self, authorization_key): 601 | """Set authorization key 602 | 603 | :param authorization_key: The authorization_key to be set. 604 | :type authorization_key: str 605 | 606 | """ 607 | self.__authorization_key = authorization_key 608 | 609 | def get_root_object(self): 610 | """Perform an initial get and store the result""" 611 | try: 612 | resp = self.get(self.default_prefix) 613 | except Exception as excp: 614 | raise excp 615 | 616 | if resp.status == 401 and self.__authorization_key is None and self.__session_key is None: 617 | # Workaround where the service incorrectly rejects access to service 618 | # root when no credentials are provided 619 | warnings.warn("Service incorrectly responded with HTTP 401 Unauthorized for the service root. Contact your vendor.") 620 | self.root = {} 621 | self.root_resp = resp 622 | return 623 | if resp.status == 503: 624 | raise ServerDownOrUnreachableError("Service is busy, " \ 625 | "return code: %d" % resp.status,response=resp) 626 | if resp.status != 200: 627 | raise ServerDownOrUnreachableError("Service not able to provide the service root, " \ 628 | "return code: %d" % resp.status,response=resp) 629 | 630 | content = resp.text 631 | 632 | try: 633 | root_data = json.loads(content) 634 | except: 635 | str = 'Service responded with invalid JSON at URI {}\n{}'.format( 636 | self.default_prefix, content) 637 | LOGGER.error(str) 638 | raise JsonDecodingError(str) from None 639 | 640 | self.root = RisObject.parse(root_data) 641 | self.root_resp = resp 642 | 643 | def get(self, path, args=None, headers=None, timeout=None, max_retry=None): 644 | """Perform a GET request 645 | 646 | :param path: The URI to access 647 | :type path: str 648 | :param args: The query parameters to provide with the request 649 | :type args: dict, optional 650 | :param headers: Additional HTTP headers to provide in the request 651 | :type headers: dict, optional 652 | :param timeout: Timeout in seconds for the initial connection for this specific request 653 | :type timeout: int, optional 654 | :param max_retry: Number of times a request will retry after a timeout for this specific request 655 | :type max_retry: int, optional 656 | :returns: returns a rest request with method 'Get' 657 | 658 | """ 659 | try: 660 | return self._rest_request(path, method='GET', args=args, 661 | headers=headers, timeout=timeout, max_retry=max_retry) 662 | except ValueError: 663 | str = "Service responded with invalid JSON at URI {}".format(path) 664 | LOGGER.error(str) 665 | raise JsonDecodingError(str) from None 666 | 667 | def head(self, path, args=None, headers=None, timeout=None, max_retry=None): 668 | """Perform a HEAD request 669 | 670 | :param path: The URI to access 671 | :type path: str 672 | :param args: The query parameters to provide with the request 673 | :type args: dict, optional 674 | :param headers: Additional HTTP headers to provide in the request 675 | :type headers: dict, optional 676 | :param timeout: Timeout in seconds for the initial connection for this specific request 677 | :type timeout: int, optional 678 | :param max_retry: Number of times a request will retry after a timeout for this specific request 679 | :type max_retry: int, optional 680 | :returns: returns a rest request with method 'Head' 681 | 682 | """ 683 | return self._rest_request(path, method='HEAD', args=args, 684 | headers=headers, timeout=timeout, max_retry=max_retry) 685 | 686 | def post(self, path, args=None, body=None, headers=None, timeout=None, max_retry=None): 687 | """Perform a POST request 688 | 689 | :param path: The URI to access 690 | :type path: str 691 | :param args: The query parameters to provide with the request 692 | :type args: dict, optional 693 | :param body: The request body to provide; use a dict for a JSON body, list for multipart forms, bytes for an octet stream, or str for an unstructured request 694 | :type body: dict or list or bytes or str, optional 695 | :param headers: Additional HTTP headers to provide in the request 696 | :type headers: dict, optional 697 | :param timeout: Timeout in seconds for the initial connection for this specific request 698 | :type timeout: int, optional 699 | :param max_retry: Number of times a request will retry after a timeout for this specific request 700 | :type max_retry: int, optional 701 | :returns: returns a rest request with method 'Post' 702 | 703 | """ 704 | return self._rest_request(path, method='POST', args=args, body=body, 705 | headers=headers, timeout=timeout, max_retry=max_retry) 706 | 707 | def put(self, path, args=None, body=None, headers=None, timeout=None, max_retry=None): 708 | """Perform a PUT request 709 | 710 | :param path: The URI to access 711 | :type path: str 712 | :param args: The query parameters to provide with the request 713 | :type args: dict, optional 714 | :param body: The request body to provide; use a dict for a JSON body, list for multipart forms, bytes for an octet stream, or str for an unstructured request 715 | :type body: dict or list or bytes or str, optional 716 | :param headers: Additional HTTP headers to provide in the request 717 | :type headers: dict, optional 718 | :param timeout: Timeout in seconds for the initial connection for this specific request 719 | :type timeout: int, optional 720 | :param max_retry: Number of times a request will retry after a timeout for this specific request 721 | :type max_retry: int, optional 722 | :returns: returns a rest request with method 'Put' 723 | 724 | """ 725 | return self._rest_request(path, method='PUT', args=args, body=body, 726 | headers=headers, timeout=timeout, max_retry=max_retry) 727 | 728 | def patch(self, path, args=None, body=None, headers=None, timeout=None, max_retry=None): 729 | """Perform a PATCH request 730 | 731 | :param path: The URI to access 732 | :type path: str 733 | :param args: The query parameters to provide with the request 734 | :type args: dict, optional 735 | :param body: The request body to provide; use a dict for a JSON body, list for multipart forms, bytes for an octet stream, or str for an unstructured request 736 | :type body: dict or list or bytes or str, optional 737 | :param headers: Additional HTTP headers to provide in the request 738 | :type headers: dict, optional 739 | :param timeout: Timeout in seconds for the initial connection for this specific request 740 | :type timeout: int, optional 741 | :param max_retry: Number of times a request will retry after a timeout for this specific request 742 | :type max_retry: int, optional 743 | :returns: returns a rest request with method 'Patch' 744 | 745 | """ 746 | return self._rest_request(path, method='PATCH', args=args, body=body, 747 | headers=headers, timeout=timeout, max_retry=max_retry) 748 | 749 | def delete(self, path, args=None, headers=None, timeout=None, max_retry=None): 750 | """Perform a DELETE request 751 | 752 | :param path: The URI to access 753 | :type path: str 754 | :param args: The query parameters to provide with the request 755 | :type args: dict, optional 756 | :param headers: Additional HTTP headers to provide in the request 757 | :type headers: dict, optional 758 | :param timeout: Timeout in seconds for the initial connection for this specific request 759 | :type timeout: int, optional 760 | :param max_retry: Number of times a request will retry after a timeout for this specific request 761 | :type max_retry: int, optional 762 | :returns: returns a rest request with method 'Delete' 763 | 764 | """ 765 | return self._rest_request(path, method='DELETE', args=args, 766 | headers=headers, timeout=timeout, max_retry=max_retry) 767 | 768 | def _get_req_headers(self, headers=None): 769 | """Get the request headers 770 | 771 | :param headers: additional headers to be utilized 772 | :type headers: dict 773 | :returns: returns headers 774 | 775 | """ 776 | headers = headers if isinstance(headers, dict) else dict() 777 | 778 | if self.__session_key: 779 | headers['X-Auth-Token'] = self.__session_key 780 | elif self.__authorization_key: 781 | headers['Authorization'] = self.__authorization_key 782 | 783 | headers_keys = set(k.lower() for k in headers) 784 | if 'accept' not in headers_keys: 785 | headers['Accept'] = '*/*' 786 | 787 | return headers 788 | 789 | def _rest_request(self, path, method='GET', args=None, body=None, 790 | headers=None, allow_redirects=True, timeout=None, max_retry=None): 791 | """Rest request main function 792 | 793 | :param path: The URI to access 794 | :type path: str 795 | :param method: The HTTP method to invoke on the URI; GET if not provided 796 | :type method: str, optional 797 | :param args: The query parameters to provide with the request 798 | :type args: dict, optional 799 | :param body: The request body to provide; use a dict for a JSON body, list for multipart forms, bytes for an octet stream, or str for an unstructured request 800 | :type body: dict or list or bytes or str, optional 801 | :param headers: Additional HTTP headers to provide in the request 802 | :type headers: dict, optional 803 | :param allow_redirects: Controls whether redirects are followed 804 | :type allow_redirects: bool, optional 805 | :param timeout: Timeout in seconds for the initial connection for this specific request 806 | :type timeout: int, optional 807 | :param max_retry: Number of times a request will retry after a timeout for this specific request 808 | :type max_retry: int, optional 809 | :returns: returns a RestResponse object 810 | 811 | """ 812 | if timeout is None: 813 | timeout = self._timeout 814 | 815 | if max_retry is None: 816 | max_retry = self._max_retry 817 | 818 | headers = self._get_req_headers(headers) 819 | reqpath = path.replace('//', '/') 820 | 821 | if body is not None: 822 | if isinstance(body, dict) or isinstance(body, list): 823 | if headers.get('Content-Type', None) == 'multipart/form-data': 824 | # Body contains part values, either as 825 | # - dict (where key is part name, and value is string) 826 | # - list of tuples (if the order is important) 827 | # - dict (where values are tuples as they would 828 | # be provided to requests' `files` parameter) 829 | # See https://toolbelt.readthedocs.io/en/latest/uploading-data.html#requests_toolbelt.multipart.encoder.MultipartEncoder 830 | # 831 | # Redfish specification requires two parts: 832 | # (1) UpdateParameters (JSON formatted, 833 | # adhering to the UpdateService Schema) 834 | # (2) UpdateFile (binary file to use for this update) 835 | # 836 | # The third part is optional: OemXXX 837 | encoder = MultipartEncoder(body) 838 | body = encoder.to_string() 839 | 840 | # Overwrite Content-Type, because we have to include 841 | # the boundary that the encoder generated. 842 | # Will be of the form: "multipart/form-data; boundary=abc' 843 | # where the boundary value is a UUID. 844 | headers['Content-Type'] = encoder.content_type 845 | else: 846 | headers['Content-Type'] = 'application/json' 847 | body = json.dumps(body) 848 | elif isinstance(body, bytes): 849 | headers['Content-Type'] = 'application/octet-stream' 850 | body = body 851 | else: 852 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 853 | body = urlencode(body) 854 | 855 | if method == 'PUT': 856 | resp = self._rest_request(path=path, timeout=timeout, max_retry=max_retry) 857 | 858 | try: 859 | if resp.getheader('content-encoding') == 'gzip': 860 | buf = StringIO() 861 | gfile = gzip.GzipFile(mode='wb', fileobj=buf) 862 | 863 | try: 864 | gfile.write(str(body)) 865 | finally: 866 | gfile.close() 867 | 868 | compresseddata = buf.getvalue() 869 | if compresseddata: 870 | data = bytearray() 871 | data.extend(memoryview(compresseddata)) 872 | body = data 873 | except BaseException as excp: 874 | LOGGER.error('Error occur while compressing body: %s', excp) 875 | raise 876 | 877 | query_str = None 878 | if args: 879 | if method == 'GET': 880 | # Workaround for this: https://github.com/psf/requests/issues/993 881 | # Redfish supports some query parameters without using '=', which is apparently against HTML5 882 | none_list = [] 883 | args_copy = {} 884 | for query in args: 885 | if args[query] is None: 886 | none_list.append(query) 887 | else: 888 | args_copy[query] = args[query] 889 | query_str = urlencode(args_copy, quote_via=quote, safe="/?:!$'()*+,;\\=") 890 | for query in none_list: 891 | if len(query_str) == 0: 892 | query_str += query 893 | else: 894 | query_str += '&' + query 895 | elif method == 'PUT' or method == 'POST' or method == 'PATCH': 896 | LOGGER.warning('For POST, PUT and PATCH methods, the provided "args" parameter "{}" is ignored.' 897 | .format(args)) 898 | if not body: 899 | LOGGER.warning('Use the "body" parameter to supply the request payload.') 900 | 901 | restreq = RestRequest(reqpath, method=method, body=body) 902 | 903 | attempts = 0 904 | restresp = None 905 | cause_exception = None 906 | while attempts <= max_retry: 907 | if LOGGER.isEnabledFor(logging.DEBUG): 908 | headerstr = '' 909 | if headers is not None: 910 | for header in headers: 911 | if header.lower() == "authorization": 912 | headerstr += '\t{}: \n'.format(header) 913 | else: 914 | headerstr += '\t{}: {}\n'.format(header, headers[header]) 915 | try: 916 | logbody = 'No request body' 917 | if restreq.body: 918 | if restreq.body[0] == '{': 919 | # Mask password properties 920 | # NOTE: If the password itself contains a double quote, it will not redact the entire password 921 | logbody = re.sub(r'"Password"\s*:\s*".*?"', '"Password": ""', restreq.body) 922 | else: 923 | raise ValueError('Body of message is binary') 924 | LOGGER.debug('HTTP REQUEST (%s) for %s:\nHeaders:\n%s\nBody: %s\n'% \ 925 | (restreq.method, restreq.path, headerstr, logbody)) 926 | except: 927 | LOGGER.debug('HTTP REQUEST (%s) for %s:\nHeaders:\n%s\nBody: %s\n'% \ 928 | (restreq.method, restreq.path, headerstr, 'binary body')) 929 | attempts = attempts + 1 930 | LOGGER.info('Attempt %s of %s', attempts, path) 931 | 932 | try: 933 | if sys.version_info < (3, 3): 934 | inittime = time.clock() 935 | else: 936 | inittime = time.perf_counter() 937 | 938 | # TODO: Migration to requests lost the "CA directory" capability; need to revisit 939 | verify = False 940 | if self.cafile: 941 | verify = self.cafile 942 | resp = self._session.request(method.upper(), "{}{}".format(self.__base_url, reqpath), data=body, 943 | headers=headers, timeout=timeout, allow_redirects=allow_redirects, 944 | verify=verify, proxies=self._proxies, params=query_str) 945 | 946 | if sys.version_info < (3, 3): 947 | endtime = time.clock() 948 | else: 949 | endtime = time.perf_counter() 950 | LOGGER.info('Response Time for %s to %s: %s seconds.' % 951 | (method, reqpath, str(endtime-inittime))) 952 | 953 | restresp = RestResponse(restreq, resp) 954 | except Exception as excp: 955 | if not cause_exception: 956 | cause_exception = excp 957 | LOGGER.info('Retrying %s [%s]'% (path, excp)) 958 | time.sleep(1) 959 | 960 | continue 961 | else: 962 | break 963 | 964 | if attempts <= self._max_retry: 965 | if LOGGER.isEnabledFor(logging.DEBUG): 966 | headerstr = '' 967 | 968 | if restresp is not None: 969 | for header in restresp.getheaders(): 970 | headerstr += '\t' + header[0] + ': ' + header[1] + '\n' 971 | 972 | try: 973 | try: 974 | restrespstr = json.dumps(json.loads(restresp.read), indent=4) 975 | except: 976 | restrespstr = restresp.read 977 | LOGGER.debug('HTTP RESPONSE for %s:\nCode: %s\n\nHeaders:\n' \ 978 | '%s\nBody Response of %s:\n%s\n'%\ 979 | (restresp.request.path, 980 | str(restresp._http_response.status_code)+ ' ' + \ 981 | restresp._http_response.reason, 982 | headerstr, restresp.request.path, restrespstr)) 983 | except: 984 | LOGGER.debug('HTTP RESPONSE:\nCode:%s', restresp) 985 | else: 986 | LOGGER.debug('HTTP RESPONSE: ') 987 | 988 | return restresp 989 | else: 990 | raise RetriesExhaustedError() from cause_exception 991 | 992 | def login(self, username=None, password=None, auth=AuthMethod.SESSION, headers=None): 993 | """Login and start a REST session. Remember to call logout() when""" 994 | """ you are done. 995 | 996 | :param username: the user name. 997 | :type username: str. 998 | :param password: the password. 999 | :type password: str. 1000 | :param auth: authentication method 1001 | :type auth: object/instance of class AuthMethod 1002 | :param headers: Additional HTTP headers to provide in the request 1003 | :type headers: dict, optional 1004 | 1005 | """ 1006 | if getattr(self, "root_resp", None) is None: 1007 | self.get_root_object() 1008 | 1009 | self.__username = username if username else self.__username 1010 | self.__password = password if password else self.__password 1011 | 1012 | headers = headers if headers else {} 1013 | 1014 | if auth == AuthMethod.BASIC: 1015 | auth_key = base64.b64encode(('%s:%s' % (self.__username, 1016 | self.__password)).encode('utf-8')).decode('utf-8') 1017 | self.__authorization_key = 'Basic %s' % auth_key 1018 | 1019 | headers['Authorization'] = self.__authorization_key 1020 | 1021 | respvalidate = self._rest_request(self.login_url, headers=headers) 1022 | 1023 | if respvalidate.status == 401: 1024 | # Invalid credentials supplied 1025 | raise InvalidCredentialsError('HTTP 401 Unauthorized returned: Invalid credentials supplied') 1026 | elif auth == AuthMethod.SESSION: 1027 | data = dict() 1028 | data['UserName'] = self.__username 1029 | data['Password'] = self.__password 1030 | 1031 | resp = self._rest_request(self.login_url, method="POST",body=data, 1032 | headers=headers, allow_redirects=False) 1033 | 1034 | LOGGER.info('Login returned code %s: %s', resp.status, resp.text) 1035 | 1036 | self.__session_key = resp.session_key 1037 | self.__session_location = resp.session_location 1038 | 1039 | message_item = search_message(resp, "Base", "PasswordChangeRequired") 1040 | if not message_item is None: 1041 | raise RedfishPasswordChangeRequiredError("Password Change Required\n", message_item["MessageArgs"][0]) 1042 | 1043 | if not self.__session_key and resp.status not in [200, 201, 202, 204]: 1044 | if resp.status == 401: 1045 | # Invalid credentials supplied 1046 | raise InvalidCredentialsError('HTTP 401 Unauthorized returned: Invalid credentials supplied') 1047 | else: 1048 | # Other type of error during session creation 1049 | error_str = resp.text 1050 | try: 1051 | error_str = resp.dict["error"]["@Message.ExtendedInfo"][0]["Message"] 1052 | except: 1053 | try: 1054 | error_str = resp.dict["error"]["message"] 1055 | except: 1056 | pass 1057 | raise SessionCreationError('HTTP {}: Failed to create the session\n{}'.format(resp.status, error_str)) 1058 | else: 1059 | pass 1060 | 1061 | def logout(self): 1062 | """ Logout of session. YOU MUST CALL THIS WHEN YOU ARE DONE TO FREE""" 1063 | """ UP SESSIONS""" 1064 | if self.__session_key: 1065 | session_loc = urlparse(self.__session_location).path 1066 | 1067 | resp = self.delete(session_loc) 1068 | if resp.status not in [200, 202, 204]: 1069 | raise BadRequestError("Invalid session resource: %s, "\ 1070 | "return code: %d" % (session_loc, resp.status)) 1071 | 1072 | LOGGER.info("User logged out: %s", resp.text) 1073 | 1074 | self.__session_key = None 1075 | self.__session_location = None 1076 | self.__authorization_key = None 1077 | self._session.close() 1078 | 1079 | class HttpClient(RestClientBase): 1080 | """A client for Rest""" 1081 | def __init__(self, base_url, username=None, password=None, 1082 | default_prefix='/redfish/v1/', 1083 | sessionkey=None, capath=None, 1084 | cafile=None, timeout=None, 1085 | max_retry=None, proxies=None, check_connectivity=True, 1086 | https_adapter=None): 1087 | """Initialize HttpClient 1088 | 1089 | :param base_url: The url of the remote system 1090 | :type base_url: str 1091 | :param username: The user name used for authentication 1092 | :type username: str 1093 | :param password: The password used for authentication 1094 | :type password: str 1095 | :param default_prefix: The default root point 1096 | :type default_prefix: str 1097 | :param sessionkey: session key for the current login of base_url 1098 | :type sessionkey: str 1099 | :param capath: Path to a directory containing CA certificates 1100 | :type capath: str 1101 | :param cafile: Path to a file of CA certs 1102 | :type cafile: str 1103 | :param timeout: Timeout in seconds for the initial connection 1104 | :type timeout: int 1105 | :param max_retry: Number of times a request will retry after a timeout 1106 | :type max_retry: int 1107 | :param proxies: Dictionary containing protocol to proxy URL mappings 1108 | :type proxies: dict 1109 | :param check_connectivity: A boolean to determine whether the client immediately checks for 1110 | connectivity to the base_url or not. 1111 | :type check_connectivity: bool 1112 | :param https_adapter session adapter for HTTPS 1113 | :type https_adapter: requests.adpaters.HTTPAdapter 1114 | """ 1115 | super(HttpClient, self).__init__(base_url, username=username, 1116 | password=password, default_prefix=default_prefix, 1117 | sessionkey=sessionkey, capath=capath, 1118 | cafile=cafile, timeout=timeout, 1119 | max_retry=max_retry, proxies=proxies, 1120 | check_connectivity=check_connectivity,https_adapter=https_adapter) 1121 | 1122 | try: 1123 | self.login_url = self.root.Links.Sessions['@odata.id'] 1124 | except (KeyError, AttributeError): 1125 | # While the "Links/Sessions" property is required, we can fallback 1126 | # on the URI hardened in 1.6.0 of the specification if not found 1127 | LOGGER.debug('"Links/Sessions" not found in Service Root.') 1128 | self.login_url = '/redfish/v1/SessionService/Sessions' 1129 | 1130 | def _rest_request(self, path='', method="GET", args=None, body=None, 1131 | headers=None, allow_redirects=True, timeout=None, max_retry=None): 1132 | """Rest request for HTTP client 1133 | 1134 | :param path: path within tree 1135 | :type path: str 1136 | :param method: method to be implemented 1137 | :type method: str 1138 | :param args: the arguments for method 1139 | :type args: dict 1140 | :param body: body payload for the rest call 1141 | :type body: dict 1142 | :param headers: provide additional headers 1143 | :type headers: dict 1144 | :param allow_redirects: controls whether redirects are followed 1145 | :type allow_redirects: bool 1146 | :param timeout: Timeout in seconds for the initial connection for this specific request 1147 | :type timeout: int 1148 | :param max_retry: Number of times a request will retry after a timeout for this specific request 1149 | :type max_retry: int 1150 | :returns: returns a rest request 1151 | 1152 | """ 1153 | return super(HttpClient, self)._rest_request(path=path, method=method, 1154 | args=args, body=body, 1155 | headers=headers, 1156 | allow_redirects=allow_redirects, timeout=timeout, max_retry=max_retry) 1157 | 1158 | def _get_req_headers(self, headers=None, providerheader=None): 1159 | """Get the request headers for HTTP client 1160 | 1161 | :param headers: additional headers to be utilized 1162 | :type headers: dict 1163 | :returns: returns request headers 1164 | 1165 | """ 1166 | headers = super(HttpClient, self)._get_req_headers(headers) 1167 | headers_keys = set(k.lower() for k in headers) 1168 | if 'odata-version' not in headers_keys: 1169 | headers['OData-Version'] = '4.0' 1170 | 1171 | return headers 1172 | 1173 | def redfish_client(base_url=None, username=None, password=None, 1174 | default_prefix='/redfish/v1/', 1175 | sessionkey=None, capath=None, 1176 | cafile=None, timeout=None, 1177 | max_retry=None, proxies=None, check_connectivity=True, 1178 | https_adapter=None): 1179 | """Create and return appropriate REDFISH client instance.""" 1180 | """ Instantiates appropriate Redfish object based on existing""" 1181 | """ configuration. Use this to retrieve a pre-configured Redfish object 1182 | 1183 | :param base_url: rest host or ip address. 1184 | :type base_url: str. 1185 | :param username: user name required to login to server 1186 | :type: str 1187 | :param password: password credentials required to login 1188 | :type password: str 1189 | :param default_prefix: default root to extract tree 1190 | :type default_prefix: str 1191 | :param sessionkey: session key credential for current login 1192 | :type sessionkey: str 1193 | :param capath: Path to a directory containing CA certificates 1194 | :type capath: str 1195 | :param cafile: Path to a file of CA certs 1196 | :type cafile: str 1197 | :param timeout: Timeout in seconds for the initial connection 1198 | :type timeout: int 1199 | :param max_retry: Number of times a request will retry after a timeout 1200 | :type max_retry: int 1201 | :param proxies: Dictionary containing protocol to proxy URL mappings 1202 | :type proxies: dict 1203 | :param check_connectivity: A boolean to determine whether the client immediately checks for 1204 | connectivity to the base_url or not. 1205 | :type check_connectivity: bo#ol 1206 | :param https_adapter session adapter for HTTPS 1207 | :type https_adapter: requests.adpaters.HTTPAdapter 1208 | :returns: a client object. 1209 | 1210 | """ 1211 | if "://" not in base_url: 1212 | warnings.warn("Scheme not specified for '{}'; adding 'https://'".format(base_url)) 1213 | base_url = "https://" + base_url 1214 | return HttpClient(base_url=base_url, username=username, password=password, 1215 | default_prefix=default_prefix, sessionkey=sessionkey, 1216 | capath=capath, cafile=cafile, timeout=timeout, 1217 | max_retry=max_retry, proxies=proxies, check_connectivity=check_connectivity, 1218 | https_adapter=https_adapter) 1219 | --------------------------------------------------------------------------------