├── clab_connector ├── utils │ ├── __init__.py │ ├── constants.py │ ├── kubernetes_utils.py │ ├── exceptions.py │ ├── helpers.py │ ├── api_utils.py │ ├── logging_config.py │ └── yaml_processor.py ├── __init__.py ├── cli │ ├── __init__.py │ └── common.py ├── clients │ ├── eda │ │ ├── __init__.py │ │ ├── http_client.py │ │ └── client.py │ └── kubernetes │ │ ├── __init__.py │ │ └── client.py ├── models │ ├── node │ │ ├── __init__.py │ │ ├── factory.py │ │ ├── base.py │ │ ├── arista_ceos.py │ │ ├── nokia_srl.py │ │ └── nokia_sros.py │ ├── link.py │ └── topology.py ├── services │ ├── integration │ │ ├── __init__.py │ │ ├── sros_post_integration.py │ │ └── ceos_post_integration.py │ ├── removal │ │ ├── __init__.py │ │ └── topology_remover.py │ ├── export │ │ └── topology_exporter.py │ └── manifest │ │ └── manifest_generator.py └── templates │ ├── node-user-group.yaml.j2 │ ├── artifact.j2 │ ├── node-user.j2 │ ├── topolink.j2 │ ├── init.yaml.j2 │ ├── interface.j2 │ ├── nodesecurityprofile.yaml.j2 │ ├── toponode.j2 │ └── node-profile.j2 ├── docs └── connector.png ├── .github ├── dependabot.yml └── workflows │ └── ruff.yml ├── example-topologies ├── EDA-sros.legacy.clab.yml ├── EDA-tiny.clab.yml ├── EDA-sros.clab.yml └── EDA-T2.clab.yml ├── Makefile ├── pyproject.toml ├── .gitignore ├── LICENSE └── README.md /clab_connector/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clab_connector/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/__init__.py 2 | -------------------------------------------------------------------------------- /docs/connector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eda-labs/clab-connector/HEAD/docs/connector.png -------------------------------------------------------------------------------- /clab_connector/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/cli/__init__.py 2 | 3 | """CLI package for clab-connector.""" 4 | -------------------------------------------------------------------------------- /clab_connector/clients/eda/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/eda/__init__.py 2 | 3 | """EDA client package (REST API interactions).""" 4 | -------------------------------------------------------------------------------- /clab_connector/models/node/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/__init__.py 2 | 3 | """Node package for domain models related to nodes.""" 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "uv" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /clab_connector/services/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/integration/__init__.py 2 | 3 | """Integration services (Onboarding topology).""" 4 | -------------------------------------------------------------------------------- /clab_connector/services/removal/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/removal/__init__.py 2 | 3 | """Removal services (Uninstall/teardown of topology).""" 4 | -------------------------------------------------------------------------------- /clab_connector/clients/kubernetes/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/kubernetes/__init__.py 2 | 3 | """Kubernetes client package (kubectl interactions, etc.).""" 4 | -------------------------------------------------------------------------------- /clab_connector/utils/constants.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/constants.py 2 | 3 | """Common constants used across the clab-connector package.""" 4 | 5 | # Prefix used for log messages that denote actions within a main step 6 | SUBSTEP_INDENT = " " 7 | -------------------------------------------------------------------------------- /clab_connector/templates/node-user-group.yaml.j2: -------------------------------------------------------------------------------- 1 | apiVersion: aaa.eda.nokia.com/v1alpha1 2 | kind: NodeGroup 3 | metadata: 4 | name: sudo 5 | namespace: {{ namespace }} 6 | spec: 7 | services: 8 | - GNMI 9 | - CLI 10 | - NETCONF 11 | - GNOI 12 | - GNSI 13 | superuser: true 14 | 15 | -------------------------------------------------------------------------------- /clab_connector/templates/artifact.j2: -------------------------------------------------------------------------------- 1 | apiVersion: artifacts.eda.nokia.com/v1 2 | kind: Artifact 3 | metadata: 4 | name: {{ artifact_name }} 5 | namespace: {{ namespace }} 6 | spec: 7 | filePath: {{ artifact_filename }} 8 | remoteFileUrl: 9 | fileUrl: "{{ artifact_url }}" 10 | repo: clab-schemaprofiles 11 | -------------------------------------------------------------------------------- /clab_connector/utils/kubernetes_utils.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/kubernetes_utils.py 2 | 3 | """Minimal Kubernetes utilities to eliminate duplication""" 4 | 5 | from kubernetes import config 6 | 7 | 8 | def load_k8s_config() -> None: 9 | """Load Kubernetes config, trying in-cluster first, then local.""" 10 | 11 | try: 12 | config.load_incluster_config() 13 | except Exception: 14 | config.load_kube_config() # Will raise if no config found 15 | -------------------------------------------------------------------------------- /clab_connector/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/exceptions.py 2 | 3 | 4 | class ClabConnectorError(Exception): 5 | """ 6 | Base exception for all clab-connector errors. 7 | """ 8 | 9 | 10 | class EDAConnectionError(ClabConnectorError): 11 | """ 12 | Raised when the EDA client cannot connect or authenticate. 13 | """ 14 | 15 | 16 | class TopologyFileError(ClabConnectorError): 17 | """Raised when a topology file is missing or invalid.""" 18 | -------------------------------------------------------------------------------- /clab_connector/templates/node-user.j2: -------------------------------------------------------------------------------- 1 | apiVersion: core.eda.nokia.com/v1 2 | kind: NodeUser 3 | metadata: 4 | name: {{ node_user }} 5 | namespace: {{ namespace }} 6 | spec: 7 | username: {{ username }} 8 | password: {{ password }} 9 | groupBindings: 10 | - groups: 11 | - sudo 12 | nodeSelector: 13 | - {{ node_selector }} 14 | {%- if ssh_pub_keys and ssh_pub_keys|length > 0 %} 15 | sshPublicKeys: 16 | {% for key in ssh_pub_keys %} 17 | - {{ key }} 18 | {% endfor %} 19 | {%- endif %} 20 | -------------------------------------------------------------------------------- /clab_connector/templates/topolink.j2: -------------------------------------------------------------------------------- 1 | apiVersion: core.eda.nokia.com/v1 2 | kind: TopoLink 3 | metadata: 4 | labels: 5 | eda.nokia.com/role: {{ link_role }} 6 | name: {{ link_name }} 7 | namespace: {{ namespace }} 8 | spec: 9 | links: 10 | - local: 11 | interface: {{ local_interface }} 12 | interfaceResource: {{ local_node }}-{{ local_interface }} 13 | node: {{ local_node }} 14 | remote: 15 | interface: {{ remote_interface }} 16 | interfaceResource: {{ remote_node }}-{{ remote_interface }} 17 | node: {{ remote_node }} 18 | type: {{ link_role }} 19 | 20 | -------------------------------------------------------------------------------- /clab_connector/templates/init.yaml.j2: -------------------------------------------------------------------------------- 1 | apiVersion: bootstrap.eda.nokia.com/v1alpha1 2 | kind: Init 3 | metadata: 4 | name: {{ name }} 5 | namespace: {{ namespace }} 6 | spec: 7 | commitSave: true 8 | mgmt: 9 | ipv4DHCP: true 10 | ipv6DHCP: true 11 | {%- if gateway is defined %} 12 | staticRoutes: 13 | - nextHop: {{ gateway }} 14 | prefix: 0.0.0.0/0 15 | {%- endif %} 16 | {%- if nodeselectors is defined and nodeselectors is iterable and (nodeselectors is not string and nodeselectors is not mapping) %} 17 | nodeSelector: 18 | {%- for nodeselector in nodeselectors %} 19 | - {{ nodeselector }} 20 | {%- endfor %} 21 | {%- endif %} -------------------------------------------------------------------------------- /clab_connector/templates/interface.j2: -------------------------------------------------------------------------------- 1 | apiVersion: interfaces.eda.nokia.com/v1alpha1 2 | kind: Interface 3 | metadata: 4 | name: {{ interface_name }} 5 | namespace: {{ namespace }} 6 | {%- if label_value %} 7 | labels: 8 | {{ label_key }}: {{ label_value }} 9 | {%- endif %} 10 | spec: 11 | enabled: true 12 | {% if encap_type is not none %} 13 | encapType: {{ encap_type }} 14 | {% endif %} 15 | ethernet: 16 | stormControl: 17 | enabled: false 18 | lldp: true 19 | members: 20 | - enabled: true 21 | interface: {{ interface }} 22 | lacpPortPriority: 32768 23 | node: {{ node_name }} 24 | type: interface 25 | description: '{{ description }}' 26 | 27 | -------------------------------------------------------------------------------- /clab_connector/cli/common.py: -------------------------------------------------------------------------------- 1 | # clab_connector/cli/common.py 2 | 3 | """Minimal shared utilities to eliminate CLI duplication""" 4 | 5 | from clab_connector.clients.eda.client import EDAClient 6 | 7 | 8 | def create_eda_client(**kwargs) -> EDAClient: 9 | """Create EDA client from common parameters""" 10 | return EDAClient( 11 | hostname=kwargs["eda_url"], 12 | eda_user=kwargs.get("eda_user", "admin"), 13 | eda_password=kwargs.get("eda_password", "admin"), 14 | kc_secret=kwargs.get("kc_secret"), 15 | kc_user=kwargs.get("kc_user", "admin"), 16 | kc_password=kwargs.get("kc_password", "admin"), 17 | verify=kwargs.get("verify", False), 18 | ) 19 | -------------------------------------------------------------------------------- /example-topologies/EDA-sros.legacy.clab.yml: -------------------------------------------------------------------------------- 1 | name: eda_sros 2 | 3 | topology: 4 | kinds: 5 | nokia_srlinux: 6 | image: ghcr.io/nokia/srlinux:24.10.1 7 | nokia_sros: 8 | image: registry.srlinux.dev/pub/vr-sros:25.3.R2 9 | type: SR-1 10 | license: license.txt 11 | nodes: 12 | dc-gw-1: 13 | kind: nokia_sros 14 | spine1: 15 | kind: nokia_srlinux 16 | type: ixrd5 17 | leaf1: 18 | kind: nokia_srlinux 19 | type: ixrd3l 20 | leaf2: 21 | kind: nokia_srlinux 22 | type: ixrd3l 23 | 24 | links: 25 | - endpoints: ["spine1:e1-1", "leaf1:e1-33"] 26 | - endpoints: ["spine1:e1-2", "leaf2:e1-34"] 27 | - endpoints: ["spine1:e1-3", "dc-gw-1:1/1/3"] -------------------------------------------------------------------------------- /clab_connector/templates/nodesecurityprofile.yaml.j2: -------------------------------------------------------------------------------- 1 | apiVersion: core.eda.nokia.com/v1 2 | kind: NodeSecurityProfile 3 | metadata: 4 | name: managed-tls 5 | namespace: {{ namespace }} 6 | spec: 7 | nodeSelector: 8 | - eda.nokia.com/security-profile=managed,containerlab=managedSrl 9 | - eda.nokia.com/security-profile=managed,containerlab=managedSros 10 | - eda.nokia.com/security-profile=managed,containerlab=managedEos 11 | tls: 12 | csrParams: 13 | certificateValidity: 2160h 14 | city: Sunnyvale 15 | country: US 16 | csrSuite: CSRSUITE_X509_KEY_TYPE_RSA_2048_SIGNATURE_ALGORITHM_SHA_2_256 17 | org: NI 18 | orgUnit: EDA 19 | state: California 20 | issuerRef: eda-node-issuer 21 | -------------------------------------------------------------------------------- /example-topologies/EDA-tiny.clab.yml: -------------------------------------------------------------------------------- 1 | name: eda_tiny 2 | 3 | topology: 4 | kinds: 5 | nokia_srlinux: 6 | image: ghcr.io/nokia/srlinux:25.3.1 7 | nodes: 8 | dut1: 9 | kind: nokia_srlinux 10 | type: ixrd3l 11 | dut2: 12 | kind: nokia_srlinux 13 | type: ixrd3l 14 | dut3: 15 | kind: nokia_srlinux 16 | type: ixrd5 17 | client1: 18 | kind: linux 19 | image: ghcr.io/srl-labs/network-multitool 20 | 21 | links: 22 | # spine - leaf 23 | - endpoints: [ "dut1:e1-1", "dut3:e1-1" ] 24 | - endpoints: [ "dut1:e1-2", "dut3:e1-2" ] 25 | - endpoints: [ "dut2:e1-1", "dut3:e1-3" ] 26 | - endpoints: [ "dut2:e1-2", "dut3:e1-4" ] 27 | - endpoints: [ "dut3:e1-5", "client1:eth1" ] -------------------------------------------------------------------------------- /example-topologies/EDA-sros.clab.yml: -------------------------------------------------------------------------------- 1 | name: eda_sros 2 | 3 | topology: 4 | kinds: 5 | nokia_srlinux: 6 | image: ghcr.io/nokia/srlinux:25.7.1 7 | nokia_srsim: 8 | image: registry.srlinux.dev/pub/nokia_srsim:25.7.R1 9 | type: SR-1 10 | license: /opt/nokia/license_sros.txt 11 | nodes: 12 | dc-gw-1: 13 | kind: nokia_srsim 14 | env: 15 | NOKIA_SROS_MDA_1: me12-100gb-qsfp28 16 | spine1: 17 | kind: nokia_srlinux 18 | type: ixr-d5 19 | leaf1: 20 | kind: nokia_srlinux 21 | type: ixr-d3l 22 | leaf2: 23 | kind: nokia_srlinux 24 | type: ixr-d3l 25 | 26 | links: 27 | - endpoints: ["spine1:e1-1", "leaf1:e1-33"] 28 | - endpoints: ["spine1:e1-2", "leaf2:e1-34"] 29 | - endpoints: ["spine1:e1-3", "dc-gw-1:1/1/c3/1"] 30 | -------------------------------------------------------------------------------- /clab_connector/templates/toponode.j2: -------------------------------------------------------------------------------- 1 | apiVersion: core.eda.nokia.com/v1 2 | kind: TopoNode 3 | metadata: 4 | name: {{ node_name }} 5 | labels: 6 | eda.nokia.com/role: {{ role_value }} 7 | eda-connector.nokia.com/topology: {{ topology_name }} 8 | eda-connector.nokia.com/role: {{ topology_name}}-{{ role_value }} 9 | eda.nokia.com/security-profile: managed 10 | containerlab: {{ containerlab_label }} 11 | namespace: {{ namespace }} 12 | spec: 13 | {% if components %} 14 | component: 15 | {% for component in components %} 16 | - kind: {{ component.kind }} 17 | slot: '{{ component.slot }}' 18 | type: {{ component.type }} 19 | {% endfor %} 20 | {% endif %} 21 | nodeProfile: {{ node_profile }} 22 | operatingSystem: {{ kind }} 23 | platform: {{ platform }} 24 | version: {{ sw_version }} 25 | productionAddress: 26 | ipv4: {{ mgmt_ip }} 27 | ipv6: '' 28 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Linting 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - '**.py' 8 | - 'pyproject.toml' 9 | - '.github/workflows/ruff.yml' 10 | push: 11 | branches: [ main ] 12 | paths: 13 | - '**.py' 14 | - 'pyproject.toml' 15 | - '.github/workflows/ruff.yml' 16 | 17 | jobs: 18 | ruff: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v6 26 | with: 27 | enable-cache: true 28 | cache-dependency-glob: "uv.lock" 29 | 30 | - name: Set up Python 31 | run: uv python install 3.11 32 | 33 | - name: Install dependencies 34 | run: uv sync --frozen 35 | 36 | - name: Run Ruff check 37 | run: uv run ruff check . 38 | 39 | - name: Run Ruff format check 40 | run: uv run ruff format --check . -------------------------------------------------------------------------------- /clab_connector/templates/node-profile.j2: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: core.eda.nokia.com/v1 3 | kind: NodeProfile 4 | metadata: 5 | name: {{ profile_name }} 6 | namespace: {{ namespace }} 7 | spec: 8 | port: {{ gnmi_port }} 9 | annotate: {{ annotate | default("true") }} 10 | operatingSystem: {{ operating_system }} 11 | version: {{ sw_version }} 12 | {% if version_path is defined and version_path != "" %} 13 | versionPath: {{ version_path }} 14 | {% else %} 15 | versionPath: "" 16 | {% endif %} 17 | {% if version_match is defined and version_match != "" %} 18 | versionMatch: {{ version_match }} 19 | {% else %} 20 | versionMatch: "" 21 | {% endif %} 22 | yang: {{ yang_path }} 23 | nodeUser: {{ node_user }} 24 | onboardingPassword: {{ onboarding_password }} 25 | onboardingUsername: {{ onboarding_username }} 26 | images: 27 | - image: fake.bin 28 | imageMd5: fake.bin.md5 29 | {% if license is defined %} 30 | license: {{ license }} 31 | {% endif %} 32 | {% if llm_db is defined %} 33 | llmDb: {{ llm_db }} 34 | {% endif %} 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help install lint format test clean check all 2 | 3 | help: ## Show this help message 4 | @echo "Usage: make [target]" 5 | @echo "" 6 | @echo "Available targets:" 7 | @awk 'BEGIN {FS = ":.*##"; printf "\n"} /^[a-zA-Z_-]+:.*?##/ { printf " %-15s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) 8 | 9 | install: ## Install project dependencies with uv 10 | uv sync 11 | 12 | lint: ## Run ruff linter checks 13 | uv run ruff check . 14 | 15 | format: ## Format code with ruff 16 | uv run ruff format . 17 | 18 | test: ## Run tests with pytest 19 | uv run pytest 20 | 21 | test-cov: ## Run tests with coverage 22 | uv run pytest --cov=clab_connector 23 | 24 | check: lint ## Run all checks (lint) - required before committing 25 | @echo "✅ All checks passed!" 26 | 27 | fix: ## Auto-fix linting issues and format code 28 | uv run ruff check --fix . 29 | uv run ruff format . 30 | 31 | clean: ## Clean up cache and temporary files 32 | find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true 33 | find . -type f -name "*.pyc" -delete 34 | find . -type f -name "*.pyo" -delete 35 | find . -type f -name "*.pyd" -delete 36 | find . -type f -name ".coverage" -delete 37 | find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true 38 | find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true 39 | find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true 40 | 41 | all: check test ## Run all checks and tests -------------------------------------------------------------------------------- /clab_connector/models/node/factory.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/factory.py 2 | 3 | import logging 4 | 5 | from .arista_ceos import AristaCEOSNode 6 | from .base import Node 7 | from .nokia_srl import NokiaSRLinuxNode 8 | from .nokia_sros import NokiaSROSNode 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | KIND_MAPPING = { 13 | "nokia_srlinux": NokiaSRLinuxNode, 14 | "nokia_sros": NokiaSROSNode, 15 | "nokia_srsim": NokiaSROSNode, 16 | "arista_ceos": AristaCEOSNode, 17 | } 18 | 19 | 20 | def create_node(name: str, config: dict) -> Node: 21 | """ 22 | Create a node instance based on the kind specified in config. 23 | 24 | Parameters 25 | ---------- 26 | name : str 27 | The name of the node. 28 | config : dict 29 | A dictionary containing 'kind', 'type', 'version', 'mgmt_ipv4', etc. 30 | 31 | Returns 32 | ------- 33 | Node or None 34 | An appropriate Node subclass instance if supported; otherwise None. 35 | """ 36 | kind = config.get("kind") 37 | if not kind: 38 | logger.error(f"No 'kind' in config for node '{name}'") 39 | return None 40 | 41 | cls = KIND_MAPPING.get(kind) 42 | if cls is None: 43 | logger.info(f"Unsupported kind '{kind}' for node '{name}'") 44 | return None 45 | 46 | return cls( 47 | name=name, 48 | kind=kind, 49 | node_type=config.get("type"), 50 | version=config.get("version"), 51 | mgmt_ipv4=config.get("mgmt_ipv4"), 52 | mgmt_ipv4_prefix_length=config.get("mgmt_ipv4_prefix_length"), 53 | ) 54 | -------------------------------------------------------------------------------- /clab_connector/utils/helpers.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/helpers.py 2 | 3 | import logging 4 | import os 5 | 6 | from jinja2 import Environment, FileSystemLoader, select_autoescape 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 11 | TEMPLATE_DIR = os.path.join(PACKAGE_ROOT, "templates") 12 | 13 | template_environment = Environment( 14 | loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape() 15 | ) 16 | 17 | 18 | def render_template(template_name: str, data: dict) -> str: 19 | """ 20 | Render a Jinja2 template by name, using a data dictionary. 21 | 22 | Parameters 23 | ---------- 24 | template_name : str 25 | The name of the template file (e.g., "node-profile.j2"). 26 | data : dict 27 | A dictionary of values to substitute into the template. 28 | 29 | Returns 30 | ------- 31 | str 32 | The rendered template as a string. 33 | """ 34 | template = template_environment.get_template(template_name) 35 | return template.render(data) 36 | 37 | 38 | def normalize_name(name: str) -> str: 39 | """ 40 | Convert a name to a normalized, EDA-safe format. 41 | 42 | Parameters 43 | ---------- 44 | name : str 45 | The original name. 46 | 47 | Returns 48 | ------- 49 | str 50 | The normalized name. 51 | """ 52 | safe_name = name.lower().replace("_", "-").replace(" ", "-") 53 | safe_name = "".join(c for c in safe_name if c.isalnum() or c in ".-").strip(".-") 54 | if not safe_name or not safe_name[0].isalnum(): 55 | safe_name = "x" + safe_name 56 | if not safe_name[-1].isalnum(): 57 | safe_name += "0" 58 | return safe_name 59 | -------------------------------------------------------------------------------- /example-topologies/EDA-T2.clab.yml: -------------------------------------------------------------------------------- 1 | name: eda_t2 2 | 3 | mgmt: 4 | network: eda_t2_mgmt 5 | ipv4-subnet: 10.58.2.0/24 6 | 7 | topology: 8 | kinds: 9 | nokia_srlinux: 10 | image: ghcr.io/nokia/srlinux:24.10.1 11 | nodes: 12 | spine-1: 13 | kind: nokia_srlinux 14 | type: ixrd3l 15 | mgmt-ipv4: 10.58.2.115 16 | spine-2: 17 | kind: nokia_srlinux 18 | type: ixrd3l 19 | mgmt-ipv4: 10.58.2.116 20 | leaf-1: 21 | kind: nokia_srlinux 22 | type: ixrd2l 23 | mgmt-ipv4: 10.58.2.117 24 | leaf-2: 25 | kind: nokia_srlinux 26 | type: ixrd2l 27 | mgmt-ipv4: 10.58.2.118 28 | leaf-3: 29 | kind: nokia_srlinux 30 | type: ixrd2l 31 | mgmt-ipv4: 10.58.2.119 32 | leaf-4: 33 | kind: nokia_srlinux 34 | type: ixrd2l 35 | mgmt-ipv4: 10.58.2.120 36 | links: 37 | # spine - leaf 38 | - endpoints: ["spine-1:e1-3", "leaf-1:e1-31"] 39 | - endpoints: ["spine-1:e1-5", "leaf-1:e1-33"] 40 | - endpoints: ["spine-1:e1-4", "leaf-2:e1-31"] 41 | - endpoints: ["spine-1:e1-6", "leaf-2:e1-33"] 42 | - endpoints: ["spine-1:e1-7", "leaf-3:e1-31"] 43 | - endpoints: ["spine-1:e1-9", "leaf-3:e1-33"] 44 | - endpoints: ["spine-1:e1-8", "leaf-4:e1-31"] 45 | - endpoints: ["spine-1:e1-10", "leaf-4:e1-33"] 46 | - endpoints: ["spine-2:e1-3", "leaf-1:e1-32"] 47 | - endpoints: ["spine-2:e1-5", "leaf-1:e1-34"] 48 | - endpoints: ["spine-2:e1-4", "leaf-2:e1-32"] 49 | - endpoints: ["spine-2:e1-6", "leaf-2:e1-34"] 50 | - endpoints: ["spine-2:e1-7", "leaf-3:e1-32"] 51 | - endpoints: ["spine-2:e1-9", "leaf-3:e1-34"] 52 | - endpoints: ["spine-2:e1-8", "leaf-4:e1-32"] 53 | - endpoints: ["spine-2:e1-10", "leaf-4:e1-34"] 54 | -------------------------------------------------------------------------------- /clab_connector/services/removal/topology_remover.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/removal/topology_remover.py 2 | 3 | import logging 4 | 5 | from clab_connector.clients.eda.client import EDAClient 6 | from clab_connector.models.topology import parse_topology_file 7 | from clab_connector.utils.constants import SUBSTEP_INDENT 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TopologyRemover: 13 | """ 14 | Handles removal of EDA resources for a given containerlab topology. 15 | 16 | Parameters 17 | ---------- 18 | eda_client : EDAClient 19 | A connected EDAClient used to remove resources from the EDA cluster. 20 | """ 21 | 22 | def __init__(self, eda_client: EDAClient): 23 | self.eda_client = eda_client 24 | self.topology = None 25 | 26 | def run(self, topology_file, namespace_override: str | None = None): 27 | """ 28 | Parse the topology file and remove its associated namespace. 29 | 30 | Parameters 31 | ---------- 32 | topology_file : str or Path 33 | The containerlab topology JSON file. 34 | namespace_override : str | None 35 | Optional namespace override to delete instead of the derived name. 36 | 37 | Returns 38 | ------- 39 | None 40 | """ 41 | self.topology = parse_topology_file( 42 | str(topology_file), namespace=namespace_override 43 | ) 44 | 45 | logger.info("== Removing namespace ==") 46 | self.remove_namespace() 47 | self.eda_client.commit_transaction("remove namespace") 48 | 49 | logger.info("Done!") 50 | 51 | def remove_namespace(self): 52 | """ 53 | Delete the EDA namespace corresponding to this topology. 54 | """ 55 | ns = self.topology.namespace 56 | logger.info(f"{SUBSTEP_INDENT}Removing namespace {ns}") 57 | self.eda_client.add_delete_to_transaction( 58 | namespace="", kind="Namespace", name=ns 59 | ) 60 | -------------------------------------------------------------------------------- /clab_connector/utils/api_utils.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/api_utils.py 2 | 3 | """Minimal API utilities to eliminate duplication""" 4 | 5 | import json 6 | import logging 7 | 8 | HTTP_OK = 200 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def try_api_endpoints(client, endpoints, log_name="resource"): 14 | """Try multiple API endpoints until one succeeds""" 15 | for endpoint in endpoints: 16 | try: 17 | response = client.get(endpoint) 18 | if response.status == HTTP_OK: 19 | data = json.loads(response.data.decode("utf-8")) 20 | logger.debug(f"Successfully got {log_name} via endpoint: {endpoint}") 21 | return data, endpoint 22 | else: 23 | logger.debug( 24 | f"API call to {endpoint} returned status {response.status}" 25 | ) 26 | except Exception as e: 27 | logger.debug(f"Error trying endpoint {endpoint}: {e}") 28 | 29 | logger.warning(f"Failed to get {log_name} from any API endpoint") 30 | return None, None 31 | 32 | 33 | def extract_k8s_names(data, name_filter=None): 34 | """Extract names from Kubernetes-style API response""" 35 | names = [] 36 | 37 | if isinstance(data, dict) and "items" in data: 38 | for item in data["items"]: 39 | if isinstance(item, dict) and "metadata" in item: 40 | name = item["metadata"].get("name", "") 41 | if name and (not name_filter or name_filter(name)): 42 | names.append(name) 43 | elif isinstance(data, list): 44 | for item in data: 45 | if isinstance(item, str): 46 | if not name_filter or name_filter(item): 47 | names.append(item) 48 | elif isinstance(item, dict): 49 | name = item.get("name", "") or item.get("metadata", {}).get("name", "") 50 | if name and (not name_filter or name_filter(name)): 51 | names.append(name) 52 | 53 | return names 54 | -------------------------------------------------------------------------------- /clab_connector/utils/logging_config.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/logging_config.py 2 | 3 | import logging.config 4 | from pathlib import Path 5 | 6 | 7 | def setup_logging(log_level: str = "INFO", log_file: str | None = None): 8 | """ 9 | Set up logging configuration with optional file output. 10 | 11 | Parameters 12 | ---------- 13 | log_level : str 14 | Desired logging level (e.g. "WARNING", "INFO", "DEBUG"). 15 | log_file : str | None 16 | Path to the log file. If ``None``, logs are not written to a file. 17 | 18 | Returns 19 | ------- 20 | None 21 | """ 22 | logging_config = { 23 | "version": 1, 24 | "disable_existing_loggers": False, 25 | "formatters": { 26 | "console": { 27 | "format": "%(message)s", 28 | }, 29 | "file": { 30 | "format": "%(asctime)s %(levelname)-8s %(message)s", 31 | "datefmt": "%Y-%m-%d %H:%M:%S", 32 | }, 33 | }, 34 | "handlers": { 35 | "console": { 36 | "class": "rich.logging.RichHandler", 37 | "level": log_level, 38 | "formatter": "console", 39 | "rich_tracebacks": True, 40 | "show_path": True, 41 | "markup": True, 42 | "log_time_format": "[%X]", 43 | }, 44 | }, 45 | "loggers": { 46 | "": { # Root logger 47 | "handlers": ["console"], 48 | "level": log_level, 49 | }, 50 | }, 51 | } 52 | 53 | if log_file: 54 | log_path = Path(log_file) 55 | log_path.parent.mkdir(parents=True, exist_ok=True) 56 | 57 | logging_config["handlers"]["file"] = { 58 | "class": "logging.FileHandler", 59 | "filename": str(log_path), 60 | "level": log_level, 61 | "formatter": "file", 62 | } 63 | logging_config["loggers"][""]["handlers"].append("file") 64 | 65 | logging.config.dictConfig(logging_config) 66 | 67 | # Reduce verbosity from lower-level clients when using INFO level 68 | if logging.getLevelName(log_level) == "INFO": 69 | # Apply to entire clients package to catch any submodules 70 | logging.getLogger("clab_connector.clients").setLevel(logging.WARNING) 71 | -------------------------------------------------------------------------------- /clab_connector/utils/yaml_processor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import yaml 4 | 5 | from clab_connector.utils.constants import SUBSTEP_INDENT 6 | 7 | INLINE_LIST_LENGTH = 2 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class YAMLProcessor: 13 | class CustomDumper(yaml.SafeDumper): 14 | """ 15 | Custom YAML dumper that adjusts the indentation for lists and maintains certain lists in inline format. 16 | """ 17 | 18 | def custom_list_representer(self, dumper, data): 19 | # Check if we are at the specific list under 'links' with 'endpoints' 20 | if ( 21 | len(data) == INLINE_LIST_LENGTH 22 | and isinstance(data[0], str) 23 | and ":" in data[0] 24 | ): 25 | return dumper.represent_sequence( 26 | "tag:yaml.org,2002:seq", data, flow_style=True 27 | ) 28 | else: 29 | return dumper.represent_sequence( 30 | "tag:yaml.org,2002:seq", data, flow_style=False 31 | ) 32 | 33 | def custom_dict_representer(self, dumper, data): 34 | return dumper.represent_dict(data.items()) 35 | 36 | def __init__(self): 37 | # Assign custom representers to the CustomDumper class 38 | self.CustomDumper.add_representer(list, self.custom_list_representer) 39 | self.CustomDumper.add_representer(dict, self.custom_dict_representer) 40 | 41 | def load_yaml(self, yaml_str): 42 | try: 43 | # Load YAML data 44 | data = yaml.safe_load(yaml_str) 45 | return data 46 | 47 | except yaml.YAMLError as e: 48 | logger.error(f"Error loading YAML: {e!s}") 49 | raise 50 | 51 | def save_yaml(self, data, output_file, flow_style=None): 52 | try: 53 | # Save YAML data 54 | with open(output_file, "w") as file: 55 | if flow_style is None: 56 | yaml.dump( 57 | data, 58 | file, 59 | Dumper=self.CustomDumper, 60 | sort_keys=False, 61 | default_flow_style=False, 62 | indent=2, 63 | ) 64 | else: 65 | yaml.dump(data, file, default_flow_style=False, sort_keys=False) 66 | 67 | logger.info(f"{SUBSTEP_INDENT}YAML file saved as '{output_file}'.") 68 | 69 | except OSError as e: 70 | logger.error(f"Error saving YAML file: {e!s}") 71 | raise 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "clab-connector" 7 | version = "0.8.5" 8 | description = "EDA Containerlab Connector" 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | dependencies = [ 12 | "bcrypt==5.0.0", 13 | "certifi==2025.10.5", 14 | "cffi==2.0.0", 15 | "charset-normalizer==3.4.4", 16 | "cryptography==46.0.3", 17 | "idna==3.11", 18 | "jinja2==3.1.6", 19 | "kubernetes==33.1.0", 20 | "markupsafe==3.0.3", 21 | "paramiko>=4.0.0", 22 | "pycparser==2.23", 23 | "pynacl==1.6.0", 24 | "pyyaml==6.0.3", 25 | "requests==2.32.5", 26 | "typer==0.19.2", 27 | "urllib3==2.5.0", 28 | "click==8.3.0", 29 | "rich>=14.2.0", 30 | ] 31 | 32 | [project.scripts] 33 | clab-connector = "clab_connector.cli.main:app" 34 | 35 | [tool.hatch.build] 36 | include = [ 37 | "clab_connector/**/*.py", 38 | "clab_connector/**/*.j2", 39 | "clab_connector/templates/*" 40 | ] 41 | 42 | [tool.hatch.build.targets.wheel] 43 | packages = ["clab_connector"] 44 | 45 | [tool.ruff] 46 | line-length = 88 47 | target-version = "py311" 48 | extend-exclude = ["example-topologies"] 49 | 50 | [tool.ruff.lint] 51 | select = [ 52 | "E", # pycodestyle errors 53 | "W", # pycodestyle warnings 54 | "F", # Pyflakes (includes F401 unused imports, F841 unused vars) 55 | "UP", # pyupgrade 56 | "B", # flake8-bugbear 57 | "SIM", # flake8-simplify 58 | "I", # isort 59 | "N", # pep8-naming 60 | "C90", # mccabe complexity 61 | "RUF", # Ruff-specific rules 62 | "ARG", # flake8-unused-arguments (unused function arguments) 63 | "PIE", # flake8-pie (includes unnecessary placeholders) 64 | "PL", # Pylint (includes unused private members) 65 | ] 66 | ignore = [ 67 | "E501", # Line too long (handled by formatter) 68 | "W291", # Trailing whitespace (handled by formatter) 69 | "W293", # Blank line contains whitespace (handled by formatter) 70 | ] 71 | 72 | [tool.ruff.lint.per-file-ignores] 73 | "__init__.py" = ["F401"] # Allow unused imports in __init__.py 74 | "tests/**/*.py" = ["B008"] # Allow function calls in argument defaults for tests 75 | 76 | [tool.ruff.lint.flake8-quotes] 77 | docstring-quotes = "double" 78 | inline-quotes = "double" 79 | 80 | [tool.ruff.lint.pydocstyle] 81 | convention = "google" 82 | 83 | [tool.ruff.lint.mccabe] 84 | max-complexity = 15 85 | 86 | [tool.ruff.lint.pylint] 87 | max-args = 15 88 | max-branches = 13 89 | max-returns = 6 90 | max-statements = 50 91 | 92 | [tool.ruff.format] 93 | quote-style = "double" 94 | indent-style = "space" 95 | skip-magic-trailing-comma = false 96 | line-ending = "auto" 97 | 98 | [tool.mypy] 99 | python_version = "3.11" 100 | warn_return_any = true 101 | warn_unused_configs = true 102 | check_untyped_defs = true 103 | warn_redundant_casts = true 104 | warn_unused_ignores = true 105 | strict_equality = true 106 | exclude = ["example-topologies/"] 107 | 108 | [[tool.mypy.overrides]] 109 | module = ["paramiko.*", "kubernetes.*", "bcrypt.*"] 110 | ignore_missing_imports = true 111 | 112 | 113 | [tool.isort] 114 | profile = "black" 115 | line_length = 88 116 | multi_line_output = 3 117 | include_trailing_comma = true 118 | force_grid_wrap = 0 119 | use_parentheses = true 120 | ensure_newline_before_comments = true 121 | skip_glob = ["example-topologies/**/*"] 122 | 123 | [tool.black] 124 | line-length = 88 125 | target-version = ['py311'] 126 | include = '\.pyi?$' 127 | extend-exclude = ''' 128 | /( 129 | example-topologies 130 | )/ 131 | ''' 132 | 133 | [dependency-groups] 134 | dev = [ 135 | "bandit>=1.8.6", 136 | "black>=25.9.0", 137 | "isort>=7.0.0", 138 | "mypy>=1.18.2", 139 | "pytest>=8.4.2", 140 | "pytest-cov>=7.0.0", 141 | "ruff>=0.14.2", 142 | ] 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | *.bak 165 | example-topologies/*/ 166 | 167 | .envrc 168 | CLAUDE.md 169 | AGENTS.md 170 | license.txt 171 | patch.sh -------------------------------------------------------------------------------- /clab_connector/clients/eda/http_client.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/eda/http_client.py 2 | 3 | import logging 4 | import os 5 | import re 6 | from urllib.parse import urlparse 7 | 8 | import urllib3 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def get_proxy_settings(): 14 | """ 15 | Read proxy environment variables. 16 | 17 | Returns 18 | ------- 19 | tuple 20 | (http_proxy, https_proxy, no_proxy). 21 | """ 22 | http_upper = os.environ.get("HTTP_PROXY") 23 | http_lower = os.environ.get("http_proxy") 24 | https_upper = os.environ.get("HTTPS_PROXY") 25 | https_lower = os.environ.get("https_proxy") 26 | no_upper = os.environ.get("NO_PROXY") 27 | no_lower = os.environ.get("no_proxy") 28 | 29 | if http_upper and http_lower and http_upper != http_lower: 30 | logger.warning("Both HTTP_PROXY and http_proxy are set. Using HTTP_PROXY.") 31 | if https_upper and https_lower and https_upper != https_lower: 32 | logger.warning("Both HTTPS_PROXY and https_proxy are set. Using HTTPS_PROXY.") 33 | if no_upper and no_lower and no_upper != no_lower: 34 | logger.warning("Both NO_PROXY and no_proxy are set. Using NO_PROXY.") 35 | 36 | http_proxy = http_upper if http_upper else http_lower 37 | https_proxy = https_upper if https_upper else https_lower 38 | no_proxy = no_upper if no_upper else no_lower or "" 39 | return http_proxy, https_proxy, no_proxy 40 | 41 | 42 | def should_bypass_proxy(url, no_proxy=None): 43 | """ 44 | Check if a URL should bypass proxy based on NO_PROXY settings. 45 | 46 | Parameters 47 | ---------- 48 | url : str 49 | The URL to check. 50 | no_proxy : str, optional 51 | NO_PROXY environment variable content. 52 | 53 | Returns 54 | ------- 55 | bool 56 | True if the URL is matched by no_proxy patterns, False otherwise. 57 | """ 58 | if no_proxy is None: 59 | _, _, no_proxy = get_proxy_settings() 60 | if not no_proxy: 61 | return False 62 | 63 | parsed_url = urlparse(url if "//" in url else f"http://{url}") 64 | hostname = parsed_url.hostname 65 | if not hostname: 66 | return False 67 | 68 | no_proxy_parts = [p.strip() for p in no_proxy.split(",") if p.strip()] 69 | 70 | for np_entry in no_proxy_parts: 71 | pattern_val = np_entry[1:] if np_entry.startswith(".") else np_entry 72 | # Convert wildcard to regex 73 | pattern = re.escape(pattern_val).replace(r"\*", ".*") 74 | if re.match(f"^{pattern}$", hostname, re.IGNORECASE): 75 | return True 76 | 77 | return False 78 | 79 | 80 | def create_pool_manager(url=None, verify=True): 81 | """ 82 | Create an appropriate urllib3 PoolManager or ProxyManager for the given URL. 83 | 84 | Parameters 85 | ---------- 86 | url : str, optional 87 | The base URL used to decide if proxy should be bypassed. 88 | verify : bool 89 | Whether to enforce certificate validation. 90 | 91 | Returns 92 | ------- 93 | urllib3.PoolManager or urllib3.ProxyManager 94 | The configured HTTP client manager. 95 | """ 96 | http_proxy, https_proxy, no_proxy = get_proxy_settings() 97 | if url and should_bypass_proxy(url, no_proxy): 98 | logger.debug(f"URL {url} in NO_PROXY, returning direct PoolManager.") 99 | return urllib3.PoolManager( 100 | cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", 101 | retries=urllib3.Retry(3), 102 | ) 103 | proxy_url = https_proxy or http_proxy 104 | if proxy_url: 105 | logger.debug(f"Using ProxyManager: {proxy_url}") 106 | return urllib3.ProxyManager( 107 | proxy_url, 108 | cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", 109 | retries=urllib3.Retry(3), 110 | ) 111 | logger.debug("No proxy, returning direct PoolManager.") 112 | return urllib3.PoolManager( 113 | cert_reqs="CERT_REQUIRED" if verify else "CERT_NONE", 114 | retries=urllib3.Retry(3), 115 | ) 116 | -------------------------------------------------------------------------------- /clab_connector/models/link.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/link.py 2 | 3 | import logging 4 | 5 | from clab_connector.utils import helpers 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | ENDPOINT_PARTS = 2 10 | 11 | 12 | class Link: 13 | """ 14 | Represents a bidirectional link between two nodes. 15 | 16 | Parameters 17 | ---------- 18 | node_1 : Node 19 | The first node in the link. 20 | intf_1 : str 21 | The interface name on the first node. 22 | node_2 : Node 23 | The second node in the link. 24 | intf_2 : str 25 | The interface name on the second node. 26 | """ 27 | 28 | def __init__(self, node_1, intf_1, node_2, intf_2): 29 | self.node_1 = node_1 30 | self.intf_1 = intf_1 31 | self.node_2 = node_2 32 | self.intf_2 = intf_2 33 | 34 | def __repr__(self): 35 | """ 36 | Return a string representation of the link. 37 | 38 | Returns 39 | ------- 40 | str 41 | A description of the link endpoints. 42 | """ 43 | return f"Link({self.node_1}-{self.intf_1}, {self.node_2}-{self.intf_2})" 44 | 45 | def is_topolink(self): 46 | """ 47 | Check if both endpoints are EDA-supported nodes. 48 | 49 | Returns 50 | ------- 51 | bool 52 | True if both nodes support EDA, False otherwise. 53 | """ 54 | if self.node_1 is None or not self.node_1.is_eda_supported(): 55 | return False 56 | return not (self.node_2 is None or not self.node_2.is_eda_supported()) 57 | 58 | def is_edge_link(self): 59 | """Check if exactly one endpoint is EDA-supported and the other is a linux node.""" 60 | if not self.node_1 or not self.node_2: 61 | return False 62 | if self.node_1.is_eda_supported() and self.node_2.kind == "linux": 63 | return True 64 | return bool(self.node_2.is_eda_supported() and self.node_1.kind == "linux") 65 | 66 | def get_link_name(self, topology): 67 | """ 68 | Create a unique name for the link resource. 69 | 70 | Parameters 71 | ---------- 72 | topology : Topology 73 | The topology that owns this link. 74 | 75 | Returns 76 | ------- 77 | str 78 | A link name safe for EDA. 79 | """ 80 | return helpers.normalize_name( 81 | f"{self.node_1.get_node_name(topology)}-{self.intf_1}-{self.node_2.get_node_name(topology)}-{self.intf_2}" 82 | ) 83 | 84 | def get_topolink_yaml(self, topology): 85 | """ 86 | Render and return the TopoLink YAML if the link is EDA-supported. 87 | 88 | Parameters 89 | ---------- 90 | topology : Topology 91 | The topology that owns this link. 92 | 93 | Returns 94 | ------- 95 | str or None 96 | The rendered TopoLink CR YAML, or None if not EDA-supported. 97 | """ 98 | if self.is_topolink(): 99 | role = "interSwitch" 100 | elif self.is_edge_link(): 101 | role = "edge" 102 | else: 103 | return None 104 | data = { 105 | "namespace": topology.namespace, 106 | "link_role": role, 107 | "link_name": self.get_link_name(topology), 108 | "local_node": self.node_1.get_node_name(topology), 109 | "local_interface": self.node_1.get_interface_name_for_kind(self.intf_1), 110 | "remote_node": self.node_2.get_node_name(topology), 111 | "remote_interface": self.node_2.get_interface_name_for_kind(self.intf_2), 112 | } 113 | return helpers.render_template("topolink.j2", data) 114 | 115 | 116 | def create_link(endpoints: list, nodes: list) -> Link: 117 | """ 118 | Create a Link object from two endpoint definitions and a list of Node objects. 119 | 120 | Parameters 121 | ---------- 122 | endpoints : list 123 | A list of exactly two endpoint strings, e.g. ["nodeA:e1-1", "nodeB:e1-1"]. 124 | nodes : list 125 | A list of Node objects in the topology. 126 | 127 | Returns 128 | ------- 129 | Link 130 | A Link object representing the connection. 131 | 132 | Raises 133 | ------ 134 | ValueError 135 | If the endpoint format is invalid or length is not 2. 136 | """ 137 | 138 | if len(endpoints) != ENDPOINT_PARTS: 139 | raise ValueError(f"Link endpoints must be a list of length {ENDPOINT_PARTS}") 140 | 141 | def parse_endpoint(ep): 142 | parts = ep.split(":") 143 | if len(parts) != ENDPOINT_PARTS: 144 | raise ValueError(f"Invalid endpoint '{ep}', must be 'node:iface'") 145 | return parts[0], parts[1] 146 | 147 | node_a, if_a = parse_endpoint(endpoints[0]) 148 | node_b, if_b = parse_endpoint(endpoints[1]) 149 | 150 | node_a_obj = next((n for n in nodes if n.name == node_a), None) 151 | node_b_obj = next((n for n in nodes if n.name == node_b), None) 152 | return Link(node_a_obj, if_a, node_b_obj, if_b) 153 | -------------------------------------------------------------------------------- /clab_connector/services/export/topology_exporter.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/export/topology_exporter.py 2 | 3 | import logging 4 | from ipaddress import IPv4Address, IPv4Network 5 | 6 | from clab_connector.clients.kubernetes.client import ( 7 | list_topolinks_in_namespace, 8 | list_toponodes_in_namespace, 9 | ) 10 | from clab_connector.utils.constants import SUBSTEP_INDENT 11 | from clab_connector.utils.yaml_processor import YAMLProcessor 12 | 13 | 14 | class TopologyExporter: 15 | """ 16 | TopologyExporter retrieves EDA toponodes/topolinks from a namespace 17 | and converts them to a .clab.yaml data structure. 18 | 19 | Parameters 20 | ---------- 21 | namespace : str 22 | The Kubernetes namespace that contains EDA toponodes/topolinks. 23 | output_file : str 24 | The path where the .clab.yaml file will be written. 25 | logger : logging.Logger 26 | A logger instance for output/diagnostics. 27 | """ 28 | 29 | def __init__(self, namespace: str, output_file: str, logger: logging.Logger): 30 | self.namespace = namespace 31 | self.output_file = output_file 32 | self.logger = logger 33 | 34 | def run(self): 35 | """ 36 | Fetch the nodes and links, build containerlab YAML, and write to output_file. 37 | """ 38 | # 1. Fetch data 39 | try: 40 | node_items = list_toponodes_in_namespace(self.namespace) 41 | link_items = list_topolinks_in_namespace(self.namespace) 42 | except Exception as e: 43 | self.logger.error(f"Failed to list toponodes/topolinks: {e}") 44 | raise 45 | 46 | # 2. Gather mgmt IP addresses for optional mgmt subnet 47 | mgmt_ips = self._collect_management_ips(node_items) 48 | 49 | mgmt_subnet = self._derive_mgmt_subnet(mgmt_ips) 50 | 51 | clab_data = { 52 | "name": self.namespace, # Use namespace as "lab name" 53 | "mgmt": {"network": f"{self.namespace}-mgmt", "ipv4-subnet": mgmt_subnet}, 54 | "topology": { 55 | "nodes": {}, 56 | "links": [], 57 | }, 58 | } 59 | 60 | # 3. Convert each toponode into containerlab node config 61 | for node_item in node_items: 62 | node_name, node_def = self._build_node_definition(node_item) 63 | if node_name and node_def: 64 | clab_data["topology"]["nodes"][node_name] = node_def 65 | 66 | # 4. Convert each topolink into containerlab link config 67 | for link_item in link_items: 68 | self._build_link_definitions(link_item, clab_data["topology"]["links"]) 69 | 70 | # 5. Write the .clab.yaml 71 | self._write_clab_yaml(clab_data) 72 | 73 | def _collect_management_ips(self, node_items): 74 | ips = [] 75 | for node_item in node_items: 76 | spec = node_item.get("spec", {}) 77 | status = node_item.get("status", {}) 78 | production_addr = ( 79 | spec.get("productionAddress") or status.get("productionAddress") or {} 80 | ) 81 | mgmt_ip = production_addr.get("ipv4") 82 | 83 | if not mgmt_ip and "node-details" in status: 84 | node_details = status["node-details"] 85 | mgmt_ip = node_details.split(":")[0] 86 | 87 | if mgmt_ip: 88 | try: 89 | ips.append(IPv4Address(mgmt_ip)) 90 | except ValueError: 91 | self.logger.warning( 92 | f"{SUBSTEP_INDENT}Invalid IP address found: {mgmt_ip}" 93 | ) 94 | return ips 95 | 96 | def _derive_mgmt_subnet(self, mgmt_ips): 97 | """ 98 | Given a list of IPv4Addresses, compute a smallest common subnet. 99 | If none, fallback to '172.80.80.0/24'. 100 | """ 101 | if not mgmt_ips: 102 | self.logger.warning( 103 | f"{SUBSTEP_INDENT}No valid management IPs found, using default subnet" 104 | ) 105 | return "172.80.80.0/24" 106 | 107 | min_ip = min(mgmt_ips) 108 | max_ip = max(mgmt_ips) 109 | 110 | min_bits = format(int(min_ip), "032b") 111 | max_bits = format(int(max_ip), "032b") 112 | 113 | common_prefix = 0 114 | for i in range(32): 115 | if min_bits[i] == max_bits[i]: 116 | common_prefix += 1 117 | else: 118 | break 119 | 120 | subnet = IPv4Network(f"{min_ip}/{common_prefix}", strict=False) 121 | return str(subnet) 122 | 123 | def _build_node_definition(self, node_item): 124 | """ 125 | Convert an EDA toponode item into a containerlab 'node definition'. 126 | Returns (node_name, node_def) or (None, None) if skipped. 127 | """ 128 | meta = node_item.get("metadata", {}) 129 | spec = node_item.get("spec", {}) 130 | status = node_item.get("status", {}) 131 | 132 | node_name = meta.get("name") 133 | if not node_name: 134 | self.logger.warning( 135 | f"{SUBSTEP_INDENT}Node item missing metadata.name, skipping." 136 | ) 137 | return None, None 138 | 139 | operating_system = ( 140 | spec.get("operatingSystem") or status.get("operatingSystem") or "" 141 | ) 142 | version = spec.get("version") or status.get("version") or "" 143 | 144 | production_addr = ( 145 | spec.get("productionAddress") or status.get("productionAddress") or {} 146 | ) 147 | mgmt_ip = production_addr.get("ipv4") 148 | 149 | # If no productionAddress IP, try node-details 150 | if not mgmt_ip and "node-details" in status: 151 | node_details = status["node-details"] 152 | mgmt_ip = node_details.split(":")[0] 153 | 154 | if not mgmt_ip: 155 | self.logger.warning( 156 | f"{SUBSTEP_INDENT}No mgmt IP found for node '{node_name}', skipping." 157 | ) 158 | return None, None 159 | 160 | # guess 'nokia_srlinux' if operating_system is 'srl*' 161 | kind = "nokia_srlinux" 162 | if operating_system.lower().startswith("sros"): 163 | kind = "nokia_srsim" 164 | 165 | node_def = { 166 | "kind": kind, 167 | "mgmt-ipv4": mgmt_ip, 168 | } 169 | if version: 170 | node_def["image"] = f"ghcr.io/nokia/srlinux:{version}" 171 | 172 | return node_name, node_def 173 | 174 | def _build_link_definitions(self, link_item, links_array): 175 | link_spec = link_item.get("spec", {}) 176 | link_entries = link_spec.get("links", []) 177 | meta = link_item.get("metadata", {}) 178 | link_name = meta.get("name", "unknown-link") 179 | 180 | for entry in link_entries: 181 | local_node = entry.get("local", {}).get("node") 182 | local_intf = entry.get("local", {}).get("interface") 183 | remote_node = entry.get("remote", {}).get("node") 184 | remote_intf = entry.get("remote", {}).get("interface") 185 | if local_node and local_intf and remote_node and remote_intf: 186 | links_array.append( 187 | { 188 | "endpoints": [ 189 | f"{local_node}:{local_intf}", 190 | f"{remote_node}:{remote_intf}", 191 | ] 192 | } 193 | ) 194 | else: 195 | self.logger.warning( 196 | f"{SUBSTEP_INDENT}Incomplete link entry in {link_name}, skipping that entry." 197 | ) 198 | 199 | def _write_clab_yaml(self, clab_data): 200 | """ 201 | Save the final containerlab data structure as YAML to self.output_file. 202 | """ 203 | processor = YAMLProcessor() 204 | try: 205 | processor.save_yaml(clab_data, self.output_file) 206 | self.logger.info( 207 | f"{SUBSTEP_INDENT}Exported containerlab file: {self.output_file}" 208 | ) 209 | except OSError as e: 210 | self.logger.error(f"Failed to write containerlab file: {e}") 211 | raise 212 | -------------------------------------------------------------------------------- /clab_connector/models/node/base.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/base.py 2 | 3 | import logging 4 | 5 | from clab_connector.clients.kubernetes.client import ping_from_bsvr 6 | from clab_connector.utils import helpers 7 | from clab_connector.utils.exceptions import ClabConnectorError 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Node: 13 | """ 14 | Base Node class for representing a generic containerlab node. 15 | 16 | Parameters 17 | ---------- 18 | name : str 19 | The name of the node. 20 | kind : str 21 | The kind of the node (e.g. nokia_srlinux). 22 | node_type : str 23 | The specific node type (e.g. ixrd2). 24 | version : str 25 | The software version of the node. 26 | mgmt_ipv4 : str 27 | The management IPv4 address of the node. 28 | mgmt_ipv4_prefix_length : str 29 | The management IPv4 address prefix length of the node. 30 | """ 31 | 32 | def __init__( 33 | self, name, kind, node_type, version, mgmt_ipv4, mgmt_ipv4_prefix_length 34 | ): 35 | self.name = name 36 | self.kind = kind 37 | self.node_type = node_type or self.get_default_node_type() 38 | self.version = version 39 | self.mgmt_ipv4 = mgmt_ipv4 40 | self.mgmt_ipv4_prefix_length = mgmt_ipv4_prefix_length 41 | 42 | def _require_version(self): 43 | """Raise an error if the node has no software version defined.""" 44 | if not self.version: 45 | raise ClabConnectorError(f"Node {self.name} is missing a version") 46 | 47 | def __repr__(self): 48 | """ 49 | Return a string representation of the node. 50 | 51 | Returns 52 | ------- 53 | str 54 | A string describing the node and its parameters. 55 | """ 56 | return ( 57 | f"Node(name={self.name}, kind={self.kind}, type={self.node_type}, " 58 | f"version={self.version}, mgmt_ipv4={self.mgmt_ipv4}, mgmt_ipv4_prefix_length={self.mgmt_ipv4_prefix_length})" 59 | ) 60 | 61 | def ping(self): 62 | """ 63 | Attempt to ping the node from the EDA bootstrap server (bsvr). 64 | 65 | Returns 66 | ------- 67 | bool 68 | True if the ping is successful, raises a RuntimeError otherwise. 69 | """ 70 | logger.debug(f"Pinging node '{self.name}' IP {self.mgmt_ipv4}") 71 | if ping_from_bsvr(self.mgmt_ipv4): 72 | logger.debug(f"Ping to '{self.name}' ({self.mgmt_ipv4}) successful") 73 | return True 74 | else: 75 | msg = f"Ping to '{self.name}' ({self.mgmt_ipv4}) failed" 76 | logger.error(msg) 77 | raise RuntimeError(msg) 78 | 79 | def get_node_name(self, _topology): 80 | """ 81 | Generate a name suitable for EDA resources, based on the node name. 82 | 83 | Parameters 84 | ---------- 85 | topology : Topology 86 | The topology the node belongs to. 87 | 88 | Returns 89 | ------- 90 | str 91 | A normalized node name safe for EDA. 92 | """ 93 | return helpers.normalize_name(self.name) 94 | 95 | def get_default_node_type(self): 96 | """ 97 | Get the default node type if none is specified. 98 | 99 | Returns 100 | ------- 101 | str or None 102 | A default node type or None. 103 | """ 104 | return None 105 | 106 | def get_platform(self): 107 | """ 108 | Return the platform name for the node. 109 | 110 | Returns 111 | ------- 112 | str 113 | The platform name (default 'UNKNOWN'). 114 | """ 115 | return "UNKNOWN" 116 | 117 | def is_eda_supported(self): 118 | """ 119 | Check whether the node kind is supported by EDA. 120 | 121 | Returns 122 | ------- 123 | bool 124 | True if supported, False otherwise. 125 | """ 126 | return False 127 | 128 | def get_profile_name(self, topology): 129 | """ 130 | Get the name of the NodeProfile for this node. 131 | 132 | Parameters 133 | ---------- 134 | topology : Topology 135 | The topology this node belongs to. 136 | 137 | Returns 138 | ------- 139 | str 140 | The NodeProfile name for EDA resource creation. 141 | 142 | Raises 143 | ------ 144 | NotImplementedError 145 | Must be implemented by subclasses. 146 | """ 147 | raise NotImplementedError("Must be implemented by subclass") 148 | 149 | def get_node_profile(self, _topology): 150 | """ 151 | Render and return NodeProfile YAML for the node. 152 | 153 | Parameters 154 | ---------- 155 | topology : Topology 156 | The topology the node belongs to. 157 | 158 | Returns 159 | ------- 160 | str or None 161 | The rendered NodeProfile YAML, or None if not applicable. 162 | """ 163 | return None 164 | 165 | def get_toponode(self, _topology): 166 | """ 167 | Render and return TopoNode YAML for the node. 168 | 169 | Parameters 170 | ---------- 171 | topology : Topology 172 | The topology the node belongs to. 173 | 174 | Returns 175 | ------- 176 | str or None 177 | The rendered TopoNode YAML, or None if not applicable. 178 | """ 179 | return None 180 | 181 | def get_interface_name_for_kind(self, ifname): 182 | """ 183 | Convert an interface name from a containerlab style to EDA style. 184 | 185 | Parameters 186 | ---------- 187 | ifname : str 188 | The interface name in containerlab format. 189 | 190 | Returns 191 | ------- 192 | str 193 | A suitable interface name for EDA. 194 | """ 195 | return ifname 196 | 197 | def get_topolink_interface_name(self, topology, ifname): 198 | """ 199 | Generate a unique interface resource name for a link. 200 | 201 | Parameters 202 | ---------- 203 | topology : Topology 204 | The topology that this node belongs to. 205 | ifname : str 206 | The interface name (containerlab style). 207 | 208 | Returns 209 | ------- 210 | str 211 | The name that EDA will use for this interface resource. 212 | """ 213 | return ( 214 | f"{self.get_node_name(topology)}-{self.get_interface_name_for_kind(ifname)}" 215 | ) 216 | 217 | def get_topolink_interface( 218 | self, 219 | _topology, 220 | _ifname, 221 | _other_node, 222 | _edge_encapsulation: str | None = None, 223 | _isl_encapsulation: str | None = None, 224 | ): 225 | """ 226 | Render and return the interface resource YAML (Interface CR) for a link endpoint. 227 | 228 | Parameters 229 | ---------- 230 | topology : Topology 231 | The topology that this node belongs to. 232 | ifname : str 233 | The interface name on this node (containerlab style). 234 | other_node : Node 235 | The peer node at the other end of the link. 236 | 237 | Returns 238 | ------- 239 | str or None 240 | The rendered Interface CR YAML, or None if not applicable. 241 | """ 242 | return None 243 | 244 | def needs_artifact(self): 245 | """ 246 | Determine if this node requires a schema or binary artifact in EDA. 247 | 248 | Returns 249 | ------- 250 | bool 251 | True if an artifact is needed, False otherwise. 252 | """ 253 | return False 254 | 255 | def get_artifact_name(self): 256 | """ 257 | Return the artifact name if needed by the node. 258 | 259 | Returns 260 | ------- 261 | str or None 262 | The artifact name, or None if not needed. 263 | """ 264 | return None 265 | 266 | def get_artifact_info(self): 267 | """ 268 | Return the artifact name, filename, and download URL if needed. 269 | 270 | Returns 271 | ------- 272 | tuple 273 | (artifact_name, filename, download_url) or (None, None, None). 274 | """ 275 | return (None, None, None) 276 | 277 | def get_artifact_yaml(self, _artifact_name, _filename, _download_url): 278 | """ 279 | Render and return an Artifact CR YAML for this node. 280 | 281 | Parameters 282 | ---------- 283 | artifact_name : str 284 | The name of the artifact in EDA. 285 | filename : str 286 | The artifact file name. 287 | download_url : str 288 | The source URL of the artifact file. 289 | 290 | Returns 291 | ------- 292 | str or None 293 | The rendered Artifact CR YAML, or None if not applicable. 294 | """ 295 | return None 296 | -------------------------------------------------------------------------------- /clab_connector/models/node/arista_ceos.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import ClassVar 4 | 5 | from clab_connector.utils import helpers 6 | from clab_connector.utils.constants import SUBSTEP_INDENT 7 | 8 | from .base import Node 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class AristaCEOSNode(Node): 14 | """ 15 | Arista cEOS Node representation. 16 | 17 | This subclass implements specific logic for Arista cEOS nodes, including 18 | naming, interface mapping, and EDA resource generation. 19 | """ 20 | 21 | CEOS_USERNAME = "admin" 22 | CEOS_PASSWORD = "admin" 23 | GNMI_PORT = "50051" 24 | YANG_PATH = "https://eda-asvr.eda-system.svc/eda-system/clab-schemaprofiles/{artifact_name}/{filename}" 25 | 26 | # Mapping for EDA operating system 27 | EDA_OPERATING_SYSTEM: ClassVar[str] = "eos" 28 | 29 | SUPPORTED_SCHEMA_PROFILES: ClassVar[dict[str, tuple[str, str]]] = { 30 | "4.33.2f": ( 31 | "https://github.com/hellt/tmp/" 32 | "releases/download/v0.0.1-test1/eos-4.33.2f-v1.zip" 33 | ), 34 | "4.34.2f": ( 35 | "https://github.com/hellt/tmp/" 36 | "releases/download/v0.0.1-test1/eos-4.34.2f-v1.zip" 37 | ), 38 | } 39 | 40 | def get_platform(self): 41 | """ 42 | Return the platform name based on node type. 43 | 44 | """ 45 | return "EOS" # Default 46 | 47 | def is_eda_supported(self): 48 | """ 49 | Indicates cEOS nodes are EDA-supported. 50 | """ 51 | return True 52 | 53 | def _normalize_version(self, version): 54 | """ 55 | Normalize version string to ensure consistent format between TopoNode and NodeProfile. 56 | """ 57 | if not version: 58 | self._require_version() 59 | normalized = version.lower() 60 | return normalized 61 | 62 | def get_profile_name(self, topology): 63 | """ 64 | Generate a NodeProfile name specific to this cEOS node. 65 | Make sure it follows Kubernetes naming conventions (lowercase) 66 | and includes the topology name to ensure uniqueness. 67 | """ 68 | # Convert version to lowercase to comply with K8s naming rules 69 | self._require_version() 70 | normalized_version = self._normalize_version(self.version) 71 | # Include the topology name in the profile name for uniqueness 72 | return f"{topology.get_eda_safe_name()}-ceos-{normalized_version}" 73 | 74 | def get_node_profile(self, topology): 75 | """ 76 | Render the NodeProfile YAML for this cEOS node. 77 | """ 78 | logger.debug(f"Rendering node profile for {self.name}") 79 | self._require_version() 80 | artifact_name = self.get_artifact_name() 81 | normalized_version = self._normalize_version(self.version) 82 | filename = f"eos-{normalized_version}.zip" 83 | 84 | data = { 85 | "namespace": topology.namespace, 86 | "profile_name": self.get_profile_name(topology), 87 | "sw_version": normalized_version, # Use normalized version consistently 88 | "gnmi_port": self.GNMI_PORT, 89 | "operating_system": self.EDA_OPERATING_SYSTEM, 90 | "version_path": "", 91 | "version_match": "", 92 | "yang_path": self.YANG_PATH.format( 93 | artifact_name=artifact_name, filename=filename 94 | ), 95 | "annotate": "false", 96 | "node_user": "admin-ceos", 97 | "onboarding_password": "admin", 98 | "onboarding_username": "admin", 99 | } 100 | return helpers.render_template("node-profile.j2", data) 101 | 102 | def get_toponode(self, topology): 103 | """ 104 | Render the TopoNode YAML for this cEOS node. 105 | """ 106 | logger.info(f"{SUBSTEP_INDENT}Creating toponode for {self.name}") 107 | self._require_version() 108 | role_value = "leaf" 109 | nl = self.name.lower() 110 | if "spine" in nl: 111 | role_value = "spine" 112 | elif "borderleaf" in nl or "bl" in nl: 113 | role_value = "borderleaf" 114 | elif "dcgw" in nl: 115 | role_value = "dcgw" 116 | 117 | # Ensure all values are lowercase and valid 118 | node_name = self.get_node_name(topology) 119 | topo_name = topology.get_eda_safe_name() 120 | normalized_version = self._normalize_version(self.version) 121 | 122 | data = { 123 | "namespace": topology.namespace, 124 | "node_name": node_name, 125 | "topology_name": topo_name, 126 | "role_value": role_value, 127 | "node_profile": self.get_profile_name(topology), 128 | "kind": self.EDA_OPERATING_SYSTEM, 129 | "platform": self.get_platform(), 130 | "sw_version": normalized_version, 131 | "mgmt_ip": f"{self.mgmt_ipv4}/{self.mgmt_ipv4_prefix_length}", 132 | "containerlab_label": "managedEos", 133 | } 134 | return helpers.render_template("toponode.j2", data) 135 | 136 | def get_interface_name_for_kind(self, ifname): 137 | """ 138 | Convert a containerlab interface name to an Arista cEOS style interface. 139 | 140 | Parameters 141 | ---------- 142 | ifname : str 143 | Containerlab interface name, e.g., 'eth1_1'. 144 | 145 | Returns 146 | ------- 147 | str 148 | Arista cEOS style name, e.g. 'ethernet-1-1'. 149 | """ 150 | pattern = re.compile(r"^[a-zA-Z]+(\d+)_(\d+)$") 151 | match = pattern.match(ifname) 152 | if match: 153 | return f"ethernet-{match.group(1)}-{match.group(2)}" 154 | return ifname 155 | 156 | def get_topolink_interface_name(self, topology, ifname): 157 | """ 158 | Generate a unique interface resource name for a link in EDA. 159 | Creates a valid Kubernetes resource name based on the EDA interface format. 160 | 161 | This normalizes complex interface names into valid resource names. 162 | """ 163 | node_name = self.get_node_name(topology) 164 | eda_ifname = self.get_interface_name_for_kind(ifname) 165 | 166 | # No longer strip out the 'ethernet-' prefix to maintain consistency with SR Linux 167 | return f"{node_name}-{eda_ifname}" 168 | 169 | def get_topolink_interface( 170 | self, 171 | topology, 172 | ifname, 173 | other_node, 174 | edge_encapsulation: str | None = None, 175 | isl_encapsulation: str | None = None, 176 | ): 177 | """ 178 | Render the Interface CR YAML for an cEOS link endpoint. 179 | """ 180 | logger.debug(f"{SUBSTEP_INDENT}Creating topolink interface for {self.name}") 181 | role = "interSwitch" 182 | if other_node is None or not other_node.is_eda_supported(): 183 | role = "edge" 184 | peer_name = ( 185 | other_node.get_node_name(topology) 186 | if other_node is not None 187 | else "external-endpoint" 188 | ) 189 | if role == "edge": 190 | encap_type = "dot1q" if edge_encapsulation == "dot1q" else None 191 | else: 192 | encap_type = "dot1q" if isl_encapsulation == "dot1q" else None 193 | 194 | data = { 195 | "namespace": topology.namespace, 196 | "interface_name": self.get_topolink_interface_name(topology, ifname), 197 | "label_key": "eda.nokia.com/role", 198 | "label_value": role, 199 | "encap_type": encap_type, 200 | "node_name": self.get_node_name(topology), 201 | "interface": self.get_interface_name_for_kind(ifname), 202 | "description": f"{role} link to {peer_name}", 203 | } 204 | return helpers.render_template("interface.j2", data) 205 | 206 | def needs_artifact(self): 207 | """ 208 | cEOS nodes may require a YANG artifact. 209 | """ 210 | return True 211 | 212 | def get_artifact_name(self): 213 | """ 214 | Return a name for the cEOS schema artifact. 215 | """ 216 | normalized_version = self._normalize_version(self.version) 217 | return f"clab-eos-{normalized_version}" 218 | 219 | def get_artifact_info(self): 220 | """ 221 | Return artifact metadata for the cEOS YANG schema file. 222 | """ 223 | normalized_version = self._normalize_version(self.version) 224 | # Check if we have a supported schema for this normalized version 225 | if normalized_version not in self.SUPPORTED_SCHEMA_PROFILES: 226 | logger.warning( 227 | f"{SUBSTEP_INDENT}No schema profile for version {normalized_version}" 228 | ) 229 | return (None, None, None) 230 | 231 | artifact_name = self.get_artifact_name() 232 | filename = f"eos-{normalized_version}.zip" 233 | download_url = self.SUPPORTED_SCHEMA_PROFILES[normalized_version] 234 | return (artifact_name, filename, download_url) 235 | 236 | def get_artifact_yaml(self, artifact_name, filename, download_url): 237 | """ 238 | Render the Artifact CR YAML for the cEOS YANG schema. 239 | """ 240 | data = { 241 | "artifact_name": artifact_name, 242 | "namespace": "eda-system", 243 | "artifact_filename": filename, 244 | "artifact_url": download_url, 245 | } 246 | return helpers.render_template("artifact.j2", data) 247 | -------------------------------------------------------------------------------- /clab_connector/services/manifest/manifest_generator.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/manifest/manifest_generator.py 2 | 3 | import logging 4 | import os 5 | 6 | from clab_connector.models.topology import parse_topology_file 7 | from clab_connector.utils import helpers 8 | from clab_connector.utils.constants import SUBSTEP_INDENT 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ManifestGenerator: 14 | """ 15 | Generate YAML manifests (CR definitions) from a containerlab topology. 16 | The CRs include (if applicable): 17 | - Artifacts (grouped by artifact name) 18 | - Init 19 | - NodeSecurityProfile 20 | - NodeUserGroup 21 | - NodeUser 22 | - NodeProfiles 23 | - TopoNodes 24 | - Topolink Interfaces 25 | - Topolinks 26 | 27 | When the --separate option is used, the manifests are output as one file per category 28 | (e.g. artifacts.yaml, init.yaml, etc.). Otherwise all CRs are concatenated into one YAML file. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | topology_file: str, 34 | output: str | None = None, 35 | separate: bool = False, 36 | skip_edge_intfs: bool = False, 37 | namespace: str | None = None, 38 | edge_encapsulation: str | None = None, 39 | isl_encapsulation: str | None = None, 40 | ): 41 | """ 42 | Parameters 43 | ---------- 44 | topology_file : str 45 | Path to the containerlab topology JSON file. 46 | output : str 47 | If separate is False: path to the combined output file. 48 | If separate is True: path to an output directory where each category file is written. 49 | separate : bool 50 | If True, generate separate YAML files per CR category. 51 | Otherwise, generate one combined YAML file. 52 | skip_edge_intfs : bool 53 | When True, omit edge link resources and their interfaces from the 54 | generated manifests. 55 | namespace : str | None 56 | Optional namespace override. Defaults to deriving the namespace from 57 | the topology name. 58 | """ 59 | self.topology_file = topology_file 60 | self.output = output 61 | self.separate = separate 62 | self.skip_edge_intfs = skip_edge_intfs 63 | self.namespace_override = namespace 64 | self.edge_encapsulation = edge_encapsulation 65 | self.isl_encapsulation = isl_encapsulation 66 | self.topology = None 67 | # Dictionary mapping category name to a list of YAML document strings. 68 | self.cr_groups = {} 69 | 70 | def generate(self): 71 | """Parse the topology and generate the CR YAML documents grouped by category.""" 72 | self.topology = parse_topology_file( 73 | self.topology_file, namespace=self.namespace_override 74 | ) 75 | namespace = self.topology.namespace 76 | logger.info( 77 | "Generating manifests for namespace: %s%s", 78 | namespace, 79 | " (overridden)" 80 | if self.topology.namespace_overridden 81 | else " (from topology)", 82 | ) 83 | 84 | # --- Artifacts: Group each unique artifact into one document per artifact. 85 | artifacts = [] 86 | seen_artifacts = set() 87 | for node in self.topology.nodes: 88 | if not node.needs_artifact(): 89 | continue 90 | artifact_name, filename, download_url = node.get_artifact_info() 91 | if not artifact_name or not filename or not download_url: 92 | logger.warning( 93 | f"{SUBSTEP_INDENT}No artifact info for node {node.name}; skipping." 94 | ) 95 | continue 96 | if artifact_name in seen_artifacts: 97 | continue 98 | seen_artifacts.add(artifact_name) 99 | artifact_yaml = node.get_artifact_yaml( 100 | artifact_name, filename, download_url 101 | ) 102 | if artifact_yaml: 103 | artifacts.append(artifact_yaml) 104 | if artifacts: 105 | self.cr_groups["artifacts"] = artifacts 106 | 107 | # --- Init resource 108 | init_yaml = helpers.render_template( 109 | "init.yaml.j2", 110 | { 111 | "name": "init-base", 112 | "namespace": namespace, 113 | "nodeselectors": [ 114 | "containerlab=managedSrl", 115 | "containerlab=managedSros", 116 | ], 117 | }, 118 | ) 119 | init_ceos_yaml = helpers.render_template( 120 | "init.yaml.j2", 121 | { 122 | "name": "init-base-ceos", 123 | "namespace": namespace, 124 | "gateway": self.topology.mgmt_ipv4_gw, 125 | "nodeselectors": ["containerlab=managedEos"], 126 | }, 127 | ) 128 | 129 | self.cr_groups["init"] = [init_yaml, init_ceos_yaml] 130 | 131 | # --- Node Security Profile 132 | nsp_yaml = helpers.render_template( 133 | "nodesecurityprofile.yaml.j2", {"namespace": namespace} 134 | ) 135 | self.cr_groups["node-security-profile"] = [nsp_yaml] 136 | 137 | # --- Node User Group 138 | nug_yaml = helpers.render_template( 139 | "node-user-group.yaml.j2", {"namespace": namespace} 140 | ) 141 | self.cr_groups["node-user-group"] = [nug_yaml] 142 | 143 | # --- Node User 144 | # Create SRL node user 145 | srl_data = { 146 | "namespace": namespace, 147 | "node_user": "admin", 148 | "username": "admin", 149 | "password": "NokiaSrl1!", 150 | "ssh_pub_keys": self.topology.ssh_pub_keys or [], 151 | "node_selector": "containerlab=managedSrl", 152 | } 153 | srl_node_user = helpers.render_template("node-user.j2", srl_data) 154 | 155 | # Create SROS node user 156 | sros_data = { 157 | "namespace": namespace, 158 | "node_user": "admin-sros", 159 | "username": "admin", 160 | "password": "NokiaSros1!", 161 | "ssh_pub_keys": self.topology.ssh_pub_keys or [], 162 | "node_selector": "containerlab=managedSros", 163 | } 164 | sros_node_user = helpers.render_template("node-user.j2", sros_data) 165 | 166 | # Create cEOS node user 167 | ceos_data = { 168 | "namespace": namespace, 169 | "node_user": "admin-ceos", 170 | "username": "admin", 171 | "password": "admin", 172 | "ssh_pub_keys": self.topology.ssh_pub_keys or [], 173 | "node_selector": "containerlab=managedEos", 174 | } 175 | ceos_node_user = helpers.render_template("node-user.j2", ceos_data) 176 | 177 | # Add both node users to the manifest 178 | self.cr_groups["node-user"] = [srl_node_user, sros_node_user, ceos_node_user] 179 | 180 | # --- Node Profiles 181 | profiles = self.topology.get_node_profiles() 182 | if profiles: 183 | self.cr_groups["node-profiles"] = list(profiles) 184 | 185 | # --- TopoNodes 186 | toponodes = self.topology.get_toponodes() 187 | if toponodes: 188 | self.cr_groups["toponodes"] = list(toponodes) 189 | 190 | # --- Topolink Interfaces 191 | intfs = self.topology.get_topolink_interfaces( 192 | skip_edge_link_interfaces=self.skip_edge_intfs, 193 | edge_encapsulation=self.edge_encapsulation, 194 | isl_encapsulation=self.isl_encapsulation, 195 | ) 196 | if intfs: 197 | self.cr_groups["topolink-interfaces"] = list(intfs) 198 | 199 | # --- Topolinks 200 | links = self.topology.get_topolinks(skip_edge_links=self.skip_edge_intfs) 201 | if links: 202 | self.cr_groups["topolinks"] = list(links) 203 | 204 | return self.cr_groups 205 | 206 | def output_manifests(self): 207 | """Output the generated CR YAML documents either as one combined file or as separate files per category.""" 208 | if not self.cr_groups: 209 | logger.warning(f"{SUBSTEP_INDENT}No manifests were generated.") 210 | return 211 | 212 | if not self.separate: 213 | # One combined YAML file: concatenate all documents (across all groups) with separators. 214 | all_docs = [] 215 | for category, docs in self.cr_groups.items(): 216 | header = f"# --- {category.upper()} ---" 217 | all_docs.append(header) 218 | all_docs.extend(docs) 219 | combined = "\n---\n".join(all_docs) 220 | if self.output: 221 | with open(self.output, "w") as f: 222 | f.write(combined) 223 | logger.info( 224 | f"{SUBSTEP_INDENT}Combined manifest written to {self.output}" 225 | ) 226 | else: 227 | logger.info("\n" + combined) 228 | else: 229 | # Separate files per category: self.output must be a directory. 230 | output_dir = self.output or "manifests" 231 | os.makedirs(output_dir, exist_ok=True) 232 | for category, docs in self.cr_groups.items(): 233 | combined = "\n---\n".join(docs) 234 | file_path = os.path.join(output_dir, f"{category}.yaml") 235 | with open(file_path, "w") as f: 236 | f.write(combined) 237 | logger.info( 238 | f"{SUBSTEP_INDENT}Manifest for '{category}' written to {file_path}" 239 | ) 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /clab_connector/models/topology.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/topology.py 2 | 3 | import json 4 | import logging 5 | import os 6 | 7 | from clab_connector.models.link import create_link 8 | from clab_connector.models.node.base import Node 9 | from clab_connector.models.node.factory import create_node 10 | from clab_connector.utils.exceptions import ClabConnectorError, TopologyFileError 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Topology: 16 | """ 17 | Represents a containerlab topology. 18 | 19 | Parameters 20 | ---------- 21 | name : str 22 | The name of the topology. 23 | mgmt_subnet : str 24 | The management IPv4 subnet for the topology. 25 | mgmt_gw : str 26 | The management IPv4 gateway for the topology. 27 | ssh_keys : list 28 | A list of SSH public keys. 29 | nodes : list 30 | A list of Node objects in the topology. 31 | links : list 32 | A list of Link objects in the topology. 33 | clab_file_path : str 34 | Path to the original containerlab file if available. 35 | namespace : str | None 36 | Optional namespace override to use instead of deriving from the topology name. 37 | """ 38 | 39 | def __init__( 40 | self, 41 | name, 42 | mgmt_subnet, 43 | mgmt_gw, 44 | ssh_keys, 45 | nodes, 46 | links, 47 | clab_file_path="", 48 | namespace: str | None = None, 49 | ): 50 | self.name = name 51 | self.mgmt_ipv4_subnet = mgmt_subnet 52 | self.mgmt_ipv4_gw = mgmt_gw 53 | self.ssh_pub_keys = ssh_keys 54 | self.nodes = nodes 55 | self.links = links 56 | self.clab_file_path = clab_file_path 57 | self._namespace_overridden = namespace is not None 58 | self.namespace = namespace or f"clab-{self.name}" 59 | 60 | def __repr__(self): 61 | """ 62 | Return a string representation of the topology. 63 | 64 | Returns 65 | ------- 66 | str 67 | Description of the topology name, mgmt_subnet, number of nodes and links. 68 | """ 69 | return ( 70 | f"Topology(name={self.name}, mgmt_subnet={self.mgmt_ipv4_subnet}, " 71 | f"nodes={len(self.nodes)}, links={len(self.links)})" 72 | ) 73 | 74 | def get_eda_safe_name(self): 75 | """ 76 | Convert the topology name into a format safe for use in EDA. 77 | 78 | Returns 79 | ------- 80 | str 81 | A name suitable for EDA resource naming. 82 | """ 83 | safe = self.name.lower().replace("_", "-").replace(" ", "-") 84 | safe = "".join(c for c in safe if c.isalnum() or c in ".-").strip(".-") 85 | if not safe or not safe[0].isalnum(): 86 | safe = "x" + safe 87 | if not safe[-1].isalnum(): 88 | safe += "0" 89 | return safe 90 | 91 | def set_namespace(self, namespace: str): 92 | """Explicitly set the namespace for the topology.""" 93 | 94 | self.namespace = namespace 95 | self._namespace_overridden = True 96 | 97 | def reset_namespace_to_default(self): 98 | """Reset namespace derived from the topology name if not overridden.""" 99 | 100 | if not self._namespace_overridden: 101 | self.namespace = f"clab-{self.name}" 102 | 103 | @property 104 | def namespace_overridden(self) -> bool: 105 | """Return whether a namespace override has been provided.""" 106 | 107 | return self._namespace_overridden 108 | 109 | def check_connectivity(self): 110 | """ 111 | Attempt to ping each node's management IP from the bootstrap server. 112 | 113 | Raises 114 | ------ 115 | RuntimeError 116 | If any node fails to respond to ping. 117 | """ 118 | for node in self.nodes: 119 | node.ping() 120 | 121 | def get_node_profiles(self): 122 | """ 123 | Generate NodeProfile YAML for all nodes that produce them. 124 | 125 | Returns 126 | ------- 127 | list 128 | A list of node profile YAML strings. 129 | """ 130 | profiles = {} 131 | for n in self.nodes: 132 | prof = n.get_node_profile(self) 133 | if prof: 134 | key = f"{n.kind}-{n.version}" 135 | profiles[key] = prof 136 | return profiles.values() 137 | 138 | def get_toponodes(self): 139 | """ 140 | Generate TopoNode YAML for all EDA-supported nodes. 141 | 142 | Returns 143 | ------- 144 | list 145 | A list of toponode YAML strings. 146 | """ 147 | tnodes = [] 148 | for n in self.nodes: 149 | tn = n.get_toponode(self) 150 | if tn: 151 | tnodes.append(tn) 152 | return tnodes 153 | 154 | def get_topolinks(self, skip_edge_links: bool = False): 155 | """Generate TopoLink YAML for all EDA-supported links. 156 | 157 | Parameters 158 | ---------- 159 | skip_edge_links : bool, optional 160 | When True, omit TopoLink resources for edge links (links with only 161 | one EDA supported endpoint). Defaults to False. 162 | 163 | Returns 164 | ------- 165 | list 166 | A list of topolink YAML strings. 167 | """ 168 | links = [] 169 | for ln in self.links: 170 | if skip_edge_links and ln.is_edge_link(): 171 | continue 172 | if ln.is_topolink() or ln.is_edge_link(): 173 | link_yaml = ln.get_topolink_yaml(self) 174 | if link_yaml: 175 | links.append(link_yaml) 176 | return links 177 | 178 | def get_topolink_interfaces( 179 | self, 180 | skip_edge_link_interfaces: bool = False, 181 | edge_encapsulation: str | None = None, 182 | isl_encapsulation: str | None = None, 183 | ): 184 | """ 185 | Generate Interface YAML for each link endpoint (if EDA-supported). 186 | 187 | Parameters 188 | ---------- 189 | skip_edge_link_interfaces : bool, optional 190 | When True, interface resources for edge links (links where only one 191 | side is EDA-supported) are omitted. Defaults to False. 192 | 193 | Returns 194 | ------- 195 | list 196 | A list of interface YAML strings for the link endpoints. 197 | """ 198 | interfaces = [] 199 | for ln in self.links: 200 | is_edge = ln.is_edge_link() 201 | for node, ifname, peer in ( 202 | (ln.node_1, ln.intf_1, ln.node_2), 203 | (ln.node_2, ln.intf_2, ln.node_1), 204 | ): 205 | if node is None or not node.is_eda_supported(): 206 | continue 207 | if ( 208 | skip_edge_link_interfaces 209 | and is_edge 210 | and (peer is None or not peer.is_eda_supported()) 211 | ): 212 | continue 213 | intf_yaml = node.get_topolink_interface( 214 | self, 215 | ifname, 216 | peer, 217 | edge_encapsulation=edge_encapsulation, 218 | isl_encapsulation=isl_encapsulation, 219 | ) 220 | if intf_yaml: 221 | interfaces.append(intf_yaml) 222 | return interfaces 223 | 224 | 225 | def _load_topology_data(path: str) -> dict: 226 | if not os.path.isfile(path): 227 | logger.critical(f"Topology file '{path}' does not exist!") 228 | raise TopologyFileError(f"Topology file '{path}' does not exist!") 229 | 230 | try: 231 | with open(path) as f: 232 | return json.load(f) 233 | except json.JSONDecodeError as e: 234 | logger.critical(f"File '{path}' is not valid JSON.") 235 | raise TopologyFileError(f"File '{path}' is not valid JSON.") from e 236 | except OSError as e: 237 | logger.critical(f"Failed to read topology file '{path}': {e}") 238 | raise TopologyFileError(f"Failed to read topology file '{path}': {e}") from e 239 | 240 | 241 | def _parse_nodes(nodes_data: dict) -> tuple[list[Node], dict[str, Node]]: 242 | node_objects: list[Node] = [] 243 | all_nodes: dict[str, Node] = {} 244 | for node_name, node_data in nodes_data.items(): 245 | image = node_data.get("image") 246 | version = image.split(":")[-1] if image and ":" in image else None 247 | config = { 248 | "kind": node_data["kind"], 249 | "type": node_data["labels"].get("clab-node-type", "ixrd2"), 250 | "version": version, 251 | "mgmt_ipv4": node_data.get("mgmt-ipv4-address"), 252 | "mgmt_ipv4_prefix_length": node_data.get("mgmt-ipv4-prefix-length"), 253 | } 254 | node_obj = create_node(node_name, config) or Node( 255 | name=node_name, 256 | kind=node_data["kind"], 257 | node_type=config.get("type"), 258 | version=version, 259 | mgmt_ipv4=node_data.get("mgmt-ipv4-address"), 260 | mgmt_ipv4_prefix_length=node_data.get("mgmt-ipv4-prefix-length"), 261 | ) 262 | if node_obj.is_eda_supported(): 263 | if not node_obj.version: 264 | raise ClabConnectorError(f"Node {node_name} is missing a version") 265 | node_objects.append(node_obj) 266 | all_nodes[node_name] = node_obj 267 | return node_objects, all_nodes 268 | 269 | 270 | def _parse_links(links: list, all_nodes: dict[str, Node]) -> list: 271 | link_objects = [] 272 | for link_info in links: 273 | link_endpoints_info = link_info.get( 274 | "endpoints", link_info 275 | ) # Backwards compatible with clab < 0.71.0 276 | a_name = link_endpoints_info["a"]["node"] 277 | z_name = link_endpoints_info["z"]["node"] 278 | if a_name not in all_nodes or z_name not in all_nodes: 279 | continue 280 | node_a = all_nodes[a_name] 281 | node_z = all_nodes[z_name] 282 | if not (node_a.is_eda_supported() or node_z.is_eda_supported()): 283 | continue 284 | endpoints = [ 285 | f"{a_name}:{link_endpoints_info['a']['interface']}", 286 | f"{z_name}:{link_endpoints_info['z']['interface']}", 287 | ] 288 | ln = create_link(endpoints, list(all_nodes.values())) 289 | link_objects.append(ln) 290 | return link_objects 291 | 292 | 293 | def parse_topology_file(path: str, namespace: str | None = None) -> Topology: 294 | """ 295 | Parse a containerlab topology JSON file and return a Topology object. 296 | 297 | Parameters 298 | ---------- 299 | path : str 300 | Path to the containerlab topology JSON file. 301 | namespace : str | None 302 | Optional namespace override to use instead of deriving it from the topology name. 303 | 304 | Returns 305 | ------- 306 | Topology 307 | A populated Topology object. 308 | 309 | Raises 310 | ------ 311 | TopologyFileError 312 | If the file does not exist or cannot be parsed. 313 | ValueError 314 | If the file is not recognized as a containerlab topology. 315 | """ 316 | logger.info(f"Parsing topology file '{path}'") 317 | data = _load_topology_data(path) 318 | 319 | if data.get("type") != "clab": 320 | raise ValueError("Not a valid containerlab topology file (missing 'type=clab')") 321 | 322 | name = data["name"] 323 | mgmt_subnet = data["clab"]["config"]["mgmt"].get("ipv4-subnet") 324 | mgmt_gw = data["clab"]["config"]["mgmt"].get("ipv4-gw") 325 | ssh_keys = data.get("ssh-pub-keys", []) 326 | file_path = "" 327 | 328 | if data["nodes"]: 329 | first_key = next(iter(data["nodes"])) 330 | file_path = data["nodes"][first_key]["labels"].get("clab-topo-file", "") 331 | 332 | node_objects, all_nodes = _parse_nodes(data["nodes"]) 333 | link_objects = _parse_links(data["links"], all_nodes) 334 | 335 | topo = Topology( 336 | name=name, 337 | mgmt_subnet=mgmt_subnet, 338 | mgmt_gw=mgmt_gw, 339 | ssh_keys=ssh_keys, 340 | nodes=node_objects, 341 | links=link_objects, 342 | clab_file_path=file_path, 343 | namespace=namespace, 344 | ) 345 | 346 | original = topo.name 347 | topo.name = topo.get_eda_safe_name() 348 | if topo.name != original: 349 | logger.debug(f"Renamed topology '{original}' -> '{topo.name}' for EDA safety") 350 | topo.reset_namespace_to_default() 351 | return topo 352 | -------------------------------------------------------------------------------- /clab_connector/models/node/nokia_srl.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/nokia_srl.py 2 | 3 | import logging 4 | import re 5 | from typing import ClassVar 6 | 7 | from clab_connector.utils import helpers 8 | from clab_connector.utils.constants import SUBSTEP_INDENT 9 | 10 | from .base import Node 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class NokiaSRLinuxNode(Node): 16 | """ 17 | Nokia SR Linux Node representation. 18 | 19 | This subclass implements specific logic for SR Linux nodes, including 20 | naming, interface mapping, and EDA resource generation. 21 | """ 22 | 23 | SRL_USERNAME = "admin" 24 | SRL_PASSWORD = "NokiaSrl1!" 25 | NODE_TYPE = "srlinux" 26 | GNMI_PORT = "57410" 27 | VERSION_PATH = ".system.information.version" 28 | YANG_PATH = "https://eda-asvr.eda-system.svc/eda-system/clab-schemaprofiles/{artifact_name}/{filename}" 29 | SRL_IMAGE = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin" 30 | SRL_IMAGE_MD5 = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin.md5" 31 | LLM_DB_PATH = "https://eda-asvr.eda-system.svc/eda-system/llm-dbs/llm-db-srlinux-ghcr-{version}/llm-embeddings-srl-{version}.tar.gz" 32 | 33 | # Mapping for EDA operating system 34 | EDA_OPERATING_SYSTEM: ClassVar[str] = "srl" 35 | 36 | def __init__( 37 | self, 38 | name, 39 | kind, 40 | node_type, 41 | version, 42 | mgmt_ipv4, 43 | mgmt_ipv4_prefix_length, 44 | ): 45 | """Initialize a Nokia SR Linux node and check for deprecated type syntax.""" 46 | super().__init__( 47 | name, 48 | kind, 49 | node_type, 50 | version, 51 | mgmt_ipv4, 52 | mgmt_ipv4_prefix_length, 53 | ) 54 | 55 | # Check if using old syntax (without dash) and warn about deprecation 56 | if self.node_type and "-" not in self.node_type: 57 | if "ixr" in self.node_type.lower(): 58 | logger.warning( 59 | f"Node '{self.name}' uses deprecated type syntax '{self.node_type}'. " 60 | f"Please update to '{self.node_type.replace('ixr', 'ixr-')}'. " 61 | "Old syntax will be deprecated in early 2026." 62 | ) 63 | elif self.node_type.lower() == "ixsa1": 64 | logger.warning( 65 | f"Node '{self.name}' uses deprecated type syntax '{self.node_type}'. " 66 | f"Please update to 'ixs-a1'. " 67 | "Old syntax will be deprecated in early 2026." 68 | ) 69 | 70 | SUPPORTED_SCHEMA_PROFILES: ClassVar[dict[str, str]] = { 71 | "24.10.1": ( 72 | "https://github.com/nokia/srlinux-yang-models/" 73 | "releases/download/v24.10.1/srlinux-24.10.1-492.zip" 74 | ), 75 | "24.10.2": ( 76 | "https://github.com/nokia/srlinux-yang-models/" 77 | "releases/download/v24.10.2/srlinux-24.10.2-357.zip" 78 | ), 79 | "24.10.3": ( 80 | "https://github.com/nokia/srlinux-yang-models/" 81 | "releases/download/v24.10.3/srlinux-24.10.3-201.zip" 82 | ), 83 | "24.10.4": ( 84 | "https://github.com/nokia-eda/schema-profiles/" 85 | "releases/download/nokia-srl-24.10.4/srlinux-24.10.4-244.zip" 86 | ), 87 | "24.10.5": ( 88 | "https://github.com/nokia-eda/schema-profiles/" 89 | "releases/download/nokia-srl-24.10.5/srlinux-24.10.5-344.zip" 90 | ), 91 | "25.3.1": ( 92 | "https://github.com/nokia/srlinux-yang-models/" 93 | "releases/download/v25.3.1/srlinux-25.3.1-149.zip" 94 | ), 95 | "25.3.2": ( 96 | "https://github.com/nokia-eda/schema-profiles/" 97 | "releases/download/nokia-srl-25.3.2/srlinux-25.3.2-312.zip" 98 | ), 99 | "25.3.3": ( 100 | "https://github.com/nokia-eda/schema-profiles/" 101 | "releases/download/nokia-srl-25.3.3/srlinux-25.3.3-158.zip" 102 | ), 103 | "25.7.1": ( 104 | "https://github.com/nokia-eda/schema-profiles/" 105 | "releases/download/nokia-srl-25.7.1/srlinux-25.7.1-187.zip" 106 | ), 107 | "25.7.2": ( 108 | "https://github.com/nokia-eda/schema-profiles/" 109 | "releases/download/nokia-srl-25.7.2/srlinux-25.7.2-266.zip" 110 | ), 111 | "25.10.1": ( 112 | "https://github.com/nokia-eda/schema-profiles/" 113 | "releases/download/nokia-srl-25.10.1/srlinux-25.10.1-399.zip" 114 | ), 115 | } 116 | 117 | def get_default_node_type(self): 118 | """ 119 | Return the default node type for an SR Linux node. 120 | 121 | Returns 122 | ------- 123 | str 124 | The default node type (e.g., "ixr-d3l"). 125 | """ 126 | return "ixr-d3l" 127 | 128 | def get_platform(self): 129 | """ 130 | Return the platform name based on node type. 131 | 132 | Returns 133 | ------- 134 | str 135 | The platform name (e.g. '7220 IXR-D3L'). 136 | """ 137 | m = re.match(r"(?i)(^ixr|^sxr|^ixs)-?(.*)$", self.node_type) 138 | if m: 139 | prefix = m.group(1) or "" 140 | suffix = m.group(2) or "" 141 | if prefix.lower().startswith("ixr") and suffix.lower().startswith( 142 | ("h", "d") 143 | ): 144 | return f"7220 IXR-{suffix.upper()}" 145 | elif prefix.lower().startswith("sxr"): 146 | return f"7730 IXR-{suffix.upper()}" 147 | elif prefix.lower().startswith("ixs"): 148 | return f"7215 IXS-{suffix.upper()}" 149 | else: 150 | return f"7250 IXR-{suffix.upper()}" 151 | else: 152 | return "NoMatchOnClabType" 153 | 154 | def is_eda_supported(self): 155 | """ 156 | Indicates SR Linux nodes are EDA-supported. 157 | 158 | Returns 159 | ------- 160 | bool 161 | True for SR Linux. 162 | """ 163 | return True 164 | 165 | def get_profile_name(self, topology): 166 | """ 167 | Generate a NodeProfile name specific to this SR Linux node. 168 | 169 | Parameters 170 | ---------- 171 | topology : Topology 172 | The topology object. 173 | 174 | Returns 175 | ------- 176 | str 177 | The NodeProfile name for EDA. 178 | """ 179 | self._require_version() 180 | return f"{topology.get_eda_safe_name()}-{self.NODE_TYPE}-{self.version}" 181 | 182 | def get_node_profile(self, topology): 183 | """ 184 | Render the NodeProfile YAML for this SR Linux node. 185 | """ 186 | logger.debug(f"Rendering node profile for {self.name}") 187 | self._require_version() 188 | artifact_name = self.get_artifact_name() 189 | filename = f"srlinux-{self.version}.zip" 190 | 191 | data = { 192 | "namespace": topology.namespace, 193 | "profile_name": self.get_profile_name(topology), 194 | "sw_version": self.version, 195 | "gnmi_port": self.GNMI_PORT, 196 | "operating_system": self.EDA_OPERATING_SYSTEM, 197 | "version_path": self.VERSION_PATH, 198 | "version_match": "v{}.*".format(self.version.replace(".", "\\.")), 199 | "yang_path": self.YANG_PATH.format( 200 | artifact_name=artifact_name, filename=filename 201 | ), 202 | "node_user": "admin", 203 | "onboarding_password": self.SRL_PASSWORD, 204 | "onboarding_username": self.SRL_USERNAME, 205 | "sw_image": self.SRL_IMAGE.format(version=self.version), 206 | "sw_image_md5": self.SRL_IMAGE_MD5.format(version=self.version), 207 | "llm_db": self.LLM_DB_PATH.format(version=self.version), 208 | } 209 | return helpers.render_template("node-profile.j2", data) 210 | 211 | def get_toponode(self, topology): 212 | """ 213 | Render the TopoNode YAML for this SR Linux node. 214 | """ 215 | logger.info(f"{SUBSTEP_INDENT}Creating toponode for {self.name}") 216 | self._require_version() 217 | role_value = "leaf" 218 | nl = self.name.lower() 219 | if "spine" in nl: 220 | role_value = "spine" 221 | elif "borderleaf" in nl or "bl" in nl: 222 | role_value = "borderleaf" 223 | elif "dcgw" in nl: 224 | role_value = "dcgw" 225 | 226 | data = { 227 | "namespace": topology.namespace, 228 | "node_name": self.get_node_name(topology), 229 | "topology_name": topology.get_eda_safe_name(), 230 | "role_value": role_value, 231 | "node_profile": self.get_profile_name(topology), 232 | "kind": self.EDA_OPERATING_SYSTEM, 233 | "platform": self.get_platform(), 234 | "sw_version": self.version, 235 | "mgmt_ip": self.mgmt_ipv4, 236 | "containerlab_label": "managedSrl", 237 | } 238 | return helpers.render_template("toponode.j2", data) 239 | 240 | def get_interface_name_for_kind(self, ifname): 241 | """ 242 | Convert a containerlab interface name to an SR Linux style interface. 243 | 244 | Parameters 245 | ---------- 246 | ifname : str 247 | Containerlab interface name, e.g., 'e1-1'. 248 | 249 | Returns 250 | ------- 251 | str 252 | SR Linux style name, e.g. 'ethernet-1-1'. 253 | """ 254 | pattern = re.compile(r"^e(\d+)-(\d+)$") 255 | match = pattern.match(ifname) 256 | if match: 257 | return f"ethernet-{match.group(1)}-{match.group(2)}" 258 | return ifname 259 | 260 | def get_topolink_interface( 261 | self, 262 | topology, 263 | ifname, 264 | other_node, 265 | edge_encapsulation: str | None = None, 266 | isl_encapsulation: str | None = None, 267 | ): 268 | """ 269 | Render the Interface CR YAML for an SR Linux link endpoint. 270 | 271 | Parameters 272 | ---------- 273 | topology : Topology 274 | The topology object. 275 | ifname : str 276 | The containerlab interface name on this node. 277 | other_node : Node 278 | The peer node. 279 | 280 | Returns 281 | ------- 282 | str 283 | The rendered Interface CR YAML. 284 | """ 285 | logger.debug(f"{SUBSTEP_INDENT}Creating topolink interface for {self.name}") 286 | role = "interSwitch" 287 | if other_node is None or not other_node.is_eda_supported(): 288 | role = "edge" 289 | peer_name = ( 290 | other_node.get_node_name(topology) 291 | if other_node is not None 292 | else "external-endpoint" 293 | ) 294 | if role == "edge": 295 | encap_type = "dot1q" if edge_encapsulation == "dot1q" else None 296 | else: 297 | encap_type = "dot1q" if isl_encapsulation == "dot1q" else None 298 | 299 | data = { 300 | "namespace": topology.namespace, 301 | "interface_name": self.get_topolink_interface_name(topology, ifname), 302 | "label_key": "eda.nokia.com/role", 303 | "label_value": role, 304 | "encap_type": encap_type, 305 | "node_name": self.get_node_name(topology), 306 | "interface": self.get_interface_name_for_kind(ifname), 307 | "description": f"{role} link to {peer_name}", 308 | } 309 | return helpers.render_template("interface.j2", data) 310 | 311 | def needs_artifact(self): 312 | """ 313 | SR Linux nodes may require a YANG artifact. 314 | 315 | Returns 316 | ------- 317 | bool 318 | True if an artifact is needed based on the version. 319 | """ 320 | return True 321 | 322 | def get_artifact_name(self): 323 | """ 324 | Return a name for the SR Linux schema artifact. 325 | 326 | Returns 327 | ------- 328 | str 329 | A string such as 'clab-srlinux-24.10.1'. 330 | """ 331 | return f"clab-srlinux-{self.version}" 332 | 333 | def get_artifact_info(self): 334 | """ 335 | Return artifact metadata for the SR Linux YANG schema file. 336 | 337 | Returns 338 | ------- 339 | tuple 340 | (artifact_name, filename, download_url) 341 | """ 342 | if self.version not in self.SUPPORTED_SCHEMA_PROFILES: 343 | logger.warning( 344 | f"{SUBSTEP_INDENT}No schema profile for version {self.version}" 345 | ) 346 | return (None, None, None) 347 | artifact_name = self.get_artifact_name() 348 | filename = f"srlinux-{self.version}.zip" 349 | download_url = self.SUPPORTED_SCHEMA_PROFILES[self.version] 350 | return (artifact_name, filename, download_url) 351 | 352 | def get_artifact_yaml(self, artifact_name, filename, download_url): 353 | """ 354 | Render the Artifact CR YAML for the SR Linux YANG schema. 355 | 356 | Parameters 357 | ---------- 358 | artifact_name : str 359 | The name of the artifact in EDA. 360 | filename : str 361 | The artifact file name. 362 | download_url : str 363 | The download URL of the artifact file. 364 | 365 | Returns 366 | ------- 367 | str 368 | The rendered Artifact CR YAML. 369 | """ 370 | data = { 371 | "artifact_name": artifact_name, 372 | "namespace": "eda-system", 373 | "artifact_filename": filename, 374 | "artifact_url": download_url, 375 | } 376 | return helpers.render_template("artifact.j2", data) 377 | -------------------------------------------------------------------------------- /clab_connector/clients/kubernetes/client.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/kubernetes/client.py 2 | 3 | import logging 4 | import re 5 | import time 6 | 7 | import yaml 8 | 9 | import kubernetes as k8s 10 | from clab_connector.utils.constants import SUBSTEP_INDENT 11 | from kubernetes import config 12 | from kubernetes.client.rest import ApiException 13 | from kubernetes.stream import stream 14 | from kubernetes.utils import create_from_yaml 15 | 16 | k8s_client = k8s.client 17 | 18 | HTTP_STATUS_CONFLICT = 409 19 | HTTP_STATUS_NOT_FOUND = 404 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | # Attempt to load config: 24 | # 1) If in a Kubernetes pod, load in-cluster config 25 | # 2) Otherwise load local kube config 26 | try: 27 | config.load_incluster_config() 28 | logger.debug("Using in-cluster Kubernetes config.") 29 | except Exception: 30 | try: 31 | config.load_kube_config() 32 | logger.debug("Using local kubeconfig.") 33 | except Exception: 34 | logger.debug("Kubernetes configuration could not be loaded") 35 | 36 | 37 | def get_toolbox_pod() -> str: 38 | """ 39 | Retrieves the name of the toolbox pod in the eda-system namespace, 40 | identified by labelSelector: eda.nokia.com/app=eda-toolbox. 41 | 42 | Returns 43 | ------- 44 | str 45 | The name of the first matching toolbox pod. 46 | 47 | Raises 48 | ------ 49 | RuntimeError 50 | If no toolbox pod is found. 51 | """ 52 | v1 = k8s_client.CoreV1Api() 53 | label_selector = "eda.nokia.com/app=eda-toolbox" 54 | pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) 55 | if not pods.items: 56 | raise RuntimeError("No toolbox pod found in 'eda-system' namespace.") 57 | return pods.items[0].metadata.name 58 | 59 | 60 | def get_bsvr_pod() -> str: 61 | """ 62 | Retrieves the name of the bootstrapserver (bsvr) pod in eda-system, 63 | identified by labelSelector: eda.nokia.com/app=bootstrapserver. 64 | 65 | Returns 66 | ------- 67 | str 68 | The name of the first matching bsvr pod. 69 | 70 | Raises 71 | ------ 72 | RuntimeError 73 | If no bsvr pod is found. 74 | """ 75 | v1 = k8s_client.CoreV1Api() 76 | label_selector = "eda.nokia.com/app=bootstrapserver" 77 | pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) 78 | if not pods.items: 79 | raise RuntimeError("No bsvr pod found in 'eda-system' namespace.") 80 | return pods.items[0].metadata.name 81 | 82 | 83 | def ping_from_bsvr(target_ip: str) -> bool: 84 | """ 85 | Ping a target IP from the bsvr pod. 86 | 87 | Parameters 88 | ---------- 89 | target_ip : str 90 | IP address to ping. 91 | 92 | Returns 93 | ------- 94 | bool 95 | True if ping indicates success, False otherwise. 96 | """ 97 | logger.debug(f"Pinging '{target_ip}' from the bsvr pod...") 98 | bsvr_name = get_bsvr_pod() 99 | core_api = k8s_client.CoreV1Api() 100 | command = ["ping", "-c", "1", target_ip] 101 | try: 102 | resp = stream( 103 | core_api.connect_get_namespaced_pod_exec, 104 | name=bsvr_name, 105 | namespace="eda-system", 106 | command=command, 107 | stderr=True, 108 | stdin=False, 109 | stdout=True, 110 | tty=False, 111 | ) 112 | # A quick check for "1 packets transmitted, 1 received" 113 | if "1 packets transmitted, 1 received" in resp: 114 | logger.info(f"{SUBSTEP_INDENT}Ping from bsvr to {target_ip} succeeded") 115 | return True 116 | else: 117 | logger.error( 118 | f"{SUBSTEP_INDENT}Ping from bsvr to {target_ip} failed:\n{resp}" 119 | ) 120 | return False 121 | except ApiException as exc: 122 | logger.error(f"{SUBSTEP_INDENT}API error during ping: {exc}") 123 | return False 124 | 125 | 126 | def apply_manifest(yaml_str: str, namespace: str = "eda-system") -> None: 127 | """ 128 | Apply a YAML manifest using Python's create_from_yaml(). 129 | 130 | Parameters 131 | ---------- 132 | yaml_str : str 133 | The YAML content to apply. 134 | namespace : str 135 | The namespace into which to apply this resource. 136 | 137 | Raises 138 | ------ 139 | RuntimeError 140 | If applying the manifest fails. 141 | """ 142 | try: 143 | # Parse the YAML string into a dict 144 | manifest = yaml.safe_load(yaml_str) 145 | 146 | # Get the API version and kind 147 | api_version = manifest.get("apiVersion") 148 | kind = manifest.get("kind") 149 | 150 | if not api_version or not kind: 151 | raise RuntimeError("YAML manifest must specify apiVersion and kind") 152 | 153 | # Split API version into group and version 154 | if "/" in api_version: 155 | group, version = api_version.split("/") 156 | else: 157 | group = "" 158 | version = api_version 159 | 160 | # Use CustomObjectsApi for custom resources 161 | custom_api = k8s_client.CustomObjectsApi() 162 | 163 | try: 164 | if group: 165 | # For custom resources (like Artifact) 166 | custom_api.create_namespaced_custom_object( 167 | group=group, 168 | version=version, 169 | namespace=namespace, 170 | plural=f"{kind.lower()}s", # Convention is to use lowercase plural 171 | body=manifest, 172 | ) 173 | else: 174 | # For core resources 175 | create_from_yaml( 176 | k8s_client=k8s_client.ApiClient(), 177 | yaml_file=yaml.dump(manifest), 178 | namespace=namespace, 179 | ) 180 | logger.info( 181 | f"{SUBSTEP_INDENT}Successfully applied {kind} to namespace '{namespace}'" 182 | ) 183 | except ApiException as e: 184 | if e.status == HTTP_STATUS_CONFLICT: # Already exists 185 | logger.info( 186 | f"{SUBSTEP_INDENT}{kind} already exists in namespace '{namespace}'" 187 | ) 188 | else: 189 | raise 190 | 191 | except Exception as exc: 192 | logger.error(f"Failed to apply manifest: {exc}") 193 | raise RuntimeError(f"Failed to apply manifest: {exc}") from exc 194 | 195 | 196 | def edactl_namespace_bootstrap(namespace: str) -> int | None: 197 | """ 198 | Emulate `kubectl exec -- edactl namespace bootstrap ` 199 | by streaming an exec call into the toolbox pod. 200 | 201 | Parameters 202 | ---------- 203 | namespace : str 204 | Namespace to bootstrap in EDA. 205 | 206 | Returns 207 | ------- 208 | Optional[int] 209 | The transaction ID if found, or None if skipping/existing. 210 | """ 211 | toolbox = get_toolbox_pod() 212 | core_api = k8s_client.CoreV1Api() 213 | cmd = ["edactl", "namespace", "bootstrap", namespace] 214 | try: 215 | resp = stream( 216 | core_api.connect_get_namespaced_pod_exec, 217 | name=toolbox, 218 | namespace="eda-system", 219 | command=cmd, 220 | stderr=True, 221 | stdin=False, 222 | stdout=True, 223 | tty=False, 224 | ) 225 | if "already exists" in resp: 226 | logger.info( 227 | f"{SUBSTEP_INDENT}Namespace {namespace} already exists, skipping bootstrap." 228 | ) 229 | return None 230 | 231 | match = re.search(r"Transaction (\d+)", resp) 232 | if match: 233 | tx_id = int(match.group(1)) 234 | logger.info( 235 | f"{SUBSTEP_INDENT}Created namespace {namespace} (Transaction: {tx_id})" 236 | ) 237 | return tx_id 238 | 239 | logger.info( 240 | f"{SUBSTEP_INDENT}Created namespace {namespace}, no transaction ID found." 241 | ) 242 | return None 243 | except ApiException as exc: 244 | logger.error(f"Failed to bootstrap namespace {namespace}: {exc}") 245 | raise 246 | 247 | 248 | def wait_for_namespace( 249 | namespace: str, max_retries: int = 10, retry_delay: int = 1 250 | ) -> bool: 251 | """ 252 | Wait for a namespace to exist in Kubernetes. 253 | 254 | Parameters 255 | ---------- 256 | namespace : str 257 | Namespace to wait for. 258 | max_retries : int 259 | Maximum number of attempts. 260 | retry_delay : int 261 | Delay (seconds) between attempts. 262 | 263 | Returns 264 | ------- 265 | bool 266 | True if the namespace is found, else raises. 267 | 268 | Raises 269 | ------ 270 | RuntimeError 271 | If the namespace is not found within the given attempts. 272 | """ 273 | v1 = k8s_client.CoreV1Api() 274 | for attempt in range(max_retries): 275 | try: 276 | v1.read_namespace(name=namespace) 277 | logger.info(f"{SUBSTEP_INDENT}Namespace {namespace} is available") 278 | return True 279 | except ApiException as exc: 280 | if exc.status == HTTP_STATUS_NOT_FOUND: 281 | logger.debug( 282 | f"Waiting for namespace '{namespace}' (attempt {attempt + 1}/{max_retries})" 283 | ) 284 | time.sleep(retry_delay) 285 | else: 286 | logger.error(f"Error retrieving namespace {namespace}: {exc}") 287 | raise 288 | raise RuntimeError(f"Timed out waiting for namespace {namespace}") 289 | 290 | 291 | def update_namespace_description( 292 | namespace: str, description: str, max_retries: int = 10, retry_delay: int = 2 293 | ) -> bool: 294 | """ 295 | Patch a namespace's description. For EDA, this may be a custom CRD 296 | (group=core.eda.nokia.com, version=v1, plural=namespaces). 297 | Handles 404 errors with retries if the namespace is not yet available. 298 | 299 | Parameters 300 | ---------- 301 | namespace : str 302 | The namespace to patch. 303 | description : str 304 | The new description. 305 | max_retries : int 306 | Maximum number of retry attempts. 307 | retry_delay : int 308 | Delay in seconds between retries. 309 | 310 | Returns 311 | ------- 312 | bool 313 | True if successful, False if couldn't update after retries. 314 | """ 315 | crd_api = k8s_client.CustomObjectsApi() 316 | group = "core.eda.nokia.com" 317 | version = "v1" 318 | plural = "namespaces" 319 | 320 | patch_body = {"spec": {"description": description}} 321 | 322 | # Check if namespace exists in Kubernetes first 323 | v1 = k8s_client.CoreV1Api() 324 | try: 325 | v1.read_namespace(name=namespace) 326 | except ApiException as exc: 327 | if exc.status == HTTP_STATUS_NOT_FOUND: 328 | logger.warning( 329 | f"{SUBSTEP_INDENT}Kubernetes namespace '{namespace}' does not exist. Cannot update EDA description." 330 | ) 331 | return False 332 | else: 333 | logger.error(f"Error checking namespace '{namespace}': {exc}") 334 | raise 335 | 336 | # Try to update the EDA namespace description with retries 337 | for attempt in range(max_retries): 338 | try: 339 | resp = crd_api.patch_namespaced_custom_object( 340 | group=group, 341 | version=version, 342 | namespace="eda-system", 343 | plural=plural, 344 | name=namespace, 345 | body=patch_body, 346 | ) 347 | logger.debug( 348 | f"Namespace '{namespace}' patched with description. resp={resp}" 349 | ) 350 | return True 351 | except ApiException as exc: 352 | if exc.status == HTTP_STATUS_NOT_FOUND: 353 | logger.info( 354 | f"{SUBSTEP_INDENT}EDA namespace '{namespace}' not found (attempt {attempt + 1}/{max_retries}). Retrying in {retry_delay}s..." 355 | ) 356 | time.sleep(retry_delay) 357 | else: 358 | logger.error(f"Failed to patch namespace '{namespace}': {exc}") 359 | raise 360 | 361 | logger.warning( 362 | f"{SUBSTEP_INDENT}Could not update description for namespace '{namespace}' after {max_retries} attempts." 363 | ) 364 | return False 365 | 366 | 367 | def edactl_revert_commit(commit_hash: str) -> bool: 368 | """ 369 | Revert an EDA commit by running `edactl git revert ` in the toolbox pod. 370 | 371 | Parameters 372 | ---------- 373 | commit_hash : str 374 | The commit hash to revert. 375 | 376 | Returns 377 | ------- 378 | bool 379 | True if revert is successful, False otherwise. 380 | """ 381 | toolbox = get_toolbox_pod() 382 | core_api = k8s_client.CoreV1Api() 383 | cmd = ["edactl", "git", "revert", commit_hash] 384 | try: 385 | resp = stream( 386 | core_api.connect_get_namespaced_pod_exec, 387 | name=toolbox, 388 | namespace="eda-system", 389 | command=cmd, 390 | stderr=True, 391 | stdin=False, 392 | stdout=True, 393 | tty=False, 394 | ) 395 | if "Successfully reverted commit" in resp: 396 | logger.info(f"Successfully reverted commit {commit_hash}") 397 | return True 398 | else: 399 | logger.error(f"Failed to revert commit {commit_hash}: {resp}") 400 | return False 401 | except ApiException as exc: 402 | logger.error(f"Failed to revert commit {commit_hash}: {exc}") 403 | return False 404 | 405 | 406 | def list_toponodes_in_namespace(namespace: str): 407 | crd_api = k8s_client.CustomObjectsApi() 408 | group = "core.eda.nokia.com" 409 | version = "v1" 410 | plural = "toponodes" 411 | # We do a namespaced call 412 | toponodes = crd_api.list_namespaced_custom_object( 413 | group=group, version=version, namespace=namespace, plural=plural 414 | ) 415 | # returns a dict with "items": [...] 416 | return toponodes.get("items", []) 417 | 418 | 419 | def list_topolinks_in_namespace(namespace: str): 420 | crd_api = k8s_client.CustomObjectsApi() 421 | group = "core.eda.nokia.com" 422 | version = "v1" 423 | plural = "topolinks" 424 | topolinks = crd_api.list_namespaced_custom_object( 425 | group=group, version=version, namespace=namespace, plural=plural 426 | ) 427 | return topolinks.get("items", []) 428 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Containerlab EDA Connector Tool 2 | 3 |

4 | Containerlab EDA Connector 5 |

6 | 7 | Integrate your [Containerlab](https://containerlab.dev/) topology seamlessly with [EDA (Event-Driven Automation)](https://docs.eda.dev) to streamline network automation and management. 8 | 9 | 10 | 11 | 12 | ## Overview 13 | 14 | There are two primary methods to create and experiment with network functions provided by EDA: 15 | 16 | 1. **Real Hardware:** Offers robust and reliable performance but can be challenging to acquire and maintain, especially for large-scale setups. 17 | 2. **Sandbox System:** Highly flexible and cost-effective but limited in adding secondary containers like authentication servers or establishing external connectivity. 18 | 19 | [Containerlab](https://containerlab.dev/) bridges these gaps by providing an elegant solution for network emulation using container-based topologies. This tool enhances your Containerlab experience by automating the onboarding process into EDA, ensuring a smooth and efficient integration. 20 | 21 | ## 🚨 Important Requirements 22 | 23 | > [!IMPORTANT] 24 | > **EDA Installation Mode:** This tool **requires EDA to be installed with `Simulate=False`**. Ensure that your EDA deployment is configured accordingly. 25 | > 26 | > **Hardware License:** A valid **`hardware license` for EDA version 24.12.1** is mandatory for using this connector tool. 27 | > 28 | > **Containerlab Topologies:** Your Containerlab nodes **should NOT have startup-configs defined**. Nodes with startup-configs are not EDA-ready and will not integrate properly. 29 | 30 | ## Prerequisites 31 | 32 | Before running the Containerlab EDA Connector tool, ensure the following prerequisites are met: 33 | 34 | - **EDA Setup:** 35 | - Installed without simulation (`Simulate=False`). 36 | - Contains a valid `hardware license` for version 24.12.1. 37 | - **Network Connectivity:** 38 | - EDA nodes can ping the Containerlab's management IP. 39 | - **Containerlab:** 40 | - Minimum required version - `v0.62.2` 41 | - Nodes should not have startup-configs defined 42 | - **kubectl:** 43 | - You must have `kubectl` installed and configured to connect to the same Kubernetes cluster that is running EDA. The connector will use `kubectl apply` in the background to create the necessary `Artifact` resources. 44 | 45 | 46 | > [!NOTE] 47 | > **Proxy Settings:** This tool does utilize the system's proxy (`$HTTP_PROXY` and `$HTTPS_PROXY` ) variables. 48 | 49 | ## Installation 50 | 51 | Follow these steps to set up the Containerlab EDA Connector tool: 52 | 53 | > [!TIP] 54 | > **Why uv?** 55 | > [uv](https://docs.astral.sh/uv) is a single, ultra-fast tool that can replace `pip`, `pipx`, `virtualenv`, `pip-tools`, `poetry`, and more. It automatically manages Python versions, handles ephemeral or persistent virtual environments (`uv venv`), lockfiles, and often runs **10–100× faster** than pip installs. 56 | 57 | 1. **Install uv** (no Python needed): 58 | 59 | ``` 60 | # On Linux and macOS 61 | curl -LsSf https://astral.sh/uv/install.sh | sh 62 | ``` 63 | 64 | 2. **Install clab-connector** 65 | ``` 66 | uv tool install git+https://github.com/eda-labs/clab-connector.git 67 | ``` 68 | 69 | 3. **Run the Connector** 70 | 71 | ``` 72 | clab-connector --help 73 | ``` 74 | 75 | > [!TIP] 76 | > Upgrade clab-connector to the latest version using `uv tool upgrade clab-connector`. 77 | 78 | ### Checking Version and Upgrading 79 | 80 | To check the currently installed version of clab-connector: 81 | 82 | ``` 83 | uv tool list 84 | ``` 85 | 86 | To upgrade clab-connector to the latest version: 87 | 88 | ``` 89 | uv tool upgrade clab-connector 90 | ``` 91 | 92 | ### Alternative: Using pip 93 | 94 | If you'd rather use pip or can't install uv: 95 | 96 | 1. **Create & Activate a Virtual Environment after cloning**: 97 | 98 | ``` 99 | python -m venv venv 100 | source venv/bin/activate 101 | ``` 102 | 103 | 2. **Install Your Project** (which reads `pyproject.toml` for dependencies): 104 | 105 | ``` 106 | pip install . 107 | ``` 108 | 109 | 3. **Run the Connector**: 110 | 111 | ``` 112 | clab-connector --help 113 | ``` 114 | 115 | 116 | 117 | ## Usage 118 | 119 | The tool offers two primary subcommands: `integrate` and `remove`. 120 | 121 | #### Integrate Containerlab with EDA 122 | 123 | To integrate your Containerlab topology with EDA you need the path to the 124 | `topology-data.json` file created by Containerlab when it deploys the lab. This 125 | file resides in the Containerlab Lab Directory as described in the 126 | [documentation](https://containerlab.dev/manual/conf-artifacts/). Once you have 127 | the path, run the following command: 128 | 129 | ``` 130 | clab-connector integrate \ 131 | --topology-data path/to/topology-data.json \ 132 | --eda-url https://eda.example.com \ 133 | --eda-user youruser \ 134 | --eda-password yourpassword 135 | ``` 136 | 137 | | Option | Required | Default | Description 138 | |-------------------------|----------|---------|--------------------------------------------------------| 139 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 140 | | `--eda-url`, `-e` | Yes | None | EDA deployment hostname or IP address | 141 | | `--eda-user` | No | admin | EDA username | 142 | | `--eda-password` | No | admin | EDA password | 143 | | `--kc-user` | No | admin | Keycloak master realm admin user | 144 | | `--kc-password` | No | admin | Keycloak master realm admin password | 145 | | `--kc-secret` | No | None | Use given EDA client secret and skip Keycloak flow | 146 | | `--namespace`, `-n` | No | None | Namespace to use instead of deriving from the topology | 147 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 148 | | `--log-file`, `-f` | No | None | Optional log file path | 149 | | `--verify` | No | False | Enable certificate verification for EDA | 150 | | `--skip-edge-intfs` | No | False | Skip creation of edge links and their interfaces | 151 | | `--edge-encapsulation` | No | None | Encapsulation for generated edge interfaces (`dot1q`) | 152 | | `--isl-encapsulation` | No | None | Encapsulation for inter-switch interfaces (`dot1q`) | 153 | 154 | 155 | > [!NOTE] 156 | > When SR Linux and SR OS nodes are onboarded, the connector creates the `admin` user with default passwords of `NokiaSrl1!` for SR Linux and `NokiaSros1!` for SROS. 157 | 158 | #### Remove Containerlab Integration from EDA 159 | 160 | Remove the previously integrated Containerlab topology from EDA: 161 | 162 | ``` 163 | clab-connector remove \ 164 | --topology-data path/to/topology-data.json \ 165 | --eda-url https://eda.example.com \ 166 | --eda-user youruser \ 167 | --eda-password yourpassword 168 | ``` 169 | 170 | > [!NOTE] 171 | > If you integrated into a custom namespace, pass the same value with `--namespace` so the connector removes the correct lab. 172 | 173 | | Option | Required | Default | Description 174 | |-------------------------|----------|---------|--------------------------------------------------------| 175 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 176 | | `--eda-url`, `-e` | Yes | None | EDA deployment hostname or IP address | 177 | | `--eda-user` | No | admin | EDA username | 178 | | `--eda-password` | No | admin | EDA password | 179 | | `--kc-user` | No | admin | Keycloak master realm admin user | 180 | | `--kc-password` | No | admin | Keycloak master realm admin password | 181 | | `--kc-secret` | No | None | Use given EDA client secret and skip Keycloak flow | 182 | | `--namespace`, `-n` | No | None | Namespace to use instead of deriving from the topology | 183 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 184 | | `--log-file`, `-f` | No | None | Optional log file path | 185 | | `--verify` | No | False | Enable certificate verification for EDA | 186 | 187 | 188 | 189 | #### Check Synchronization Status 190 | 191 | The `check-sync` command allows you to check the synchronization status of your nodes in EDA. It provides a detailed view of which nodes are ready, which are still syncing, and which ones have errors: 192 | 193 | ``` 194 | clab-connector check-sync \ 195 | --topology-data path/to/topology-data.json \ 196 | --eda-url https://eda.example.com \ 197 | --verbose 198 | ``` 199 | 200 | | Option | Required | Default | Description | 201 | |-------------------------|----------|---------|---------------------------------------------------------------| 202 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 203 | | `--eda-url`, `-e` | Yes | None | EDA deployment hostname or IP address | 204 | | `--eda-user` | No | admin | EDA username | 205 | | `--eda-password` | No | admin | EDA password | 206 | | `--kc-user` | No | admin | Keycloak master realm admin user | 207 | | `--kc-password` | No | admin | Keycloak master realm admin password | 208 | | `--kc-secret` | No | None | Use given EDA client secret and skip Keycloak flow | 209 | | `--namespace` | No | None | Override the namespace (instead of deriving from topology) | 210 | | `--verbose`, `-v` | No | False | Show detailed information about node status and API sources | 211 | | `--wait` | No | False | Wait for all nodes to be ready | 212 | | `--timeout` | No | 90 | Timeout in seconds when waiting for nodes to be ready | 213 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 214 | | `--log-file`, `-f` | No | None | Optional log file path | 215 | | `--verify` | No | False | Enable certificate verification for EDA | 216 | 217 | #### Export a lab from EDA to Containerlab 218 | 219 | ``` 220 | clab-connector export-lab \ 221 | --namespace eda 222 | ``` 223 | 224 | | Option | Required | Default | Description | 225 | |---------------------|----------|-----------|------------------------------------------------------------------------| 226 | | `--namespace`, `-n` | Yes | None | Namespace in which the lab is deployed in EDA | 227 | | `--output`, `-o` | No | None | Output .clab.yaml file path | 228 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 229 | | `--log-file` | No | None | Optional log file path | 230 | 231 | #### Generate CR YAML Manifests 232 | The `generate-crs` command allows you to generate all the CR YAML manifests that would be applied to EDA—grouped by category. By default all manifests are concatenated into a single file. If you use the --separate flag, the manifests are written into separate files per category (e.g. `artifacts.yaml`, `init.yaml`, `node-security-profile.yaml`, etc.). 233 | You can also use `--skip-edge-intfs` to omit edge link resources and their interfaces. 234 | 235 | 236 | ##### Combined file example: 237 | ``` 238 | clab-connector generate-crs \ 239 | --topology-data path/to/topology-data.json \ 240 | --output all-crs.yaml 241 | ``` 242 | ##### Separate files example: 243 | ``` 244 | clab-connector generate-crs \ 245 | --topology-data path/to/topology-data.json \ 246 | --separate \ 247 | --output manifests 248 | ``` 249 | 250 | | Option | Required | Default | Description 251 | |-------------------------|----------|---------|--------------------------------------------------------| 252 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 253 | | `--output`, `-o` | No | None | Output file path or directory | 254 | | `--separate` | No | False | Generate separate YAML files for each CR | 255 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 256 | | `--log-file`, `-f` | No | None | Optional log file path | 257 | | `--skip-edge-intfs` | No | False | Skip creation of edge links and their interfaces | 258 | | `--edge-encapsulation` | No | None | Encapsulation for generated edge interfaces (`dot1q`) | 259 | | `--isl-encapsulation` | No | None | Encapsulation for inter-switch interfaces (`dot1q`) | 260 | | `--namespace`, `-n` | No | None | Namespace to use instead of deriving from the topology | 261 | 262 | 263 | 264 | ### Example Command 265 | 266 | ``` 267 | clab-connector -l INFO integrate -t topology-data.json -e https://eda.example.com 268 | ``` 269 | 270 | ## Example Topologies 271 | 272 | Explore the [example-topologies](./example-topologies/) directory for sample Containerlab topology files to get started quickly. 273 | 274 | ## Requesting Support 275 | 276 | If you encounter issues or have questions, please reach out through the following channels: 277 | 278 | - **GitHub Issues:** [Create an issue](https://github.com/eda-labs/clab-connector/issues) on GitHub. 279 | - **Discord:** Join our [Discord community](https://eda.dev/discord) 280 | 281 | > [!TIP] 282 | > Running the script with `-l INFO` or `-l DEBUG` flags can provide additional insights into any failures or issues. 283 | 284 | ## Contributing 285 | 286 | Contributions are welcome! Please fork the repository and submit a pull request with your enhancements. 287 | 288 | ### Development Setup 289 | 290 | 1. **Clone the repository:** 291 | ```bash 292 | git clone https://github.com/eda-labs/clab-connector.git 293 | cd clab-connector 294 | ``` 295 | 296 | 2. **Install development dependencies:** 297 | ```bash 298 | make install 299 | ``` 300 | 301 | ### Code Quality Standards 302 | 303 | **All code must pass linting and formatting checks before being committed.** We use [Ruff](https://github.com/astral-sh/ruff) for both linting and formatting. 304 | 305 | #### Using Make (Recommended) 306 | 307 | The project includes a Makefile with convenient commands: 308 | 309 | ```bash 310 | # Run linting checks 311 | make lint 312 | 313 | # Format code 314 | make format 315 | 316 | # Auto-fix linting issues and format 317 | make fix 318 | 319 | # Run all checks (required before committing) 320 | make check 321 | 322 | # Run tests 323 | make test 324 | 325 | # See all available commands 326 | make help 327 | ``` 328 | 329 | #### Manual Commands 330 | 331 | If you prefer running commands directly: 332 | 333 | ```bash 334 | # Check for linting issues 335 | uv run ruff check . 336 | 337 | # Format code 338 | uv run ruff format . 339 | 340 | # Auto-fix and format 341 | uv run ruff check --fix . 342 | uv run ruff format . 343 | ``` 344 | 345 | ### Before Submitting a PR 346 | 347 | 1. **Ensure all checks pass:** 348 | ```bash 349 | make check 350 | ``` 351 | 352 | 2. **Run tests:** 353 | ```bash 354 | make test 355 | ``` 356 | 357 | 3. **Format your code:** 358 | ```bash 359 | make format 360 | ``` 361 | 362 | Our CI pipeline will automatically verify that your code passes all ruff checks. PRs with failing checks cannot be merged. 363 | 364 | ## Acknowledgements 365 | 366 | - [Containerlab](https://containerlab.dev/) for providing an excellent network emulation platform. 367 | - [EDA (Event-Driven Automation)](https://docs.eda.dev/) for the robust automation capabilities. 368 | 369 | -------------------------------------------------------------------------------- /clab_connector/services/integration/sros_post_integration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """sros_post_integration.py - SROS post-integration helpers""" 3 | 4 | from __future__ import annotations 5 | 6 | import contextlib 7 | import logging 8 | import re 9 | import subprocess 10 | import tempfile 11 | import time 12 | from pathlib import Path 13 | 14 | import paramiko 15 | from rich.markup import escape 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | # Default retry parameters 20 | RETRIES = 20 21 | DELAY = 2.0 22 | 23 | 24 | def _run_with_retry( 25 | cmd: str, quiet: bool, retries: int = RETRIES, delay: float = DELAY 26 | ) -> None: 27 | """Run a shell command with retries.""" 28 | for attempt in range(retries): 29 | suppress_stderr = quiet or (attempt < retries - 1) 30 | try: 31 | subprocess.check_call( 32 | cmd, 33 | shell=True, 34 | stdout=subprocess.DEVNULL if quiet else None, 35 | stderr=subprocess.DEVNULL if suppress_stderr else None, 36 | ) 37 | if attempt > 0: 38 | logger.info("Command succeeded on attempt %s/%s", attempt + 1, retries) 39 | return 40 | except subprocess.CalledProcessError: 41 | if attempt == retries - 1: 42 | logger.error("Command failed after %s attempts: %s", retries, cmd) 43 | raise 44 | logger.warning( 45 | "Command failed (attempt %s/%s), retrying in %ss...", 46 | attempt + 1, 47 | retries, 48 | delay, 49 | ) 50 | time.sleep(delay) 51 | 52 | 53 | # --------------------------------------------------------------------------- # 54 | # SSH helpers # 55 | # --------------------------------------------------------------------------- # 56 | def verify_ssh_credentials( 57 | mgmt_ip: str, 58 | username: str, 59 | passwords: list[str], 60 | quiet: bool = False, 61 | ) -> str | None: 62 | """ 63 | Return the first password that opens an SSH session, else None. 64 | """ 65 | for pw in passwords: 66 | client = paramiko.SSHClient() 67 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 68 | try: 69 | if not quiet: 70 | logger.debug( 71 | "Trying SSH to %s with user '%s' and password '%s'", 72 | mgmt_ip, 73 | username, 74 | pw, 75 | ) 76 | 77 | client.connect( 78 | hostname=mgmt_ip, 79 | port=22, 80 | username=username, 81 | password=pw, 82 | timeout=10, 83 | banner_timeout=10, 84 | allow_agent=False, 85 | look_for_keys=False, 86 | ) 87 | 88 | # If we reach this point authentication succeeded. 89 | if not quiet: 90 | logger.info("Password '%s' works for %s", pw, mgmt_ip) 91 | return pw 92 | 93 | except paramiko.AuthenticationException: 94 | if not quiet: 95 | logger.debug("Password '%s' rejected for %s", pw, mgmt_ip) 96 | except (TimeoutError, OSError, paramiko.SSHException) as e: 97 | if not quiet: 98 | logger.debug("SSH connection problem with %s: %s", mgmt_ip, e) 99 | finally: 100 | with contextlib.suppress(Exception): 101 | client.close() 102 | 103 | return None 104 | 105 | 106 | def transfer_file( 107 | src_path: Path, 108 | dest_path: str, 109 | username: str, 110 | mgmt_ip: str, 111 | password: str, 112 | quiet: bool = False, 113 | tries: int = 2, 114 | ) -> bool: 115 | """ 116 | SCP file to the target node using Paramiko SFTP. 117 | """ 118 | try: 119 | if not quiet: 120 | logger.debug("SCP %s → %s@%s:%s", src_path, username, mgmt_ip, dest_path) 121 | 122 | transport = paramiko.Transport((mgmt_ip, 22)) 123 | transport.connect(username=username, password=password) 124 | 125 | sftp = paramiko.SFTPClient.from_transport(transport) 126 | sftp.put(str(src_path), dest_path) 127 | sftp.close() 128 | transport.close() 129 | return True 130 | except Exception as e: 131 | if not quiet: 132 | logger.debug("SCP failed: %s", e) 133 | tries -= 1 134 | if tries > 0: 135 | logger.info( 136 | "SCP failed! Waiting 20 seconds before retrying. Retrying %s more time%s", 137 | tries, 138 | "s" if tries > 1 else "", 139 | ) 140 | time.sleep(20) 141 | return transfer_file( 142 | src_path=src_path, 143 | dest_path=dest_path, 144 | username=username, 145 | mgmt_ip=mgmt_ip, 146 | password=password, 147 | quiet=quiet, 148 | tries=tries, 149 | ) 150 | else: 151 | return False 152 | 153 | 154 | def execute_ssh_commands( 155 | script_path: Path, 156 | username: str, 157 | mgmt_ip: str, 158 | node_name: str, 159 | password: str, 160 | quiet: bool = False, 161 | ) -> bool: 162 | """ 163 | Push the command file line-by-line over an interactive shell. 164 | No timeouts version that will wait as long as needed for each command. 165 | """ 166 | try: 167 | commands = script_path.read_text().splitlines() 168 | 169 | # This will send 5 empty commands at the end, making sure everything gets executed till the last bit 170 | for _i in range(5): 171 | commands.append("") 172 | 173 | client = paramiko.SSHClient() 174 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 175 | client.connect( 176 | hostname=mgmt_ip, 177 | username=username, 178 | password=password, 179 | allow_agent=False, 180 | look_for_keys=False, 181 | ) 182 | 183 | chan = client.invoke_shell() 184 | output = [] 185 | 186 | for cmd in commands: 187 | if cmd.strip() == "commit": 188 | time.sleep(2) # Wait 2 seconds before sending commit 189 | 190 | if cmd.strip() == "": 191 | time.sleep( 192 | 0.5 193 | ) # Wait 0.5 seconds before sending on empty command 194 | 195 | chan.send(cmd + "\n") 196 | while not chan.recv_ready(): 197 | pass 198 | 199 | buffer = "" 200 | while chan.recv_ready(): 201 | buffer += chan.recv(4096).decode() 202 | output.append(buffer) 203 | 204 | # Get any remaining output 205 | while chan.recv_ready(): 206 | output.append(chan.recv(4096).decode()) 207 | 208 | chan.close() 209 | client.close() 210 | 211 | if not quiet: 212 | logger.info( 213 | "Configuration of %s completed (output %d chars)", 214 | node_name, 215 | sum(map(len, output)), 216 | ) 217 | textoutput = escape("".join(output)) 218 | logger.debug("Output: %s", textoutput) 219 | return True 220 | except Exception as e: 221 | logger.error("SSH exec error on %s: %s", node_name, e) 222 | return False 223 | 224 | 225 | # --------------------------------------------------------------------------- # 226 | # Helper utilities # 227 | # --------------------------------------------------------------------------- # 228 | def _extract_file(cmd: str, path: Path, desc: str, quiet: bool) -> int: 229 | """Run `cmd` until `path` exists and is non-empty.""" 230 | for attempt in range(RETRIES): 231 | _run_with_retry(cmd, quiet, retries=1) 232 | size = path.stat().st_size if path.exists() else 0 233 | if size > 0: 234 | if attempt > 0: 235 | logger.info( 236 | "%s extraction succeeded on attempt %s/%s", 237 | desc, 238 | attempt + 1, 239 | RETRIES, 240 | ) 241 | logger.info("%s file size: %s bytes", desc, size) 242 | return size 243 | if attempt == RETRIES - 1: 244 | raise ValueError(f"{desc} file is empty after extraction") 245 | logger.warning( 246 | "%s file empty (attempt %s/%s), re-extracting...", 247 | desc, 248 | attempt + 1, 249 | RETRIES, 250 | ) 251 | time.sleep(DELAY) 252 | 253 | 254 | def _extract_config(cmd: str, path: Path, quiet: bool) -> str: 255 | """Extract a config file and return the inner configure block.""" 256 | for attempt in range(RETRIES): 257 | _run_with_retry(cmd, quiet, retries=1) 258 | cfg_text = path.read_text() if path.exists() else "" 259 | if not cfg_text.strip(): 260 | if attempt == RETRIES - 1: 261 | raise ValueError("Config file is empty after extraction") 262 | logger.warning( 263 | "Config file empty (attempt %s/%s), re-extracting...", 264 | attempt + 1, 265 | RETRIES, 266 | ) 267 | time.sleep(DELAY) 268 | continue 269 | match = re.search(r"configure\s*\{(.*)\}", cfg_text, re.DOTALL) 270 | if match and match.group(1).strip(): 271 | if attempt > 0: 272 | logger.info( 273 | "Config extraction succeeded on attempt %s/%s", attempt + 1, RETRIES 274 | ) 275 | return match.group(1).strip() 276 | if attempt == RETRIES - 1: 277 | raise ValueError("Could not find inner config block") 278 | logger.warning( 279 | "Config block not found (attempt %s/%s), re-extracting...", 280 | attempt + 1, 281 | RETRIES, 282 | ) 283 | time.sleep(DELAY) 284 | 285 | 286 | def _extract_cert_and_config( 287 | node_name: str, 288 | namespace: str, 289 | version: str, 290 | cert_p: Path, 291 | key_p: Path, 292 | cfg_p: Path, 293 | quiet: bool, 294 | ) -> str: 295 | logger.info("Extracting TLS cert / key …") 296 | 297 | # Extract cert and key with retries and validation 298 | cert_cmd = ( 299 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 300 | f"-n eda-system -o jsonpath='{{.data.tls\\.crt}}' " 301 | f"| base64 -d > {cert_p}" 302 | ) 303 | key_cmd = ( 304 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 305 | f"-n eda-system -o jsonpath='{{.data.tls\\.key}}' " 306 | f"| base64 -d > {key_p}" 307 | ) 308 | 309 | _extract_file(cert_cmd, cert_p, "Certificate", quiet) 310 | _extract_file(key_cmd, key_p, "Private key", quiet) 311 | 312 | logger.info("Extracting initial config …") 313 | 314 | # Extract and parse config with retries 315 | extract_cmd = ( 316 | f"kubectl get artifact initcfg-{node_name}-{version} -n {namespace} " 317 | f"-o jsonpath='{{.spec.textFile.content}}' " 318 | f"| sed 's/\\n/\\n/g' > {cfg_p}" 319 | ) 320 | 321 | return _extract_config(extract_cmd, cfg_p, quiet) 322 | 323 | 324 | def _copy_certificates( 325 | dest_roots: tuple[str, str], 326 | cert_p: Path, 327 | key_p: Path, 328 | username: str, 329 | mgmt_ip: str, 330 | working_pw: str, 331 | quiet: bool, 332 | ) -> str: 333 | logger.info("Copying certificates to device …") 334 | 335 | for root in dest_roots: 336 | logger.info(f"Attempting to copy certificates to root: {root}") 337 | 338 | cert_success = transfer_file( 339 | cert_p, root + "edaboot.crt", username, mgmt_ip, working_pw, quiet 340 | ) 341 | if cert_success: 342 | logger.info(f"Certificate copied successfully to {root}edaboot.crt") 343 | else: 344 | logger.warning(f"Failed to copy certificate to {root}edaboot.crt") 345 | continue 346 | 347 | key_success = transfer_file( 348 | key_p, root + "edaboot.key", username, mgmt_ip, working_pw, quiet 349 | ) 350 | if key_success: 351 | logger.info(f"Private key copied successfully to {root}edaboot.key") 352 | logger.info( 353 | f"Both certificate files copied successfully using root: {root}" 354 | ) 355 | return root 356 | else: 357 | logger.warning(f"Failed to copy private key to {root}edaboot.key") 358 | 359 | raise RuntimeError("Failed to copy certificate/key to device") 360 | 361 | 362 | def _build_command_script(script_p: Path, dest_root: str, inner_cfg: str) -> None: 363 | with script_p.open("w") as f: 364 | f.write("environment more false\n") 365 | f.write("environment print-detail false\n") 366 | f.write("environment confirmations false\n") 367 | f.write( 368 | f"admin system security pki import type certificate input-url {dest_root}edaboot.crt output-file edaboot.crt format pem\n" 369 | ) 370 | f.write( 371 | f"admin system security pki import type key input-url {dest_root}edaboot.key output-file edaboot.key format pem\n" 372 | ) 373 | f.write("configure global\n") 374 | f.write(inner_cfg + "\n") 375 | f.write("commit\n") 376 | f.write("exit all\n") 377 | 378 | 379 | # --------------------------------------------------------------------------- # 380 | # High-level workflow # 381 | # --------------------------------------------------------------------------- # 382 | def prepare_sros_node( 383 | node_name: str, 384 | namespace: str, 385 | version: str, 386 | mgmt_ip: str, 387 | node_type: str, 388 | username: str = "admin", 389 | password: str | None = None, 390 | quiet: bool = True, 391 | ) -> bool: 392 | """ 393 | Perform SROS-specific post-integration steps. 394 | """ 395 | # First check if we can login with admin:admin 396 | # If we can't, assume the node is already bootstrapped 397 | admin_pwd = "admin" if node_type == "nokia_sros" else "NokiaSros1!" 398 | can_login = verify_ssh_credentials(mgmt_ip, username, [admin_pwd], quiet) 399 | 400 | if not can_login and node_type == "nokia_sros": 401 | logger.info("Node: %s already bootstrapped", node_name) 402 | return True 403 | if not can_login: 404 | logger.error("Can't login to node %s of kind %s", node_name, node_type) 405 | 406 | # Proceed with original logic if admin:admin works 407 | # 1. determine password list (keep provided one first if present) 408 | pwd_list: list[str] = [] 409 | if password: 410 | pwd_list.append(password) 411 | pwd_list.append("admin") 412 | 413 | logger.info("Verifying SSH credentials for %s ...", node_name) 414 | working_pw = verify_ssh_credentials(mgmt_ip, username, pwd_list, quiet) 415 | 416 | if not working_pw: 417 | logger.error("No valid password found - aborting") 418 | return False 419 | # 2. create temp artefacts 420 | with tempfile.TemporaryDirectory() as tdir: 421 | tdir_path = Path(tdir) 422 | cert_p = tdir_path / "edaboot.crt" 423 | key_p = tdir_path / "edaboot.key" 424 | cfg_p = tdir_path / "config.cfg" 425 | script_p = tdir_path / "sros_commands.txt" 426 | 427 | try: 428 | inner_cfg = _extract_cert_and_config( 429 | node_name, namespace, version, cert_p, key_p, cfg_p, quiet 430 | ) 431 | dest_root = _copy_certificates( 432 | ("cf3:/", "/"), cert_p, key_p, username, mgmt_ip, working_pw, quiet 433 | ) 434 | _build_command_script(script_p, dest_root, inner_cfg) 435 | 436 | logger.info("Pushing configuration to %s …", node_name) 437 | return execute_ssh_commands( 438 | script_p, username, mgmt_ip, node_name, working_pw, quiet 439 | ) 440 | 441 | except ( 442 | subprocess.CalledProcessError, 443 | FileNotFoundError, 444 | ValueError, 445 | RuntimeError, 446 | ) as e: 447 | logger.error("Post-integration failed: %s", e) 448 | return False 449 | except Exception as e: 450 | logger.exception("Unexpected error: %s", e) 451 | return False 452 | -------------------------------------------------------------------------------- /clab_connector/clients/eda/client.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/eda/client.py 2 | 3 | """ 4 | This module provides the EDAClient class for communicating with the EDA REST API. 5 | Starting with EDA v24.12.1, authentication is handled via Keycloak. 6 | 7 | We support two flows: 8 | 1. If kc_secret is known (user passes --kc-secret), we do resource-owner 9 | password flow directly in realm='eda'. 10 | 11 | 2. If kc_secret is unknown, we do an admin login in realm='master' using 12 | kc_user / kc_password to retrieve the 'eda' client secret, 13 | then proceed with resource-owner flow in realm='eda'. 14 | """ 15 | 16 | import json 17 | import logging 18 | from urllib.parse import urlencode 19 | 20 | import yaml 21 | 22 | from clab_connector.clients.eda.http_client import create_pool_manager 23 | from clab_connector.utils.constants import SUBSTEP_INDENT 24 | from clab_connector.utils.exceptions import EDAConnectionError 25 | 26 | HTTP_OK = 200 27 | HTTP_NO_CONTENT = 204 28 | MAJOR_V1_THRESHOLD = 24 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class EDAClient: 34 | """ 35 | EDAClient communicates with the EDA REST API via Keycloak flows. 36 | 37 | Parameters 38 | ---------- 39 | hostname : str 40 | The base URL for EDA, e.g. "https://my-eda.example". 41 | eda_user : str 42 | EDA user in realm='eda'. 43 | eda_password : str 44 | EDA password in realm='eda'. 45 | kc_secret : str, optional 46 | Known Keycloak client secret for 'eda'. If not provided, we do the admin 47 | realm flow to retrieve it using kc_user/kc_password. 48 | verify : bool 49 | Whether to verify SSL certificates (default=True). 50 | kc_user : str 51 | Keycloak "master" realm admin username (default="admin"). 52 | kc_password : str 53 | Keycloak "master" realm admin password (default="admin"). 54 | """ 55 | 56 | KEYCLOAK_ADMIN_REALM = "master" 57 | KEYCLOAK_ADMIN_CLIENT_ID = "admin-cli" 58 | EDA_REALM = "eda" 59 | EDA_API_CLIENT_ID = "eda" 60 | 61 | CORE_GROUP = "core.eda.nokia.com" 62 | CORE_VERSION = "v1" 63 | 64 | def __init__( 65 | self, 66 | hostname: str, 67 | eda_user: str, 68 | eda_password: str, 69 | kc_secret: str | None = None, 70 | verify: bool = True, 71 | kc_user: str = "admin", 72 | kc_password: str = "admin", 73 | ): 74 | self.url = hostname.rstrip("/") 75 | self.eda_user = eda_user 76 | self.eda_password = eda_password 77 | self.kc_secret = kc_secret # If set, we skip the admin login 78 | self.verify = verify 79 | self.kc_user = kc_user 80 | self.kc_password = kc_password 81 | 82 | self.access_token = None 83 | self.refresh_token = None 84 | self.version = None 85 | self.transactions = [] 86 | 87 | self.http = create_pool_manager(url=self.url, verify=self.verify) 88 | 89 | def login(self): 90 | """ 91 | Acquire an access token via Keycloak resource-owner flow in realm='eda'. 92 | If kc_secret is not provided, fetch it using kc_user/kc_password in realm='master'. 93 | """ 94 | if not self.kc_secret: 95 | logger.debug( 96 | "No kc_secret provided; retrieving it from Keycloak master realm." 97 | ) 98 | self.kc_secret = self._fetch_client_secret_via_admin() 99 | logger.info( 100 | f"{SUBSTEP_INDENT}Successfully retrieved EDA client secret from Keycloak." 101 | ) 102 | 103 | logger.debug( 104 | "Acquiring user access token via Keycloak resource-owner flow (realm=eda)." 105 | ) 106 | self.access_token = self._fetch_user_token(self.kc_secret) 107 | if not self.access_token: 108 | raise EDAConnectionError("Could not retrieve an access token for EDA.") 109 | 110 | logger.debug("Keycloak-based login successful (realm=eda).") 111 | 112 | def _fetch_client_secret_via_admin(self) -> str: 113 | """ 114 | Use kc_user/kc_password in realm='master' to retrieve 115 | the client secret for 'eda' client in realm='eda'. 116 | 117 | Returns 118 | ------- 119 | str 120 | The 'eda' client secret. 121 | 122 | Raises 123 | ------ 124 | EDAConnectionError 125 | If we fail to fetch an admin token or the 'eda' client secret. 126 | """ 127 | if not self.kc_user or not self.kc_password: 128 | raise EDAConnectionError( 129 | "Cannot fetch 'eda' client secret: no kc_secret provided and no kc_user/kc_password available." 130 | ) 131 | 132 | admin_token = self._fetch_admin_token(self.kc_user, self.kc_password) 133 | if not admin_token: 134 | raise EDAConnectionError( 135 | "Failed to fetch Keycloak admin token in realm=master." 136 | ) 137 | 138 | admin_api_url = ( 139 | f"{self.url}/core/httpproxy/v1/keycloak/" 140 | f"admin/realms/{self.EDA_REALM}/clients" 141 | ) 142 | headers = { 143 | "Authorization": f"Bearer {admin_token}", 144 | "Content-Type": "application/json", 145 | } 146 | 147 | resp = self.http.request("GET", admin_api_url, headers=headers) 148 | if resp.status != HTTP_OK: 149 | raise EDAConnectionError( 150 | f"Failed to list clients in realm='{self.EDA_REALM}': {resp.data.decode()}" 151 | ) 152 | 153 | clients = json.loads(resp.data.decode("utf-8")) 154 | eda_client = next( 155 | (c for c in clients if c.get("clientId") == self.EDA_API_CLIENT_ID), None 156 | ) 157 | if not eda_client: 158 | raise EDAConnectionError( 159 | f"Client '{self.EDA_API_CLIENT_ID}' not found in realm='{self.EDA_REALM}'." 160 | ) 161 | 162 | client_id = eda_client["id"] 163 | secret_url = f"{admin_api_url}/{client_id}/client-secret" 164 | secret_resp = self.http.request("GET", secret_url, headers=headers) 165 | if secret_resp.status != HTTP_OK: 166 | raise EDAConnectionError( 167 | f"Failed to fetch '{self.EDA_API_CLIENT_ID}' client secret: {secret_resp.data.decode()}" 168 | ) 169 | 170 | return json.loads(secret_resp.data.decode("utf-8"))["value"] 171 | 172 | def _fetch_admin_token(self, admin_user: str, admin_password: str) -> str: 173 | """ 174 | Fetch an admin token from the 'master' realm using admin_user/admin_password. 175 | """ 176 | token_url = ( 177 | f"{self.url}/core/httpproxy/v1/keycloak/" 178 | f"realms/{self.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" 179 | ) 180 | form_data = { 181 | "grant_type": "password", 182 | "client_id": self.KEYCLOAK_ADMIN_CLIENT_ID, 183 | "username": admin_user, 184 | "password": admin_password, 185 | } 186 | encoded_data = urlencode(form_data).encode("utf-8") 187 | 188 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 189 | resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) 190 | if resp.status != HTTP_OK: 191 | raise EDAConnectionError( 192 | f"Failed Keycloak admin login in realm='{self.KEYCLOAK_ADMIN_REALM}': {resp.data.decode()}" 193 | ) 194 | 195 | token_json = json.loads(resp.data.decode("utf-8")) 196 | return token_json.get("access_token") 197 | 198 | def _fetch_user_token(self, client_secret: str) -> str: 199 | """ 200 | Resource-owner password flow in realm='eda' using eda_user/eda_password. 201 | """ 202 | token_url = ( 203 | f"{self.url}/core/httpproxy/v1/keycloak/" 204 | f"realms/{self.EDA_REALM}/protocol/openid-connect/token" 205 | ) 206 | form_data = { 207 | "grant_type": "password", 208 | "client_id": self.EDA_API_CLIENT_ID, 209 | "client_secret": client_secret, 210 | "scope": "openid", 211 | "username": self.eda_user, 212 | "password": self.eda_password, 213 | } 214 | encoded_data = urlencode(form_data).encode("utf-8") 215 | 216 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 217 | resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) 218 | if resp.status != HTTP_OK: 219 | raise EDAConnectionError(f"Failed user token request: {resp.data.decode()}") 220 | 221 | token_json = json.loads(resp.data.decode("utf-8")) 222 | return token_json.get("access_token") 223 | 224 | # --------------------------------------------------------------------- 225 | # Below here, the rest of the class is unchanged: GET/POST, commit tx, etc. 226 | # --------------------------------------------------------------------- 227 | 228 | def get_headers(self, requires_auth: bool = True) -> dict: 229 | headers = {} 230 | if requires_auth: 231 | if not self.access_token: 232 | logger.debug("No access_token found; performing Keycloak login...") 233 | self.login() 234 | headers["Authorization"] = f"Bearer {self.access_token}" 235 | return headers 236 | 237 | def get(self, api_path: str, requires_auth: bool = True): 238 | url = f"{self.url}/{api_path}" 239 | logger.debug(f"GET {url}") 240 | return self.http.request("GET", url, headers=self.get_headers(requires_auth)) 241 | 242 | def post(self, api_path: str, payload: dict, requires_auth: bool = True): 243 | url = f"{self.url}/{api_path}" 244 | logger.debug(f"POST {url}") 245 | body = json.dumps(payload).encode("utf-8") 246 | headers = self.get_headers(requires_auth) 247 | headers["Content-Type"] = "application/json" 248 | return self.http.request("POST", url, headers=headers, body=body) 249 | 250 | def patch(self, api_path: str, payload: str, requires_auth: bool = True): 251 | url = f"{self.url}/{api_path}" 252 | logger.debug(f"PATCH {url}") 253 | body = payload.encode("utf-8") 254 | headers = self.get_headers(requires_auth) 255 | headers["Content-Type"] = "application/json" 256 | return self.http.request("PATCH", url, headers=headers, body=body) 257 | 258 | def is_up(self) -> bool: 259 | logger.info(f"{SUBSTEP_INDENT}Checking EDA health") 260 | resp = self.get("core/about/health", requires_auth=False) 261 | if resp.status != HTTP_OK: 262 | return False 263 | 264 | data = json.loads(resp.data.decode("utf-8")) 265 | return data.get("status") == "UP" 266 | 267 | def get_version(self) -> str: 268 | if self.version is not None: 269 | return self.version 270 | 271 | logger.debug("Retrieving EDA version") 272 | resp = self.get("core/about/version") 273 | if resp.status != HTTP_OK: 274 | raise EDAConnectionError(f"Version check failed: {resp.data.decode()}") 275 | 276 | data = json.loads(resp.data.decode("utf-8")) 277 | raw_ver = data["eda"]["version"] 278 | self.version = raw_ver.split("-")[0] 279 | logger.debug(f"EDA version: {self.version}") 280 | return self.version 281 | 282 | def is_authenticated(self) -> bool: 283 | try: 284 | self.get_version() 285 | return True 286 | except EDAConnectionError: 287 | return False 288 | 289 | def add_to_transaction(self, cr_type: str, payload: dict) -> dict: 290 | item = {"type": {cr_type: payload}} 291 | self.transactions.append(item) 292 | logger.debug(f"Adding item to transaction: {json.dumps(item, indent=2)}") 293 | return item 294 | 295 | def add_create_to_transaction(self, resource_yaml: str) -> dict: 296 | return self.add_to_transaction( 297 | "create", {"value": yaml.safe_load(resource_yaml)} 298 | ) 299 | 300 | def add_replace_to_transaction(self, resource_yaml: str) -> dict: 301 | return self.add_to_transaction( 302 | "replace", {"value": yaml.safe_load(resource_yaml)} 303 | ) 304 | 305 | def add_delete_to_transaction( 306 | self, 307 | namespace: str, 308 | kind: str, 309 | name: str, 310 | group: str | None = None, 311 | version: str | None = None, 312 | ): 313 | group = group or self.CORE_GROUP 314 | version = version or self.CORE_VERSION 315 | self.add_to_transaction( 316 | "delete", 317 | { 318 | "gvk": { 319 | "group": group, 320 | "version": version, 321 | "kind": kind, 322 | }, 323 | "name": name, 324 | "namespace": namespace, 325 | }, 326 | ) 327 | 328 | def is_transaction_item_valid(self, item: dict) -> bool: 329 | logger.debug("Validating transaction item") 330 | 331 | # Determine which validation endpoint to use based on the EDA version 332 | version = self.get_version() 333 | logger.debug(f"EDA version for validation: {version}") 334 | 335 | if version.startswith("v"): 336 | version = version[1:] 337 | 338 | parts = version.split(".") 339 | major = int(parts[0]) if parts[0].isdigit() else 0 340 | 341 | # v2 is the default. Only 24.x releases still use the v1 endpoint. 342 | if major == MAJOR_V1_THRESHOLD: 343 | logger.debug("Using v1 transaction validation endpoint") 344 | resp = self.post("core/transaction/v1/validate", item) 345 | else: 346 | logger.debug("Using v2 transaction validation endpoint") 347 | resp = self.post("core/transaction/v2/validate", [item]) 348 | 349 | if resp.status == HTTP_NO_CONTENT: 350 | logger.debug("Transaction item validation success.") 351 | return True 352 | 353 | data = json.loads(resp.data.decode("utf-8")) 354 | logger.warning(f"{SUBSTEP_INDENT}Validation error: {data}") 355 | return False 356 | 357 | def commit_transaction( 358 | self, 359 | description: str, 360 | dryrun: bool = False, 361 | result_type: str = "normal", 362 | retain: bool = True, 363 | ) -> str: 364 | version = self.get_version() 365 | logger.debug(f"EDA version for transaction: {version}") 366 | 367 | if version.startswith("v"): 368 | version = version[1:] 369 | 370 | parts = version.split(".") 371 | major = int(parts[0]) if parts[0].isdigit() else 0 372 | 373 | payload = { 374 | "description": description, 375 | "dryrun": dryrun, 376 | "resultType": result_type, 377 | "retain": retain, 378 | "crs": self.transactions, 379 | } 380 | logger.info( 381 | f"{SUBSTEP_INDENT}Committing transaction: {description}, {len(self.transactions)} items" 382 | ) 383 | if major == MAJOR_V1_THRESHOLD: 384 | logger.debug("Using v1 transaction commit endpoint") 385 | resp = self.post("core/transaction/v1", payload) 386 | else: 387 | logger.debug("Using v2 transaction commit endpoint") 388 | resp = self.post("core/transaction/v2", payload) 389 | if resp.status != HTTP_OK: 390 | raise EDAConnectionError( 391 | f"Transaction request failed: {resp.data.decode()}" 392 | ) 393 | 394 | data = json.loads(resp.data.decode("utf-8")) 395 | tx_id = data.get("id") 396 | if not tx_id: 397 | raise EDAConnectionError(f"No transaction ID in response: {data}") 398 | 399 | logger.info(f"{SUBSTEP_INDENT}Waiting for transaction {tx_id} to complete...") 400 | if major == MAJOR_V1_THRESHOLD: 401 | details_path = f"core/transaction/v1/details/{tx_id}?waitForComplete=true&failOnErrors=true" 402 | else: 403 | details_path = f"core/transaction/v2/result/summary/{tx_id}" 404 | details_resp = self.get(details_path) 405 | if details_resp.status != HTTP_OK: 406 | raise EDAConnectionError( 407 | f"Transaction detail request failed: {details_resp.data.decode()}" 408 | ) 409 | 410 | details = json.loads(details_resp.data.decode("utf-8")) 411 | if "code" in details: 412 | logger.error(f"Transaction commit failed: {details}") 413 | raise EDAConnectionError(f"Transaction commit failed: {details}") 414 | 415 | logger.info(f"{SUBSTEP_INDENT}Commit successful.") 416 | self.transactions = [] 417 | return tx_id 418 | -------------------------------------------------------------------------------- /clab_connector/models/node/nokia_sros.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import ClassVar 4 | 5 | from clab_connector.utils import helpers 6 | from clab_connector.utils.constants import SUBSTEP_INDENT 7 | 8 | from .base import Node 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class NokiaSROSNode(Node): 14 | """ 15 | Nokia SROS Node representation. 16 | 17 | This subclass implements specific logic for SROS nodes, including 18 | naming, interface mapping, and EDA resource generation. 19 | """ 20 | 21 | SROS_USERNAME = "admin" 22 | SROS_PASSWORD = "NokiaSros1!" 23 | NODE_TYPE = "sros" 24 | GNMI_PORT = "57400" 25 | VERSION_PATH = ".system.information.version" 26 | YANG_PATH = "https://eda-asvr.eda-system.svc/eda-system/clab-schemaprofiles/{artifact_name}/{filename}" 27 | LLM_DB_PATH = "https://eda-asvr.eda-system.svc/eda-system/llm-dbs/llm-db-sros-ghcr-{version}/llm-embeddings-sros-{version_short}.tar.gz" 28 | 29 | # Mapping for EDA operating system 30 | EDA_OPERATING_SYSTEM: ClassVar[str] = "sros" 31 | 32 | SUPPORTED_SCHEMA_PROFILES: ClassVar[dict[str, tuple[str, str]]] = { 33 | "24.10.r4": ( 34 | "https://github.com/nokia-eda/schema-profiles/" 35 | "releases/download/nokia-sros-v24.10.r4/sros-24.10.r4.zip" 36 | ), 37 | "24.10.r5": ( 38 | "https://github.com/nokia-eda/schema-profiles/" 39 | "releases/download/nokia-sros-v24.10.r5/sros-24.10.r5.zip" 40 | ), 41 | "24.10.r6": ( 42 | "https://github.com/nokia-eda/schema-profiles/" 43 | "releases/download/nokia-sros-v24.10.r6/sros-24.10.r6.zip" 44 | ), 45 | "25.3.r2": ( 46 | "https://github.com/nokia-eda/schema-profiles/" 47 | "releases/download/nokia-sros-v25.3.r2/sros-25.3.r2.zip" 48 | ), 49 | "25.7.r1": ( 50 | "https://github.com/nokia-eda/schema-profiles/" 51 | "releases/download/nokia-sros-v25.7.r1/sros-25.7.r1.zip" 52 | ), 53 | "25.7.r2": ( 54 | "https://github.com/nokia-eda/schema-profiles/" 55 | "releases/download/nokia-sros-25.7.r2/sros-25.7.r2.zip" 56 | ), 57 | "25.10.r1": ( 58 | "https://github.com/nokia-eda/schema-profiles/" 59 | "releases/download/nokia-sros-25.10.r1/sros-25.10.r1.zip" 60 | ), 61 | } 62 | 63 | # Map of node types to their line card and MDA components 64 | SROS_COMPONENTS: ClassVar[dict[str, dict[str, dict[str, str]] | dict[str, int]]] = { 65 | "sr-1": { 66 | "lineCard": {"slot": "1", "type": "iom-1"}, 67 | "mda": {"slot": "1-a", "type": "me12-100gb-qsfp28"}, 68 | "connectors": 12, # Number of connectors 69 | }, 70 | "sr-1s": { 71 | "lineCard": {"slot": "1", "type": "xcm-1s"}, 72 | "mda": {"slot": "1-a", "type": "s36-100gb-qsfp28"}, 73 | "connectors": 36, 74 | }, 75 | "sr-2s": { 76 | "lineCard": {"slot": "1", "type": "xcm-2s"}, 77 | "mda": {"slot": "1-a", "type": "ms8-100gb-sfpdd+2-100gb-qsfp28"}, 78 | "connectors": 10, 79 | }, 80 | "sr-7s": { 81 | "lineCard": {"slot": "1", "type": "xcm-7s"}, 82 | "mda": {"slot": "1-a", "type": "s36-100gb-qsfp28"}, 83 | "connectors": 36, 84 | }, 85 | } 86 | 87 | def _get_components(self): 88 | """ 89 | Generate component information based on the node type. 90 | 91 | Returns 92 | ------- 93 | list 94 | A list of component dictionaries for the TopoNode resource. 95 | """ 96 | # Default to empty component list 97 | components = [] 98 | 99 | # Normalize node type for lookup 100 | node_type = self.node_type.lower() if self.node_type else "" 101 | 102 | # Check if node type is in the mapping 103 | if node_type in self.SROS_COMPONENTS: 104 | # Get component info for this node type 105 | component_info = self.SROS_COMPONENTS[node_type] 106 | 107 | # Add line card component 108 | if "lineCard" in component_info: 109 | lc = component_info["lineCard"] 110 | components.append( 111 | {"kind": "lineCard", "slot": lc["slot"], "type": lc["type"]} 112 | ) 113 | 114 | # Add MDA component 115 | if "mda" in component_info: 116 | mda = component_info["mda"] 117 | components.append( 118 | {"kind": "mda", "slot": mda["slot"], "type": mda["type"]} 119 | ) 120 | 121 | # Add connector components 122 | if "connectors" in component_info: 123 | num_connectors = component_info["connectors"] 124 | for i in range(1, num_connectors + 1): 125 | components.append( 126 | { 127 | "kind": "connector", 128 | "slot": f"1-a-{i}", 129 | "type": "c1-100g", # Default connector type 130 | } 131 | ) 132 | 133 | return components 134 | 135 | def get_default_node_type(self): 136 | """ 137 | Return the default node type for an SROS node. 138 | """ 139 | return "sr7750" # Default to 7750 SR router type 140 | 141 | def get_platform(self): 142 | """ 143 | Return the platform name based on node type. 144 | 145 | Returns 146 | ------- 147 | str 148 | The platform name (e.g. '7750 SR-1'). 149 | """ 150 | if self.node_type and self.node_type.lower().startswith("sr-"): 151 | # For SR-1, SR-7, SR-1s, etc. - preserve the exact case 152 | # Only uppercase the "SR" part, keep the suffix as-is 153 | if "-" in self.node_type: 154 | parts = self.node_type.split("-", 1) 155 | return f"7750 {parts[0].upper()}-{parts[1]}" 156 | return f"7750 {self.node_type.upper()}" 157 | return "7750 SR" # Default fallback 158 | 159 | def is_eda_supported(self): 160 | """ 161 | Indicates SROS nodes are EDA-supported. 162 | """ 163 | return True 164 | 165 | def _normalize_version(self, version): 166 | """ 167 | Normalize version string to ensure consistent format between TopoNode and NodeProfile. 168 | """ 169 | if not version: 170 | self._require_version() 171 | normalized = version.lower() 172 | return normalized 173 | 174 | def get_profile_name(self, topology): 175 | """ 176 | Generate a NodeProfile name specific to this SROS node. 177 | Make sure it follows Kubernetes naming conventions (lowercase) 178 | and includes the topology name to ensure uniqueness. 179 | """ 180 | # Convert version to lowercase to comply with K8s naming rules 181 | self._require_version() 182 | normalized_version = self._normalize_version(self.version) 183 | # Include the topology name in the profile name for uniqueness 184 | return f"{topology.get_eda_safe_name()}-sros-{normalized_version}" 185 | 186 | def get_node_profile(self, topology): 187 | """ 188 | Render the NodeProfile YAML for this SROS node. 189 | """ 190 | logger.debug(f"Rendering node profile for {self.name}") 191 | self._require_version() 192 | artifact_name = self.get_artifact_name() 193 | normalized_version = self._normalize_version(self.version) 194 | filename = f"sros-{normalized_version}.zip" 195 | 196 | # Extract version parts for LLM path, ensure consistent formatting 197 | version_short = normalized_version.replace(".", "-") 198 | 199 | data = { 200 | "namespace": topology.namespace, 201 | "profile_name": self.get_profile_name(topology), 202 | "sw_version": normalized_version, # Use normalized version consistently 203 | "gnmi_port": self.GNMI_PORT, 204 | "operating_system": self.EDA_OPERATING_SYSTEM, 205 | "version_path": "", 206 | "version_match": "", 207 | "yang_path": self.YANG_PATH.format( 208 | artifact_name=artifact_name, filename=filename 209 | ), 210 | "annotate": "false", 211 | "node_user": "admin-sros", 212 | "onboarding_password": "NokiaSros1!", 213 | "onboarding_username": "admin", 214 | "license": f"sros-ghcr-{normalized_version}-dummy-license", 215 | "llm_db": self.LLM_DB_PATH.format( 216 | version=normalized_version, version_short=version_short 217 | ), 218 | } 219 | return helpers.render_template("node-profile.j2", data) 220 | 221 | def get_toponode(self, topology): 222 | """ 223 | Render the TopoNode YAML for this SROS node. 224 | """ 225 | logger.info(f"{SUBSTEP_INDENT}Creating toponode for {self.name}") 226 | self._require_version() 227 | role_value = "backbone" 228 | 229 | # Ensure all values are lowercase and valid 230 | node_name = self.get_node_name(topology) 231 | topo_name = topology.get_eda_safe_name() 232 | normalized_version = self._normalize_version(self.version) 233 | 234 | # Generate component information based on node type 235 | components = self._get_components() 236 | 237 | data = { 238 | "namespace": topology.namespace, 239 | "node_name": node_name, 240 | "topology_name": topo_name, 241 | "role_value": role_value, 242 | "node_profile": self.get_profile_name(topology), 243 | "kind": self.EDA_OPERATING_SYSTEM, 244 | "platform": self.get_platform(), 245 | "sw_version": normalized_version, 246 | "mgmt_ip": self.mgmt_ipv4, 247 | "containerlab_label": "managedSros", 248 | "components": components, # Add component information 249 | } 250 | return helpers.render_template("toponode.j2", data) 251 | 252 | def get_interface_name_for_kind(self, ifname): 253 | """Convert a containerlab interface name to the SR OS EDA format. 254 | 255 | Supported input formats: 256 | - "1-2-3" -> "ethernet-1-b-3" 257 | - "1-2-c3-4" -> "ethernet-1-b-3-4" (mda>1) | "1-1-c3-4" -> "ethernet-1-3-4" (mda=1) 258 | - "1-x2-1-3" -> "ethernet-1-2-1-3" 259 | - "1-x2-1-c3-1" -> "ethernet-1-2-1-3-1" 260 | 261 | Args: 262 | ifname: Interface name in containerlab format 263 | 264 | Returns: 265 | Interface name in SR OS EDA format 266 | """ 267 | 268 | def mda_to_letter(mda_num): 269 | """Convert MDA number to letter (1->a, 2->b, etc.)""" 270 | return chr(96 + int(mda_num)) 271 | 272 | # Define patterns with their transformation logic 273 | kind_patterns = { 274 | "nokia_srsim": [ 275 | # Pattern: "1-2-3" -> "ethernet-1-b-3" 276 | ( 277 | r"^e(\d+)-(\d+)-(\d+)$", 278 | lambda m: f"ethernet-{m[0]}-{mda_to_letter(m[1])}-{m[2]}", 279 | ), 280 | # Pattern: "1-2-c3-4" -> conditional format 281 | ( 282 | r"^e(\d+)-(\d+)-c(\d+)-(\d+)$", 283 | lambda m: f"ethernet-{m[0]}-{m[2]}-{m[3]}" 284 | if m[2] == "1" 285 | else f"ethernet-{m[0]}-{mda_to_letter(m[1])}-{m[2]}-{m[3]}", 286 | ), 287 | # Pattern: "1-x2-1-c3-1" -> "ethernet-1-2-1-c3-1" 288 | ( 289 | r"^e(\d+)-x(\d+)-(\d+)-c(\d+)-(\d+)$", 290 | lambda m: f"ethernet-{m[0]}-{m[1]}-{mda_to_letter(m[2])}-{m[3]}-{m[4]}", 291 | ), 292 | # Pattern: "1-x2-1-3" -> "ethernet-1-2-1-3" 293 | ( 294 | r"^e(\d+)-x(\d+)-(\d+)-(\d+)$", 295 | lambda m: f"ethernet-{m[0]}-{m[1]}-{mda_to_letter(m[2])}-{m[3]}", 296 | ), 297 | ], 298 | "nokia_sros": [ 299 | # Pattern: "1/2/3" -> "ethernet-1-b-3-1" 300 | ( 301 | r"^(\d+)/(\d+)/(\d+)$", 302 | lambda m: f"ethernet-{m[0]}-{mda_to_letter(m[1])}-{m[2]}-1", 303 | ), 304 | # Pattern: "1/2/c3/4" -> conditional format 305 | ( 306 | r"^(\d+)/(\d+)/c(\d+)/(\d+)$", 307 | lambda m: f"ethernet-{m[0]}-{m[2]}-{m[3]}" 308 | if m[2] == "1" 309 | else f"ethernet-{m[0]}-{mda_to_letter(m[1])}-{m[2]}-{m[3]}", 310 | ), 311 | # Pattern: "1/x2/1/c3/1" -> "ethernet-1-2-1-c3-1" 312 | ( 313 | r"^(\d+)/x(\d+)/(\d+)/c(\d+)/(\d+)$", 314 | lambda m: f"ethernet-{m[0]}-{m[1]}-{mda_to_letter(m[2])}-{m[3]}-{m[4]}", 315 | ), 316 | # Pattern: "eth1" -> "ethernet-1-a-1-1" 317 | (r"^eth(\d+)$", lambda m: f"ethernet-1-a-{m[0]}-1"), 318 | # Pattern: "e1-2" -> "ethernet-1-a-2-1" 319 | (r"^e(\d+)-(\d+)$", lambda m: f"ethernet-{m[0]}-a-{m[1]}-1"), 320 | # Pattern: "lo1" -> "loopback-1" 321 | (r"^lo(\d+)$", lambda m: f"loopback-{m[0]}"), 322 | ], 323 | } 324 | # Try each pattern 325 | patterns = kind_patterns.get(self.kind, []) 326 | if not patterns: 327 | return "Bollocks" 328 | for pattern, transformer in patterns: 329 | match = re.match(pattern, ifname) 330 | if match: 331 | return transformer(match.groups()) 332 | 333 | # Return "bollocks" if no pattern matches 334 | return "Bollocks" 335 | 336 | def get_topolink_interface_name(self, topology, ifname): 337 | """ 338 | Generate a unique interface resource name for a link in EDA. 339 | Creates a valid Kubernetes resource name based on the EDA interface format. 340 | 341 | This normalizes complex interface names into valid resource names. 342 | """ 343 | node_name = self.get_node_name(topology) 344 | eda_ifname = self.get_interface_name_for_kind(ifname) 345 | 346 | # No longer strip out the 'ethernet-' prefix to maintain consistency with SR Linux 347 | return f"{node_name}-{eda_ifname}" 348 | 349 | def get_topolink_interface( 350 | self, 351 | topology, 352 | ifname, 353 | other_node, 354 | edge_encapsulation: str | None = None, 355 | isl_encapsulation: str | None = None, 356 | ): 357 | """ 358 | Render the Interface CR YAML for an SROS link endpoint. 359 | """ 360 | logger.debug(f"{SUBSTEP_INDENT}Creating topolink interface for {self.name}") 361 | role = "interSwitch" 362 | if other_node is None or not other_node.is_eda_supported(): 363 | role = "edge" 364 | peer_name = ( 365 | other_node.get_node_name(topology) 366 | if other_node is not None 367 | else "external-endpoint" 368 | ) 369 | if role == "edge": 370 | encap_type = "dot1q" if edge_encapsulation == "dot1q" else None 371 | else: 372 | encap_type = "dot1q" if isl_encapsulation == "dot1q" else None 373 | 374 | data = { 375 | "namespace": topology.namespace, 376 | "interface_name": self.get_topolink_interface_name(topology, ifname), 377 | "label_key": "eda.nokia.com/role", 378 | "label_value": role, 379 | "encap_type": encap_type, 380 | "node_name": self.get_node_name(topology), 381 | "interface": self.get_interface_name_for_kind(ifname), 382 | "description": f"{role} link to {peer_name}", 383 | } 384 | return helpers.render_template("interface.j2", data) 385 | 386 | def needs_artifact(self): 387 | """ 388 | SROS nodes may require a YANG artifact. 389 | """ 390 | return True 391 | 392 | def get_artifact_name(self): 393 | """ 394 | Return a name for the SROS schema artifact. 395 | """ 396 | normalized_version = self._normalize_version(self.version) 397 | return f"clab-sros-ghcr-{normalized_version}" 398 | 399 | def get_artifact_info(self): 400 | """ 401 | Return artifact metadata for the SROS YANG schema file. 402 | """ 403 | normalized_version = self._normalize_version(self.version) 404 | # Check if we have a supported schema for this normalized version 405 | if normalized_version not in self.SUPPORTED_SCHEMA_PROFILES: 406 | logger.warning( 407 | f"{SUBSTEP_INDENT}No schema profile for version {normalized_version}" 408 | ) 409 | return (None, None, None) 410 | 411 | artifact_name = self.get_artifact_name() 412 | filename = f"sros-{normalized_version}.zip" 413 | download_url = self.SUPPORTED_SCHEMA_PROFILES[normalized_version] 414 | return (artifact_name, filename, download_url) 415 | 416 | def get_artifact_yaml(self, artifact_name, filename, download_url): 417 | """ 418 | Render the Artifact CR YAML for the SROS YANG schema. 419 | """ 420 | data = { 421 | "artifact_name": artifact_name, 422 | "namespace": "eda-system", 423 | "artifact_filename": filename, 424 | "artifact_url": download_url, 425 | } 426 | return helpers.render_template("artifact.j2", data) 427 | -------------------------------------------------------------------------------- /clab_connector/services/integration/ceos_post_integration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ceos_post_integration.py - Arista cEOS post-integration helpers""" 3 | 4 | from __future__ import annotations 5 | 6 | import contextlib 7 | import logging 8 | import subprocess 9 | import tempfile 10 | import time 11 | from pathlib import Path 12 | 13 | import paramiko 14 | from rich.markup import escape 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # Default retry parameters 19 | RETRIES = 20 20 | DELAY = 2.0 21 | 22 | 23 | def _run_with_retry( 24 | cmd: str, quiet: bool, retries: int = RETRIES, delay: float = DELAY 25 | ) -> None: 26 | """Run a shell command with retries.""" 27 | for attempt in range(retries): 28 | suppress_stderr = quiet or (attempt < retries - 1) 29 | try: 30 | subprocess.check_call( 31 | cmd, 32 | shell=True, 33 | stdout=subprocess.DEVNULL if quiet else None, 34 | stderr=subprocess.DEVNULL if suppress_stderr else None, 35 | ) 36 | if attempt > 0: 37 | logger.info("Command succeeded on attempt %s/%s", attempt + 1, retries) 38 | return 39 | except subprocess.CalledProcessError: 40 | if attempt == retries - 1: 41 | logger.error("Command failed after %s attempts: %s", retries, cmd) 42 | raise 43 | logger.warning( 44 | "Command failed (attempt %s/%s), retrying in %ss...", 45 | attempt + 1, 46 | retries, 47 | delay, 48 | ) 49 | time.sleep(delay) 50 | 51 | 52 | # --------------------------------------------------------------------------- # 53 | # SSH helpers # 54 | # --------------------------------------------------------------------------- # 55 | def verify_ssh_credentials( 56 | mgmt_ip: str, 57 | username: str, 58 | passwords: list[str], 59 | quiet: bool = False, 60 | ) -> str | None: 61 | """ 62 | Return the first password that opens an SSH session, else None. 63 | """ 64 | for pw in passwords: 65 | client = paramiko.SSHClient() 66 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 67 | try: 68 | if not quiet: 69 | logger.debug( 70 | "Trying SSH to %s with user '%s' and password '%s'", 71 | mgmt_ip, 72 | username, 73 | pw, 74 | ) 75 | 76 | client.connect( 77 | hostname=mgmt_ip, 78 | port=22, 79 | username=username, 80 | password=pw, 81 | timeout=10, 82 | banner_timeout=10, 83 | allow_agent=False, 84 | look_for_keys=False, 85 | ) 86 | 87 | # If we reach this point authentication succeeded. 88 | if not quiet: 89 | logger.info("Password '%s' works for %s", pw, mgmt_ip) 90 | return pw 91 | 92 | except paramiko.AuthenticationException: 93 | if not quiet: 94 | logger.debug("Password '%s' rejected for %s", pw, mgmt_ip) 95 | except (TimeoutError, OSError, paramiko.SSHException) as e: 96 | if not quiet: 97 | logger.debug("SSH connection problem with %s: %s", mgmt_ip, e) 98 | finally: 99 | with contextlib.suppress(Exception): 100 | client.close() 101 | 102 | return None 103 | 104 | 105 | def transfer_file( 106 | src_path: Path, 107 | dest_path: str, 108 | username: str, 109 | mgmt_ip: str, 110 | password: str, 111 | quiet: bool = False, 112 | tries: int = 2, 113 | ) -> bool: 114 | """ 115 | SCP file to the target node using Paramiko SFTP. 116 | """ 117 | try: 118 | if not quiet: 119 | logger.debug("SCP %s → %s@%s:%s", src_path, username, mgmt_ip, dest_path) 120 | 121 | transport = paramiko.Transport((mgmt_ip, 22)) 122 | transport.connect(username=username, password=password) 123 | 124 | sftp = paramiko.SFTPClient.from_transport(transport) 125 | sftp.put(str(src_path), dest_path) 126 | sftp.close() 127 | transport.close() 128 | return True 129 | except Exception as e: 130 | if not quiet: 131 | logger.debug("SCP failed: %s", e) 132 | tries -= 1 133 | if tries > 0: 134 | logger.info( 135 | "SCP failed! Waiting 20 seconds before retrying. Retrying %s more time%s", 136 | tries, 137 | "s" if tries > 1 else "", 138 | ) 139 | time.sleep(20) 140 | return transfer_file( 141 | src_path=src_path, 142 | dest_path=dest_path, 143 | username=username, 144 | mgmt_ip=mgmt_ip, 145 | password=password, 146 | quiet=quiet, 147 | tries=tries, 148 | ) 149 | else: 150 | return False 151 | 152 | 153 | def execute_ssh_commands( 154 | script_path: Path, 155 | username: str, 156 | mgmt_ip: str, 157 | node_name: str, 158 | password: str, 159 | quiet: bool = False, 160 | prompt_terminator_chars: list[str] | None = None, 161 | prompt_termination_offset: int = -1, 162 | timeout: float = 30.0, 163 | ) -> bool: 164 | """ 165 | Push the command file line-by-line over an interactive shell. 166 | This waits for prompt after each command and raises exception if timeout is reached. 167 | """ 168 | if prompt_terminator_chars is None: 169 | prompt_terminator_chars = [">", "#", "$"] 170 | 171 | try: 172 | commands = script_path.read_text().splitlines() 173 | 174 | # This will add 1 empty commands at the end, making sure everything gets executed till the last bit 175 | # 1 is enough because we check for prompt 176 | commands.append("") 177 | # This will insert 1 empty command at the beginning to wait for first prompt 178 | commands.insert(0, "") 179 | 180 | client = paramiko.SSHClient() 181 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 182 | client.connect( 183 | hostname=mgmt_ip, 184 | username=username, 185 | password=password, 186 | allow_agent=False, 187 | look_for_keys=False, 188 | ) 189 | 190 | chan = client.invoke_shell() 191 | output = [] 192 | 193 | time.sleep(2) 194 | 195 | for cmd in commands: 196 | if cmd.strip() == "write": 197 | time.sleep(2) # Wait 2 seconds before sending write 198 | 199 | if cmd.strip() == "": 200 | time.sleep( 201 | 0.5 202 | ) # Wait 0.5 seconds before sending on empty command 203 | 204 | chan.send(cmd + "\n") 205 | 206 | while not chan.recv_ready(): 207 | pass 208 | 209 | command_timeout = timeout 210 | while command_timeout > 0: 211 | buffer = "" 212 | while chan.recv_ready(): 213 | buffer += chan.recv(4096).decode() 214 | output.append(buffer) 215 | 216 | last_line = "".join(output).splitlines()[-1] 217 | prompt_search_offset = ( 218 | prompt_termination_offset if prompt_termination_offset < 0 else -1 219 | ) 220 | if ( 221 | len(last_line) >= -prompt_search_offset 222 | and last_line[prompt_search_offset] in prompt_terminator_chars 223 | ): 224 | break 225 | command_timeout -= 0.5 226 | if command_timeout < 0: 227 | raise RuntimeError("Timeout reached while waiting for prompt!") 228 | time.sleep(0.5) 229 | logger.debug( 230 | "Waited %s seconds for prompt after command '%s'", 231 | timeout - command_timeout, 232 | cmd, 233 | ) 234 | 235 | # Get any remaining output 236 | while chan.recv_ready(): 237 | output.append(chan.recv(4096).decode()) 238 | 239 | chan.close() 240 | client.close() 241 | 242 | if not quiet: 243 | logger.info( 244 | "Configuration of %s completed (output %d chars)", 245 | node_name, 246 | sum(map(len, output)), 247 | ) 248 | textoutput = escape("".join(output)) 249 | logger.debug("Output: %s", textoutput) 250 | return True 251 | except Exception as e: 252 | logger.error("SSH exec error on %s: %s", node_name, e) 253 | return False 254 | 255 | 256 | # --------------------------------------------------------------------------- # 257 | # Helper utilities # 258 | # --------------------------------------------------------------------------- # 259 | def _extract_file(cmd: str, path: Path, desc: str, quiet: bool) -> int: 260 | """Run `cmd` until `path` exists and is non-empty.""" 261 | for attempt in range(RETRIES): 262 | _run_with_retry(cmd, quiet, retries=1) 263 | size = path.stat().st_size if path.exists() else 0 264 | if size > 0: 265 | if attempt > 0: 266 | logger.info( 267 | "%s extraction succeeded on attempt %s/%s", 268 | desc, 269 | attempt + 1, 270 | RETRIES, 271 | ) 272 | logger.info("%s file size: %s bytes", desc, size) 273 | return size 274 | if attempt == RETRIES - 1: 275 | raise ValueError(f"{desc} file is empty after extraction") 276 | logger.warning( 277 | "%s file empty (attempt %s/%s), re-extracting...", 278 | desc, 279 | attempt + 1, 280 | RETRIES, 281 | ) 282 | time.sleep(DELAY) 283 | 284 | 285 | def _extract_cert_and_config( 286 | node_name: str, 287 | namespace: str, 288 | version: str, 289 | cert_p: Path, 290 | key_p: Path, 291 | cfg_p: Path, 292 | quiet: bool, 293 | ): 294 | logger.info("Extracting TLS cert / key …") 295 | 296 | # Extract cert and key with retries and validation 297 | cert_cmd = ( 298 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 299 | f"-n eda-system -o jsonpath='{{.data.tls\\.crt}}' " 300 | f"| base64 -d > {cert_p}" 301 | ) 302 | key_cmd = ( 303 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 304 | f"-n eda-system -o jsonpath='{{.data.tls\\.key}}' " 305 | f"| base64 -d > {key_p}" 306 | ) 307 | 308 | _extract_file(cert_cmd, cert_p, "Certificate", quiet) 309 | _extract_file(key_cmd, key_p, "Private key", quiet) 310 | 311 | logger.info("Extracting initial config …") 312 | 313 | # Extract and parse config with retries 314 | extract_cmd = ( 315 | f"kubectl get artifact initcfg-{node_name}-{version} -n {namespace} " 316 | f"-o jsonpath='{{.spec.textFile.content}}' " 317 | f"| sed 's/\\n/\\n/g' > {cfg_p}" 318 | ) 319 | 320 | _extract_file(extract_cmd, cfg_p, "Startup-config", quiet) 321 | 322 | 323 | def _copy_files_and_config( 324 | dest_roots: tuple[str, str], 325 | cert_p: Path, 326 | key_p: Path, 327 | postscript_p: Path, 328 | config_p: Path, 329 | username: str, 330 | mgmt_ip: str, 331 | working_pw: str, 332 | quiet: bool, 333 | ) -> str: 334 | logger.info("Copying files to device …") 335 | 336 | for root in dest_roots: 337 | logger.info(f"Attempting to copy files to root: {root}") 338 | 339 | cfg_success = transfer_file( 340 | config_p, root + "startup-config", username, mgmt_ip, working_pw, quiet 341 | ) 342 | if cfg_success: 343 | logger.info(f"Config copied successfully to {root}startup-config") 344 | else: 345 | logger.warning(f"Failed to copy config to {root}startup-config") 346 | continue 347 | 348 | _build_post_script(postscript_p, root) 349 | post_success = transfer_file( 350 | postscript_p, root + "copy-certs.sh", username, mgmt_ip, working_pw, quiet 351 | ) 352 | if post_success: 353 | logger.info(f"Post script copied successfully to {root}copy-certs.sh") 354 | else: 355 | logger.warning(f"Failed to copy post script to {root}copy-certs.sh") 356 | continue 357 | 358 | cert_success = transfer_file( 359 | cert_p, root + "edaboot.crt", username, mgmt_ip, working_pw, quiet 360 | ) 361 | if cert_success: 362 | logger.info(f"Certificate copied successfully to {root}edaboot.crt") 363 | else: 364 | logger.warning(f"Failed to copy certificate to {root}edaboot.crt") 365 | continue 366 | 367 | key_success = transfer_file( 368 | key_p, root + "edaboot.key", username, mgmt_ip, working_pw, quiet 369 | ) 370 | if key_success: 371 | logger.info(f"Private key copied successfully to {root}edaboot.key") 372 | logger.info(f"All files copied successfully using root: {root}") 373 | return root 374 | else: 375 | logger.warning(f"Failed to copy private key to {root}edaboot.key") 376 | 377 | raise RuntimeError("Failed to copy files to device") 378 | 379 | 380 | def _build_enable_scp_script(script_p: Path) -> None: 381 | with script_p.open("w") as f: 382 | f.write("enable\n") 383 | f.write("configure terminal\n") 384 | f.write("aaa authorization exec default local\n") 385 | f.write("exit\n") 386 | f.write("write\n") 387 | 388 | 389 | def _build_command_script(script_p: Path, dest_root: str) -> None: 390 | with script_p.open("w") as f: 391 | f.write("enable\n") 392 | f.write("configure replace startup-config ignore-errors\n") 393 | f.write(f"copy file:{dest_root}edaboot.crt certificate:\n") 394 | f.write(f"copy file:{dest_root}edaboot.key sslkey:\n") 395 | f.write("configure terminal\n") 396 | f.write("management api gnmi\n") 397 | f.write(" transport grpc discovery\n") 398 | f.write(" ssl profile edaboot\n") 399 | f.write("management api gnmi\n") 400 | f.write(" transport grpc mgmt\n") 401 | f.write(" ssl profile EDA\n") 402 | f.write("exit\n") 403 | f.write("write\n") 404 | 405 | 406 | def _build_post_script(script_p: Path, dest_root: str) -> None: 407 | with script_p.open("w") as f: 408 | f.write("#!/usr/bin/Cli -p2\n") 409 | f.write(f"copy file:{dest_root}edaboot.crt certificate:\n") 410 | f.write(f"copy file:{dest_root}edaboot.key sslkey:\n") 411 | f.write("configure terminal\n") 412 | f.write("management api gnmi\n") 413 | f.write(" transport grpc discovery\n") 414 | f.write(" ssl profile edaboot\n") 415 | f.write("management api gnmi\n") 416 | f.write(" transport grpc mgmt\n") 417 | f.write(" ssl profile EDA\n") 418 | 419 | 420 | # --------------------------------------------------------------------------- # 421 | # High-level workflow # 422 | # --------------------------------------------------------------------------- # 423 | def prepare_ceos_node( 424 | node_name: str, 425 | namespace: str, 426 | version: str, 427 | mgmt_ip: str, 428 | username: str = "admin", 429 | password: str | None = None, 430 | quiet: bool = True, 431 | ) -> bool: 432 | """ 433 | Perform EOS-specific post-integration steps. 434 | """ 435 | # 1. determine password list (keep provided one first if present) 436 | pwd_list: list[str] = [] 437 | if password: 438 | pwd_list.append(password) 439 | pwd_list.append("admin") 440 | 441 | logger.info("Verifying SSH credentials for %s ...", node_name) 442 | working_pw = verify_ssh_credentials(mgmt_ip, username, pwd_list, quiet) 443 | 444 | if not working_pw: 445 | logger.error("No valid password found - aborting") 446 | return False 447 | # 2. create temp artefacts 448 | with tempfile.TemporaryDirectory() as tdir: 449 | tdir_path = Path(tdir) 450 | cert_p = tdir_path / "edaboot.crt" 451 | key_p = tdir_path / "edaboot.key" 452 | cfg_p = tdir_path / "startup-config" 453 | post_p = tdir_path / "copy-certs.sh" 454 | prescript_p = tdir_path / "ceos_enable_scp.txt" 455 | script_p = tdir_path / "ceos_integrate_commands.txt" 456 | 457 | try: 458 | _extract_cert_and_config( 459 | node_name, namespace, version, cert_p, key_p, cfg_p, quiet 460 | ) 461 | 462 | _build_enable_scp_script(prescript_p) 463 | 464 | if not execute_ssh_commands( 465 | prescript_p, username, mgmt_ip, node_name, working_pw, quiet 466 | ): 467 | raise RuntimeError("Unable to enable SCP") 468 | 469 | dest_root = _copy_files_and_config( 470 | ("/mnt/flash/", "/"), 471 | cert_p, 472 | key_p, 473 | post_p, 474 | cfg_p, 475 | username, 476 | mgmt_ip, 477 | working_pw, 478 | quiet, 479 | ) 480 | 481 | _build_command_script(script_p, dest_root) 482 | 483 | logger.info("Pushing configuration to %s …", node_name) 484 | return execute_ssh_commands( 485 | script_p, username, mgmt_ip, node_name, working_pw, quiet 486 | ) 487 | 488 | except ( 489 | subprocess.CalledProcessError, 490 | FileNotFoundError, 491 | ValueError, 492 | RuntimeError, 493 | ) as e: 494 | logger.error("Post-integration failed: %s", e) 495 | return False 496 | except Exception as e: 497 | logger.exception("Unexpected error: %s", e) 498 | return False 499 | --------------------------------------------------------------------------------