Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pbench Server client library and functional tests #2941

Merged
merged 6 commits into from
Aug 5, 2022
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
21 changes: 13 additions & 8 deletions exec-unittests
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ fi
shift

# The first argument will be which major sub-system to run tests for: the
# agent side or the server side of the source tree.
# agent code, the functional test client code, or the server code.

major="${1}"
shift
major_list="agent server"
major_list="agent client server"
if [[ -n "${major}" ]]; then
if [[ "${major}" != "agent" && "${major}" != "server" && "${major}" != "both" ]]; then
printf -- "Expected major sub-system to be either 'agent', 'server', or 'both', got '%s'\n" "${major}" >&2
if [[ "${major}" != "agent" && "${major}" != "server" && "${major}" != "client" && "${major}" != "all" ]]; then
printf -- "Expected major sub-system to be 'agent', 'client', 'server', or 'all', got '%s'\n" "${major}" >&2
exit 2
fi
if [[ "${major}" != "both" ]]; then
if [[ "${major}" != "all" ]]; then
major_list="${major}"
fi
fi
Expand Down Expand Up @@ -131,7 +131,10 @@ if [[ -z "${subtst}" || "${subtst}" == "python" ]]; then
for _major in ${major_list}; do
_pytest_majors="${_pytest_majors} pbench.test.unit.${_major}"
if [[ "${_major}" == "agent" ]]; then
_pytest_majors="${_pytest_majors} pbench.test.functional"
# TODO: real functional tests require a deployed instance. Current
# agent "functional" tests are mocked Click tests rather than true
# "functional" tests.
_pytest_majors="${_pytest_majors} pbench.test.functional.agent"
webbnh marked this conversation as resolved.
Show resolved Hide resolved
fi
done

Expand Down Expand Up @@ -162,7 +165,9 @@ trap "rm -f ${_para_jobs_file}" EXIT INT TERM
let count=0
for _major in ${major_list}; do
# Verify the Agent or Server Makefile functions correctly.
verify_make_source_tree ${_major} || rc=1
if [[ "${_major}" == "agent" || "${_major}" == "server" ]]; then
verify_make_source_tree ${_major} || rc=1
fi

if [[ "${_major}" == "agent" ]]; then
# The parallel program is really cool. The usage of `parallel` is
Expand All @@ -186,7 +191,7 @@ for _major in ${major_list}; do
(( count++ ))
run_legacy ${_major} bin ${posargs} || rc=1
fi
else
elif [[ "${_major}" != "client" ]]; then
printf -- "Error - unrecognized major test sub-set, '%s'\n" "${_major}" >&2
rc=1
fi
Expand Down
311 changes: 311 additions & 0 deletions lib/pbench/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
from typing import Dict, List, Optional, Union
from urllib import parse

import requests
from requests.structures import CaseInsensitiveDict

# A type defined to conform to the semantic definition of a JSON structure
# with Python syntax.
JSONSTRING = str
JSONNUMBER = Union[int, float]
JSONVALUE = Union["JSONOBJECT", "JSONARRAY", JSONSTRING, JSONNUMBER, bool, None]
JSONARRAY = List[JSONVALUE]
JSONOBJECT = Dict[JSONSTRING, JSONVALUE]
JSON = JSONVALUE
webbnh marked this conversation as resolved.
Show resolved Hide resolved


class PbenchClientError(Exception):
"""
Base class for exceptions reported by the Pbench Server client.
"""

pass


class IncorrectParameterCount(PbenchClientError):
def __init__(self, api: str, cnt: int, uri_params: Optional[JSONOBJECT]):
self.api = api
self.cnt = cnt
self.uri_params = uri_params

def __str__(self) -> str:
return f"API template {self.api} requires {self.cnt} parameters ({self.uri_params})"


class PbenchServerClient:
DEFAULT_SCHEME = "http"

def __init__(self, host: str):
"""
Create a Pbench Server client object.

The connect method should be called to establish a connection and set
up the endpoints map before using any other methods.

Args:
host: Pbench Server hostname
"""
self.host: str = host
url_parts = parse.urlsplit(host)
if url_parts.scheme:
self.scheme = url_parts.scheme
url = self.host
else:
self.scheme = self.DEFAULT_SCHEME
url = f"{self.scheme}://{self.host}"
self.url = url
self.username: Optional[str] = None
self.auth_token: Optional[str] = None
self.session: Optional[requests.Session] = None
self.endpoints: Optional[JSONOBJECT] = None

def _headers(
self, user_headers: Optional[Dict[str, str]] = None
) -> CaseInsensitiveDict:
"""
Create an HTTP request headers dictionary.

The connect method can set default HTTP headers which apply to all
server calls. This method implicitly adds an authentication token
if the client has logged in and also allows the caller to override
that or any other default session header. (For example, to change the
default "accept" datatype, or to force an invalid authentication token
for testing.)

Args:
user_headers: Addition request headers

Returns:
Case-insensitive header dictionary
"""
headers = CaseInsensitiveDict()
if self.auth_token:
headers["authorization"] = f"Bearer {self.auth_token}"
webbnh marked this conversation as resolved.
Show resolved Hide resolved
if user_headers:
headers.update(user_headers)
return headers

def _uri(self, api: str, uri_params: Optional[JSONOBJECT] = None) -> str:
"""
Compute the Pbench Server URI for an operation. This uses the endpoints
definition stored by connect. If parameters are given, then it will use
the template "uri" object to find the named URI template and format it
with the provided parameter values.

Args:
api: The name of the API
uri_params: A dictionary of named parameter values for the template

Returns:
A fully specified URI
"""
if not uri_params:
return self.endpoints["api"][api]
else:
description = self.endpoints["uri"][api]
template = description["template"]
cnt = len(description["params"])
if cnt != len(uri_params):
raise IncorrectParameterCount(api, cnt, uri_params)
return template.format(**uri_params)

def get(
self,
api: str,
uri_params: Optional[JSONOBJECT] = None,
*,
headers: Optional[Dict[str, str]] = None,
**kwargs,
) -> requests.Response:
"""
Issue a get HTTP operation through the cached session, constructing a
URI from the "api" name and parameters, adding or overwriting HTTP
request headers as specified.

HTTP errors are raised by exception to ensure they're not overlooked.

Args:
api: The name of the Pbench Server API
uri_params: A dictionary of named parameters to expand a URI template
headers: A dictionary of header/value pairs
kwargs: Additional `requests` parameters (e.g., params)

Returns:
An HTTP Response object
"""
url = self._uri(api, uri_params)
response = self.session.get(url, headers=self._headers(headers), **kwargs)
response.raise_for_status()
return response

def head(
self,
api: str,
uri_params: Optional[JSONOBJECT] = None,
*,
headers: Optional[Dict[str, str]] = None,
**kwargs,
) -> requests.Response:
"""
Issue a head HTTP operation through the cached session, constructing a
URI from the "api" name and parameters, adding or overwriting HTTP
request headers as specified. `head` is a GET that returns only status
and headers, without a response payload.

HTTP errors are raised by exception to ensure they're not overlooked.

Args:
api: The name of the Pbench Server API
uri_params: A dictionary of named parameters to expand a URI template
headers: A dictionary of header/value pairs
kwargs: Additional `requests` parameters (e.g., params)

Returns:
An HTTP Response object
"""
url = self._uri(api, uri_params)
response = self.session.head(url, headers=self._headers(headers), **kwargs)
response.raise_for_status()
return response

def put(
self,
api: str,
uri_params: Optional[JSONOBJECT] = None,
*,
json: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
**kwargs,
) -> requests.Response:
"""
Issue a put HTTP operation through the cached session, constructing a
URI from the "api" name and parameters, adding or overwriting HTTP
request headers as specified, and optionally passing a JSON request
payload.

HTTP errors are raised by exception to ensure they're not overlooked.

Args:
api: The name of the Pbench Server API
uri_params: A dictionary of named parameters to expand a URI template
json: A JSON request payload as a Python dictionary
headers: A dictionary of header/value pairs
kwargs: Additional `requests` parameters (e.g., params)

Returns:
An HTTP Response object
"""
url = self._uri(api, uri_params)
response = self.session.put(
url, json=json, headers=self._headers(headers), **kwargs
)
response.raise_for_status()
return response

def post(
self,
api: str,
uri_params: Optional[JSONOBJECT] = None,
*,
json: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
**kwargs,
) -> requests.Response:
"""
Issue a post HTTP operation through the cached session, constructing a
URI from the "api" name and parameters, adding or overwriting HTTP
request headers as specified, and optionally passing a JSON request
payload.

HTTP errors are raised by exception to ensure they're not overlooked.

Args:
api: The name of the Pbench Server API
uri_params: A dictionary of named parameters to expand a URI template
json: A JSON request payload as a Python dictionary
headers: A dictionary of header/value pairs
kwargs: Additional `requests` parameters (e.g., params)

Returns:
An HTTP Response object
"""
url = self._uri(api, uri_params)
response = self.session.post(
url, json=json, headers=self._headers(headers), **kwargs
)
response.raise_for_status()
return response

def delete(
self,
api: str,
uri_params: Optional[JSONOBJECT] = None,
*,
headers: Optional[Dict[str, str]] = None,
**kwargs,
) -> requests.Response:
"""
Issue a delete HTTP operation through the cached session, constructing
a URI from the "api" name and parameters, adding or overwriting HTTP
request headers as specified.

HTTP errors are raised by exception to ensure they're not overlooked.

Args:
api: The name of the Pbench Server API
uri_params: A dictionary of named parameters to expand a URI template
headers: A dictionary of header/value pairs
kwargs: Additional `requests` parameters (e.g., params)

Returns:
An HTTP Response object
"""
url = self._uri(api, uri_params)
response = self.session.delete(url, headers=self._headers(headers), **kwargs)
response.raise_for_status()
return response

def connect(self, headers: Optional[Dict[str, str]] = None) -> None:
"""
Connect to the Pbench Server host using the endpoints API to be sure
that it responds, and cache the endpoints response payload.
webbnh marked this conversation as resolved.
Show resolved Hide resolved

This also allows the client to add default HTTP headers to the session
which will be used for all operations unless overridden for specific
operations.

Args:
headers: A dict of default HTTP headers
"""
url = parse.urljoin(self.url, "api/v1/endpoints")
self.session = requests.Session()
if headers:
self.session.headers.update(headers)
response = self.session.get(url)
response.raise_for_status()
self.endpoints = response.json()
assert self.endpoints

def login(self, user: str, password: str) -> None:
"""
Login to a specified username with the password, and store the
resulting authentication token.

Args:
user: Account username
password: Account password
"""
response = self.post("login", json={"username": user, "password": password})
response.raise_for_status()
json = response.json()
self.username = json["username"]
self.auth_token = json["auth_token"]

def logout(self) -> None:
"""
Logout the currently authenticated user and remove the authentication
token.
"""
self.post("logout")
self.username = None
self.auth_token = None
23 changes: 23 additions & 0 deletions lib/pbench/test/functional/server/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os

import pytest

from pbench.client import PbenchServerClient


@pytest.fixture(scope="module")
def pbench_server_client():
"""
Used by Pbench Server functional tests to connect to a server.

If run without a PBENCH_SERVER environment variable pointing to the server
instance, this will fail the test run.
"""
host = os.environ.get("PBENCH_SERVER")
assert (
host
), "Pbench Server functional tests require that PBENCH_SERVER be set to the hostname of a server"
pbench_client = PbenchServerClient(host)
pbench_client.connect({"accept": "application/json"})
assert pbench_client.endpoints
return pbench_client
Loading