Repository navigation
feat: implement network node discovery (Issue #111) #119
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
Open
jennifer-bowser
wants to merge
17
commits into
issue-110-auth-token-mgmt
from
issue-111-implement-network-node-discovery
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
6dbde79
implement network node discovery
jennifer-bowser 9cdcaf6
resolve merge conflicts
jennifer-bowser 702ca28
wip - inject dependencies into classes
jennifer-bowser 7f2935d
resolve merge conflict
jennifer-bowser e0d9de6
exclude our own node from the node registry
jennifer-bowser 67cf670
adds trailing slash to base VLM network URL + moves tests for the nod…
jennifer-bowser ce5eaba
don't refresh nodes just because the registry doesn't contain any ent…
jennifer-bowser 95c31df
add test for case when only self exists during node discovery
jennifer-bowser cfffa0b
whoops redo adding documentation to TokenProvider's '__init__' method
jennifer-bowser 754c1c1
document possible Exceptions that may be raised when retrieving nodes…
jennifer-bowser e3abeb4
add support for paginated results for node registry calls
jennifer-bowser c2d1c64
fix failing test
jennifer-bowser 07eecf1
resolve merge conflicts
jennifer-bowser 2a51aff
include status code in response when request to Auth0 for node list f…
jennifer-bowser 9ced5da
mock vlm network cred env vars so tests don't fail
jennifer-bowser 2934883
Merge branch 'issue-110-auth-token-mgmt' into issue-111-implement-net…
jennifer-bowser 91c843f
move non-configurable values out of 'Settings'
jennifer-bowser File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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/clientsto/api/v2/clients/. Onvlm-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:
There was a problem hiding this comment.
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