Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Commit

Permalink
introduce session states
Browse files Browse the repository at this point in the history
  • Loading branch information
sbasan committed Mar 1, 2024
1 parent 0ac5601 commit 1b19a3d
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 107 deletions.
2 changes: 2 additions & 0 deletions catalystwan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import urllib3

USER_AGENT = f"{__package__}/{metadata.version(__package__)}"


def with_proc_info_header(method: Callable[..., str]) -> Callable[..., str]:
"""
Expand Down
6 changes: 6 additions & 0 deletions catalystwan/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ class TenantMigrationPreconditionsError(CatalystwanException):
pass


class ManagerReadyTimeout(CatalystwanException):
"""Raised when wainting for server ready flag took longer than expected"""

pass


class CatalystwanDeprecationWarning(DeprecationWarning):
"""Warning issued when using deprecated features or functionality in the Catalystwan SDK.
Expand Down
234 changes: 137 additions & 97 deletions catalystwan/session.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
from __future__ import annotations

import logging
import time
from enum import Enum
from importlib import metadata
from pathlib import Path
from time import monotonic
from typing import Any, Callable, ClassVar, Dict, List, Optional, Union
from urllib.parse import urljoin, urlparse, urlunparse

from packaging.version import Version # type: ignore
from requests import PreparedRequest, Request, Response, Session, head
from requests import PreparedRequest, Request, Response, Session, get, head
from requests.auth import AuthBase
from requests.exceptions import ConnectionError, HTTPError, RequestException
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed # type: ignore

from catalystwan import USER_AGENT
from catalystwan.api.api_container import APIContainer
from catalystwan.endpoints import APIEndpointClient
from catalystwan.endpoints.client import AboutInfo, ServerInfo
from catalystwan.endpoints.endpoints_container import APIEndpointContainter
from catalystwan.exceptions import (
DefaultPasswordError,
ManagerHTTPError,
ManagerReadyTimeout,
ManagerRequestException,
SessionNotCreatedError,
TenantSubdomainNotFound,
Expand All @@ -32,7 +32,6 @@
from catalystwan.vmanage_auth import vManageAuth

JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None]
USER_AGENT = f"{__package__}/{metadata.version(__package__)}"


class UserMode(str, Enum):
Expand All @@ -50,6 +49,15 @@ class TenancyMode(str, Enum):
MULTI_TENANT = "MultiTenant"


class ManagerSessionState(Enum):
# there are some similiarities to state-machine but flow is only in one direction
# and does not depend on external inputs
RESTART_IMMINENT = 0
WAIT_SERVER_READY_AFTER_RESTART = 1
LOGIN = 2
OPERATIVE = 3


def determine_session_type(
tenancy_mode: Optional[str], user_mode: Optional[str], view_mode: Optional[str]
) -> SessionType:
Expand All @@ -75,7 +83,7 @@ def create_manager_session(
subdomain: Optional[str] = None,
logger: Optional[logging.Logger] = None,
) -> ManagerSession:
"""Factory function that creates session object based on provided arguments.
"""Factory method that creates session object and performs login according to parameters
Args:
url (str): IP address or domain name
Expand All @@ -84,55 +92,22 @@ def create_manager_session(
port (int): port
subdomain: subdomain specifying to which view switch when creating provider as a tenant session,
works only on provider user mode
logger: logger for logging API requests
logger: override default module logger
Returns:
ManagerSession: Configured Session to perform tasks on vManage.
ManagerSession: logged-in and operative session to perform tasks on SDWAN Manager.
"""
session = ManagerSession(url=url, username=username, password=password, port=port, subdomain=subdomain)
session.auth = vManageAuth(session.base_url, username, password, verify=False)

if logger:
session.logger = logger
session.auth.logger = logger

if subdomain:
tenant_id = session.get_tenant_id()
vsession_id = session.get_virtual_session_id(tenant_id)
session.headers.update({"VSessionId": vsession_id})

try:
server_info = session.server()
except DefaultPasswordError:
server_info = ServerInfo.parse_obj({})

session.server_name = server_info.server
session.state = ManagerSessionState.LOGIN
session.on_session_create_hook()

tenancy_mode = server_info.tenancy_mode
user_mode = server_info.user_mode
view_mode = server_info.view_mode

session._session_type = determine_session_type(tenancy_mode, user_mode, view_mode)
if user_mode is UserMode.TENANT and subdomain:
raise SessionNotCreatedError(
f"Session not created. Subdomain {subdomain} passed to tenant session, "
"cannot switch to tenant from tenant user mode."
)
elif session._session_type is SessionType.NOT_DEFINED:
session.logger.warning(
"Cannot determine session type for "
f"tenancy-mode: {tenancy_mode}, user-mode: {user_mode}, view-mode: {view_mode}"
)

session.logger.info(
f"Logged to vManage({session.platform_version}) as {username}. The session type is {session.session_type}"
)
session.cookies.set("JSESSIONID", session.auth.set_cookie.get("JSESSIONID"))
return session


class vManageResponseAdapter(Session):
class ManagerResponseAdapter(Session):
def request(self, method, url, *args, **kwargs) -> ManagerResponse:
return ManagerResponse(super().request(method, url, *args, **kwargs))

Expand All @@ -149,7 +124,7 @@ def delete(self, url, *args, **kwargs) -> ManagerResponse:
return ManagerResponse(super().delete(url, *args, **kwargs))


class ManagerSession(vManageResponseAdapter, APIEndpointClient):
class ManagerSession(ManagerResponseAdapter, APIEndpointClient):
"""Base class for API sessions for vManage client.
Defines methods and handles session connectivity available for provider, provider as tenant, and tenant.
Expand Down Expand Up @@ -183,7 +158,6 @@ def __init__(
self.username = username
self.password = password
self.subdomain = subdomain

self._session_type = SessionType.NOT_DEFINED
self.server_name: Optional[str] = None
self.logger = logging.getLogger(__name__)
Expand All @@ -198,6 +172,118 @@ def __init__(
self.endpoints = APIEndpointContainter(self)
self._platform_version: str = ""
self._api_version: Version
self._state: ManagerSessionState = ManagerSessionState.WAIT_SERVER_READY_AFTER_RESTART
self.restart_timeout: int = 600
self.polling_requests_timeout: int = 10

@property
def state(self) -> ManagerSessionState:
return self._state

@state.setter
def state(self, state: ManagerSessionState) -> None:
"""Resets the session to given state and manages transition to desired OPERATIONAL state"""
self._state = state
self.logger.debug(f"Session entered state: {self.state.name}")

if state == ManagerSessionState.OPERATIVE:
# this is desired state, nothing to be done
return
elif state == ManagerSessionState.RESTART_IMMINENT:
# in this state we process requests normally
# but when ConnectionError is caught we enter WAIT_SERVER_READY_AFTER_RESTART
# state change is achieved with cooperation with request method
return
elif state == ManagerSessionState.WAIT_SERVER_READY_AFTER_RESTART:
self.wait_server_ready(self.restart_timeout)
self.state = ManagerSessionState.LOGIN
elif state == ManagerSessionState.LOGIN:
self.login()
self.state = ManagerSessionState.OPERATIVE
return

def login(self) -> None:
"""Performs login to SDWAN Manager and fetches important server info to instance variables"""

self.auth = vManageAuth(self.base_url, self.username, self.password, verify=False)
self.auth.logger = self.logger

if self.subdomain:
tenant_id = self.get_tenant_id()
vsession_id = self.get_virtual_session_id(tenant_id)
self.headers.update({"VSessionId": vsession_id})
try:
server_info = self.server()
except DefaultPasswordError:
server_info = ServerInfo.parse_obj({})

self.server_name = server_info.server

tenancy_mode = server_info.tenancy_mode
user_mode = server_info.user_mode
view_mode = server_info.view_mode

self._session_type = determine_session_type(tenancy_mode, user_mode, view_mode)
if user_mode is UserMode.TENANT and self.subdomain:
raise SessionNotCreatedError(
f"Session not created. Subdomain {self.subdomain} passed to tenant session, "
"cannot switch to tenant from tenant user mode."
)
elif self._session_type is SessionType.NOT_DEFINED:
self.logger.warning(
"Cannot determine session type for "
f"tenancy-mode: {tenancy_mode}, user-mode: {user_mode}, view-mode: {view_mode}"
)

self.logger.info(
f"Logged to vManage({self.platform_version}) as {self.username}. The session type is {self.session_type}"
)
self.cookies.set("JSESSIONID", self.auth.set_cookie.get("JSESSIONID"))
return

def wait_server_ready(self, timeout: int) -> None:
"""Waits until server is ready for API requests with given timeout in seconds"""

begin = monotonic()
self.logger.info(f"Waiting for server ready with timeout {timeout} seconds.")

def elapsed() -> float:
return monotonic() - begin

# wait for http connectivity
while elapsed() < timeout:
try:
resp = head(
self.base_url,
timeout=self.polling_requests_timeout,
verify=False,
headers={"User-Agent": USER_AGENT},
)
self.logger.debug(self.response_trace(resp, None))
except ConnectionError as error:
self.logger.debug(self.response_trace(error.response, error.request))
continue
break

# wait server ready flag
server_ready_url = self.get_full_url("/dataservice/client/server/ready")
while elapsed() < timeout:
try:
resp = get(
server_ready_url,
timeout=self.polling_requests_timeout,
verify=False,
headers={"User-Agent": USER_AGENT},
)
self.logger.debug(self.response_trace(resp, None))
if resp.json().get("isServerReady") is True:
self.logger.debug(f"Waiting for server ready took: {elapsed()} seconds.")
return
except RequestException as exception:
self.logger.debug(self.response_trace(exception.response, exception.request))
raise ManagerRequestException(request=exception.request, response=exception.response)

raise ManagerReadyTimeout(f"Waiting for server ready took longer than {timeout} seconds.")

def request(self, method, url, *args, **kwargs) -> ManagerResponse:
full_url = self.get_full_url(url)
Expand All @@ -206,13 +292,15 @@ def request(self, method, url, *args, **kwargs) -> ManagerResponse:
self.logger.debug(self.response_trace(response, None))
except RequestException as exception:
self.logger.debug(self.response_trace(exception.response, exception.request))
if isinstance(exception, ConnectionError) and self.state == ManagerSessionState.RESTART_IMMINENT:
self.state = ManagerSessionState.WAIT_SERVER_READY_AFTER_RESTART
return self.request(method, url, *args, **kwargs)
self.logger.error(exception)
raise ManagerRequestException(request=exception.request, response=exception.response)

if self.enable_relogin and response.jsessionid_expired:
self.logger.warning("Logging to session again. Reason: expired JSESSIONID detected in response headers")
self.auth = vManageAuth(self.base_url, self.username, self.password, verify=False)
self.cookies.set("JSESSIONID", self.auth.set_cookie.get("JSESSIONID"))
if self.enable_relogin and response.jsessionid_expired and self.state != ManagerSessionState.LOGIN:
self.logger.warning("Logging to session. Reason: expired JSESSIONID detected in response headers")
self.state = ManagerSessionState.LOGIN
return self.request(method, url, *args, **kwargs)

if response.request.url and "passwordReset.html" in response.request.url:
Expand Down Expand Up @@ -278,40 +366,6 @@ def get_file(self, url: str, filename: Path) -> Response:
file.write(response.content)
return response

def wait_for_server_reachability(self, retries: int, delay: int, initial_delay: int = 0) -> bool:
"""Checks if vManage API is reachable by sending server request.
Retries on HTTPError for specified number of times.
Delays between each request are configurable,
It is intended to be used as a probe, so it doesn't raise original error from exception.
Args:
retries: total number of retires
delay: time to wait between each retry
initial_delay: time before sending first request
Returns:
Bool: True if device is reachable, False if not
"""

def _log_exception(retry_state):
self.logger.error(f"Cannot reach server, original exception: {retry_state.outcome.exception()}")
return False

if initial_delay:
time.sleep(initial_delay)

@retry(
wait=wait_fixed(delay),
retry=retry_if_exception_type(HTTPError),
stop=stop_after_attempt(retries),
retry_error_callback=_log_exception,
)
def _send_server_request():
return self.server()

return True if _send_server_request() else False

def get_tenant_id(self) -> str:
"""Gets tenant UUID for its subdomain.
Expand Down Expand Up @@ -366,20 +420,6 @@ def __prepare_session(self, verify: bool, auth: Optional[AuthBase]) -> None:
self.auth = auth
self.verify = verify

def __is_jsession_updated(self, response: ManagerResponse) -> bool:
if (jsessionid := response.cookies.get("JSESSIONID")) and isinstance(self.auth, vManageAuth):
if jsessionid != self.auth.set_cookie.get("JSESSIONID"):
return True
return False

def check_vmanage_server_connection(self) -> bool:
try:
head(self.base_url, timeout=15, verify=False)
except ConnectionError:
return False
else:
return True

@property
def session_type(self) -> SessionType:
return self._session_type
Expand Down
3 changes: 2 additions & 1 deletion catalystwan/tests/test_session.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import unittest
from typing import Optional
from unittest.mock import patch
from uuid import uuid4

import pytest # type: ignore
from parameterized import parameterized # type: ignore
Expand All @@ -15,7 +16,7 @@ class TestSession(unittest.TestCase):
def setUp(self):
self.url = "example.com"
self.username = "admin"
self.password = "admin_password" # pragma: allowlist secret
self.password = str(uuid4())

def test_session_str(self):
# Arrange, Act
Expand Down
Loading

0 comments on commit 1b19a3d

Please sign in to comment.