Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
12 changes: 6 additions & 6 deletions src/anyvlm/auth/token_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
import requests
from requests.models import Response

from anyvlm.utils.constants import (
VLM_NETWORK_AUDIENCE_URL,
VLM_NETWORK_TOKEN_REQUEST_URL,
)
from anyvlm.utils.exceptions import TokenRetrievalError


class TokenProvider:
"""Handles retrieving/caching valid bearer tokens"""

BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com"
AUDIENCE_URL: str = BASE_VLM_AUTH_URL + "/api/v2/"
TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token"

# used to add margin to the token expiry time, to account for the time
# it takes for the token provider to send its response back to AnyVLM
TOKEN_EXPIRY_MARGIN_SECONDS = 30
Expand Down Expand Up @@ -54,11 +54,11 @@ def _refresh_token(self) -> None:
"""Retrieves and sets a new JWT token and updates the token expiry time"""
now: datetime = datetime.now(tz=UTC)
response: Response = requests.post(
url=self.TOKEN_REQUEST_URL,
url=VLM_NETWORK_TOKEN_REQUEST_URL,
json={
"client_id": self.client_id,
"client_secret": self.client_secret,
"audience": self.AUDIENCE_URL,
"audience": VLM_NETWORK_AUDIENCE_URL,
"grant_type": "client_credentials",
},
timeout=10,
Expand Down
94 changes: 94 additions & 0 deletions src/anyvlm/outbound/node_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Retrieves information about other nodes on the VLM Network"""

import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from http import HTTPMethod, HTTPStatus

from requests import Response

from anyvlm.outbound.vlm_client import VlmClient
from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL
from anyvlm.utils.exceptions import NodeRetrievalError


@dataclass
class VlmNode:
"""Houses metadata about a VLM Network node"""

client_id: str
client_name: str
match_url: str | None


class NodeRegistry:
"""Manages the list of other known nodes on the VLM Network"""

NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24)
NODES_RESULTS_PAGE_SIZE: int = (
100 # Auth0's node list is paginated w/ a max of 100 results per page
)

_nodes: list[VlmNode]
_nodes_expiry: datetime
_vlm_client: VlmClient

def __init__(self, vlm_client: VlmClient) -> None:
"""Initializes a list of all other nodes currently on the VLM Network

:param vlm_client: A 'VlmClient' instance used to send outbound HTTP requests to the VLM Network
"""
self._vlm_client = vlm_client
self.refresh_nodes()

def get_nodes(self) -> list[VlmNode]:
"""Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.
See 'self.refresh_nodes()' for list of possible Exceptions that may be raised.
"""
now: datetime = datetime.now(tz=UTC)
if (not self._nodes_expiry) or (now >= self._nodes_expiry):
self.refresh_nodes()
return self._nodes

def refresh_nodes(self) -> None:
"""Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check

:raises: `NodeRetrievalError` if request to the VLM Network to obtain the list of registered nodes fails
:raises: `TokenRetrievalError` if unable to obtain a JWT token to send the request to the VLM Network
"""
refreshed_nodes: list[VlmNode] = []
page = 0
while True:
response: Response = self._vlm_client.request(
method_type=HTTPMethod.GET,
url=(
f"{VLM_NETWORK_NODES_REQUEST_URL}?page={page}"
f"&per_page={self.NODES_RESULTS_PAGE_SIZE}"
),
)

if response.status_code != HTTPStatus.OK:
error_message: str = f"Unable to fetch VLM Network nodes from Auth0 - received {response.status_code} response"
raise NodeRetrievalError(error_message)

data = response.json()
refreshed_nodes.extend(
VlmNode(
client_id=entry.get("client_id", ""),
client_name=entry.get("name", ""),
match_url=entry.get("client_metadata", {}).get("match_url"),
)
for entry in data
if entry.get("client_id")
!= os.environ.get("VLM_CLIENT_ID") # exclude our own node
)

# we know we've hit the last page if the number of results is less than our requested limit
if len(data) < self.NODES_RESULTS_PAGE_SIZE:
break
page += 1

# Update the cache only after all pages have been retrieved successfully.
self._nodes = refreshed_nodes

self._nodes_expiry = datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL
7 changes: 7 additions & 0 deletions src/anyvlm/utils/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Global constants"""

# VLM Network URLs
VLM_NETWORK_ROOT_URL: str = "https://vlm-auth.us.auth0.com/"
VLM_NETWORK_AUDIENCE_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/"
VLM_NETWORK_TOKEN_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "oauth/token/"
VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients/"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex finding: Clarification question (severity unassigned); Non-blocking: Verify the discovery endpoint’s trailing slash

Moving this URL into a constant also changes /api/v2/clients to /api/v2/clients/. On vlm-auth.us.auth0.com, unauthenticated GETs return 401 without the trailing slash and 404 with it. Does the new URL succeed with a valid bearer token? I haven’t verified authenticated behavior.

To preserve the previous endpoint, the replacement would be:

Suggested change
VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients/"
VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1e05144 appears to be the requested change

4 changes: 4 additions & 0 deletions src/anyvlm/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ class VariantLookupError(Exception):

class TokenRetrievalError(Exception):
"""Raised when a token cannot be retrieved"""


class NodeRetrievalError(Exception):
"""Raised when the list of nodes on the VLM network cannot be retrieved"""
162 changes: 162 additions & 0 deletions tests/unit/test_node_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from datetime import UTC, datetime, timedelta
from http import HTTPMethod, HTTPStatus
from typing import Any
from unittest.mock import MagicMock, call

import pytest

from anyvlm.outbound.node_registry import NodeRegistry
from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL
from anyvlm.utils.exceptions import NodeRetrievalError


def _response(
status_code: HTTPStatus = HTTPStatus.OK, data: list[Any] | None = None
) -> MagicMock:
response: MagicMock = MagicMock()
response.status_code = status_code
response.json.return_value = data or []
return response


def test_loads_nodes_and_exposes_client_ids() -> None:
client: MagicMock = MagicMock()
client.request.return_value = _response(
data=[
{
"client_id": "client-one",
"name": "One",
"client_metadata": {"match_url": "https://one.example"},
},
{"client_id": "client-two", "name": "Two"},
]
)

registry: NodeRegistry = NodeRegistry(vlm_client=client)

assert [node.client_id for node in registry.get_nodes()] == [
"client-one",
"client-two",
]
client.request.assert_called_once_with(
method_type=HTTPMethod.GET,
url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100",
)


def test_loads_nodes_from_multiple_pages() -> None:
client: MagicMock = MagicMock()
client.request.side_effect = [
_response(data=[{"client_id": "client-one"}] * 100),
_response(data=[{"client_id": "client-two"}]),
]

registry: NodeRegistry = NodeRegistry(vlm_client=client)

assert [node.client_id for node in registry.get_nodes()] == [
"client-one",
] * 100 + ["client-two"]
assert client.request.call_args_list == [
call(
method_type=HTTPMethod.GET,
url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100",
),
call(
method_type=HTTPMethod.GET,
url=VLM_NETWORK_NODES_REQUEST_URL + "?page=1&per_page=100",
),
]


def test_refresh_nodes_replaces_cached_nodes() -> None:
client: MagicMock = MagicMock()
client.request.side_effect = [
_response(data=[{"client_id": "old"}]),
_response(data=[{"client_id": "new"}]),
]

registry: NodeRegistry = NodeRegistry(vlm_client=client)
registry.refresh_nodes()

assert [node.client_id for node in registry.get_nodes()] == ["new"]


def test_refreshes_expired_cache_on_get_nodes() -> None:
client: MagicMock = MagicMock()
client.request.side_effect = [
_response(data=[{"client_id": "initial"}]),
_response(data=[{"client_id": "refreshed"}]),
]

registry: NodeRegistry = NodeRegistry(vlm_client=client)
registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage]
assert [node.client_id for node in registry.get_nodes()] == ["refreshed"]

assert client.request.call_count == 2


def test_raises_when_node_retrieval_fails() -> None:
client: MagicMock = MagicMock()
client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE)

with pytest.raises(NodeRetrievalError, match="Unable to fetch"):
NodeRegistry(vlm_client=client)


def test_registry_excludes_self_when_other_nodes_exist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# this is our own node's client_id
self_vlm_client_id = "self-client"
monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id)

client: MagicMock = MagicMock()
client.request.return_value = _response(
data=[
{
"client_id": "client-one",
"name": "One",
"client_metadata": {"match_url": "https://one.example"},
},
{
"client_id": self_vlm_client_id,
"name": "GenomicMedLabs' AnyVLM instance",
},
]
)

registry: NodeRegistry = NodeRegistry(vlm_client=client)

assert [node.client_id for node in registry.get_nodes()] == [
"client-one",
]
client.request.assert_called_once_with(
method_type=HTTPMethod.GET,
url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100",
)


def test_registry_returns_empty_list_when_only_self_exists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# this is our own node's client_id
self_vlm_client_id = "self-client"
monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id)

client: MagicMock = MagicMock()
client.request.return_value = _response(
data=[
{
"client_id": self_vlm_client_id,
"name": "GenomicMedLabs' AnyVLM instance",
},
]
)

registry: NodeRegistry = NodeRegistry(vlm_client=client)

assert [node.client_id for node in registry.get_nodes()] == []
client.request.assert_called_once_with(
method_type=HTTPMethod.GET,
url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100",
)
Loading