diff --git a/mapping_provider/__init__.py b/mapping_provider/__init__.py index aa309f9cfa78136a8ef621a23d83e13d37b4dffa..e21cff9b0db891eabb24d1d0c0dbc4dc9fbfed5b 100644 --- a/mapping_provider/__init__.py +++ b/mapping_provider/__init__.py @@ -1,8 +1,9 @@ -"""Initializes the FastAPI application.""" +"""Create a FastAPI application for the mapping provider.""" from fastapi import FastAPI from mapping_provider.api.common import router as version_router +from mapping_provider.api.network_graph import router as graph_router def create_app() -> FastAPI: @@ -11,5 +12,13 @@ def create_app() -> FastAPI: title="Mapping provider", description="Mapping provider endpoints for GÉANT maps", ) + + # Force configuration to be loaded at startup to avoid issues with missing config + from mapping_provider.dependencies import load_config + + _ = load_config() + app.include_router(version_router) + app.include_router(graph_router) + return app diff --git a/mapping_provider/api/network_graph.py b/mapping_provider/api/network_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..3dcc9261ed32a69e066a952331f080b891c65895 --- /dev/null +++ b/mapping_provider/api/network_graph.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from mapping_provider.dependencies import config_dep +from mapping_provider.services.gap import load_routers, load_trunks + +router = APIRouter() + + +@router.get("/network-graph") +def get_network_graph(config: config_dep) -> dict[str, list[str] | list[list[str]]]: + """Network graph endpoint.""" + return {"routers": load_routers(config), "trunks": load_trunks(config)} diff --git a/mapping_provider/app.py b/mapping_provider/app.py new file mode 100644 index 0000000000000000000000000000000000000000..44befa20eb045e3712c7976c0f6ab10f5f222cb9 --- /dev/null +++ b/mapping_provider/app.py @@ -0,0 +1,10 @@ +"""The main module that runs the application.""" + +import uvicorn + +from mapping_provider import create_app + +app = create_app() + +if __name__ == "__main__": + uvicorn.run("mapping_provider.app:app", host="127.0.0.1", port=8000, reload=True) diff --git a/mapping_provider/config.py b/mapping_provider/config.py new file mode 100644 index 0000000000000000000000000000000000000000..181badc097e049d8b2fcdbd6387d8f752d3b1e9c --- /dev/null +++ b/mapping_provider/config.py @@ -0,0 +1,44 @@ +"""Configuration file for the Mapping Provider.""" + +import json +from typing import Any, TextIO + +import jsonschema + +CONFIG_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "aai": { + "type": "object", + "properties": { + "discovery_endpoint_url": {"type": "string"}, + "client_id": {"type": "string"}, + "secret": {"type": "string"}, + }, + "required": ["client_id", "secret", "discovery_endpoint_url"], + "additionalProperties": False, + }, + "orchestrator": { + "type": "object", + "properties": { + "url": {"type": "string"}, + }, + "required": ["url"], + "additionalProperties": False, + }, + }, + "required": ["aai", "orchestrator"], + "additionalProperties": False, +} + + +def load(f: TextIO) -> dict[str, Any]: + """loads, validates and returns configuration parameters. + + :param f: file-like object that produces the config file + :return: + """ + config: dict[str, Any] = json.loads(f.read()) + jsonschema.validate(config, CONFIG_SCHEMA) + return config diff --git a/mapping_provider/dependencies.py b/mapping_provider/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..ac00f546f2cada354e720a060b026ce0e9829dda --- /dev/null +++ b/mapping_provider/dependencies.py @@ -0,0 +1,21 @@ +"""FastAPI project dependencies.""" + +import os +from functools import lru_cache +from typing import Annotated, Any + +from fastapi import Depends + +from mapping_provider import config + + +@lru_cache +def load_config() -> dict[str, Any]: + """Load and cache the application configuration.""" + config_file = os.environ.get("CONFIG_FILE_NAME", "config.json") + with open(config_file) as f: + return config.load(f) + + +# Dependency for injecting config into routes/services +config_dep = Annotated[dict[str, Any], Depends(load_config)] diff --git a/mapping_provider/main.py b/mapping_provider/main.py deleted file mode 100644 index dfcd3b48ae2aeb815081e1cf6fe07e90020d8eff..0000000000000000000000000000000000000000 --- a/mapping_provider/main.py +++ /dev/null @@ -1,6 +0,0 @@ -"""The main module that runs the application.""" - -from mapping_provider import create_app - -app = create_app() - diff --git a/mapping_provider/services/__init__.py b/mapping_provider/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..450b5883ca2b2c915c327f42f2d003800f29a747 --- /dev/null +++ b/mapping_provider/services/__init__.py @@ -0,0 +1 @@ +"""External services for the mapping provider.""" diff --git a/mapping_provider/services/gap.py b/mapping_provider/services/gap.py new file mode 100644 index 0000000000000000000000000000000000000000..4b052427eb177f35f6aca48e881c4fcaa519986f --- /dev/null +++ b/mapping_provider/services/gap.py @@ -0,0 +1,149 @@ +import logging +from collections.abc import Callable +from typing import Any + +import requests +from requests import Session + +from mapping_provider.dependencies import config_dep + +logger = logging.getLogger(__name__) + +GRANT_TYPE = "client_credentials" +SCOPE = "openid profile email aarc" + + +def get_token_endpoint(discovery_endpoint_url: str, session: Session) -> str: + """Fetch the token endpoint URL from discovery document.""" + response = session.get(discovery_endpoint_url) + response.raise_for_status() + data: dict[str, Any] = response.json() + return str(data["token_endpoint"]) + + +def get_token(config: dict[str, Any], session: Session) -> str: + """Retrieve an access token using client credentials flow.""" + aai_config = config["aai"] + token_endpoint = get_token_endpoint(aai_config["discovery_endpoint_url"], session) + response = session.post( + token_endpoint, + data={ + "grant_type": GRANT_TYPE, + "scope": SCOPE, + "client_id": aai_config["client_id"], + "client_secret": aai_config["secret"], + }, + ) + response.raise_for_status() + data: dict[str, Any] = response.json() + return str(data["access_token"]) + + +def make_request(query: str, token: str, config: dict[str, Any], session: Session) -> dict[Any, Any]: + """Make a GraphQL request to the orchestrator API.""" + api_url = f"{config['orchestrator']['url']}/api/graphql" + headers = {"Authorization": f"Bearer {token}"} + response = session.post(api_url, headers=headers, json={"query": query}) + response.raise_for_status() + + data: dict[str, Any] = response.json() + if "errors" in data: + logger.error(f"GraphQL query returned errors: {data['errors']}. Query: {query}") + raise ValueError(f"GraphQL query errors: {data['errors']}") + + return data + + +def extract_router(product_block_instances: list[dict[str, Any]]) -> str | None: + """Extract router FQDNs from productBlockInstances.""" + for instance in product_block_instances: + for value in instance.get("productBlockInstanceValues", []): + if value.get("field") == "routerFqdn" and value.get("value"): + return str(value["value"]) + return None + + +def extract_trunk(product_block_instances: list[dict[str, Any]]) -> list[str] | None: + """Extract trunks from productBlockInstances.""" + fqdns = [] + for instance in product_block_instances: + for value in instance.get("productBlockInstanceValues", []): + if value.get("field") == "routerFqdn" and value.get("value"): + fqdns.append(value["value"]) + break + + if len(fqdns) >= 2: + return [fqdns[0], fqdns[1]] + return None + + +def load_inventory( + config: dict[str, Any], + token: str, + tag: str, + session: Session, + extractor: Callable[[list[dict[str, Any]]], Any | None], +) -> list[Any]: + """ + Generic function to load inventory items based on tag and extractor function. + + The extractor receives a list of productBlockInstances and returns parsed output. + """ + results = [] + end_cursor = 0 + has_next_page = True + + while has_next_page: + query = f""" + query {{ + subscriptions( + filterBy: {{field: "status", value: "PROVISIONING|ACTIVE"}}, + first: 100, + after: {end_cursor}, + query: "tag:({tag})" + ) {{ + pageInfo {{ + hasNextPage + endCursor + }} + page {{ + subscriptionId + product {{ + tag + }} + productBlockInstances {{ + productBlockInstanceValues + }} + }} + }} + }} + """ + + data = make_request(query, token, config, session) + page_data = data.get("data", {}).get("subscriptions", {}).get("page", []) + page_info = data.get("data", {}).get("subscriptions", {}).get("pageInfo", {}) + + for item in page_data: + instances = item.get("productBlockInstances", []) + extracted = extractor(instances) + if extracted: + results.append(extracted) + + has_next_page = page_info.get("hasNextPage", False) + end_cursor = page_info.get("endCursor", 0) + + return results + + +def load_routers(config: config_dep) -> list[str]: + """Load routers (nodes) from orchestrator.""" + with requests.Session() as session: + token = get_token(config, session) + return load_inventory(config, token, tag="RTR", session=session, extractor=extract_router) + + +def load_trunks(config: config_dep) -> list[list[str]]: + """Load trunks (edges) from orchestrator.""" + with requests.Session() as session: + token = get_token(config, session) + return load_inventory(config, token, tag="IPTRUNK", session=session, extractor=extract_trunk) diff --git a/pyproject.toml b/pyproject.toml index cdeac565c37fdc9a4f47530c7a09f6413028ef66..b3e6aa622f2818c8b7cfd4b9129181489fbcbe01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] line-length = 120 -target-version = "py313" +target-version = "py312" select = ["E", "F", "I", "B", "UP", "N"] fixable = ["ALL"] exclude = ["tests", "docs", "build"] @@ -9,4 +9,14 @@ exclude = ["tests", "docs", "build"] python_version = "3.13" strict = true warn_unused_ignores = true -warn_return_any = true \ No newline at end of file +warn_return_any = true +allow_untyped_decorators = true +ignore_missing_imports = true +exclude = [ + "venv", + "test/*", + "docs" +] +[[tool.mypy.overrides]] +module = ["requests.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8cedf04a6b774601a712430bfb7494d302f9fb80..05d5101385419bdb28496d1d71c40b42b3bd3e60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ fastapi uvicorn[standard] +requests +jsonschema +setuptools sphinx sphinx-rtd-theme diff --git a/setup.py b/setup.py index 5e5209c7d2b398fa6ee6b7e28f954dc397cc8133..32b9b6f685d6922861561509461b692ce2cca0de 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,9 @@ setup( python_requires=">=3.10", install_requires=[ "fastapi", - "uvicorn[standard]" + "uvicorn[standard]", + "jsonschema", + "requests", ], long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", @@ -23,4 +25,4 @@ setup( "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], -) \ No newline at end of file +) diff --git a/tox.ini b/tox.ini index c34930bd56b4c60894c3bcfb52180adfcf5447dc..23c347f9597748c0010af8f2d6c3428a65141dbe 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,11 @@ [tox] -envlist = lint, typecheck, docs +envlist = py313 -[testenv:lint] -description = Lint code with Ruff -deps = ruff -commands = ruff check mapping_provider - -[testenv:typecheck] -description = Type-check code with mypy -deps = mypy -commands = mypy mapping_provider - -[testenv:docs] -description = Build docs +[testenv] deps = - sphinx - sphinx-rtd-theme - sphinxcontrib-plantuml - sphinxcontrib-drawio - sphinxcontrib-openapi -commands = sphinx-build -b html docs/source docs/build - + -r requirements.txt +commands = + ruff check --respect-gitignore --preview . + ruff format --respect-gitignore --preview --check . + mypy .