diff --git a/clab_connector/__init__.py b/clab_connector/__init__.py index e69de29..2a51bb2 100644 --- a/clab_connector/__init__.py +++ b/clab_connector/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/__init__.py + +__version__ = "0.2.0" diff --git a/clab_connector/cli/__init__.py b/clab_connector/cli/__init__.py index e6f7fa2..9e496d7 100644 --- a/clab_connector/cli/__init__.py +++ b/clab_connector/cli/__init__.py @@ -1 +1,3 @@ -"""CLI package for clab-connector.""" \ No newline at end of file +# clab_connector/cli/__init__.py + +"""CLI package for clab-connector.""" diff --git a/clab_connector/cli/main.py b/clab_connector/cli/main.py index 7fb1b43..28f53e7 100644 --- a/clab_connector/cli/main.py +++ b/clab_connector/cli/main.py @@ -1,6 +1,9 @@ +# clab_connector/cli/main.py + import logging import os from enum import Enum +from typing import Optional from pathlib import Path from typing import List @@ -10,8 +13,10 @@ from rich import print as rprint from typing_extensions import Annotated -from clab_connector.core.integrate import IntegrateCommand -from clab_connector.core.remove import RemoveCommand +from clab_connector.services.integration.topology_integrator import TopologyIntegrator +from clab_connector.services.removal.topology_remover import TopologyRemover +from clab_connector.utils.logging_config import setup_logging +from clab_connector.clients.eda.client import EDAClient # Disable urllib3 warnings urllib3.disable_warnings() @@ -27,22 +32,31 @@ class LogLevel(str, Enum): CRITICAL = "CRITICAL" +app = typer.Typer( + name="clab-connector", + help="Integrate or remove an existing containerlab topology with EDA (Event-Driven Automation)", + add_completion=True, +) + + def complete_json_files( ctx: typer.Context, param: typer.Option, incomplete: str ) -> List[str]: - """Provide completion for JSON files""" + """ + Complete JSON file paths for CLI autocomplete. + """ current = Path(incomplete) if incomplete else Path.cwd() - if not current.is_dir(): current = current.parent - return [str(path) for path in current.glob("*.json") if incomplete in str(path)] def complete_eda_url( ctx: typer.Context, param: typer.Option, incomplete: str ) -> List[str]: - """Provide completion for EDA URL""" + """ + Complete EDA URL for CLI autocomplete. + """ if not incomplete: return ["https://"] if not incomplete.startswith("https://"): @@ -50,34 +64,40 @@ def complete_eda_url( return [] -app = typer.Typer( - name="clab-connector", - help="Integrate an existing containerlab topology with EDA (Event-Driven Automation)", - add_completion=True, -) - - -def setup_logging(log_level: str): - """Configure logging with colored output""" - logging.basicConfig( - level=log_level, - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(rich_tracebacks=True)], +def execute_integration(args): + """ + Execute integration logic by creating the EDAClient and calling the TopologyIntegrator. + """ + eda_client = EDAClient( + hostname=args.eda_url, + username=args.eda_user, + password=args.eda_password, + verify=args.verify, + client_secret=args.client_secret, + ) + integrator = TopologyIntegrator(eda_client) + integrator.run( + topology_file=args.topology_data, + eda_url=args.eda_url, + eda_user=args.eda_user, + eda_password=args.eda_password, + verify=args.verify, ) - - logger = logging.getLogger(__name__) - logger.warning(f"Supported containerlab kinds are: {SUPPORTED_KINDS}") -def execute_command(command_class, args): - """Execute a command with common error handling""" - try: - cmd = command_class() - cmd.run(args) - except Exception as e: - rprint(f"[red]Error: {str(e)}[/red]") - raise typer.Exit(code=1) +def execute_removal(args): + """ + Execute removal logic by creating the EDAClient and calling the TopologyRemover. + """ + eda_client = EDAClient( + hostname=args.eda_url, + username=args.eda_user, + password=args.eda_password, + verify=args.verify, + client_secret=args.client_secret, + ) + remover = TopologyRemover(eda_client) + remover.run(topology_file=args.topology_data) @app.command(name="integrate", help="Integrate containerlab with EDA") @@ -105,34 +125,47 @@ def integrate_cmd( ), ], eda_user: str = typer.Option( - "admin", "--eda-user", help="The username of the EDA user" + "admin", "--eda-user", help="User to log in (realm='eda' and admin realm)" ), eda_password: str = typer.Option( - "admin", "--eda-password", help="The password of the EDA user" + "admin", "--eda-password", help="Password for EDA user" + ), + client_secret: Optional[str] = typer.Option( + None, + "--client-secret", + help="Keycloak client secret for the 'eda' client (if already known). If not specified, the secret is fetched from Keycloak using the same eda-user/eda-password in the 'master' realm.", ), log_level: LogLevel = typer.Option( LogLevel.WARNING, "--log-level", "-l", help="Set logging level" ), + log_file: Optional[str] = typer.Option( + None, "--log-file", "-f", help="Optional log file path" + ), verify: bool = typer.Option( False, "--verify", help="Enables certificate verification for EDA" ), ): - setup_logging(log_level.value) - os.environ["no_proxy"] = eda_url + """ + CLI command to integrate a containerlab topology with EDA. + """ + setup_logging(log_level.value, log_file) + logger = logging.getLogger(__name__) + logger.warning(f"Supported containerlab kinds are: {SUPPORTED_KINDS}") - args = type( - "Args", - (), - { - "topology_data": topology_data, - "eda_url": eda_url, - "eda_user": eda_user, - "eda_password": eda_password, - "verify": verify, - }, - )() + Args = type("Args", (), {}) + args = Args() + args.topology_data = topology_data + args.eda_url = eda_url + args.eda_user = eda_user + args.eda_password = eda_password + args.client_secret = client_secret + args.verify = verify - execute_command(IntegrateCommand, args) + try: + execute_integration(args) + except Exception as e: + rprint(f"[red]Error: {str(e)}[/red]") + raise typer.Exit(code=1) @app.command(name="remove", help="Remove containerlab integration from EDA") @@ -150,37 +183,49 @@ def remove_cmd( shell_complete=complete_json_files, ), ], - eda_url: str = typer.Option( - ..., "--eda-url", "-e", help="The hostname or IP of your EDA deployment" - ), + eda_url: str = typer.Option(..., "--eda-url", "-e", help="EDA deployment hostname"), eda_user: str = typer.Option( - "admin", "--eda-user", help="The username of the EDA user" + "admin", "--eda-user", help="User to log in (realm='eda' and admin realm)" ), eda_password: str = typer.Option( - "admin", "--eda-password", help="The password of the EDA user" + "admin", "--eda-password", help="Password for EDA user" + ), + client_secret: Optional[str] = typer.Option( + None, + "--client-secret", + help="Keycloak client secret for 'eda' client (if already known). If not specified, the secret is fetched from Keycloak using the same eda-user/eda-password in the 'master' realm.", ), log_level: LogLevel = typer.Option( LogLevel.WARNING, "--log-level", "-l", help="Set logging level" ), - verify: bool = typer.Option( - False, "--verify", help="Enables certificate verification for EDA" + log_file: Optional[str] = typer.Option( + None, + "--log-file", + "-f", + help="Optional log file path", ), + verify: bool = typer.Option(False, "--verify", help="Verify EDA certs"), ): - setup_logging(log_level.value) - - args = type( - "Args", - (), - { - "topology_data": topology_data, - "eda_url": eda_url, - "eda_user": eda_user, - "eda_password": eda_password, - "verify": verify, - }, - )() - - execute_command(RemoveCommand, args) + """ + CLI command to remove an existing containerlab-EDA integration (delete the namespace). + """ + setup_logging(log_level.value, log_file) + logger = logging.getLogger(__name__) + + Args = type("Args", (), {}) + args = Args() + args.topology_data = topology_data + args.eda_url = eda_url + args.eda_user = eda_user + args.eda_password = eda_password + args.client_secret = client_secret + args.verify = verify + + try: + execute_removal(args) + except Exception as e: + rprint(f"[red]Error: {str(e)}[/red]") + raise typer.Exit(code=1) if __name__ == "__main__": diff --git a/clab_connector/clients/eda/__init__.py b/clab_connector/clients/eda/__init__.py new file mode 100644 index 0000000..2b30bf6 --- /dev/null +++ b/clab_connector/clients/eda/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/clients/eda/__init__.py + +"""EDA client package (REST API interactions).""" diff --git a/clab_connector/clients/eda/client.py b/clab_connector/clients/eda/client.py new file mode 100644 index 0000000..68abbca --- /dev/null +++ b/clab_connector/clients/eda/client.py @@ -0,0 +1,378 @@ +# clab_connector/clients/eda/client.py + +""" +This module provides the EDAClient class for communicating with the EDA REST API. +Starting with EDA v24.12.1, authentication is handled via Keycloak. + +We support two flows: +1. If client_secret is known (user passes --client-secret), we do resource-owner + password flow directly in realm='eda'. +2. If client_secret is unknown, we do an admin login in realm='master' using + the same eda_user/eda_password (assuming they are Keycloak admin credentials), + retrieve the 'eda' client secret, then proceed with resource-owner flow. +""" + +import json +import logging +import yaml +from urllib.parse import urlencode + +from clab_connector.clients.eda.http_client import create_pool_manager +from clab_connector.utils.exceptions import EDAConnectionError + +logger = logging.getLogger(__name__) + + +class EDAClient: + """ + EDAClient communicates with the EDA REST API via Keycloak flows. + + Parameters + ---------- + hostname : str + The base URL for EDA, e.g. "https://my-eda.example". + username : str + EDA user (also used as Keycloak admin if secret is unknown). + password : str + Password for the above user (also used as Keycloak admin if secret unknown). + verify : bool + Whether to verify SSL certificates. + client_secret : str, optional + Known Keycloak client secret for 'eda'. If not provided, we do the admin + realm flow to retrieve it using username/password. + """ + + KEYCLOAK_ADMIN_REALM = "master" + KEYCLOAK_ADMIN_CLIENT_ID = "admin-cli" + EDA_REALM = "eda" + EDA_API_CLIENT_ID = "eda" + + CORE_GROUP = "core.eda.nokia.com" + CORE_VERSION = "v1" + + def __init__( + self, + hostname: str, + username: str, + password: str, + verify: bool = True, + client_secret: str = None, + ): + self.url = hostname.rstrip("/") + self.username = username + self.password = password + self.verify = verify + self.client_secret = client_secret + + self.access_token = None + self.refresh_token = None + self.version = None + self.transactions = [] + + self.http = create_pool_manager(url=self.url, verify=self.verify) + + def login(self): + """ + Acquire an access token via Keycloak resource-owner flow in realm='eda'. + If client_secret is unknown, fetch it using admin credentials in realm='master'. + """ + if not self.client_secret: + logger.info("No client_secret provided; fetching via Keycloak admin API...") + self.client_secret = self._fetch_client_secret_via_admin() + logger.info("Successfully retrieved client_secret from Keycloak.") + + logger.info("Acquiring user access token via Keycloak resource-owner flow...") + self.access_token = self._fetch_user_token(self.client_secret) + if not self.access_token: + raise EDAConnectionError("Could not retrieve an access token for EDA.") + + logger.info("Keycloak-based login successful.") + + def _fetch_client_secret_via_admin(self) -> str: + """ + Use the same username/password as Keycloak admin in realm='master'. + Then retrieve the client secret for the 'eda' client in realm='eda'. + + Returns + ------- + str + The client_secret for 'eda'. + + Raises + ------ + EDAConnectionError + If we fail to fetch an admin token or the 'eda' client secret. + """ + admin_token = self._fetch_admin_token() + if not admin_token: + raise EDAConnectionError("Failed to fetch Keycloak admin token.") + + # List clients in the "eda" realm + admin_api_url = ( + f"{self.url}/core/httpproxy/v1/keycloak/" + f"admin/realms/{self.EDA_REALM}/clients" + ) + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + + resp = self.http.request("GET", admin_api_url, headers=headers) + if resp.status != 200: + raise EDAConnectionError( + f"Failed to list clients in realm '{self.EDA_REALM}': {resp.data.decode()}" + ) + + clients = json.loads(resp.data.decode("utf-8")) + eda_client = next( + (c for c in clients if c.get("clientId") == self.EDA_API_CLIENT_ID), None + ) + if not eda_client: + raise EDAConnectionError("Client 'eda' not found in realm 'eda'") + + # Get the client secret + client_id = eda_client["id"] + secret_url = f"{admin_api_url}/{client_id}/client-secret" + secret_resp = self.http.request("GET", secret_url, headers=headers) + if secret_resp.status != 200: + raise EDAConnectionError( + f"Failed to fetch 'eda' client secret: {secret_resp.data.decode()}" + ) + + return json.loads(secret_resp.data.decode("utf-8"))["value"] + + def _fetch_admin_token(self) -> str: + """ + Fetch an admin token from the 'master' realm using self.username/password. + """ + token_url = ( + f"{self.url}/core/httpproxy/v1/keycloak/" + f"realms/{self.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" + ) + form_data = { + "grant_type": "password", + "client_id": self.KEYCLOAK_ADMIN_CLIENT_ID, + "username": self.username, + "password": self.password, + } + encoded_data = urlencode(form_data).encode("utf-8") + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) + if resp.status != 200: + raise EDAConnectionError( + f"Failed Keycloak admin login: {resp.data.decode()}" + ) + + token_json = json.loads(resp.data.decode("utf-8")) + return token_json.get("access_token") + + def _fetch_user_token(self, client_secret: str) -> str: + """ + Resource-owner password flow in the 'eda' realm using self.username/password. + """ + token_url = ( + f"{self.url}/core/httpproxy/v1/keycloak/" + f"realms/{self.EDA_REALM}/protocol/openid-connect/token" + ) + form_data = { + "grant_type": "password", + "client_id": self.EDA_API_CLIENT_ID, + "client_secret": client_secret, + "scope": "openid", + "username": self.username, + "password": self.password, + } + encoded_data = urlencode(form_data).encode("utf-8") + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) + if resp.status != 200: + raise EDAConnectionError(f"Failed user token request: {resp.data.decode()}") + + token_json = json.loads(resp.data.decode("utf-8")) + return token_json.get("access_token") + + def get_headers(self, requires_auth: bool = True) -> dict: + """ + Construct HTTP headers, adding Bearer auth if requires_auth=True. + """ + headers = {} + if requires_auth: + if not self.access_token: + logger.debug("No access_token found; performing Keycloak login...") + self.login() + headers["Authorization"] = f"Bearer {self.access_token}" + return headers + + def get(self, api_path: str, requires_auth: bool = True): + """ + Perform an HTTP GET request against the EDA API. + """ + url = f"{self.url}/{api_path}" + logger.info(f"GET {url}") + return self.http.request("GET", url, headers=self.get_headers(requires_auth)) + + def post(self, api_path: str, payload: dict, requires_auth: bool = True): + """ + Perform an HTTP POST request with a JSON body to the EDA API. + """ + url = f"{self.url}/{api_path}" + logger.info(f"POST {url}") + body = json.dumps(payload).encode("utf-8") + return self.http.request( + "POST", url, headers=self.get_headers(requires_auth), body=body + ) + + def is_up(self) -> bool: + """ + Check if EDA is healthy by calling /core/about/health. + """ + logger.info("Checking EDA health") + resp = self.get("core/about/health", requires_auth=False) + if resp.status != 200: + return False + + data = json.loads(resp.data.decode("utf-8")) + return data.get("status") == "UP" + + def get_version(self) -> str: + """ + Retrieve and cache the EDA version from /core/about/version. + """ + if self.version is not None: + return self.version + + logger.info("Retrieving EDA version") + resp = self.get("core/about/version") + if resp.status != 200: + raise EDAConnectionError(f"Version check failed: {resp.data.decode()}") + + data = json.loads(resp.data.decode("utf-8")) + raw_ver = data["eda"]["version"] + self.version = raw_ver.split("-")[0] + logger.info(f"EDA version: {self.version}") + return self.version + + def is_authenticated(self) -> bool: + """ + Check if the client is authenticated by trying to get the version. + """ + try: + self.get_version() + return True + except EDAConnectionError: + return False + + def add_to_transaction(self, cr_type: str, payload: dict) -> dict: + """ + Append an operation (create/replace/delete) to the transaction list. + """ + item = {"type": {cr_type: payload}} + self.transactions.append(item) + logger.debug(f"Adding item to transaction: {json.dumps(item, indent=2)}") + return item + + def add_create_to_transaction(self, resource_yaml: str) -> dict: + """ + Add a 'create' resource to the transaction from YAML content. + """ + return self.add_to_transaction( + "create", {"value": yaml.safe_load(resource_yaml)} + ) + + def add_replace_to_transaction(self, resource_yaml: str) -> dict: + """ + Add a 'replace' resource to the transaction from YAML content. + """ + return self.add_to_transaction( + "replace", {"value": yaml.safe_load(resource_yaml)} + ) + + def add_delete_to_transaction( + self, + namespace: str, + kind: str, + name: str, + group: str = None, + version: str = None, + ): + """ + Add a 'delete' operation for a resource by namespace/kind/name. + """ + group = group or self.CORE_GROUP + version = version or self.CORE_VERSION + self.add_to_transaction( + "delete", + { + "gvk": { + "group": group, + "version": version, + "kind": kind, + }, + "name": name, + "namespace": namespace, + }, + ) + + def is_transaction_item_valid(self, item: dict) -> bool: + """ + Validate a single transaction item with /core/transaction/v1/validate. + """ + logger.info("Validating transaction item") + resp = self.post("core/transaction/v1/validate", item) + if resp.status == 204: # 204 means success + logger.info("Transaction item validation success.") + return True + + data = json.loads(resp.data.decode("utf-8")) + logger.warning(f"Validation error: {data}") + return False + + def commit_transaction( + self, + description: str, + dryrun: bool = False, + resultType: str = "normal", + retain: bool = True, + ) -> str: + """ + Commit accumulated transaction items to EDA. + """ + payload = { + "description": description, + "dryrun": dryrun, + "resultType": resultType, + "retain": retain, + "crs": self.transactions, + } + logger.info( + f"Committing transaction: {description}, {len(self.transactions)} items" + ) + resp = self.post("core/transaction/v1", payload) + if resp.status != 200: + raise EDAConnectionError( + f"Transaction request failed: {resp.data.decode()}" + ) + + data = json.loads(resp.data.decode("utf-8")) + tx_id = data.get("id") + if not tx_id: + raise EDAConnectionError(f"No transaction ID in response: {data}") + + logger.info(f"Waiting for transaction {tx_id} to complete...") + details_path = f"core/transaction/v1/details/{tx_id}?waitForComplete=true&failOnErrors=true" + details_resp = self.get(details_path) + if details_resp.status != 200: + raise EDAConnectionError( + f"Transaction detail request failed: {details_resp.data.decode()}" + ) + + details = json.loads(details_resp.data.decode("utf-8")) + if "code" in details: + logger.error(f"Transaction commit failed: {details}") + raise EDAConnectionError(f"Transaction commit failed: {details}") + + logger.info("Commit successful.") + self.transactions = [] + return tx_id diff --git a/clab_connector/clients/eda/http_client.py b/clab_connector/clients/eda/http_client.py new file mode 100644 index 0000000..51b0e61 --- /dev/null +++ b/clab_connector/clients/eda/http_client.py @@ -0,0 +1,115 @@ +# clab_connector/clients/eda/http_client.py + +import logging +import os +import re +import urllib3 +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +def get_proxy_settings(): + """ + Read proxy environment variables. + + Returns + ------- + tuple + (http_proxy, https_proxy, no_proxy). + """ + http_upper = os.environ.get("HTTP_PROXY") + http_lower = os.environ.get("http_proxy") + https_upper = os.environ.get("HTTPS_PROXY") + https_lower = os.environ.get("https_proxy") + no_upper = os.environ.get("NO_PROXY") + no_lower = os.environ.get("no_proxy") + + if http_upper and http_lower and http_upper != http_lower: + logger.warning("Both HTTP_PROXY and http_proxy are set. Using HTTP_PROXY.") + if https_upper and https_lower and https_upper != https_lower: + logger.warning("Both HTTPS_PROXY and https_proxy are set. Using HTTPS_PROXY.") + if no_upper and no_lower and no_upper != no_lower: + logger.warning("Both NO_PROXY and no_proxy are set. Using NO_PROXY.") + + http_proxy = http_upper if http_upper else http_lower + https_proxy = https_upper if https_upper else https_lower + no_proxy = no_upper if no_upper else no_lower or "" + return http_proxy, https_proxy, no_proxy + + +def should_bypass_proxy(url, no_proxy=None): + """ + Check if a URL should bypass proxy based on NO_PROXY settings. + + Parameters + ---------- + url : str + The URL to check. + no_proxy : str, optional + NO_PROXY environment variable content. + + Returns + ------- + bool + True if the URL is matched by no_proxy patterns, False otherwise. + """ + if no_proxy is None: + _, _, no_proxy = get_proxy_settings() + if not no_proxy: + return False + + parsed_url = urlparse(url if "//" in url else f"http://{url}") + hostname = parsed_url.hostname + if not hostname: + return False + + no_proxy_parts = [p.strip() for p in no_proxy.split(",") if p.strip()] + + for np_val in no_proxy_parts: + if np_val.startswith("."): + np_val = np_val[1:] + # Convert wildcard to regex + pattern = re.escape(np_val).replace(r"\*", ".*") + if re.match(f"^{pattern}$", hostname, re.IGNORECASE): + return True + + return False + + +def create_pool_manager(url=None, verify=True): + """ + Create an appropriate urllib3 PoolManager or ProxyManager for the given URL. + + Parameters + ---------- + url : str, optional + The base URL used to decide if proxy should be bypassed. + verify : bool + Whether to enforce certificate validation. + + Returns + ------- + urllib3.PoolManager or urllib3.ProxyManager + The configured HTTP client manager. + """ + http_proxy, https_proxy, no_proxy = get_proxy_settings() + if url and should_bypass_proxy(url, no_proxy): + logger.debug(f"URL {url} in NO_PROXY, returning direct PoolManager.") + return urllib3.PoolManager( + cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", + retries=urllib3.Retry(3), + ) + proxy_url = https_proxy or http_proxy + if proxy_url: + logger.debug(f"Using ProxyManager: {proxy_url}") + return urllib3.ProxyManager( + proxy_url, + cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", + retries=urllib3.Retry(3), + ) + logger.debug("No proxy, returning direct PoolManager.") + return urllib3.PoolManager( + cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", + retries=urllib3.Retry(3), + ) diff --git a/clab_connector/clients/kubernetes/__init__.py b/clab_connector/clients/kubernetes/__init__.py new file mode 100644 index 0000000..ed6f9fa --- /dev/null +++ b/clab_connector/clients/kubernetes/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/clients/kubernetes/__init__.py + +"""Kubernetes client package (kubectl interactions, etc.).""" diff --git a/clab_connector/clients/kubernetes/client.py b/clab_connector/clients/kubernetes/client.py new file mode 100644 index 0000000..cfec5e5 --- /dev/null +++ b/clab_connector/clients/kubernetes/client.py @@ -0,0 +1,345 @@ +# clab_connector/clients/kubernetes/client.py + +import logging +import re +import time +import yaml +from typing import Optional + +from kubernetes import client, config +from kubernetes.client.rest import ApiException +from kubernetes.stream import stream +from kubernetes.utils import create_from_yaml + +logger = logging.getLogger(__name__) + +# Attempt to load config: +# 1) If in a Kubernetes pod, load in-cluster config +# 2) Otherwise load local kube config +try: + config.load_incluster_config() + logger.debug("Using in-cluster Kubernetes config.") +except Exception: + config.load_kube_config() + logger.debug("Using local kubeconfig.") + + +def get_toolbox_pod() -> str: + """ + Retrieves the name of the toolbox pod in the eda-system namespace, + identified by labelSelector: eda.nokia.com/app=eda-toolbox. + + Returns + ------- + str + The name of the first matching toolbox pod. + + Raises + ------ + RuntimeError + If no toolbox pod is found. + """ + v1 = client.CoreV1Api() + label_selector = "eda.nokia.com/app=eda-toolbox" + pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) + if not pods.items: + raise RuntimeError("No toolbox pod found in 'eda-system' namespace.") + return pods.items[0].metadata.name + + +def get_bsvr_pod() -> str: + """ + Retrieves the name of the bootstrapserver (bsvr) pod in eda-system, + identified by labelSelector: eda.nokia.com/app=bootstrapserver. + + Returns + ------- + str + The name of the first matching bsvr pod. + + Raises + ------ + RuntimeError + If no bsvr pod is found. + """ + v1 = client.CoreV1Api() + label_selector = "eda.nokia.com/app=bootstrapserver" + pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) + if not pods.items: + raise RuntimeError("No bsvr pod found in 'eda-system' namespace.") + return pods.items[0].metadata.name + + +def ping_from_bsvr(target_ip: str) -> bool: + """ + Ping a target IP from the bsvr pod. + + Parameters + ---------- + target_ip : str + IP address to ping. + + Returns + ------- + bool + True if ping indicates success, False otherwise. + """ + logger.debug(f"Pinging '{target_ip}' from the bsvr pod...") + bsvr_name = get_bsvr_pod() + core_api = client.CoreV1Api() + command = ["ping", "-c", "1", target_ip] + try: + resp = stream( + core_api.connect_get_namespaced_pod_exec, + name=bsvr_name, + namespace="eda-system", + command=command, + stderr=True, + stdin=False, + stdout=True, + tty=False, + ) + # A quick check for "1 packets transmitted, 1 received" + if "1 packets transmitted, 1 received" in resp: + logger.info(f"Ping from bsvr to {target_ip} succeeded") + return True + else: + logger.error(f"Ping from bsvr to {target_ip} failed:\n{resp}") + return False + except ApiException as exc: + logger.error(f"API error during ping: {exc}") + return False + + +def apply_manifest(yaml_str: str, namespace: str = "eda-system") -> None: + """ + Apply a YAML manifest using Python's create_from_yaml(). + + Parameters + ---------- + yaml_str : str + The YAML content to apply. + namespace : str + The namespace into which to apply this resource. + + Raises + ------ + RuntimeError + If applying the manifest fails. + """ + try: + # Parse the YAML string into a dict + manifest = yaml.safe_load(yaml_str) + + # Get the API version and kind + api_version = manifest.get("apiVersion") + kind = manifest.get("kind") + + if not api_version or not kind: + raise RuntimeError("YAML manifest must specify apiVersion and kind") + + # Split API version into group and version + if "/" in api_version: + group, version = api_version.split("/") + else: + group = "" + version = api_version + + # Use CustomObjectsApi for custom resources + custom_api = client.CustomObjectsApi() + + try: + if group: + # For custom resources (like Artifact) + custom_api.create_namespaced_custom_object( + group=group, + version=version, + namespace=namespace, + plural=f"{kind.lower()}s", # Convention is to use lowercase plural + body=manifest, + ) + else: + # For core resources + v1 = client.CoreV1Api() + create_from_yaml( + k8s_client=client.ApiClient(), + yaml_file=yaml.dump(manifest), + namespace=namespace, + ) + logger.info(f"Successfully applied {kind} to namespace '{namespace}'") + except ApiException as e: + if e.status == 409: # Already exists + logger.info(f"{kind} already exists in namespace '{namespace}'") + else: + raise + + except Exception as exc: + logger.error(f"Failed to apply manifest: {exc}") + raise RuntimeError(f"Failed to apply manifest: {exc}") + + +def edactl_namespace_bootstrap(namespace: str) -> Optional[int]: + """ + Emulate `kubectl exec -- edactl namespace bootstrap ` + by streaming an exec call into the toolbox pod. + + Parameters + ---------- + namespace : str + Namespace to bootstrap in EDA. + + Returns + ------- + Optional[int] + The transaction ID if found, or None if skipping/existing. + """ + toolbox = get_toolbox_pod() + core_api = client.CoreV1Api() + cmd = ["edactl", "namespace", "bootstrap", namespace] + try: + resp = stream( + core_api.connect_get_namespaced_pod_exec, + name=toolbox, + namespace="eda-system", + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + ) + if "already exists" in resp: + logger.info(f"Namespace {namespace} already exists, skipping bootstrap.") + return None + + match = re.search(r"Transaction (\d+)", resp) + if match: + tx_id = int(match.group(1)) + logger.info(f"Created namespace {namespace} (Transaction: {tx_id})") + return tx_id + + logger.info(f"Created namespace {namespace}, no transaction ID found.") + return None + except ApiException as exc: + logger.error(f"Failed to bootstrap namespace {namespace}: {exc}") + raise + + +def wait_for_namespace( + namespace: str, max_retries: int = 10, retry_delay: int = 1 +) -> bool: + """ + Wait for a namespace to exist in Kubernetes. + + Parameters + ---------- + namespace : str + Namespace to wait for. + max_retries : int + Maximum number of attempts. + retry_delay : int + Delay (seconds) between attempts. + + Returns + ------- + bool + True if the namespace is found, else raises. + + Raises + ------ + RuntimeError + If the namespace is not found within the given attempts. + """ + v1 = client.CoreV1Api() + for attempt in range(max_retries): + try: + v1.read_namespace(name=namespace) + logger.info(f"Namespace {namespace} is available") + return True + except ApiException as exc: + if exc.status == 404: + logger.debug( + f"Waiting for namespace '{namespace}' (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_delay) + else: + logger.error(f"Error retrieving namespace {namespace}: {exc}") + raise + raise RuntimeError(f"Timed out waiting for namespace {namespace}") + + +def update_namespace_description(namespace: str, description: str) -> None: + """ + Patch a namespace's description. For EDA, this may be a custom CRD + (group=core.eda.nokia.com, version=v1, plural=namespaces). + + Parameters + ---------- + namespace : str + The namespace to patch. + description : str + The new description. + + Raises + ------ + ApiException + If the patch fails. + """ + crd_api = client.CustomObjectsApi() + group = "core.eda.nokia.com" + version = "v1" + plural = "namespaces" + + patch_body = {"spec": {"description": description}} + + try: + resp = crd_api.patch_namespaced_custom_object( + group=group, + version=version, + namespace="eda-system", # If it's a cluster-scoped CRD, use patch_cluster_custom_object + plural=plural, + name=namespace, + body=patch_body, + ) + logger.info(f"Namespace '{namespace}' patched with description. resp={resp}") + except ApiException as exc: + logger.error(f"Failed to patch namespace '{namespace}': {exc}") + raise + + +def edactl_revert_commit(commit_hash: str) -> bool: + """ + Revert an EDA commit by running `edactl git revert ` in the toolbox pod. + + Parameters + ---------- + commit_hash : str + The commit hash to revert. + + Returns + ------- + bool + True if revert is successful, False otherwise. + """ + toolbox = get_toolbox_pod() + core_api = client.CoreV1Api() + cmd = ["edactl", "git", "revert", commit_hash] + try: + resp = stream( + core_api.connect_get_namespaced_pod_exec, + name=toolbox, + namespace="eda-system", + command=cmd, + stderr=True, + stdin=False, + stdout=True, + tty=False, + ) + if "Successfully reverted commit" in resp: + logger.info(f"Successfully reverted commit {commit_hash}") + return True + else: + logger.error(f"Failed to revert commit {commit_hash}: {resp}") + return False + except ApiException as exc: + logger.error(f"Failed to revert commit {commit_hash}: {exc}") + return False diff --git a/clab_connector/core/eda.py b/clab_connector/core/eda.py deleted file mode 100644 index 2e69632..0000000 --- a/clab_connector/core/eda.py +++ /dev/null @@ -1,425 +0,0 @@ -import json -import logging -import yaml - -from clab_connector.core.http_client import create_pool_manager - -# configure logging -logger = logging.getLogger(__name__) - - -class EDA: - CORE_GROUP = "core.eda.nokia.com" - CORE_VERSION = "v1" - INTERFACE_GROUP = "interfaces.eda.nokia.com" - INTERFACE_VERSION = "v1alpha1" - - def __init__(self, hostname, username, password, verify): - """ - Constructor - - Parameters - ---------- - hostname: EDA hostname (IP or FQDN) - username: EDA user name - password: EDA user password - verify: Whether to verify the certificate when communicating with EDA - """ - self.url = f"{hostname}" - self.username = username - self.password = password - self.verify = verify - self.access_token = None - self.refresh_token = None - self.version = None - self.transactions = [] - - self.http = create_pool_manager(url=self.url, verify=self.verify) - - def login(self): - """ - Retrieves an access_token and refresh_token from the EDA API - """ - payload = {"username": self.username, "password": self.password} - - response = self.post("auth/login", payload, False) - response_data = json.loads(response.data.decode("utf-8")) - - if "code" in response_data and response_data["code"] != 200: - raise Exception( - f"Could not authenticate with EDA, error message: '{response_data['message']} {response_data['details']}'" - ) - - self.access_token = response_data["access_token"] - self.refresh_token = response_data["refresh_token"] - - def get_headers(self, requires_auth): - """ - Configures the right headers for an HTTP request - - Parameters - ---------- - requires_auth: Whether the request requires authorization - - Returns - ------- - A header dictionary - """ - headers = {} - if requires_auth: - if self.access_token is None: - logger.info("No access_token found, authenticating...") - self.login() - - headers["Authorization"] = f"Bearer {self.access_token}" - - return headers - - def get(self, api_path, requires_auth=True): - """ - Performs an HTTP GET request, taking the right proxy settings into account - - Parameters - ---------- - api_path: path to be appended to the base EDA hostname - requires_auth: Whether this request requires authentication - - Returns - ------- - The HTTP response - """ - url = f"{self.url}/{api_path}" - logger.info(f"Performing GET request to '{url}'") - - return self.http.request("GET", url, headers=self.get_headers(requires_auth)) - - def post(self, api_path, payload, requires_auth=True): - """ - Performs an HTTP POST request, taking the right proxy settings into account - - Parameters - ---------- - api_path: path to be appended to the base EDA hostname - payload: JSON data for the request - requires_auth: Whether this request requires authentication - - Returns - ------- - The HTTP response - """ - url = f"{self.url}/{api_path}" - logger.info(f"Performing POST request to '{url}'") - return self.http.request( - "POST", - url, - headers=self.get_headers(requires_auth), - body=json.dumps(payload).encode("utf-8"), - ) - - def is_up(self): - """ - Gets the health of EDA - - Returns - ------- - True if EDA status is "UP", False otherwise - """ - logger.info("Checking whether EDA is up") - health = self.get("core/about/health", requires_auth=False) - health_data = json.loads(health.data.decode("utf-8")) - logger.debug(health_data) - return health_data["status"] == "UP" - - def get_version(self): - """ - Retrieves the EDA version number - """ - # caching this, as it might get called a lot when backwards compatibility - # starts becoming a point of focus - if self.version is not None: - return self.version - - logger.info("Getting EDA version") - version_response = self.get("core/about/version") - version_data = json.loads(version_response.data.decode("utf-8")) - version = version_data["eda"]["version"].split("-")[0] - logger.info(f"EDA version is {version}") - - # storing this to make the tool backwards compatible - self.version = version - return version - - def is_authenticated(self): - """ - Retrieves the version number of EDA to see if we can authenticate correctly - - Returns - ------- - True if we can authenticate in EDA, False otherwise - """ - logger.info("Checking whether we can authenticate with EDA") - self.get_version() - # if the previous method did not raise an exception, authentication was successful - return True - - def add_to_transaction(self, cr_type, payload): - """ - Adds a transaction to the basket - - Parameters - ---------- - type: action type, possible values: ['create', 'delete', 'replace', 'modify'] - payload: the operation's payload - - Returns - ------- - The newly added transaction item - """ - - item = {"type": {cr_type: payload}} - - self.transactions.append(item) - logger.debug(f"Adding item to transaction: {json.dumps(item, indent=4)}") - - return item - - def add_create_to_transaction(self, resource): - """ - Adds a 'create' operation to the transaction - - Parameters - ---------- - resource: the resource to be created - - Returns - ------- - The created item - """ - return self.add_to_transaction("create", {"value": yaml.safe_load(resource)}) - - def add_replace_to_transaction(self, resource): - """ - Adds a 'replace' operation to the transaction - - Parameters - ---------- - resource: the resource to be replaced - - Returns - ------- - The replaced item - """ - return self.add_to_transaction("replace", {"value": yaml.safe_load(resource)}) - - def add_delete_to_transaction( - self, namespace, kind, name, group=CORE_GROUP, version=CORE_VERSION - ): - """ - Adds a 'delete' operation to the transaction - - Parameters - ---------- - namespace: the namespace of the resource to be deleted - kind: the kind of the resource to be deleted - group: the group of the resource to be deleted - version: the version of the resource to be deleted - - Returns - ------- - The created item - """ - self.add_to_transaction( - "delete", - { - "gvk": { # Group, Version, Kind - "group": group, - "version": version, - "kind": kind, - }, - "name": name, - "namespace": namespace, - }, - ) - - def is_transaction_item_valid(self, item): - """ - Validates a transaction item - - Parameters - ---------- - item: the item to be validated - - Returns - ------- - True if the transaction is valid, False otherwise - """ - logger.info("Validating transaction item") - - response = self.post("core/transaction/v1/validate", item) - if response.status == 204: - logger.info("Validation successful") - return True - - response_data = json.loads( - response.data.decode("utf-8") - ) # Need to decode response data - - if "code" in response_data: - message = f"{response_data['message']}" - if "details" in response_data: - message = f"{message} - {response_data['details']}" - logger.warning( - f"While validating a transaction item, the following validation error was returned (code {response_data['code']}): '{message}'" - ) - - return False - - def commit_transaction( - self, description, dryrun=False, resultType="normal", retain=True - ): - """ - Commits the transaction to EDA, and waits for the transaction to complete - - Parameters - ---------- - description: Description provided for this transaction - dryrun: Whether this commit should be treated as a dryrun - resultType: Don't know yet what this does - retain: Don't know yet what this does - """ - - payload = { - "description": description, - "dryrun": dryrun, - "resultType": resultType, - "retain": retain, - "crs": self.transactions, - } - - logger.info(f"Committing transaction with {len(self.transactions)} item(s)") - logger.debug(json.dumps(payload, indent=4)) - - response = self.post("core/transaction/v1", payload) - response_data = json.loads(response.data.decode("utf-8")) - if "id" not in response_data: - raise Exception( - f"Could not find transaction ID in response {response_data}" - ) - - transactionId = response_data["id"] - - logger.info(f"Waiting for transaction with ID {transactionId} to complete") - result = json.loads( - self.get( - f"core/transaction/v1/details/{transactionId}?waitForComplete=true&failOnErrors=true" - ).data.decode("utf-8") - ) - - if "code" in result: - message = f"{result['message']}" - - if "details" in message: - message = f"{message} - {result['details']}" - - errors = [] - if "errors" in result: - errors = [ - f"{x['error']['message']} {x['error']['details']}" - for x in result["errors"] - ] - - logger.error( - f"Committing transaction failed (error code {result['code']}). Error message: '{message} {errors}'" - ) - raise Exception("Failed to commit - see error above") - - logger.info("Commit successful") - self.transactions = [] - return transactionId - - def revert_transaction(self, transactionId): - """ - Reverts a transaction in EDA - - Parameters - ---------- - transactionId: ID of the transaction to revert - - Returns - ------- - True if revert was successful, raises exception otherwise - """ - logger.info(f"Reverting transaction with ID {transactionId}") - - # First wait for the transaction details to ensure it's committed - self.get( - f"core/transaction/v1/details/{transactionId}?waitForComplete=true" - ).json() - - response = self.post(f"core/transaction/v1/revert/{transactionId}", {}) - result = json.loads(response.data.decode("utf-8")) - - if "code" in result and result["code"] != 0: - message = f"{result['message']}" - - if "details" in result: - message = f"{message} - {result['details']}" - - errors = [] - if "errors" in result: - errors = [ - f"{x['error']['message']} {x['error']['details']}" - for x in result["errors"] - ] - - logger.error( - f"Reverting transaction failed (error code {result['code']}). Error message: '{message} {errors}'" - ) - raise Exception("Failed to revert transaction - see error above") - - logger.info("Transaction revert successful") - return True - - def restore_transaction(self, transactionId): - """ - Restores to a specific transaction ID in EDA - - Parameters - ---------- - transactionId: ID of the transaction to restore to (will restore to transactionId - 1) - - Returns - ------- - True if restore was successful, raises exception otherwise - """ - restore_point = int(transactionId) - logger.info(f"Restoring to transaction ID {restore_point}") - - # First wait for the transaction details to ensure it's committed - self.get( - f"core/transaction/v1/details/{transactionId}?waitForComplete=true" - ).json() - - response = self.post(f"core/transaction/v1/restore/{restore_point}", {}) - result = json.loads(response.data.decode("utf-8")) - - if "code" in result and result["code"] != 0: - message = f"{result['message']}" - - if "details" in result: - message = f"{message} - {result['details']}" - - errors = [] - if "errors" in result: - errors = [ - f"{x['error']['message']} {x['error']['details']}" - for x in result["errors"] - ] - - logger.error( - f"Restoring to transaction failed (error code {result['code']}). Error message: '{message} {errors}'" - ) - raise Exception("Failed to restore transaction - see error above") - - logger.info("Transaction restore successful") - return True diff --git a/clab_connector/core/helpers.py b/clab_connector/core/helpers.py deleted file mode 100644 index a2baa04..0000000 --- a/clab_connector/core/helpers.py +++ /dev/null @@ -1,118 +0,0 @@ -import json -import logging -import os.path -import sys -from typing import TYPE_CHECKING - -from jinja2 import Environment, FileSystemLoader, select_autoescape - -if TYPE_CHECKING: - from clab_connector.core.topology import Topology - -# Get the path to the package root (clab_connector directory) -PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -TEMPLATE_DIR = os.path.join(PACKAGE_ROOT, "templates") - -# Create the Jinja2 environment with the correct template directory -template_environment = Environment( - loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape() -) - -# set up logging -logger = logging.getLogger(__name__) - - -def parse_topology(topology_file) -> "Topology": - """ - Parses a topology file from JSON - - Parameters - ---------- - topology_file: topology-data file (json format) - - Returns - ------- - A parsed Topology object - """ - from clab_connector.core.topology import ( - Topology, - ) # Import here to avoid circular import - - logger.info(f"Parsing topology file '{topology_file}'") - if not os.path.isfile(topology_file): - logger.critical(f"Topology file '{topology_file}' does not exist!") - sys.exit(1) - - try: - with open(topology_file, "r") as f: - data = json.load(f) - # Check if this is a topology-data.json file - if "type" in data and data["type"] == "clab": - # Initialize with empty values, will be populated by from_topology_data - topo = Topology( - name=data["name"], - mgmt_ipv4_subnet="", - ssh_pub_keys=data.get( - "ssh-pub-keys", [] - ), # Get SSH keys with empty list as default - nodes=[], - links=[], - ) - topo = topo.from_topology_data(data) - # Sanitize the topology name after parsing - original_name = topo.name - topo.name = topo.get_eda_safe_name() - logger.info( - f"Sanitized topology name from '{original_name}' to '{topo.name}'" - ) - return topo - # If not a topology-data.json file, error our - raise Exception("Not a valid topology data file provided") - except json.JSONDecodeError: - logger.critical( - f"File '{topology_file}' is not supported. Please provide a valid JSON topology file." - ) - sys.exit(1) - - -def render_template(template_name, data): - """ - Loads a jinja template and renders it with the data provided - - Parameters - ---------- - template_name: name of the template in the 'templates' folder - data: data to be rendered in the template - - Returns - ------- - The rendered template, as str - """ - template = template_environment.get_template(template_name) - return template.render(data) - - -def normalize_name(name: str) -> str: - """ - Returns a Kubernetes-compliant name by: - - Converting to lowercase - - Replacing underscores and spaces with hyphens - - Removing any other invalid characters - - Ensuring it starts and ends with alphanumeric characters - """ - # Convert to lowercase and replace underscores/spaces with hyphens - safe_name = name.lower().replace("_", "-").replace(" ", "-") - - # Remove any characters that aren't lowercase alphanumeric, dots or hyphens - safe_name = "".join(c for c in safe_name if c.isalnum() or c in ".-") - - # Ensure it starts and ends with alphanumeric character - safe_name = safe_name.strip(".-") - - # Handle empty string or invalid result - if not safe_name or not safe_name[0].isalnum(): - safe_name = "x" + safe_name - if not safe_name[-1].isalnum(): - safe_name = safe_name + "0" - - return safe_name diff --git a/clab_connector/core/http_client.py b/clab_connector/core/http_client.py deleted file mode 100644 index 9907471..0000000 --- a/clab_connector/core/http_client.py +++ /dev/null @@ -1,139 +0,0 @@ -import logging -import os -import re -import urllib3 -from urllib.parse import urlparse - -logger = logging.getLogger(__name__) - - -def get_proxy_settings(): - """ - Get proxy settings from environment variables. - Handles both upper and lowercase variants. - - Returns - ------- - tuple: (http_proxy, https_proxy, no_proxy) - """ - # Check both variants - http_upper = os.environ.get("HTTP_PROXY") - http_lower = os.environ.get("http_proxy") - https_upper = os.environ.get("HTTPS_PROXY") - https_lower = os.environ.get("https_proxy") - no_upper = os.environ.get("NO_PROXY") - no_lower = os.environ.get("no_proxy") - - # Log if both variants are set - if http_upper and http_lower and http_upper != http_lower: - logger.warning( - f"Both HTTP_PROXY ({http_upper}) and http_proxy ({http_lower}) are set with different values. Using HTTP_PROXY." - ) - - if https_upper and https_lower and https_upper != https_lower: - logger.warning( - f"Both HTTPS_PROXY ({https_upper}) and https_proxy ({https_lower}) are set with different values. Using HTTPS_PROXY." - ) - - if no_upper and no_lower and no_upper != no_lower: - logger.warning( - f"Both NO_PROXY ({no_upper}) and no_proxy ({no_lower}) are set with different values. Using NO_PROXY." - ) - - # Use uppercase variants if set, otherwise lowercase - http_proxy = http_upper if http_upper is not None else http_lower - https_proxy = https_upper if https_upper is not None else https_lower - no_proxy = no_upper if no_upper is not None else no_lower or "" - - return http_proxy, https_proxy, no_proxy - - -def should_bypass_proxy(url, no_proxy=None): - """ - Check if the given URL should bypass proxy based on NO_PROXY settings. - - Parameters - ---------- - url : str - The URL to check - no_proxy : str, optional - The NO_PROXY string to use. If None, gets from environment. - - Returns - ------- - bool - True if proxy should be bypassed, False otherwise - """ - if no_proxy is None: - _, _, no_proxy = get_proxy_settings() - - if not no_proxy: - return False - - parsed_url = urlparse(url if "//" in url else f"http://{url}") - hostname = parsed_url.hostname - - if not hostname: - return False - - # Split NO_PROXY into parts and clean them - no_proxy_parts = [p.strip() for p in no_proxy.split(",") if p.strip()] - - for no_proxy_value in no_proxy_parts: - # Convert .foo.com to foo.com - if no_proxy_value.startswith("."): - no_proxy_value = no_proxy_value[1:] - - # Handle IP addresses and CIDR notation - if re.match(r"^(?:\d{1,3}\.){3}\d{1,3}(?:/\d{1,2})?$", no_proxy_value): - # TODO: Implement CIDR matching if needed - if hostname == no_proxy_value: - return True - # Handle domain names with wildcards - else: - pattern = re.escape(no_proxy_value).replace(r"\*", ".*") - if re.match(f"^{pattern}$", hostname, re.IGNORECASE): - return True - - return False - - -def create_pool_manager(url=None, verify=True): - """ - Create a PoolManager or ProxyManager based on environment settings and URL - - Parameters - ---------- - url : str, optional - The URL that will be accessed with this pool manager - If provided, NO_PROXY rules will be checked - verify : bool - Whether to verify SSL certificates - - Returns - ------- - urllib3.PoolManager or urllib3.ProxyManager - """ - http_proxy, https_proxy, no_proxy = get_proxy_settings() - - # Check if this URL should bypass proxy - if url and should_bypass_proxy(url, no_proxy): - logger.debug(f"URL {url} matches NO_PROXY rules, creating direct PoolManager") - return urllib3.PoolManager( - cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", - retries=urllib3.Retry(3), - ) - - proxy_url = https_proxy or http_proxy - if proxy_url: - logger.debug(f"Creating ProxyManager with proxy URL: {proxy_url}") - return urllib3.ProxyManager( - proxy_url, - cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", - retries=urllib3.Retry(3), - ) - - logger.debug("Creating PoolManager without proxy") - return urllib3.PoolManager( - cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", retries=urllib3.Retry(3) - ) diff --git a/clab_connector/core/integrate.py b/clab_connector/core/integrate.py deleted file mode 100644 index d604e0e..0000000 --- a/clab_connector/core/integrate.py +++ /dev/null @@ -1,371 +0,0 @@ -import logging -import typer - -from clab_connector.core import helpers -from clab_connector.core.eda import EDA -from clab_connector.core.k8s_utils import ( - apply_manifest, - edactl_namespace_bootstrap, - wait_for_namespace, - update_namespace_description, -) - -logger = logging.getLogger(__name__) - - -class IntegrateCommand: - PARSER_NAME = "integrate" - PARSER_ALIASES = [PARSER_NAME, "i"] - - def run(self, args): - """ - Run the program with the arguments specified for this sub-command - - Parameters - ---------- - args: input arguments returned by the argument parser - """ - self.args = args - self.topology = helpers.parse_topology(self.args.topology_data) - self.topology.log_debug() - - # Check if there are any supported nodes - supported_nodes = [node for node in self.topology.nodes if node.kind == "srl"] - if not supported_nodes: - logger.error( - "No supported nodes (nokia_srlinux) found in the topology. Exiting." - ) - raise typer.Exit(code=1) - - self.eda = EDA( - args.eda_url, - args.eda_user, - args.eda_password, - args.verify, - ) - - print("== Running pre-checks ==") - self.prechecks() - - print("== Creating namespace ==") - self.create_namespace() - - print("== Creating artifacts ==") - self.create_artifacts() - - print("== Creating init ==") - self.create_init() - self.eda.commit_transaction( - "EDA Containerlab Connector: create init (bootstrap)" - ) - - print("== Creating node security profile ==") - self.create_node_security_profile() - - print("== Creating node users ==") - self.create_node_user_groups() - self.create_node_users() - self.eda.commit_transaction( - "EDA Containerlab Connector: create node users and groups" - ) - - print("== Creating node profiles ==") - self.create_node_profiles() - self.eda.commit_transaction("EDA Containerlab Connector: create node profiles") - - print("== Onboarding nodes ==") - self.create_toponodes() - self.eda.commit_transaction("EDA Containerlab Connector: create nodes") - - print("== Adding topolink interfaces ==") - self.create_topolink_interfaces() - self.eda.commit_transaction( - "EDA Containerlab Connector: create topolink interfaces" - ) - - print("== Creating topolinks ==") - self.create_topolinks() - self.eda.commit_transaction("EDA Containerlab Connector: create topolinks") - - print("Done!") - - def prechecks(self): - """ - Performs pre-checks to see if everything is reachable - """ - # check if the nodes are reachable - self.topology.check_connectivity() - - # check if EDA is reachable - if not self.eda.is_up(): - raise Exception("EDA status is not 'UP'") - - # check if we can authenticate with EDA - if not self.eda.is_authenticated(): - raise Exception( - "Could not authenticate to EDA with the provided credentials" - ) - - def create_artifacts(self): - """Creates artifacts needed by nodes that need them""" - logger.info("Creating artifacts for nodes that need them") - - nodes_by_artifact = {} # Track which nodes use which artifacts - - # First pass: collect all nodes and their artifacts - for node in self.topology.nodes: - if not node.needs_artifact(): - continue - - # Get artifact details - artifact_name, filename, download_url = node.get_artifact_info() - - if not artifact_name or not filename or not download_url: - logger.warning(f"Could not get artifact details for {node}. Skipping.") - continue - - if artifact_name not in nodes_by_artifact: - nodes_by_artifact[artifact_name] = { - "nodes": [], - "filename": filename, - "download_url": download_url, - "version": node.version, - } - nodes_by_artifact[artifact_name]["nodes"].append(node.name) - - # Second pass: create artifacts - for artifact_name, info in nodes_by_artifact.items(): - first_node = info["nodes"][0] - logger.info( - f"Creating YANG artifact for node: {first_node} (version {info['version']})" - ) - - # Get the YAML and create the artifact - artifact_yaml = self.topology.nodes[0].get_artifact_yaml( - artifact_name, info["filename"], info["download_url"] - ) - - if not artifact_yaml: - logger.warning( - f"Could not generate artifact YAML for {first_node}. Skipping." - ) - continue - - try: - apply_manifest(artifact_yaml, namespace="eda-system") - logger.info(f"Artifact '{artifact_name}' has been created.") - # Log about other nodes using this artifact - other_nodes = info["nodes"][1:] - if other_nodes: - logger.info( - f"Using same artifact for nodes: {', '.join(other_nodes)}" - ) - except RuntimeError as ex: - if "AlreadyExists" in str(ex): - logger.info( - f"Artifact '{artifact_name}' already exists for nodes: {', '.join(info['nodes'])}" - ) - else: - logger.error(f"Error creating artifact '{artifact_name}': {ex}") - - def create_namespace(self): - """ - Creates EDA namespace named after clab- using edactl namespace bootstrap command. - """ - namespace = f"clab-{self.topology.name}" - logger.info(f"Creating namespace {namespace}") - - # Create namespace using edactl - edactl_namespace_bootstrap(namespace) - - # Wait for namespace to be available - wait_for_namespace(namespace) - - # Update namespace description - description = f"Containerlab topology. Name: {self.topology.name}, Topology file: {self.topology.clab_file_path}, IPv4 subnet: {self.topology.mgmt_ipv4_subnet}" - update_namespace_description(namespace, description) - - def create_init(self): - """ - Creates EDA init. - """ - logger.info("Creating init") - data = { - "namespace": f"clab-{self.topology.name}", - } - - nsp = helpers.render_template("init.yaml.j2", data) - logger.debug(nsp) - item = self.eda.add_replace_to_transaction(nsp) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a node security profile, see warning above. Exiting..." - ) - - def create_node_security_profile(self): - """ - Creates EDA node security profile. - """ - logger.info("Creating node security profile") - data = { - "namespace": f"clab-{self.topology.name}", - } - - nsp = helpers.render_template("nodesecurityprofile.yaml.j2", data) - logger.debug(nsp) - - try: - apply_manifest(nsp, namespace=f"clab-{self.topology.name}") - logger.info("Node security profile has been created.") - except RuntimeError as ex: - if "AlreadyExists" in str(ex): - logger.info("Node security profile already exists, skipping.") - else: - raise - - def create_node_user_groups(self): - """ - Creates node user groups for the topology. - """ - logger.info("Creating node user groups") - data = { - "namespace": f"clab-{self.topology.name}", - } - - node_user_group = helpers.render_template("node-user-group.yaml.j2", data) - logger.debug(node_user_group) - item = self.eda.add_replace_to_transaction(node_user_group) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a node user group, see warning above. Exiting..." - ) - - def create_node_users(self): - """ - Creates node users for the topology. - - Currently simple changes the admin NodeUser to feature - NokiaSrl1! password instead of the default eda124! password. - """ - logger.info("Creating node users") - data = { - "namespace": f"clab-{self.topology.name}", - "node_user": "admin", - "username": "admin", - "password": "NokiaSrl1!", - "ssh_pub_keys": self.topology.ssh_pub_keys - if hasattr(self.topology, "ssh_pub_keys") - else [], - } - node_user = helpers.render_template("node-user.j2", data) - logger.debug(node_user) - item = self.eda.add_replace_to_transaction(node_user) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a node user, see warning above. Exiting..." - ) - - def create_node_profiles(self): - """ - Creates node profiles for the topology - """ - logger.info("Creating node profiles") - profiles = self.topology.get_node_profiles() - logger.info(f"Discovered {len(profiles)} distinct node profile(s)") - for profile in profiles: - logger.debug(profile) - item = self.eda.add_replace_to_transaction(profile) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a node profile, see warning above. Exiting..." - ) - - def create_toponodes(self): - """ - Creates nodes for the topology - """ - logger.info("Creating nodes") - toponodes = self.topology.get_toponodes() - for toponode in toponodes: - logger.debug(toponode) - item = self.eda.add_replace_to_transaction(toponode) - logger.debug(item) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a toponode, see warning above. Exiting..." - ) - - def create_topolink_interfaces(self): - """ - Creates the interfaces that belong to topology links - """ - logger.info("Creating topolink interfaces") - interfaces = self.topology.get_topolink_interfaces() - for interface in interfaces: - logger.debug(interface) - item = self.eda.add_replace_to_transaction(interface) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a topolink interface, see warning above. Exiting..." - ) - - def create_topolinks(self): - """ - Creates topolinks for the topology - """ - logger.info("Creating topolinks") - topolinks = self.topology.get_topolinks() - for topolink in topolinks: - logger.debug(topolink) - item = self.eda.add_replace_to_transaction(topolink) - if not self.eda.is_transaction_item_valid(item): - raise Exception( - "Validation error when trying to create a topolink, see warning above. Exiting..." - ) - - def create_parser(self, subparsers): - """ - Creates a subparser with arguments specific to this subcommand of the program - - Parameters - ---------- - subparsers: the subparsers object for the parent command - - Returns - ------- - An argparse subparser - """ - parser = subparsers.add_parser( - self.PARSER_NAME, - help="integrate containerlab with EDA", - aliases=self.PARSER_ALIASES, - ) - - parser.add_argument( - "--topology-data", - "-t", - type=str, - required=True, - help="the containerlab topology data JSON file", - ) - - parser.add_argument( - "--eda-url", - "-e", - type=str, - required=True, - help="the hostname or IP of your EDA deployment", - ) - - parser.add_argument( - "--eda-user", type=str, default="admin", help="the username of the EDA user" - ) - - parser.add_argument( - "--eda-password", - type=str, - default="admin", - help="the password of the EDA user", - ) - - return parser diff --git a/clab_connector/core/k8s_utils.py b/clab_connector/core/k8s_utils.py deleted file mode 100644 index 766937c..0000000 --- a/clab_connector/core/k8s_utils.py +++ /dev/null @@ -1,322 +0,0 @@ -import logging -import subprocess -import time -import re -import tempfile -import os -from typing import List, Optional - -logger = logging.getLogger(__name__) - - -def run_kubectl_command( - cmd: List[str], check: bool = True -) -> subprocess.CompletedProcess: - """ - Run a kubectl command and return the result - - Parameters - ---------- - cmd: List[str] - The kubectl command to run as a list of strings - check: bool - Whether to raise an exception on non-zero return code - - Returns - ------- - subprocess.CompletedProcess - The completed process result - """ - logger.debug(f"Running command: {' '.join(cmd)}") - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=check) - if result.returncode == 0: - if result.stdout.strip(): - logger.debug(f"Command output:\n{result.stdout}") - else: - logger.debug( - f"Command failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) - return result - except subprocess.CalledProcessError as e: - logger.error(f"Command failed:\nstdout={e.stdout}\nstderr={e.stderr}") - raise - - -def get_pod_by_label(label: str, namespace: str = "eda-system") -> str: - """ - Get pod name by label selector - - Parameters - ---------- - label: str - The label selector to match pods - namespace: str - The namespace to search in - - Returns - ------- - str - The name of the first matching pod - - Raises - ------ - Exception - If no pod is found or kubectl command fails - """ - cmd = ["kubectl", "get", "pods", "-n", namespace, "-l", label, "-o", "name"] - try: - result = run_kubectl_command(cmd) - pod_name = result.stdout.strip().replace("pod/", "") - if not pod_name: - raise Exception(f"Could not find pod with label {label}") - return pod_name - except subprocess.CalledProcessError as e: - raise Exception(f"Failed to get pod name: {e}") - - -def get_toolbox_pod() -> str: - """ - Gets the name of the eda-toolbox pod - - Returns - ------- - str - The name of the eda-toolbox pod - - Raises - ------ - Exception - If pod cannot be found - """ - return get_pod_by_label("eda.nokia.com/app=eda-toolbox") - - -def get_bsvr_pod() -> str: - """ - Gets the name of the eda-bsvr pod - - Returns - ------- - str - The name of the eda-bsvr pod - - Raises - ------ - Exception - If pod cannot be found - """ - return get_pod_by_label("eda.nokia.com/app=bootstrapserver") - - -def exec_in_pod( - pod_name: str, namespace: str, command: List[str], check: bool = True -) -> subprocess.CompletedProcess: - """ - Execute a command in a pod - - Parameters - ---------- - pod_name: str - Name of the pod to execute in - namespace: str - Namespace of the pod - command: List[str] - Command to execute as list of strings - check: bool - Whether to raise an exception on non-zero return code - - Returns - ------- - subprocess.CompletedProcess - The completed process result - """ - cmd = ["kubectl", "exec", "-n", namespace, pod_name, "--"] + command - return run_kubectl_command(cmd, check=check) - - -def apply_manifest(yaml_str: str, namespace: str = "eda-system") -> None: - """ - Applies a kubernetes manifest - - Parameters - ---------- - yaml_str: str - The YAML manifest to apply - namespace: str - The namespace to apply the manifest to - - Raises - ------ - RuntimeError - If manifest application fails - """ - fd, tmp_path = tempfile.mkstemp(suffix=".yaml") - try: - with os.fdopen(fd, "w") as f: - f.write(yaml_str) - cmd = ["kubectl", "apply", "-n", namespace, "-f", tmp_path] - run_kubectl_command(cmd) - except Exception as e: - raise RuntimeError(f"Failed to apply manifest: {e}") - finally: - os.remove(tmp_path) - - -def edactl_namespace_bootstrap(namespace: str) -> Optional[int]: - """ - Execute edactl namespace bootstrap command - - Parameters - ---------- - namespace: str - The namespace to bootstrap - - Returns - ------- - Optional[int] - The transaction ID if found, None otherwise - """ - toolbox_pod = get_toolbox_pod() - result = exec_in_pod( - toolbox_pod, - "eda-system", - ["edactl", "namespace", "bootstrap", namespace], - check=False, # Don't raise exception on failure - ) - - if result.returncode != 0: - if "already exists" in result.stderr: - logger.info(f"Namespace {namespace} already exists") - return None - else: - # Log other errors and raise exception - logger.error(f"Failed to bootstrap namespace: {result.stderr}") - raise subprocess.CalledProcessError( - result.returncode, result.args, result.stdout, result.stderr - ) - - match = re.search(r"Transaction (\d+)", result.stdout) - if match: - transaction_id = int(match.group(1)) - logger.info(f"Created namespace {namespace} (Transaction: {transaction_id})") - return transaction_id - - logger.info(f"Created namespace {namespace}") - return None - - -def wait_for_namespace( - namespace: str, max_retries: int = 10, retry_delay: int = 1 -) -> bool: - """ - Wait for namespace to be available - - Parameters - ---------- - namespace: str - The namespace to wait for - max_retries: int - Maximum number of retry attempts - retry_delay: int - Delay between retries in seconds - - Returns - ------- - bool - True if namespace becomes available - - Raises - ------ - Exception - If namespace does not become available within max_retries - """ - for i in range(max_retries): - cmd = ["kubectl", "get", "namespace", namespace] - result = run_kubectl_command(cmd, check=False) - if result.returncode == 0: - logger.info(f"Namespace {namespace} is available") - return True - logger.debug(f"Waiting for namespace (attempt {i + 1}/{max_retries})") - time.sleep(retry_delay) - raise Exception(f"Timed out waiting for namespace {namespace}") - - -def update_namespace_description(namespace: str, description: str) -> None: - """ - Update namespace description using kubectl patch - - Parameters - ---------- - namespace: str - The namespace to update - description: str - The new description to set - """ - patch_data = f'{{"spec":{{"description":"{description}"}}}}' - cmd = [ - "kubectl", - "patch", - "namespace.core.eda.nokia.com", - namespace, - "-n", - "eda-system", - "--type=merge", - "-p", - patch_data, - ] - run_kubectl_command(cmd) - - -def ping_from_bsvr(target_ip: str) -> bool: - """ - Ping a target from the bootstrap server pod - - Parameters - ---------- - target_ip: str - The IP address to ping - - Returns - ------- - bool - True if ping succeeds, False otherwise - """ - bsvr_pod = get_bsvr_pod() - result = exec_in_pod( - bsvr_pod, "eda-system", ["ping", "-c", "1", target_ip], check=False - ) - success = result.returncode == 0 - if success: - logger.info(f"Ping from {bsvr_pod} to {target_ip} succeeded") - else: - logger.error(f"Ping from {bsvr_pod} to {target_ip} failed") - return success - - -def edactl_revert_commit(commit_hash: str) -> bool: - """ - Reverts to a specific commit hash - - Parameters - ---------- - commit_hash: str - The commit hash to revert to - - Returns - ------- - bool - True if revert succeeds, False otherwise - """ - toolbox_pod = get_toolbox_pod() - result = exec_in_pod( - toolbox_pod, "eda-system", ["edactl", "git", "revert", commit_hash] - ) - success = ( - "error" not in result.stdout.lower() and "error" not in result.stderr.lower() - ) - if success: - logger.info(f"Successfully reverted commit {commit_hash}") - else: - logger.error(f"Failed to revert commit {commit_hash}") - return success diff --git a/clab_connector/core/link.py b/clab_connector/core/link.py deleted file mode 100644 index 86e2cf4..0000000 --- a/clab_connector/core/link.py +++ /dev/null @@ -1,152 +0,0 @@ -import logging - -from clab_connector.core import helpers - -# set up logging -logger = logging.getLogger(__name__) - - -class Link: - def __init__(self, node_1, interface_1, node_2, interface_2): - self.node_1 = node_1 - self.interface_1 = interface_1 - self.node_2 = node_2 - self.interface_2 = interface_2 - - def __repr__(self): - return ( - f"Link({self.node_1}-{self.interface_1}, {self.node_2}-{self.interface_2})" - ) - - def get_link_name(self, topology): - """ - Returns an eda-safe name for the link - """ - return f"{self.node_1.get_node_name(topology)}-{self.interface_1}-{self.node_2.get_node_name(topology)}-{self.interface_2}" - - def get_interface1_name(self): - """ - Returns the name for the interface name of endpoint 1, as specified in EDA - """ - return self.node_1.get_interface_name_for_kind(self.interface_1) - - def get_interface2_name(self): - """ - Returns the name for the interface name of endpoint 2, as specified in EDA - """ - return self.node_2.get_interface_name_for_kind(self.interface_2) - - def is_topolink(self): - """ - Returns True if both endpoints are supported in EDA as topology nodes, False otherwise - """ - # check that both ends of the link are supported in EDA - if self.node_1 is None or not self.node_1.is_eda_supported(): - logger.debug( - f"Link {self} is not a topolink because endpoint 1 node kind '{self.node_1.kind}' is not supported in EDA" - ) - return False - if self.node_2 is None or not self.node_2.is_eda_supported(): - logger.debug( - f"Link {self} is not a topolink because endpoint 2 node kind '{self.node_2.kind}' is not supported in EDA" - ) - return False - - return True - - def get_topolink(self, topology): - """ - Returns an EDA topolink resource that represents this link - """ - logger.info(f"Rendering topolink for {self}") - if not self.is_topolink(): - logger.warning( - f"Could not render topolink, {self} is not a topolink. Please call is_topolink() first" - ) - return None - - data = { - "namespace": f"clab-{topology.name}", - "link_role": "interSwitch", - "link_name": self.get_link_name(topology), - "local_node": self.node_1.get_node_name(topology), - "local_interface": self.get_interface1_name(), - "remote_node": self.node_2.get_node_name(topology), - "remote_interface": self.get_interface2_name(), - } - - return helpers.render_template("topolink.j2", data) - - -def from_obj(python_object, nodes): - """ - Parses a link from a python array of 2 endpoints - - Parameters - ---------- - python_object: the python object containing the endpoints from the input json file - nodes: nodes part of the topology - - Returns - ------- - The parsed Link entity - """ - logger.info(f"Parsing link with endpoints {python_object}") - if "endpoints" not in python_object: - raise Exception("The python object does not contain the key 'endpoints'") - - if len(python_object["endpoints"]) != 2: - raise Exception("The endpoint array should be an array of two objects") - - endpoint_1 = python_object["endpoints"][0] - endpoint_2 = python_object["endpoints"][1] - - (node_name_1, interface_1) = split_endpoint(endpoint_1) - (node_name_2, interface_2) = split_endpoint(endpoint_2) - - node_1 = find_node(node_name_1, nodes) - node_2 = find_node(node_name_2, nodes) - - return Link(node_1, interface_1, node_2, interface_2) - - -def split_endpoint(endpoint): - """ - Splits and endpoint into its node name, and the interface - - Parameters - ---------- - endpoint: the name of an endpoint as found in the topology file - - Returns - ------- - A tuple of (node_name, node_interface) where node_name is the name of the node, and node_interface the interface - """ - parts = endpoint.split(":") - - if len(parts) != 2: - raise Exception( - f"Endpoint '{endpoint}' does not adhere to the format '[node]:[interface]'" - ) - - return (parts[0], parts[1]) - - -def find_node(node_name, nodes): - """ - Searches through the provided nodes array for a node with name node_name - - Parameters - ---------- - node_name: the name of the node that's being looked for - nodes: the array of Node that will be searched - - Returns: - -------- - The Node if it was found, None otherwise - """ - for node in nodes: - if node.name == node_name: - return node - - return None diff --git a/clab_connector/core/node.py b/clab_connector/core/node.py deleted file mode 100644 index 3b91ff3..0000000 --- a/clab_connector/core/node.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging - -from clab_connector.core import helpers -from clab_connector.core.k8s_utils import ping_from_bsvr - -# set up logging -logger = logging.getLogger(__name__) - - -class Node: - def __init__(self, name, kind, node_type, version, mgmt_ipv4): - self.name = name - self.kind = kind - - self.node_type = node_type - if node_type is None: - node_type = self.get_default_node_type() - - self.version = version - self.mgmt_ipv4 = mgmt_ipv4 - - def __repr__(self): - return f"Node(name={self.name}, kind={self.kind}, type={self.node_type}, version={self.version}, mgmt_ipv4={self.mgmt_ipv4})" - - def ping(self): - """ - Pings the node from the EDA bootstrap server pod - - Raises - ------ - RuntimeError - If ping fails or if the eda-bsvr pod cannot be found - """ - logger.debug(f"Pinging {self.kind} node '{self.name}' with IP {self.mgmt_ipv4}") - if ping_from_bsvr(self.mgmt_ipv4): - logger.info( - f"Ping to {self.kind} node '{self.name}' with IP {self.mgmt_ipv4} successful" - ) - return True - else: - error_msg = f"Ping to {self.kind} node '{self.name}' with IP {self.mgmt_ipv4} failed" - logger.error(error_msg) - raise RuntimeError(error_msg) - - def test_ssh(self): - """ - Tests the SSH connectivity to the node. This method needs to be overwritten by nodes that support it - - Returns - ------- - True if the SSH was successful, raises exception otherwise - """ - logger.info(f"Testing SSH is not supported for {self}") - - def get_node_name(self, topology): - """ - Returns an EDA-safe name for a node - """ - return helpers.normalize_name(self.name) - - def get_profile_name(self, topology): - """ - Returns an EDA-safe name for a node profile - """ - raise Exception("Node not supported in EDA") - - def get_default_node_type(self): - """ - Allows to override the default node type, if no type was provided - """ - return None - - def get_platform(self): - """ - Platform name to be used in the toponode resource - """ - return "UNKNOWN" - - def get_node_profile(self, topology): - """ - Creates a node profile for this node kind & version. This method needs to be overwritten by nodes that support it - - Returns - ------- - the rendered node-profile jinja template - """ - logger.info(f"Node profile is not supported for {self}") - return None - - def get_toponode(self, topology): - """ - Get as toponode. This method needs to be overwritten by nodes that support it - """ - logger.info(f"Toponode is not supported for {self}") - return None - - def is_eda_supported(self): - """ - Returns True if this node is supported as part of an EDA topology - """ - return False - - def get_interface_name_for_kind(self, ifname): - """ - Converts the containerlab name of an interface to the node's naming convention - - Parameters - ---------- - ifname: name of the interface as specified in the containerlab topology file - - Returns - ------- - The name of the interface as accepted by the node - """ - return ifname - - def get_topolink_interface_name(self, topology, ifname): - """ - Returns the name of this node's topolink with given interface - """ - return ( - f"{self.get_node_name(topology)}-{self.get_interface_name_for_kind(ifname)}" - ) - - def get_topolink_interface(self, topology, ifname, other_node): - """ - Creates a topolink interface for this node and interface. This method needs to be overwritten by nodes that support it - - Parameters - ---------- - topology: the parsed Topology - ifname: name of the topolink interface - other_node: node at the other end of the topolink (used for description) - - Returns - ------- - The rendered interface jinja template - """ - logger.info(f"Topolink interface is not supported for {self}") - return None - - def needs_artifact(self): - """ - Returns whether this node type needs an artifact to be created in EDA - """ - return False - - def get_artifact_name(self): - """ - Returns the standardized artifact name for this node type and version. - Should be implemented by node types that return True for needs_artifact() - - Returns - ------- - str containing the artifact name or None if not supported - """ - return None - - def get_artifact_info(self): - """ - Gets artifact information required for this node type. - Should be implemented by node types that return True for needs_artifact() - - Returns - ------- - Tuple of (artifact_name, filename, download_url) or (None, None, None) if not found - """ - return None, None, None - - def get_artifact_yaml(self, artifact_name, filename, download_url): - """ - Returns the YAML definition for creating the artifact in EDA. - Should be implemented by node types that return True for needs_artifact() - - Returns - ------- - str containing the artifact YAML definition or None if not supported - """ - return None - - -# import specific nodes down here to avoid circular dependencies -from clab_connector.core.node_srl import SRLNode # noqa: E402 - -KIND_MAPPING = { - "nokia_srlinux": "srl", -} - -SUPPORTED_NODE_TYPES = { - "srl": SRLNode, -} - - -def from_obj(name, python_object, kinds): - """ - Parses a node from a Python object - - Parameters - ---------- - name: the name of the node - python_obj: the python object for this node parsed from the json input file - kinds: the python object for the kinds in the topology file (not used for topology-data.json) - - Returns - ------- - The parsed Node entity - """ - logger.info(f"Parsing node with name '{name}'") - original_kind = python_object.get("kind") - if not original_kind: - logger.warning(f"No kind specified for node '{name}', skipping") - return None - - # Translate kind if needed - kind = KIND_MAPPING.get(original_kind) - if not kind: - logger.debug( - f"Unsupported kind '{original_kind}' for node '{name}', skipping. Supported kinds: {list(KIND_MAPPING.keys())}" - ) - return None - - node_type = python_object.get("type", None) - mgmt_ipv4 = python_object.get("mgmt-ipv4") or python_object.get("mgmt_ipv4") - version = python_object.get("version") # Get version directly if provided - - if not mgmt_ipv4: - logger.warning(f"No management IP found for node {name}") - return None - - # Create the appropriate node type using the mapping - NodeClass = SUPPORTED_NODE_TYPES[kind] - return NodeClass(name, kind, node_type, version, mgmt_ipv4) diff --git a/clab_connector/core/remove.py b/clab_connector/core/remove.py deleted file mode 100644 index d08b621..0000000 --- a/clab_connector/core/remove.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging - -from clab_connector.core import helpers -from clab_connector.core.eda import EDA - -# set up logging -logger = logging.getLogger(__name__) - - -class RemoveCommand(): - PARSER_NAME = "remove" - PARSER_ALIASES = [PARSER_NAME, "r"] - - def run(self, args): - """ - Run the program with the arguments specified for this sub-command - - Parameters - ---------- - args: input arguments returned by the argument parser - """ - self.args = args - self.topology = helpers.parse_topology(self.args.topology_data) - self.topology.log_debug() - self.eda = EDA( - args.eda_url, - args.eda_user, - args.eda_password, - args.verify, - ) - - print("== Removing namespace ==") - self.remove_namespace() - self.eda.commit_transaction("EDA Containerlab Connector: remove namespace") - - print("Done!") - - def remove_namespace(self): - """ - Removes the namespace for the topology - """ - logger.info("Removing namespace") - self.eda.add_delete_to_transaction( - "", - "Namespace", - f"clab-{self.topology.name}", - ) - - def create_parser(self, subparsers): - """ - Creates a subparser with arguments specific to this subcommand of the program - - Parameters - ---------- - subparsers: the subparsers object for the parent command - - Returns - ------- - An argparse subparser - """ - parser = subparsers.add_parser( - self.PARSER_NAME, - help="remove containerlab integration from EDA", - aliases=self.PARSER_ALIASES, - ) - - parser.add_argument( - "--topology-data", - "-t", - type=str, - required=True, - help="the containerlab topology data JSON file", - ) - - parser.add_argument( - "--eda-url", - "-e", - type=str, - required=True, - help="the hostname or IP of your EDA deployment", - ) - - parser.add_argument( - "--eda-user", type=str, default="admin", help="the username of the EDA user" - ) - - parser.add_argument( - "--eda-password", - type=str, - default="admin", - help="the password of the EDA user", - ) - - return parser diff --git a/clab_connector/core/topology.py b/clab_connector/core/topology.py deleted file mode 100644 index 4ad129c..0000000 --- a/clab_connector/core/topology.py +++ /dev/null @@ -1,233 +0,0 @@ -import logging - -from clab_connector.core import helpers -from clab_connector.core.link import from_obj as link_from_obj -from clab_connector.core.node import from_obj as node_from_obj - - -# set up logging -logger = logging.getLogger(__name__) - - -class Topology: - def __init__( - self, - name, - mgmt_ipv4_subnet, - ssh_pub_keys, - nodes, - links, - clab_file_path="", - ): - """ - Initialize a new Topology instance - - Parameters - ---------- - name: str - Name of the topology - mgmt_ipv4_subnet: str - Management IPv4 subnet for the topology - nodes: list - List of Node objects in the topology - links: list - List of Link objects connecting the nodes - clab_file_path: str - Path to the topology file a clab topology was spawned from - """ - self.name = name - self.mgmt_ipv4_subnet = mgmt_ipv4_subnet - self.ssh_pub_keys = ssh_pub_keys - self.nodes = nodes - self.links = links - self.clab_file_path = clab_file_path - - def __repr__(self): - return f"Topology(name={self.name}, mgmt_ipv4_subnet={self.mgmt_ipv4_subnet}) with {len(self.nodes)} nodes" - - def log_debug(self): - """ - Prints the topology and all nodes that belong to it to the debug logger - """ - logger.debug("=== Topology ===") - logger.debug(self) - - logger.debug("== Nodes == ") - for node in self.nodes: - logger.debug(node) - - logger.debug("== Links == ") - for link in self.links: - logger.debug(link) - - def check_connectivity(self): - """ - Checks whether all nodes are pingable, and have the SSH interface open - """ - for node in self.nodes: - node.ping() - - # for node in self.nodes: - # node.test_ssh() - - def get_eda_safe_name(self): - """ - Returns a Kubernetes-compliant name by: - - Converting to lowercase - - Replacing underscores and spaces with hyphens - - Removing any other invalid characters - - Ensuring it starts and ends with alphanumeric characters - """ - return helpers.normalize_name(self.name) - - def get_node_profiles(self): - """ - Creates node profiles for all nodes in the topology. One node profile per type/sw-version is created - """ - profiles = {} - for node in self.nodes: - node_profile = node.get_node_profile(self) - if node_profile is None: - # node profile not supported (for example, linux containers that are not managed by EDA) - continue - - if f"{node.kind}-{node.version}" not in profiles: - profiles[f"{node.kind}-{node.version}"] = node_profile - - # only return the node profiles, not the keys - return profiles.values() - - def get_toponodes(self): - """ - Create nodes for the topology - """ - toponodes = [] - for node in self.nodes: - toponode = node.get_toponode(self) - if toponode is None: - continue - - toponodes.append(toponode) - - return toponodes - - def get_topolinks(self): - """ - Create topolinks for the topology - """ - topolinks = [] - for link in self.links: - if link.is_topolink(): - topolinks.append(link.get_topolink(self)) - - return topolinks - - def get_system_interfaces(self): - """ - Create system interfaces for the nodes in the topology - """ - interfaces = [] - for node in self.nodes: - if not node.is_eda_supported(): - continue - - interface = node.get_system_interface(self) - if interface is not None: - interfaces.append(interface) - - return interfaces - - def get_topolink_interfaces(self): - """ - Create topolink interfaces for the links in the topology - """ - interfaces = [] - for link in self.links: - if link.is_topolink(): - interfaces.append( - link.node_1.get_topolink_interface( - self, link.interface_1, link.node_2 - ) - ) - interfaces.append( - link.node_2.get_topolink_interface( - self, link.interface_2, link.node_1 - ) - ) - - return interfaces - - def from_topology_data(self, json_obj): - """ - Parses a topology from a topology-data.json file - - Parameters - ---------- - json_obj: the python object parsed from the topology-data.json file - - Returns - ------- - The parsed Topology entity - """ - logger.info( - f"Parsing topology data with name '{json_obj['name']}' which contains {len(json_obj['nodes'])} nodes" - ) - - name = json_obj["name"] - mgmt_ipv4_subnet = json_obj["clab"]["config"]["mgmt"]["ipv4-subnet"] - - ssh_pub_keys = json_obj.get("ssh-pub-keys", []) - - clab_file_path = "" - for node_name, node_data in json_obj["nodes"].items(): - if clab_file_path == "": - clab_file_path = node_data["labels"].get("clab-topo-file", "") - break - # Create nodes - nodes = [] - for node_name, node_data in json_obj["nodes"].items(): - try: - # Get version from image tag - image = node_data["image"] - version = image.split(":")[-1] if ":" in image else None - - node = node_from_obj( - node_name, - { - "kind": node_data["kind"], - "type": node_data["labels"].get("clab-node-type", "ixrd2"), - "mgmt-ipv4": node_data["mgmt-ipv4-address"], - "version": version, - }, - None, - ) - if node is not None: # Only add supported nodes - nodes.append(node) - except Exception as e: - logger.warning(f"Failed to parse node {node_name}: {str(e)}") - continue - - # Create links but only for supported nodes - supported_node_names = [node.name for node in nodes] - links = [] - for link_data in json_obj["links"]: - # Only create links between supported nodes - if ( - link_data["a"]["node"] in supported_node_names - and link_data["z"]["node"] in supported_node_names - ): - link_obj = { - "endpoints": [ - f"{link_data['a']['node']}:{link_data['a']['interface']}", - f"{link_data['z']['node']}:{link_data['z']['interface']}", - ] - } - links.append(link_from_obj(link_obj, nodes)) - else: - logger.debug( - f"Skipping link between {link_data['a']['node']} and {link_data['z']['node']} as one or both nodes are not supported" - ) - - return Topology( - name, mgmt_ipv4_subnet, ssh_pub_keys, nodes, links, clab_file_path - ) diff --git a/clab_connector/models/link.py b/clab_connector/models/link.py new file mode 100644 index 0000000..62dc790 --- /dev/null +++ b/clab_connector/models/link.py @@ -0,0 +1,138 @@ +# clab_connector/models/link.py + +import logging +from clab_connector.utils import helpers + +logger = logging.getLogger(__name__) + + +class Link: + """ + Represents a bidirectional link between two nodes. + + Parameters + ---------- + node_1 : Node + The first node in the link. + intf_1 : str + The interface name on the first node. + node_2 : Node + The second node in the link. + intf_2 : str + The interface name on the second node. + """ + + def __init__(self, node_1, intf_1, node_2, intf_2): + self.node_1 = node_1 + self.intf_1 = intf_1 + self.node_2 = node_2 + self.intf_2 = intf_2 + + def __repr__(self): + """ + Return a string representation of the link. + + Returns + ------- + str + A description of the link endpoints. + """ + return f"Link({self.node_1}-{self.intf_1}, {self.node_2}-{self.intf_2})" + + def is_topolink(self): + """ + Check if both endpoints are EDA-supported nodes. + + Returns + ------- + bool + True if both nodes support EDA, False otherwise. + """ + if self.node_1 is None or not self.node_1.is_eda_supported(): + return False + if self.node_2 is None or not self.node_2.is_eda_supported(): + return False + return True + + def get_link_name(self, topology): + """ + Create a unique name for the link resource. + + Parameters + ---------- + topology : Topology + The topology that owns this link. + + Returns + ------- + str + A link name safe for EDA. + """ + return f"{self.node_1.get_node_name(topology)}-{self.intf_1}-{self.node_2.get_node_name(topology)}-{self.intf_2}" + + def get_topolink_yaml(self, topology): + """ + Render and return the TopoLink YAML if the link is EDA-supported. + + Parameters + ---------- + topology : Topology + The topology that owns this link. + + Returns + ------- + str or None + The rendered TopoLink CR YAML, or None if not EDA-supported. + """ + if not self.is_topolink(): + return None + data = { + "namespace": f"clab-{topology.name}", + "link_role": "interSwitch", + "link_name": self.get_link_name(topology), + "local_node": self.node_1.get_node_name(topology), + "local_interface": self.node_1.get_interface_name_for_kind(self.intf_1), + "remote_node": self.node_2.get_node_name(topology), + "remote_interface": self.node_2.get_interface_name_for_kind(self.intf_2), + } + return helpers.render_template("topolink.j2", data) + + +def create_link(endpoints: list, nodes: list) -> Link: + """ + Create a Link object from two endpoint definitions and a list of Node objects. + + Parameters + ---------- + endpoints : list + A list of exactly two endpoint strings, e.g. ["nodeA:e1-1", "nodeB:e1-1"]. + nodes : list + A list of Node objects in the topology. + + Returns + ------- + Link + A Link object representing the connection. + + Raises + ------ + ValueError + If the endpoint format is invalid or length is not 2. + """ + + if len(endpoints) != 2: + raise ValueError("Link endpoints must be a list of length 2") + + def parse_endpoint(ep): + parts = ep.split(":") + if len(parts) != 2: + raise ValueError(f"Invalid endpoint '{ep}', must be 'node:iface'") + return parts[0], parts[1] + + nodeA, ifA = parse_endpoint(endpoints[0]) + nodeB, ifB = parse_endpoint(endpoints[1]) + + nA = next((n for n in nodes if n.name == nodeA), None) + nB = next((n for n in nodes if n.name == nodeB), None) + + return Link(nA, ifA, nB, ifB) diff --git a/clab_connector/models/node/__init__.py b/clab_connector/models/node/__init__.py new file mode 100644 index 0000000..5463ba6 --- /dev/null +++ b/clab_connector/models/node/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/models/node/__init__.py + +"""Node package for domain models related to nodes.""" diff --git a/clab_connector/models/node/base.py b/clab_connector/models/node/base.py new file mode 100644 index 0000000..93fc32f --- /dev/null +++ b/clab_connector/models/node/base.py @@ -0,0 +1,286 @@ +# clab_connector/models/node/base.py + +import logging + +from clab_connector.utils import helpers +from clab_connector.clients.kubernetes.client import ping_from_bsvr + +logger = logging.getLogger(__name__) + + +class Node: + """ + Base Node class for representing a generic containerlab node. + + Parameters + ---------- + name : str + The name of the node. + kind : str + The kind of the node (e.g. nokia_srlinux). + node_type : str + The specific node type (e.g. ixrd2). + version : str + The software version of the node. + mgmt_ipv4 : str + The management IPv4 address of the node. + """ + + def __init__(self, name, kind, node_type, version, mgmt_ipv4): + self.name = name + self.kind = kind + self.node_type = node_type or self.get_default_node_type() + self.version = version + self.mgmt_ipv4 = mgmt_ipv4 + + def __repr__(self): + """ + Return a string representation of the node. + + Returns + ------- + str + A string describing the node and its parameters. + """ + return ( + f"Node(name={self.name}, kind={self.kind}, type={self.node_type}, " + f"version={self.version}, mgmt_ipv4={self.mgmt_ipv4})" + ) + + def ping(self): + """ + Attempt to ping the node from the EDA bootstrap server (bsvr). + + Returns + ------- + bool + True if the ping is successful, raises a RuntimeError otherwise. + """ + logger.debug(f"Pinging node '{self.name}' IP {self.mgmt_ipv4}") + if ping_from_bsvr(self.mgmt_ipv4): + logger.info(f"Ping to '{self.name}' ({self.mgmt_ipv4}) successful") + return True + else: + msg = f"Ping to '{self.name}' ({self.mgmt_ipv4}) failed" + logger.error(msg) + raise RuntimeError(msg) + + def test_ssh(self): + """ + Test SSH connectivity to the node. + + This default implementation logs that SSH is not supported. + Subclasses should override if SSH is supported. + """ + logger.info(f"SSH test not supported for {self}") + + def get_node_name(self, topology): + """ + Generate a name suitable for EDA resources, based on the node name. + + Parameters + ---------- + topology : Topology + The topology the node belongs to. + + Returns + ------- + str + A normalized node name safe for EDA. + """ + return helpers.normalize_name(self.name) + + def get_default_node_type(self): + """ + Get the default node type if none is specified. + + Returns + ------- + str or None + A default node type or None. + """ + return None + + def get_platform(self): + """ + Return the platform name for the node. + + Returns + ------- + str + The platform name (default 'UNKNOWN'). + """ + return "UNKNOWN" + + def is_eda_supported(self): + """ + Check whether the node kind is supported by EDA. + + Returns + ------- + bool + True if supported, False otherwise. + """ + return False + + def get_profile_name(self, topology): + """ + Get the name of the NodeProfile for this node. + + Parameters + ---------- + topology : Topology + The topology this node belongs to. + + Returns + ------- + str + The NodeProfile name for EDA resource creation. + + Raises + ------ + NotImplementedError + Must be implemented by subclasses. + """ + raise NotImplementedError("Must be implemented by subclass") + + def get_node_profile(self, topology): + """ + Render and return NodeProfile YAML for the node. + + Parameters + ---------- + topology : Topology + The topology the node belongs to. + + Returns + ------- + str or None + The rendered NodeProfile YAML, or None if not applicable. + """ + return None + + def get_toponode(self, topology): + """ + Render and return TopoNode YAML for the node. + + Parameters + ---------- + topology : Topology + The topology the node belongs to. + + Returns + ------- + str or None + The rendered TopoNode YAML, or None if not applicable. + """ + return None + + def get_interface_name_for_kind(self, ifname): + """ + Convert an interface name from a containerlab style to EDA style. + + Parameters + ---------- + ifname : str + The interface name in containerlab format. + + Returns + ------- + str + A suitable interface name for EDA. + """ + return ifname + + def get_topolink_interface_name(self, topology, ifname): + """ + Generate a unique interface resource name for a link. + + Parameters + ---------- + topology : Topology + The topology that this node belongs to. + ifname : str + The interface name (containerlab style). + + Returns + ------- + str + The name that EDA will use for this interface resource. + """ + return ( + f"{self.get_node_name(topology)}-{self.get_interface_name_for_kind(ifname)}" + ) + + def get_topolink_interface(self, topology, ifname, other_node): + """ + Render and return the interface resource YAML (Interface CR) for a link endpoint. + + Parameters + ---------- + topology : Topology + The topology that this node belongs to. + ifname : str + The interface name on this node (containerlab style). + other_node : Node + The peer node at the other end of the link. + + Returns + ------- + str or None + The rendered Interface CR YAML, or None if not applicable. + """ + return None + + def needs_artifact(self): + """ + Determine if this node requires a schema or binary artifact in EDA. + + Returns + ------- + bool + True if an artifact is needed, False otherwise. + """ + return False + + def get_artifact_name(self): + """ + Return the artifact name if needed by the node. + + Returns + ------- + str or None + The artifact name, or None if not needed. + """ + return None + + def get_artifact_info(self): + """ + Return the artifact name, filename, and download URL if needed. + + Returns + ------- + tuple + (artifact_name, filename, download_url) or (None, None, None). + """ + return (None, None, None) + + def get_artifact_yaml(self, artifact_name, filename, download_url): + """ + Render and return an Artifact CR YAML for this node. + + Parameters + ---------- + artifact_name : str + The name of the artifact in EDA. + filename : str + The artifact file name. + download_url : str + The source URL of the artifact file. + + Returns + ------- + str or None + The rendered Artifact CR YAML, or None if not applicable. + """ + return None diff --git a/clab_connector/models/node/factory.py b/clab_connector/models/node/factory.py new file mode 100644 index 0000000..46c87ba --- /dev/null +++ b/clab_connector/models/node/factory.py @@ -0,0 +1,46 @@ +# clab_connector/models/node/factory.py + +import logging +from .base import Node +from .nokia_srl import NokiaSRLinuxNode + +logger = logging.getLogger(__name__) + +KIND_MAPPING = { + "nokia_srlinux": NokiaSRLinuxNode, +} + + +def create_node(name: str, config: dict) -> Node: + """ + Create a node instance based on the kind specified in config. + + Parameters + ---------- + name : str + The name of the node. + config : dict + A dictionary containing 'kind', 'type', 'version', 'mgmt_ipv4', etc. + + Returns + ------- + Node or None + An appropriate Node subclass instance if supported; otherwise None. + """ + kind = config.get("kind") + if not kind: + logger.error(f"No 'kind' in config for node '{name}'") + return None + + cls = KIND_MAPPING.get(kind) + if cls is None: + logger.info(f"Unsupported kind '{kind}' for node '{name}'") + return None + + return cls( + name=name, + kind=kind, + node_type=config.get("type"), + version=config.get("version"), + mgmt_ipv4=config.get("mgmt_ipv4"), + ) diff --git a/clab_connector/core/node_srl.py b/clab_connector/models/node/nokia_srl.py similarity index 52% rename from clab_connector/core/node_srl.py rename to clab_connector/models/node/nokia_srl.py index 016aa93..40a045b 100644 --- a/clab_connector/core/node_srl.py +++ b/clab_connector/models/node/nokia_srl.py @@ -1,23 +1,22 @@ +# clab_connector/models/node/nokia_srl.py + import logging import re -import socket - -from paramiko import ( - AuthenticationException, - AutoAddPolicy, - BadHostKeyException, - SSHClient, - SSHException, -) -from clab_connector.core import helpers -from clab_connector.core.node import Node +from .base import Node +from clab_connector.utils import helpers -# set up logging logger = logging.getLogger(__name__) -class SRLNode(Node): +class NokiaSRLinuxNode(Node): + """ + Nokia SR Linux Node representation. + + This subclass implements specific logic for SR Linux nodes, including + SSH tests, naming, interface mapping, and EDA resource generation. + """ + SRL_USERNAME = "admin" SRL_PASSWORD = "NokiaSrl1!" NODE_TYPE = "srlinux" @@ -27,100 +26,81 @@ class SRLNode(Node): SRL_IMAGE = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin" SRL_IMAGE_MD5 = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin.md5" + # Mapping for EDA operating system + EDA_OPERATING_SYSTEM = "srl" + SUPPORTED_SCHEMA_PROFILES = { - "24.10.1": "https://github.com/nokia/srlinux-yang-models/releases/download/v24.10.1/srlinux-24.10.1-492.zip" + "24.10.1": ( + "https://github.com/nokia/srlinux-yang-models/" + "releases/download/v24.10.1/srlinux-24.10.1-492.zip" + ) } - def __init__(self, name, kind, node_type, version, mgmt_ipv4): - super().__init__(name, kind, node_type, version, mgmt_ipv4) - # Add cache for artifact info - self._artifact_info = None - - def test_ssh(self): + def get_default_node_type(self): """ - Tests the SSH connectivity to the node + Return the default node type for an SR Linux node. Returns ------- - True if the SSH was successful, False otherwise - """ - logger.debug( - f"Testing whether SSH works for node '{self.name}' with IP {self.mgmt_ipv4}" - ) - ssh = SSHClient() - ssh.set_missing_host_key_policy(AutoAddPolicy()) - - try: - ssh.connect( - self.mgmt_ipv4, - username=self.SRL_USERNAME, - password=self.SRL_PASSWORD, - allow_agent=False, - ) - logger.info( - f"SSH test to {self.kind} node '{self.name}' with IP {self.mgmt_ipv4} was successful" - ) - return True - except ( - BadHostKeyException, - AuthenticationException, - SSHException, - socket.error, - ) as e: - logger.critical(f"Could not connect to node {self}, exception: {e}") - raise e - - def get_default_node_type(self): - """ - Allows to override the default node type, if no type was provided + str + The default node type (e.g., "ixrd3l"). """ return "ixrd3l" def get_platform(self): """ - Platform name to be used in the toponode resource + Return the platform name based on node type. + + Returns + ------- + str + The platform name (e.g. '7220 IXR-D3L'). """ t = self.node_type.replace("ixr", "") return f"7220 IXR-{t.upper()}" - def get_profile_name(self, topology): - """ - Returns an EDA-safe name for a node profile - """ - return f"{topology.get_eda_safe_name()}-{self.NODE_TYPE}-{self.version}" - def is_eda_supported(self): """ - Returns True if this node is supported as part of an EDA topology + Indicates SR Linux nodes are EDA-supported. + + Returns + ------- + bool + True for SR Linux. """ return True - def get_interface_name_for_kind(self, ifname): + def get_profile_name(self, topology): """ - Converts the containerlab name of an interface to the node's naming convention + Generate a NodeProfile name specific to this SR Linux node. Parameters ---------- - ifname: name of the interface as specified in the containerlab topology file + topology : Topology + The topology object. Returns ------- - The name of the interface as accepted by the node + str + The NodeProfile name for EDA. """ - pattern = re.compile("^e([0-9])-([0-9]+)$") - - if pattern.match(ifname): - match = pattern.search(ifname) - return f"ethernet-{match.group(1)}-{match.group(2)}" - - return ifname + return f"{topology.get_eda_safe_name()}-{self.NODE_TYPE}-{self.version}" def get_node_profile(self, topology): """ - Creates a node profile for this node kind & version - """ - logger.info(f"Rendering node profile for {self}") + Render the NodeProfile YAML for this SR Linux node. + Parameters + ---------- + topology : Topology + The topology object. + + Returns + ------- + str + The rendered NodeProfile YAML. + """ + logger.info(f"Rendering node profile for {self.name}") artifact_name = self.get_artifact_name() filename = f"srlinux-{self.version}.zip" @@ -129,45 +109,43 @@ def get_node_profile(self, topology): "profile_name": self.get_profile_name(topology), "sw_version": self.version, "gnmi_port": self.GNMI_PORT, - "operating_system": self.kind, + "operating_system": self.EDA_OPERATING_SYSTEM, "version_path": self.VERSION_PATH, - # below evaluates to something like v24\.7\.1.* "version_match": "v{}.*".format(self.version.replace(".", "\.")), "yang_path": self.YANG_PATH.format( artifact_name=artifact_name, filename=filename ), - "node_user": "admin", + "node_user": self.SRL_USERNAME, "onboarding_password": self.SRL_PASSWORD, "onboarding_username": self.SRL_USERNAME, "sw_image": self.SRL_IMAGE.format(version=self.version), "sw_image_md5": self.SRL_IMAGE_MD5.format(version=self.version), } - return helpers.render_template("node-profile.j2", data) def get_toponode(self, topology): """ - Creates a topo node for this node + Render the TopoNode YAML for this SR Linux node. + + Parameters + ---------- + topology : Topology + The topology object. Returns ------- - the rendered toponode jinja template + str + The rendered TopoNode YAML. """ - logger.info(f"Creating toponode node for {self}") - + logger.info(f"Creating toponode for {self.name}") role_value = "leaf" - if "leaf" in self.name: - role_value = "leaf" - elif "spine" in self.name: + nl = self.name.lower() + if "spine" in nl: role_value = "spine" - elif "borderleaf" in self.name or "bl" in self.name: + elif "borderleaf" in nl or "bl" in nl: role_value = "borderleaf" - elif "dcgw" in self.name: + elif "dcgw" in nl: role_value = "dcgw" - else: - logger.debug( - f"Could not determine role of node {self}, defaulting to eda.nokia.com/role=leaf" - ) data = { "namespace": f"clab-{topology.name}", @@ -175,38 +153,52 @@ def get_toponode(self, topology): "topology_name": topology.get_eda_safe_name(), "role_value": role_value, "node_profile": self.get_profile_name(topology), - "kind": self.kind, + "kind": self.EDA_OPERATING_SYSTEM, "platform": self.get_platform(), "sw_version": self.version, "mgmt_ip": self.mgmt_ipv4, } - return helpers.render_template("toponode.j2", data) - def get_topolink_interface_name(self, topology, ifname): + def get_interface_name_for_kind(self, ifname): """ - Returns the name of this node's topolink with given interface + Convert a containerlab interface name to an SR Linux style interface. + + Parameters + ---------- + ifname : str + Containerlab interface name, e.g., 'e1-1'. + + Returns + ------- + str + SR Linux style name, e.g. 'ethernet-1-1'. """ - return ( - f"{self.get_node_name(topology)}-{self.get_interface_name_for_kind(ifname)}" - ) + pattern = re.compile(r"^e(\d+)-(\d+)$") + match = pattern.match(ifname) + if match: + return f"ethernet-{match.group(1)}-{match.group(2)}" + return ifname def get_topolink_interface(self, topology, ifname, other_node): """ - Creates a topolink interface for this node and interface + Render the Interface CR YAML for an SR Linux link endpoint. Parameters ---------- - topology: the parsed Topology - ifname: name of the topolink interface - other_node: node at the other end of the topolink (used for description) + topology : Topology + The topology object. + ifname : str + The containerlab interface name on this node. + other_node : Node + The peer node. Returns ------- - The rendered interface jinja template + str + The rendered Interface CR YAML. """ - logger.info(f"Creating topolink interface for {self}") - + logger.info(f"Creating topolink interface for {self.name}") data = { "namespace": f"clab-{topology.name}", "interface_name": self.get_topolink_interface_name(topology, ifname), @@ -217,36 +209,64 @@ def get_topolink_interface(self, topology, ifname, other_node): "interface": self.get_interface_name_for_kind(ifname), "description": f"inter-switch link to {other_node.get_node_name(topology)}", } - return helpers.render_template("interface.j2", data) def needs_artifact(self): """ - SR Linux nodes need YANG model artifacts + SR Linux nodes may require a YANG artifact. + + Returns + ------- + bool + True if an artifact is needed based on the version. """ return True def get_artifact_name(self): """ - Returns the standardized artifact name for this SR Linux version + Return a name for the SR Linux schema artifact. + + Returns + ------- + str + A string such as 'clab-srlinux-24.10.1'. """ return f"clab-srlinux-{self.version}" def get_artifact_info(self): - """Gets artifact information for this SR Linux version""" - if self.version not in self.SUPPORTED_SCHEMA_PROFILES: - logger.warning(f"No schema profile URL defined for version {self.version}") - return None, None, None + """ + Return artifact metadata for the SR Linux YANG schema file. + Returns + ------- + tuple + (artifact_name, filename, download_url) + """ + if self.version not in self.SUPPORTED_SCHEMA_PROFILES: + logger.warning(f"No schema profile for version {self.version}") + return (None, None, None) artifact_name = self.get_artifact_name() filename = f"srlinux-{self.version}.zip" download_url = self.SUPPORTED_SCHEMA_PROFILES[self.version] - - return artifact_name, filename, download_url + return (artifact_name, filename, download_url) def get_artifact_yaml(self, artifact_name, filename, download_url): """ - Renders the artifact YAML for SR Linux YANG models + Render the Artifact CR YAML for the SR Linux YANG schema. + + Parameters + ---------- + artifact_name : str + The name of the artifact in EDA. + filename : str + The artifact file name. + download_url : str + The download URL of the artifact file. + + Returns + ------- + str + The rendered Artifact CR YAML. """ data = { "artifact_name": artifact_name, diff --git a/clab_connector/models/topology.py b/clab_connector/models/topology.py new file mode 100644 index 0000000..84b37bc --- /dev/null +++ b/clab_connector/models/topology.py @@ -0,0 +1,246 @@ +# clab_connector/models/topology.py + +import logging +import os +import sys +import json + +from clab_connector.models.node.factory import create_node +from clab_connector.models.link import create_link + +logger = logging.getLogger(__name__) + + +class Topology: + """ + Represents a containerlab topology. + + Parameters + ---------- + name : str + The name of the topology. + mgmt_subnet : str + The management IPv4 subnet for the topology. + ssh_keys : list + A list of SSH public keys. + nodes : list + A list of Node objects in the topology. + links : list + A list of Link objects in the topology. + clab_file_path : str + Path to the original containerlab file if available. + """ + + def __init__(self, name, mgmt_subnet, ssh_keys, nodes, links, clab_file_path=""): + self.name = name + self.mgmt_ipv4_subnet = mgmt_subnet + self.ssh_pub_keys = ssh_keys + self.nodes = nodes + self.links = links + self.clab_file_path = clab_file_path + + def __repr__(self): + """ + Return a string representation of the topology. + + Returns + ------- + str + Description of the topology name, mgmt_subnet, number of nodes and links. + """ + return ( + f"Topology(name={self.name}, mgmt_subnet={self.mgmt_ipv4_subnet}, " + f"nodes={len(self.nodes)}, links={len(self.links)})" + ) + + def get_eda_safe_name(self): + """ + Convert the topology name into a format safe for use in EDA. + + Returns + ------- + str + A name suitable for EDA resource naming. + """ + safe = self.name.lower().replace("_", "-").replace(" ", "-") + safe = "".join(c for c in safe if c.isalnum() or c in ".-").strip(".-") + if not safe or not safe[0].isalnum(): + safe = "x" + safe + if not safe[-1].isalnum(): + safe += "0" + return safe + + def check_connectivity(self): + """ + Attempt to ping each node's management IP from the bootstrap server. + + Raises + ------ + RuntimeError + If any node fails to respond to ping. + """ + for node in self.nodes: + node.ping() + + def get_node_profiles(self): + """ + Generate NodeProfile YAML for all nodes that produce them. + + Returns + ------- + list + A list of node profile YAML strings. + """ + profiles = {} + for n in self.nodes: + prof = n.get_node_profile(self) + if prof: + key = f"{n.kind}-{n.version}" + profiles[key] = prof + return profiles.values() + + def get_toponodes(self): + """ + Generate TopoNode YAML for all EDA-supported nodes. + + Returns + ------- + list + A list of toponode YAML strings. + """ + tnodes = [] + for n in self.nodes: + tn = n.get_toponode(self) + if tn: + tnodes.append(tn) + return tnodes + + def get_topolinks(self): + """ + Generate TopoLink YAML for all EDA-supported links. + + Returns + ------- + list + A list of topolink YAML strings. + """ + links = [] + for ln in self.links: + if ln.is_topolink(): + link_yaml = ln.get_topolink_yaml(self) + if link_yaml: + links.append(link_yaml) + return links + + def get_topolink_interfaces(self): + """ + Generate Interface YAML for each link endpoint (if EDA-supported). + + Returns + ------- + list + A list of interface YAML strings for the link endpoints. + """ + interfaces = [] + for ln in self.links: + if ln.is_topolink(): + intf1 = ln.node_1.get_topolink_interface(self, ln.intf_1, ln.node_2) + intf2 = ln.node_2.get_topolink_interface(self, ln.intf_2, ln.node_1) + if intf1: + interfaces.append(intf1) + if intf2: + interfaces.append(intf2) + return interfaces + + +def parse_topology_file(path: str) -> Topology: + """ + Parse a containerlab topology JSON file and return a Topology object. + + Parameters + ---------- + path : str + Path to the containerlab topology JSON file. + + Returns + ------- + Topology + A populated Topology object. + + Raises + ------ + SystemExit + If the file does not exist or cannot be parsed. + ValueError + If the file is not recognized as a containerlab topology. + """ + logger.info(f"Parsing topology file '{path}'") + if not os.path.isfile(path): + logger.critical(f"Topology file '{path}' does not exist!") + sys.exit(1) + + try: + with open(path, "r") as f: + data = json.load(f) + except json.JSONDecodeError: + logger.critical(f"File '{path}' is not valid JSON.") + sys.exit(1) + + if data.get("type") != "clab": + raise ValueError("Not a valid containerlab topology file (missing 'type=clab')") + + name = data["name"] + mgmt_subnet = data["clab"]["config"]["mgmt"].get("ipv4-subnet") + ssh_keys = data.get("ssh-pub-keys", []) + file_path = "" + + if data["nodes"]: + first_key = next(iter(data["nodes"])) + file_path = data["nodes"][first_key]["labels"].get("clab-topo-file", "") + + # Create node objects + node_objects = [] + for node_name, node_data in data["nodes"].items(): + image = node_data.get("image") + version = None + if image and ":" in image: + version = image.split(":")[-1] + config = { + "kind": node_data["kind"], + "type": node_data["labels"].get("clab-node-type", "ixrd2"), + "version": version, + "mgmt_ipv4": node_data.get("mgmt-ipv4-address"), + } + node_obj = create_node(node_name, config) + if node_obj: + node_objects.append(node_obj) + + # Create link objects + link_objects = [] + for link_info in data["links"]: + a_node = link_info["a"]["node"] + z_node = link_info["z"]["node"] + if any(n.name == a_node for n in node_objects) and any( + n.name == z_node for n in node_objects + ): + endpoints = [ + f"{a_node}:{link_info['a']['interface']}", + f"{z_node}:{link_info['z']['interface']}", + ] + ln = create_link(endpoints, node_objects) + link_objects.append(ln) + + topo = Topology( + name=name, + mgmt_subnet=mgmt_subnet, + ssh_keys=ssh_keys, + nodes=node_objects, + links=link_objects, + clab_file_path=file_path, + ) + + original = topo.name + topo.name = topo.get_eda_safe_name() + if topo.name != original: + logger.info(f"Renamed topology '{original}' -> '{topo.name}' for EDA safety") + return topo diff --git a/clab_connector/services/integration/__init__.py b/clab_connector/services/integration/__init__.py new file mode 100644 index 0000000..713036e --- /dev/null +++ b/clab_connector/services/integration/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/services/integration/__init__.py + +"""Integration services (Onboarding topology).""" diff --git a/clab_connector/services/integration/topology_integrator.py b/clab_connector/services/integration/topology_integrator.py new file mode 100644 index 0000000..f3baa47 --- /dev/null +++ b/clab_connector/services/integration/topology_integrator.py @@ -0,0 +1,266 @@ +# clab_connector/services/integration/topology_integrator.py + +import logging + +from clab_connector.models.topology import parse_topology_file +from clab_connector.clients.eda.client import EDAClient +from clab_connector.clients.kubernetes.client import ( + apply_manifest, + edactl_namespace_bootstrap, + wait_for_namespace, + update_namespace_description, +) +from clab_connector.utils import helpers +from clab_connector.utils.exceptions import EDAConnectionError, ClabConnectorError + +logger = logging.getLogger(__name__) + + +class TopologyIntegrator: + """ + Handles creation of EDA resources for a given containerlab topology. + + Parameters + ---------- + eda_client : EDAClient + A connected EDAClient used to submit resources to the EDA cluster. + """ + + def __init__(self, eda_client: EDAClient): + self.eda_client = eda_client + self.topology = None + + def run(self, topology_file, eda_url, eda_user, eda_password, verify): + """ + Parse the topology, run connectivity checks, and create EDA resources. + + Parameters + ---------- + topology_file : str or Path + Path to the containerlab topology JSON file. + eda_url : str + EDA hostname/IP. + eda_user : str + EDA username. + eda_password : str + EDA password. + verify : bool + Certificate verification flag. + + Returns + ------- + None + + Raises + ------ + EDAConnectionError + If EDA is unreachable or credentials are invalid. + ClabConnectorError + If any resource fails validation. + """ + logger.info("Parsing topology for integration") + self.topology = parse_topology_file(str(topology_file)) + self.topology.check_connectivity() + + print("== Running pre-checks ==") + self.prechecks() + + print("== Creating namespace ==") + self.create_namespace() + + print("== Creating artifacts ==") + self.create_artifacts() + + print("== Creating init ==") + self.create_init() + self.eda_client.commit_transaction("create init (bootstrap)") + + print("== Creating node security profile ==") + self.create_node_security_profile() + + print("== Creating node users ==") + self.create_node_user_groups() + self.create_node_users() + self.eda_client.commit_transaction("create node users and groups") + + print("== Creating node profiles ==") + self.create_node_profiles() + self.eda_client.commit_transaction("create node profiles") + + print("== Onboarding nodes ==") + self.create_toponodes() + self.eda_client.commit_transaction("create nodes") + + print("== Adding topolink interfaces ==") + self.create_topolink_interfaces() + self.eda_client.commit_transaction("create topolink interfaces") + + print("== Creating topolinks ==") + self.create_topolinks() + self.eda_client.commit_transaction("create topolinks") + + print("Done!") + + def prechecks(self): + """ + Verify that EDA is up and credentials are valid. + + Raises + ------ + EDAConnectionError + If EDA is not reachable or not authenticated. + """ + if not self.eda_client.is_up(): + raise EDAConnectionError("EDA not up or unreachable") + if not self.eda_client.is_authenticated(): + raise EDAConnectionError("EDA credentials invalid") + + def create_namespace(self): + """ + Create and bootstrap a namespace for the topology in EDA. + """ + ns = f"clab-{self.topology.name}" + edactl_namespace_bootstrap(ns) + wait_for_namespace(ns) + desc = f"Containerlab {self.topology.name}: {self.topology.clab_file_path}" + update_namespace_description(ns, desc) + + def create_artifacts(self): + """ + Create Artifact resources for nodes that need them. + + Skips creation if already exists or no artifact data is available. + """ + logger.info("Creating artifacts for nodes that need them") + nodes_by_artifact = {} + for node in self.topology.nodes: + if not node.needs_artifact(): + continue + artifact_name, filename, download_url = node.get_artifact_info() + if not artifact_name or not filename or not download_url: + logger.warning(f"No artifact info for node {node.name}; skipping.") + continue + if artifact_name not in nodes_by_artifact: + nodes_by_artifact[artifact_name] = { + "nodes": [], + "filename": filename, + "download_url": download_url, + "version": node.version, + } + nodes_by_artifact[artifact_name]["nodes"].append(node.name) + + for artifact_name, info in nodes_by_artifact.items(): + first_node = info["nodes"][0] + logger.info( + f"Creating YANG artifact for node: {first_node} (version={info['version']})" + ) + artifact_yaml = self.topology.nodes[0].get_artifact_yaml( + artifact_name, info["filename"], info["download_url"] + ) + if not artifact_yaml: + logger.warning(f"Could not generate artifact YAML for {first_node}") + continue + try: + apply_manifest(artifact_yaml, namespace="eda-system") + logger.info(f"Artifact '{artifact_name}' created.") + other_nodes = info["nodes"][1:] + if other_nodes: + logger.info( + f"Using same artifact for nodes: {', '.join(other_nodes)}" + ) + except RuntimeError as ex: + if "AlreadyExists" in str(ex): + logger.info(f"Artifact '{artifact_name}' already exists.") + else: + logger.error(f"Error creating artifact '{artifact_name}': {ex}") + + def create_init(self): + """ + Create an Init resource in the namespace to bootstrap additional resources. + """ + data = {"namespace": f"clab-{self.topology.name}"} + yml = helpers.render_template("init.yaml.j2", data) + item = self.eda_client.add_replace_to_transaction(yml) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error for init resource") + + def create_node_security_profile(self): + """ + Create a NodeSecurityProfile resource that references an EDA node issuer. + """ + data = {"namespace": f"clab-{self.topology.name}"} + yaml_str = helpers.render_template("nodesecurityprofile.yaml.j2", data) + try: + apply_manifest(yaml_str, namespace=f"clab-{self.topology.name}") + logger.info("Node security profile created.") + except RuntimeError as ex: + if "AlreadyExists" in str(ex): + logger.info("Node security profile already exists, skipping.") + else: + raise + + def create_node_user_groups(self): + """ + Create a NodeGroup resource for user groups (like 'sudo'). + """ + data = {"namespace": f"clab-{self.topology.name}"} + node_user_group = helpers.render_template("node-user-group.yaml.j2", data) + item = self.eda_client.add_replace_to_transaction(node_user_group) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error for node user group") + + def create_node_users(self): + """ + Create a NodeUser resource with SSH pub keys, if any. + """ + data = { + "namespace": f"clab-{self.topology.name}", + "node_user": "admin", + "username": "admin", + "password": "NokiaSrl1!", + "ssh_pub_keys": getattr(self.topology, "ssh_pub_keys", []), + } + node_user = helpers.render_template("node-user.j2", data) + item = self.eda_client.add_replace_to_transaction(node_user) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error for node user") + + def create_node_profiles(self): + """ + Create NodeProfile resources for each EDA-supported node version-kind combo. + """ + profiles = self.topology.get_node_profiles() + for prof_yaml in profiles: + item = self.eda_client.add_replace_to_transaction(prof_yaml) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error creating node profile") + + def create_toponodes(self): + """ + Create TopoNode resources for each node. + """ + tnodes = self.topology.get_toponodes() + for node_yaml in tnodes: + item = self.eda_client.add_replace_to_transaction(node_yaml) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error creating toponode") + + def create_topolink_interfaces(self): + """ + Create Interface resources for each link endpoint in the topology. + """ + interfaces = self.topology.get_topolink_interfaces() + for intf_yaml in interfaces: + item = self.eda_client.add_replace_to_transaction(intf_yaml) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error creating topolink interface") + + def create_topolinks(self): + """ + Create TopoLink resources for each EDA-supported link in the topology. + """ + links = self.topology.get_topolinks() + for l_yaml in links: + item = self.eda_client.add_replace_to_transaction(l_yaml) + if not self.eda_client.is_transaction_item_valid(item): + raise ClabConnectorError("Validation error creating topolink") diff --git a/clab_connector/services/removal/__init__.py b/clab_connector/services/removal/__init__.py new file mode 100644 index 0000000..3de0731 --- /dev/null +++ b/clab_connector/services/removal/__init__.py @@ -0,0 +1,3 @@ +# clab_connector/services/removal/__init__.py + +"""Removal services (Uninstall/teardown of topology).""" diff --git a/clab_connector/services/removal/topology_remover.py b/clab_connector/services/removal/topology_remover.py new file mode 100644 index 0000000..e5db755 --- /dev/null +++ b/clab_connector/services/removal/topology_remover.py @@ -0,0 +1,54 @@ +# clab_connector/services/removal/topology_remover.py + +import logging + +from clab_connector.models.topology import parse_topology_file +from clab_connector.clients.eda.client import EDAClient + +logger = logging.getLogger(__name__) + + +class TopologyRemover: + """ + Handles removal of EDA resources for a given containerlab topology. + + Parameters + ---------- + eda_client : EDAClient + A connected EDAClient used to remove resources from the EDA cluster. + """ + + def __init__(self, eda_client: EDAClient): + self.eda_client = eda_client + self.topology = None + + def run(self, topology_file): + """ + Parse the topology file and remove its associated namespace. + + Parameters + ---------- + topology_file : str or Path + The containerlab topology JSON file. + + Returns + ------- + None + """ + self.topology = parse_topology_file(str(topology_file)) + + print("== Removing namespace ==") + self.remove_namespace() + self.eda_client.commit_transaction("remove namespace") + + print("Done!") + + def remove_namespace(self): + """ + Delete the EDA namespace corresponding to this topology. + """ + ns = f"clab-{self.topology.name}" + logger.info(f"Removing namespace {ns}") + self.eda_client.add_delete_to_transaction( + namespace="", kind="Namespace", name=ns + ) diff --git a/clab_connector/core/__init__.py b/clab_connector/utils/__init__.py similarity index 100% rename from clab_connector/core/__init__.py rename to clab_connector/utils/__init__.py diff --git a/clab_connector/utils/exceptions.py b/clab_connector/utils/exceptions.py new file mode 100644 index 0000000..7a0268f --- /dev/null +++ b/clab_connector/utils/exceptions.py @@ -0,0 +1,17 @@ +# clab_connector/utils/exceptions.py + + +class ClabConnectorError(Exception): + """ + Base exception for all clab-connector errors. + """ + + pass + + +class EDAConnectionError(ClabConnectorError): + """ + Raised when the EDA client cannot connect or authenticate. + """ + + pass diff --git a/clab_connector/utils/helpers.py b/clab_connector/utils/helpers.py new file mode 100644 index 0000000..307a19f --- /dev/null +++ b/clab_connector/utils/helpers.py @@ -0,0 +1,57 @@ +# clab_connector/utils/helpers.py + +import os +import logging +from jinja2 import Environment, FileSystemLoader, select_autoescape + +logger = logging.getLogger(__name__) + +PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMPLATE_DIR = os.path.join(PACKAGE_ROOT, "templates") + +template_environment = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape() +) + + +def render_template(template_name: str, data: dict) -> str: + """ + Render a Jinja2 template by name, using a data dictionary. + + Parameters + ---------- + template_name : str + The name of the template file (e.g., "node-profile.j2"). + data : dict + A dictionary of values to substitute into the template. + + Returns + ------- + str + The rendered template as a string. + """ + template = template_environment.get_template(template_name) + return template.render(data) + + +def normalize_name(name: str) -> str: + """ + Convert a name to a normalized, EDA-safe format. + + Parameters + ---------- + name : str + The original name. + + Returns + ------- + str + The normalized name. + """ + safe_name = name.lower().replace("_", "-").replace(" ", "-") + safe_name = "".join(c for c in safe_name if c.isalnum() or c in ".-").strip(".-") + if not safe_name or not safe_name[0].isalnum(): + safe_name = "x" + safe_name + if not safe_name[-1].isalnum(): + safe_name += "0" + return safe_name diff --git a/clab_connector/utils/logging_config.py b/clab_connector/utils/logging_config.py new file mode 100644 index 0000000..1b578b9 --- /dev/null +++ b/clab_connector/utils/logging_config.py @@ -0,0 +1,66 @@ +# clab_connector/utils/logging_config.py + +import logging.config +from typing import Optional +from pathlib import Path + + +def setup_logging(log_level: str = "WARNING", log_file: Optional[str] = None): + """ + Set up logging configuration with optional file output. + + Parameters + ---------- + log_level : str + Desired logging level (e.g. "WARNING", "INFO", "DEBUG"). + log_file : Optional[str] + Path to the log file. If None, logs are not written to a file. + + Returns + ------- + None + """ + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "%(message)s", + }, + "file": { + "format": "%(asctime)s %(levelname)-8s %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + }, + "handlers": { + "console": { + "class": "rich.logging.RichHandler", + "level": log_level, + "formatter": "console", + "rich_tracebacks": True, + "show_path": True, + "markup": True, + "log_time_format": "[%X]", + }, + }, + "loggers": { + "": { # Root logger + "handlers": ["console"], + "level": log_level, + }, + }, + } + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + logging_config["handlers"]["file"] = { + "class": "logging.FileHandler", + "filename": str(log_path), + "level": log_level, + "formatter": "file", + } + logging_config["loggers"][""]["handlers"].append("file") + + logging.config.dictConfig(logging_config) diff --git a/pyproject.toml b/pyproject.toml index 3466b08..d253616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "cryptography==43.0.1", "idna==3.10", "jinja2==3.1.5", + "kubernetes>=31.0.0", "markupsafe==2.1.5", "paramiko==3.5.0", "pycparser==2.22", @@ -37,4 +38,4 @@ include = [ ] [tool.hatch.build.targets.wheel] -packages = ["clab_connector"] \ No newline at end of file +packages = ["clab_connector"] diff --git a/uv.lock b/uv.lock index d391422..36b90de 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/2a/c74052e54162ec639266d91539cca7cbf3d1d3b8b36afbfeaee0ea6a1702/bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9", size = 151717 }, ] +[[package]] +name = "cachetools" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -136,6 +145,7 @@ dependencies = [ { name = "cryptography" }, { name = "idna" }, { name = "jinja2" }, + { name = "kubernetes" }, { name = "markupsafe" }, { name = "paramiko" }, { name = "pycparser" }, @@ -155,6 +165,7 @@ requires-dist = [ { name = "cryptography", specifier = "==43.0.1" }, { name = "idna", specifier = "==3.10" }, { name = "jinja2", specifier = "==3.1.5" }, + { name = "kubernetes", specifier = ">=31.0.0" }, { name = "markupsafe", specifier = "==2.1.5" }, { name = "paramiko", specifier = "==3.5.0" }, { name = "pycparser", specifier = "==2.22" }, @@ -165,9 +176,6 @@ requires-dist = [ { name = "urllib3", specifier = "==2.2.3" }, ] -[package.metadata.requires-dev] -dev = [] - [[package]] name = "click" version = "8.1.8" @@ -218,6 +226,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, ] +[[package]] +name = "durationpy" +version = "0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/e9/f49c4e7fccb77fa5c43c2480e09a857a78b41e7331a75e128ed5df45c56b/durationpy-0.9.tar.gz", hash = "sha256:fd3feb0a69a0057d582ef643c355c40d2fa1c942191f914d12203b1a01ac722a", size = 3186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461 }, +] + +[[package]] +name = "google-auth" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/af/b25763b9d35dfc2c6f9c3ec34d8d3f1ba760af3a7b7e8d5c5f0579522c45/google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00", size = 268878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/8d/4d5d5f9f500499f7bd4c93903b43e8d6976f3fc6f064637ded1a85d09b07/google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0", size = 209829 }, +] + [[package]] name = "idna" version = "3.10" @@ -239,6 +270,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] +[[package]] +name = "kubernetes" +version = "31.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "google-auth" }, + { name = "oauthlib" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/bd/ffcd3104155b467347cd9b3a64eb24182e459579845196b3a200569c8912/kubernetes-31.0.0.tar.gz", hash = "sha256:28945de906c8c259c1ebe62703b56a03b714049372196f854105afe4e6d014c0", size = 916096 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/a8/17f5e28cecdbd6d48127c22abdb794740803491f422a11905c4569d8e139/kubernetes-31.0.0-py2.py3-none-any.whl", hash = "sha256:bf141e2d380c8520eada8b351f4e319ffee9636328c137aa432bc486ca1200e1", size = 1857013 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -288,6 +341,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, +] + [[package]] name = "paramiko" version = "3.5.0" @@ -302,6 +364,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/66/14b2c030fcce69cba482d205c2d1462ca5c77303a263260dcb1192801c85/paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9", size = 227143 }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -340,6 +423,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -390,6 +485,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + [[package]] name = "rich" version = "13.9.4" @@ -403,6 +511,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] +[[package]] +name = "rsa" +version = "4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -412,6 +532,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "typer" version = "0.15.1" @@ -444,3 +573,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b763 wheels = [ { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, ] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +]