Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,6 @@ exports/

# VSCode
.vscode/

CLAUDE.md
.claude/
1 change: 1 addition & 0 deletions src/badfish/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RETRIES = 15
2 changes: 2 additions & 0 deletions src/badfish/helpers/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class BadfishException(Exception):
pass
230 changes: 230 additions & 0 deletions src/badfish/helpers/http_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import asyncio
import json
from typing import Any, Dict, Optional

import aiohttp

from badfish.helpers.async_lru import alru_cache
from badfish.helpers.exceptions import BadfishException


class HTTPClient:

def __init__(self, host: str, username: str, password: str, logger, retries: int = 15):
self.host = host
self.username = username
self.password = password
self.logger = logger
self.retries = retries
self.host_uri = f"https://{host}"
self.redfish_uri = "/redfish/v1"
self.root_uri = f"{self.host_uri}{self.redfish_uri}"
self.semaphore = asyncio.Semaphore(50)
self.token = None
self.session_id = None

async def error_handler(self, response: aiohttp.ClientResponse, message: Optional[str] = None) -> None:
try:
raw = await response.text("utf-8", "ignore")
data = json.loads(raw.strip())
except ValueError:
raise BadfishException("Error reading response from host.")

detail_message = data
if "error" in data:
try:
detail_message = str(data["error"]["@Message.ExtendedInfo"][0]["Message"])
resolution = str(data["error"]["@Message.ExtendedInfo"][0]["Resolution"])
self.logger.debug(resolution)
except (KeyError, IndexError) as ex:
self.logger.debug(ex)
if message:
self.logger.debug(detail_message)
raise BadfishException(message)
else:
raise BadfishException(detail_message)

@alru_cache(maxsize=64)
async def get_request(self, uri: str, _continue: bool = False, _get_token: bool = False):
return await self.get_raw(uri, _continue, _get_token)

@alru_cache(maxsize=64)
async def get_json(self, uri: str, _continue: bool = False, _get_token: bool = False):
response = await self.get_raw(uri, _continue, _get_token)
if not response:
return None

# Parse JSON from response
try:
raw = await response.text("utf-8", "ignore")
data = json.loads(raw.strip())
return data
except (json.JSONDecodeError, AttributeError) as e:
self.logger.debug(f"Failed to parse JSON response: {e}")
return None

async def get_raw(self, uri: str, _continue: bool = False, _get_token: bool = False):
try:
async with self.semaphore:
async with aiohttp.ClientSession() as session:
if not _get_token:
async with session.get(
uri,
headers={"X-Auth-Token": self.token} if self.token else {},
ssl=False,
timeout=60,
) as _response:
await _response.read()
else:
async with session.get(
uri,
auth=aiohttp.BasicAuth(self.username, self.password),
ssl=False,
timeout=60,
) as _response:
await _response.read()
except (Exception, TimeoutError) as ex:
if _continue:
return
else:
self.logger.debug(f"HTTPClient get_raw exception: {ex}")
self.logger.debug(f"Exception type: {type(ex)}")
raise BadfishException("Failed to communicate with server.")

return _response

async def post_request(
self,
uri: str,
payload: Dict[str, Any],
headers: Dict[str, str],
_get_token: bool = False,
):
try:
async with self.semaphore:
async with aiohttp.ClientSession() as session:
if not _get_token and self.token:
headers.update({"X-Auth-Token": self.token})
async with session.post(
uri,
data=json.dumps(payload),
headers=headers,
ssl=False,
) as _response:
if _response.status != 204:
await _response.read()
else:
return _response
except (Exception, TimeoutError):
raise BadfishException("Failed to communicate with server.")
return _response

async def patch_request(self, uri: str, payload: Dict[str, Any], headers: Dict[str, str], _continue: bool = False):
try:
async with self.semaphore:
async with aiohttp.ClientSession() as session:
if self.token:
headers.update({"X-Auth-Token": self.token})
async with session.patch(
uri,
data=json.dumps(payload),
headers=headers,
ssl=False,
) as _response:
raw_data = await _response.read()
return _response
except Exception as ex:
if _continue:
return None
else:
self.logger.debug(ex)
raise BadfishException("Failed to communicate with server.")

async def delete_request(self, uri: str, headers: Dict[str, str]):
try:
async with self.semaphore:
async with aiohttp.ClientSession() as session:
if self.token:
headers.update({"X-Auth-Token": self.token})
async with session.delete(
uri,
headers=headers,
ssl=False,
) as _response:
raw_data = await _response.read()
return _response
except (Exception, TimeoutError):
raise BadfishException("Failed to communicate with server.")

async def find_session_uri(self):
_response = await self.get_request(self.root_uri, _get_token=True)

status = _response.status
if status == 401:
raise BadfishException(f"Failed to authenticate. Verify your credentials for {self.host}")
if status not in [200, 201]:
raise BadfishException(f"Failed to communicate with {self.host}")

raw = await _response.text("utf-8", "ignore")
data = json.loads(raw.strip())

redfish_version = int(data["RedfishVersion"].replace(".", ""))
session_uri = None
if redfish_version >= 160:
session_uri = "/redfish/v1/SessionService/Sessions"
elif redfish_version < 160:
session_uri = "/redfish/v1/Sessions"

_uri = "%s%s" % (self.host_uri, session_uri)
check_response = await self.get_request(_uri, _get_token=True)
if check_response.status == 404:
session_uri = "/redfish/v1/SessionService/Sessions"

return session_uri

async def validate_credentials(self):
payload = {"UserName": self.username, "Password": self.password}
headers = {"content-type": "application/json"}
session_uri = await self.find_session_uri()
_uri = "%s%s" % (self.host_uri, session_uri)
_response = await self.post_request(_uri, payload, headers, _get_token=True)

# Mock shifting value on value access and not on call.
await _response.text("utf-8", "ignore")

status = _response.status
if status == 401:
raise BadfishException(f"Failed to authenticate. Verify your credentials for {self.host}")
if status not in [200, 201]:
raise BadfishException(f"Failed to communicate with {self.host}")

self.session_id = _response.headers.get("Location")
token = _response.headers.get("X-Auth-Token")
return token

async def delete_session(self):
try:
try:
if not self.session_id:
self.logger.debug("No session ID found, skipping session deletion")
return
headers = {"content-type": "application/json"}
_uri = "%s%s" % (self.host_uri, self.session_id)
try:
_response = await self.delete_request(_uri, headers=headers)
if _response.status in [200, 201]:
self.logger.debug(f"Session successfully deleted for {self.host}")
elif _response.status == 404:
self.logger.debug(f"Session not found (404) for {self.host}, may have been already deleted")
else:
self.logger.warning(
f"Unexpected status {_response.status} when deleting session for {self.host}."
)
except Exception as ex:
self.logger.warning(f"Failed to delete session for {self.host}: {ex}")
finally:
self.session_id = None
self.token = None
except Exception:
self.session_id = None
self.token = None
Loading