├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── clab_connector ├── __init__.py ├── cli │ ├── __init__.py │ └── main.py ├── clients │ ├── eda │ │ ├── __init__.py │ │ ├── client.py │ │ └── http_client.py │ └── kubernetes │ │ ├── __init__.py │ │ └── client.py ├── models │ ├── link.py │ ├── node │ │ ├── __init__.py │ │ ├── base.py │ │ ├── factory.py │ │ ├── nokia_srl.py │ │ └── nokia_sros.py │ └── topology.py ├── services │ ├── export │ │ └── topology_exporter.py │ ├── integration │ │ ├── __init__.py │ │ ├── sros_post_integration.py │ │ └── topology_integrator.py │ ├── manifest │ │ └── manifest_generator.py │ └── removal │ │ ├── __init__.py │ │ └── topology_remover.py ├── templates │ ├── artifact.j2 │ ├── init.yaml.j2 │ ├── interface.j2 │ ├── node-profile.j2 │ ├── node-user-group.yaml.j2 │ ├── node-user.j2 │ ├── nodesecurityprofile.yaml.j2 │ ├── topolink.j2 │ └── toponode.j2 └── utils │ ├── __init__.py │ ├── constants.py │ ├── exceptions.py │ ├── helpers.py │ ├── logging_config.py │ └── yaml_processor.py ├── docs └── connector.png ├── example-topologies ├── EDA-T2.clab.yml ├── EDA-sros.clab.yml └── EDA-tiny.clab.yml ├── pyproject.toml └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration file 2 | # See documentation: https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "pip" # For Python packages 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "weekly" -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | | Option | Required | Default | Description 137 | |-------------------------|----------|---------|-------------------------------------------------------| 138 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 139 | | `--eda-url`, `-e` | Yes | None | EDA deployment hostname or IP address | 140 | | `--eda-user` | No | admin | EDA username | 141 | | `--eda-password` | No | admin | EDA password | 142 | | `--kc-user` | No | admin | Keycloak master realm admin user | 143 | | `--kc-password` | No | admin | Keycloak master realm admin password | 144 | | `--kc-secret` | No | None | Use given EDA client secret and skip Keycloak flow | 145 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 146 | | `--log-file`, `-f` | No | None | Optional log file path | 147 | | `--verify` | No | False | Enable certificate verification for EDA | 148 | | `--skip-edge-intfs` | No | False | Skip creation of edge links and their interfaces | 149 | 150 | 151 | > [!NOTE] 152 | > 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. 153 | 154 | #### Remove Containerlab Integration from EDA 155 | 156 | Remove the previously integrated Containerlab topology from EDA: 157 | 158 | ``` 159 | clab-connector remove \ 160 | --topology-data path/to/topology-data.json \ 161 | --eda-url https://eda.example.com \ 162 | --eda-user youruser \ 163 | --eda-password yourpassword 164 | ``` 165 | | Option | Required | Default | Description 166 | |-------------------------|----------|---------|-------------------------------------------------------| 167 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 168 | | `--eda-url`, `-e` | Yes | None | EDA deployment hostname or IP address | 169 | | `--eda-user` | No | admin | EDA username | 170 | | `--eda-password` | No | admin | EDA password | 171 | | `--kc-user` | No | admin | Keycloak master realm admin user | 172 | | `--kc-password` | No | admin | Keycloak master realm admin password | 173 | | `--kc-secret` | No | None | Use given EDA client secret and skip Keycloak flow | 174 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 175 | | `--log-file`, `-f` | No | None | Optional log file path | 176 | | `--verify` | No | False | Enable certificate verification for EDA | 177 | 178 | 179 | 180 | #### Export a lab from EDA to Containerlab 181 | 182 | ``` 183 | clab-connector export-lab \ 184 | --namespace eda 185 | ``` 186 | 187 | | Option | Required | Default | Description | 188 | |---------------------|----------|-----------|------------------------------------------------------------------------| 189 | | `--namespace`, `-n` | Yes | None | Namespace in which the lab is deployed in EDA | 190 | | `--output`, `-o` | No | None | Output .clab.yaml file path | 191 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 192 | | `--log-file` | No | None | Optional log file path | 193 | 194 | #### Generate CR YAML Manifests 195 | 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.). 196 | You can also use `--skip-edge-intfs` to omit edge link resources and their interfaces. 197 | 198 | 199 | ##### Combined file example: 200 | ``` 201 | clab-connector generate-crs \ 202 | --topology-data path/to/topology-data.json \ 203 | --output all-crs.yaml 204 | ``` 205 | ##### Separate files example: 206 | ``` 207 | clab-connector generate-crs \ 208 | --topology-data path/to/topology-data.json \ 209 | --separate \ 210 | --output manifests 211 | ``` 212 | | Option | Required | Default | Description 213 | |-------------------------|----------|---------|-------------------------------------------------------| 214 | | `--topology-data`, `-t` | Yes | None | Path to the Containerlab topology data JSON file | 215 | | `--output`, `-o` | No | None | Output file path or directory | 216 | | `--separate` | No | False | Generate separate YAML files for each CR | 217 | | `--log-level`, `-l` | No | INFO | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL) | 218 | | `--log-file`, `-f` | No | None | Optional log file path | 219 | | `--skip-edge-intfs` | No | False | Skip creation of edge links and their interfaces | 220 | 221 | 222 | 223 | ### Example Command 224 | 225 | ``` 226 | clab-connector -l INFO integrate -t topology-data.json -e https://eda.example.com 227 | ``` 228 | 229 | ## Example Topologies 230 | 231 | Explore the [example-topologies](./example-topologies/) directory for sample Containerlab topology files to get started quickly. 232 | 233 | ## Requesting Support 234 | 235 | If you encounter issues or have questions, please reach out through the following channels: 236 | 237 | - **GitHub Issues:** [Create an issue](https://github.com/eda-labs/clab-connector/issues) on GitHub. 238 | - **Discord:** Join our [Discord community](https://eda.dev/discord) 239 | 240 | > [!TIP] 241 | > Running the script with `-l INFO` or `-l DEBUG` flags can provide additional insights into any failures or issues. 242 | 243 | ## Contributing 244 | 245 | Contributions are welcome! Please fork the repository and submit a pull request with your enhancements. 246 | 247 | ## Acknowledgements 248 | 249 | - [Containerlab](https://containerlab.dev/) for providing an excellent network emulation platform. 250 | - [EDA (Event-Driven Automation)](https://docs.eda.dev/) for the robust automation capabilities. 251 | -------------------------------------------------------------------------------- /clab_connector/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/__init__.py 2 | -------------------------------------------------------------------------------- /clab_connector/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/cli/__init__.py 2 | 3 | """CLI package for clab-connector.""" 4 | -------------------------------------------------------------------------------- /clab_connector/cli/main.py: -------------------------------------------------------------------------------- 1 | # clab_connector/cli/main.py 2 | 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | import typer 8 | import urllib3 9 | from rich import print as rprint 10 | from typing_extensions import Annotated 11 | 12 | # Disable urllib3 warnings (optional) 13 | urllib3.disable_warnings() 14 | 15 | SUPPORTED_KINDS = ["nokia_srlinux", "nokia_sros"] 16 | 17 | 18 | class LogLevel(str, Enum): 19 | DEBUG = "DEBUG" 20 | INFO = "INFO" 21 | WARNING = "WARNING" 22 | ERROR = "ERROR" 23 | CRITICAL = "CRITICAL" 24 | 25 | 26 | app = typer.Typer( 27 | name="clab-connector", 28 | help="Integrate or remove an existing containerlab topology with EDA (Event-Driven Automation)", 29 | add_completion=True, 30 | ) 31 | 32 | 33 | def complete_json_files( 34 | ctx: typer.Context, param: typer.Option, incomplete: str 35 | ) -> List[str]: 36 | """ 37 | Complete JSON file paths for CLI autocomplete. 38 | """ 39 | from pathlib import Path 40 | 41 | current = Path(incomplete) if incomplete else Path.cwd() 42 | if not current.is_dir(): 43 | current = current.parent 44 | return [str(path) for path in current.glob("*.json") if incomplete in str(path)] 45 | 46 | 47 | def complete_eda_url( 48 | ctx: typer.Context, param: typer.Option, incomplete: str 49 | ) -> List[str]: 50 | """ 51 | Complete EDA URL for CLI autocomplete. 52 | """ 53 | if not incomplete: 54 | return ["https://"] 55 | if not incomplete.startswith("https://"): 56 | return ["https://" + incomplete] 57 | return [] 58 | 59 | 60 | @app.command(name="integrate", help="Integrate containerlab with EDA") 61 | def integrate_cmd( 62 | topology_data: Annotated[ 63 | Path, 64 | typer.Option( 65 | "--topology-data", 66 | "-t", 67 | help="Path to containerlab topology JSON file", 68 | exists=True, 69 | file_okay=True, 70 | dir_okay=False, 71 | readable=True, 72 | shell_complete=complete_json_files, 73 | ), 74 | ], 75 | eda_url: Annotated[ 76 | str, 77 | typer.Option( 78 | "--eda-url", 79 | "-e", 80 | help="EDA deployment URL (hostname or IP)", 81 | shell_complete=complete_eda_url, 82 | ), 83 | ], 84 | eda_user: str = typer.Option( 85 | "admin", "--eda-user", help="EDA username (realm='eda')" 86 | ), 87 | eda_password: str = typer.Option( 88 | "admin", "--eda-password", help="EDA user password (realm='eda')" 89 | ), 90 | kc_user: str = typer.Option( 91 | "admin", "--kc-user", help="Keycloak master realm admin user (default: admin)" 92 | ), 93 | kc_password: str = typer.Option( 94 | "admin", 95 | "--kc-password", 96 | help="Keycloak master realm admin password (default: admin)", 97 | ), 98 | kc_secret: Optional[str] = typer.Option( 99 | None, 100 | "--kc-secret", 101 | help="If given, use this as the EDA client secret and skip Keycloak admin flow", 102 | ), 103 | log_level: LogLevel = typer.Option( 104 | LogLevel.INFO, "--log-level", "-l", help="Set logging level" 105 | ), 106 | log_file: Optional[str] = typer.Option( 107 | None, "--log-file", "-f", help="Optional log file path" 108 | ), 109 | verify: bool = typer.Option(False, "--verify", help="Enable TLS cert verification"), 110 | skip_edge_intfs: bool = typer.Option( 111 | False, 112 | "--skip-edge-intfs", 113 | help="Skip creation of edge links and their interfaces", 114 | ), 115 | ): 116 | """ 117 | CLI command to integrate a containerlab topology with EDA. 118 | """ 119 | import logging 120 | from clab_connector.utils.logging_config import setup_logging 121 | from clab_connector.clients.eda.client import EDAClient 122 | from clab_connector.services.integration.topology_integrator import ( 123 | TopologyIntegrator, 124 | ) 125 | 126 | # Set up logging now 127 | setup_logging(log_level.value, log_file) 128 | logger = logging.getLogger(__name__) 129 | logger.warning(f"Supported containerlab kinds are: {SUPPORTED_KINDS}") 130 | 131 | # Construct a small Args-like object to pass around (optional) 132 | class Args: 133 | pass 134 | 135 | args = Args() 136 | args.topology_data = topology_data 137 | args.eda_url = eda_url 138 | args.eda_user = eda_user 139 | args.eda_password = eda_password 140 | args.kc_user = kc_user 141 | args.kc_password = kc_password 142 | args.kc_secret = kc_secret 143 | args.verify = verify 144 | args.skip_edge_intfs = skip_edge_intfs 145 | 146 | def execute_integration(a): 147 | eda_client = EDAClient( 148 | hostname=a.eda_url, 149 | eda_user=a.eda_user, 150 | eda_password=a.eda_password, 151 | kc_secret=a.kc_secret, # If set, skip admin flow 152 | kc_user=a.kc_user, 153 | kc_password=a.kc_password, 154 | verify=a.verify, 155 | ) 156 | 157 | integrator = TopologyIntegrator(eda_client) 158 | integrator.run( 159 | topology_file=a.topology_data, 160 | eda_url=a.eda_url, 161 | eda_user=a.eda_user, 162 | eda_password=a.eda_password, 163 | verify=a.verify, 164 | skip_edge_intfs=a.skip_edge_intfs, 165 | ) 166 | 167 | try: 168 | execute_integration(args) 169 | except Exception as e: 170 | rprint(f"[red]Error: {str(e)}[/red]") 171 | raise typer.Exit(code=1) 172 | 173 | 174 | @app.command(name="remove", help="Remove containerlab integration from EDA") 175 | def remove_cmd( 176 | topology_data: Annotated[ 177 | Path, 178 | typer.Option( 179 | "--topology-data", 180 | "-t", 181 | help="Path to containerlab topology JSON file", 182 | exists=True, 183 | file_okay=True, 184 | dir_okay=False, 185 | readable=True, 186 | shell_complete=complete_json_files, 187 | ), 188 | ], 189 | eda_url: str = typer.Option(..., "--eda-url", "-e", help="EDA deployment hostname"), 190 | eda_user: str = typer.Option( 191 | "admin", "--eda-user", help="EDA username (realm='eda')" 192 | ), 193 | eda_password: str = typer.Option( 194 | "admin", "--eda-password", help="EDA user password (realm='eda')" 195 | ), 196 | # Keycloak options 197 | kc_user: str = typer.Option( 198 | "admin", "--kc-user", help="Keycloak master realm admin user (default: admin)" 199 | ), 200 | kc_password: str = typer.Option( 201 | "admin", 202 | "--kc-password", 203 | help="Keycloak master realm admin password (default: admin)", 204 | ), 205 | kc_secret: Optional[str] = typer.Option( 206 | None, 207 | "--kc-secret", 208 | help="If given, use this as the EDA client secret and skip Keycloak admin flow", 209 | ), 210 | log_level: LogLevel = typer.Option( 211 | LogLevel.INFO, "--log-level", "-l", help="Set logging level" 212 | ), 213 | log_file: Optional[str] = typer.Option( 214 | None, "--log-file", "-f", help="Optional log file path" 215 | ), 216 | verify: bool = typer.Option(False, "--verify", help="Enable TLS cert verification"), 217 | ): 218 | """ 219 | CLI command to remove EDA integration (delete the namespace). 220 | """ 221 | from clab_connector.utils.logging_config import setup_logging 222 | from clab_connector.clients.eda.client import EDAClient 223 | from clab_connector.services.removal.topology_remover import TopologyRemover 224 | 225 | # Set up logging 226 | setup_logging(log_level.value, log_file) 227 | class Args: 228 | pass 229 | 230 | args = Args() 231 | args.topology_data = topology_data 232 | args.eda_url = eda_url 233 | args.eda_user = eda_user 234 | args.eda_password = eda_password 235 | args.kc_user = kc_user 236 | args.kc_password = kc_password 237 | args.kc_secret = kc_secret 238 | args.verify = verify 239 | 240 | def execute_removal(a): 241 | eda_client = EDAClient( 242 | hostname=a.eda_url, 243 | eda_user=a.eda_user, 244 | eda_password=a.eda_password, 245 | kc_secret=a.kc_secret, 246 | kc_user=a.kc_user, 247 | kc_password=a.kc_password, 248 | verify=a.verify, 249 | ) 250 | remover = TopologyRemover(eda_client) 251 | remover.run(topology_file=a.topology_data) 252 | 253 | try: 254 | execute_removal(args) 255 | except Exception as e: 256 | rprint(f"[red]Error: {str(e)}[/red]") 257 | raise typer.Exit(code=1) 258 | 259 | 260 | @app.command( 261 | name="export-lab", 262 | help="Export an EDA-managed topology from a namespace to a .clab.yaml file", 263 | ) 264 | def export_lab_cmd( 265 | namespace: str = typer.Option( 266 | ..., 267 | "--namespace", 268 | "-n", 269 | help="Kubernetes namespace containing toponodes/topolinks", 270 | ), 271 | output_file: Optional[str] = typer.Option( 272 | None, "--output", "-o", help="Output .clab.yaml file path" 273 | ), 274 | log_level: LogLevel = typer.Option( 275 | LogLevel.INFO, "--log-level", help="Logging level" 276 | ), 277 | log_file: Optional[str] = typer.Option( 278 | None, "--log-file", help="Optional log file path" 279 | ), 280 | ): 281 | """ 282 | Fetch EDA toponodes & topolinks from the specified namespace 283 | and convert them to a containerlab .clab.yaml file. 284 | 285 | Example: 286 | clab-connector export-lab -n clab-my-topo --output clab-my-topo.clab.yaml 287 | """ 288 | import logging 289 | from clab_connector.utils.logging_config import setup_logging 290 | from clab_connector.services.export.topology_exporter import TopologyExporter 291 | 292 | setup_logging(log_level.value, log_file) 293 | logger = logging.getLogger(__name__) 294 | 295 | if not output_file: 296 | output_file = f"{namespace}.clab.yaml" 297 | 298 | exporter = TopologyExporter(namespace, output_file, logger) 299 | try: 300 | exporter.run() 301 | except Exception as e: 302 | logger.error(f"Failed to export lab from namespace '{namespace}': {e}") 303 | raise typer.Exit(code=1) 304 | 305 | @app.command( 306 | name="generate-crs", 307 | help="Generate CR YAML manifests from a containerlab topology without applying them to EDA." 308 | ) 309 | def generate_crs_cmd( 310 | topology_data: Annotated[ 311 | Path, 312 | typer.Option( 313 | "--topology-data", 314 | "-t", 315 | help="Path to containerlab topology JSON file", 316 | exists=True, 317 | file_okay=True, 318 | dir_okay=False, 319 | readable=True, 320 | shell_complete=complete_json_files, 321 | ), 322 | ], 323 | output_file: Optional[str] = typer.Option( 324 | None, 325 | "--output", 326 | "-o", 327 | help="Output file path for a combined manifest; if --separate is used, this is the output directory" 328 | ), 329 | separate: bool = typer.Option( 330 | False, "--separate", help="Generate separate YAML files for each CR instead of one combined file" 331 | ), 332 | log_level: LogLevel = typer.Option( 333 | LogLevel.INFO, "--log-level", "-l", help="Set logging level" 334 | ), 335 | log_file: Optional[str] = typer.Option( 336 | None, "--log-file", "-f", help="Optional log file path" 337 | ), 338 | skip_edge_intfs: bool = typer.Option( 339 | False, 340 | "--skip-edge-intfs", 341 | help="Skip creation of edge links and their interfaces", 342 | ), 343 | ): 344 | """ 345 | Generate the CR YAML manifests (artifacts, init, node security profile, 346 | node user group/user, node profiles, toponodes, topolink interfaces, and topolinks) 347 | from the given containerlab topology file. 348 | 349 | The manifests can be written as one combined YAML file (default) or as separate files 350 | (if --separate is specified). 351 | """ 352 | from clab_connector.services.manifest.manifest_generator import ManifestGenerator 353 | from clab_connector.utils.logging_config import setup_logging 354 | 355 | setup_logging(log_level.value, log_file) 356 | 357 | try: 358 | generator = ManifestGenerator( 359 | str(topology_data), 360 | output=output_file, 361 | separate=separate, 362 | skip_edge_intfs=skip_edge_intfs, 363 | ) 364 | generator.generate() 365 | generator.output_manifests() 366 | except Exception as e: 367 | rprint(f"[red]Error: {str(e)}[/red]") 368 | raise typer.Exit(code=1) 369 | 370 | if __name__ == "__main__": 371 | app() 372 | -------------------------------------------------------------------------------- /clab_connector/clients/eda/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/eda/__init__.py 2 | 3 | """EDA client package (REST API interactions).""" 4 | -------------------------------------------------------------------------------- /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 | import yaml 19 | 20 | from clab_connector.utils.constants import SUBSTEP_INDENT 21 | from urllib.parse import urlencode 22 | 23 | from clab_connector.clients.eda.http_client import create_pool_manager 24 | from clab_connector.utils.exceptions import EDAConnectionError 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class EDAClient: 30 | """ 31 | EDAClient communicates with the EDA REST API via Keycloak flows. 32 | 33 | Parameters 34 | ---------- 35 | hostname : str 36 | The base URL for EDA, e.g. "https://my-eda.example". 37 | eda_user : str 38 | EDA user in realm='eda'. 39 | eda_password : str 40 | EDA password in realm='eda'. 41 | kc_secret : str, optional 42 | Known Keycloak client secret for 'eda'. If not provided, we do the admin 43 | realm flow to retrieve it using kc_user/kc_password. 44 | verify : bool 45 | Whether to verify SSL certificates (default=True). 46 | kc_user : str 47 | Keycloak "master" realm admin username (default="admin"). 48 | kc_password : str 49 | Keycloak "master" realm admin password (default="admin"). 50 | """ 51 | 52 | KEYCLOAK_ADMIN_REALM = "master" 53 | KEYCLOAK_ADMIN_CLIENT_ID = "admin-cli" 54 | EDA_REALM = "eda" 55 | EDA_API_CLIENT_ID = "eda" 56 | 57 | CORE_GROUP = "core.eda.nokia.com" 58 | CORE_VERSION = "v1" 59 | 60 | def __init__( 61 | self, 62 | hostname: str, 63 | eda_user: str, 64 | eda_password: str, 65 | kc_secret: str = None, 66 | verify: bool = True, 67 | kc_user: str = "admin", 68 | kc_password: str = "admin", 69 | ): 70 | self.url = hostname.rstrip("/") 71 | self.eda_user = eda_user 72 | self.eda_password = eda_password 73 | self.kc_secret = kc_secret # If set, we skip the admin login 74 | self.verify = verify 75 | self.kc_user = kc_user 76 | self.kc_password = kc_password 77 | 78 | self.access_token = None 79 | self.refresh_token = None 80 | self.version = None 81 | self.transactions = [] 82 | 83 | self.http = create_pool_manager(url=self.url, verify=self.verify) 84 | 85 | def login(self): 86 | """ 87 | Acquire an access token via Keycloak resource-owner flow in realm='eda'. 88 | If kc_secret is not provided, fetch it using kc_user/kc_password in realm='master'. 89 | """ 90 | if not self.kc_secret: 91 | logger.debug( 92 | "No kc_secret provided; retrieving it from Keycloak master realm." 93 | ) 94 | self.kc_secret = self._fetch_client_secret_via_admin() 95 | logger.info(f"{SUBSTEP_INDENT}Successfully retrieved EDA client secret from Keycloak.") 96 | 97 | logger.debug( 98 | "Acquiring user access token via Keycloak resource-owner flow (realm=eda)." 99 | ) 100 | self.access_token = self._fetch_user_token(self.kc_secret) 101 | if not self.access_token: 102 | raise EDAConnectionError("Could not retrieve an access token for EDA.") 103 | 104 | logger.debug("Keycloak-based login successful (realm=eda).") 105 | 106 | def _fetch_client_secret_via_admin(self) -> str: 107 | """ 108 | Use kc_user/kc_password in realm='master' to retrieve 109 | the client secret for 'eda' client in realm='eda'. 110 | 111 | Returns 112 | ------- 113 | str 114 | The 'eda' client secret. 115 | 116 | Raises 117 | ------ 118 | EDAConnectionError 119 | If we fail to fetch an admin token or the 'eda' client secret. 120 | """ 121 | if not self.kc_user or not self.kc_password: 122 | raise EDAConnectionError( 123 | "Cannot fetch 'eda' client secret: no kc_secret provided and no kc_user/kc_password available." 124 | ) 125 | 126 | admin_token = self._fetch_admin_token(self.kc_user, self.kc_password) 127 | if not admin_token: 128 | raise EDAConnectionError( 129 | "Failed to fetch Keycloak admin token in realm=master." 130 | ) 131 | 132 | admin_api_url = ( 133 | f"{self.url}/core/httpproxy/v1/keycloak/" 134 | f"admin/realms/{self.EDA_REALM}/clients" 135 | ) 136 | headers = { 137 | "Authorization": f"Bearer {admin_token}", 138 | "Content-Type": "application/json", 139 | } 140 | 141 | resp = self.http.request("GET", admin_api_url, headers=headers) 142 | if resp.status != 200: 143 | raise EDAConnectionError( 144 | f"Failed to list clients in realm='{self.EDA_REALM}': {resp.data.decode()}" 145 | ) 146 | 147 | clients = json.loads(resp.data.decode("utf-8")) 148 | eda_client = next( 149 | (c for c in clients if c.get("clientId") == self.EDA_API_CLIENT_ID), None 150 | ) 151 | if not eda_client: 152 | raise EDAConnectionError( 153 | f"Client '{self.EDA_API_CLIENT_ID}' not found in realm='{self.EDA_REALM}'." 154 | ) 155 | 156 | client_id = eda_client["id"] 157 | secret_url = f"{admin_api_url}/{client_id}/client-secret" 158 | secret_resp = self.http.request("GET", secret_url, headers=headers) 159 | if secret_resp.status != 200: 160 | raise EDAConnectionError( 161 | f"Failed to fetch '{self.EDA_API_CLIENT_ID}' client secret: {secret_resp.data.decode()}" 162 | ) 163 | 164 | return json.loads(secret_resp.data.decode("utf-8"))["value"] 165 | 166 | def _fetch_admin_token(self, admin_user: str, admin_password: str) -> str: 167 | """ 168 | Fetch an admin token from the 'master' realm using admin_user/admin_password. 169 | """ 170 | token_url = ( 171 | f"{self.url}/core/httpproxy/v1/keycloak/" 172 | f"realms/{self.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" 173 | ) 174 | form_data = { 175 | "grant_type": "password", 176 | "client_id": self.KEYCLOAK_ADMIN_CLIENT_ID, 177 | "username": admin_user, 178 | "password": admin_password, 179 | } 180 | encoded_data = urlencode(form_data).encode("utf-8") 181 | 182 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 183 | resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) 184 | if resp.status != 200: 185 | raise EDAConnectionError( 186 | f"Failed Keycloak admin login in realm='{self.KEYCLOAK_ADMIN_REALM}': {resp.data.decode()}" 187 | ) 188 | 189 | token_json = json.loads(resp.data.decode("utf-8")) 190 | return token_json.get("access_token") 191 | 192 | def _fetch_user_token(self, client_secret: str) -> str: 193 | """ 194 | Resource-owner password flow in realm='eda' using eda_user/eda_password. 195 | """ 196 | token_url = ( 197 | f"{self.url}/core/httpproxy/v1/keycloak/" 198 | f"realms/{self.EDA_REALM}/protocol/openid-connect/token" 199 | ) 200 | form_data = { 201 | "grant_type": "password", 202 | "client_id": self.EDA_API_CLIENT_ID, 203 | "client_secret": client_secret, 204 | "scope": "openid", 205 | "username": self.eda_user, 206 | "password": self.eda_password, 207 | } 208 | encoded_data = urlencode(form_data).encode("utf-8") 209 | 210 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 211 | resp = self.http.request("POST", token_url, body=encoded_data, headers=headers) 212 | if resp.status != 200: 213 | raise EDAConnectionError(f"Failed user token request: {resp.data.decode()}") 214 | 215 | token_json = json.loads(resp.data.decode("utf-8")) 216 | return token_json.get("access_token") 217 | 218 | # --------------------------------------------------------------------- 219 | # Below here, the rest of the class is unchanged: GET/POST, commit tx, etc. 220 | # --------------------------------------------------------------------- 221 | 222 | def get_headers(self, requires_auth: bool = True) -> dict: 223 | headers = {} 224 | if requires_auth: 225 | if not self.access_token: 226 | logger.debug("No access_token found; performing Keycloak login...") 227 | self.login() 228 | headers["Authorization"] = f"Bearer {self.access_token}" 229 | return headers 230 | 231 | def get(self, api_path: str, requires_auth: bool = True): 232 | url = f"{self.url}/{api_path}" 233 | logger.debug(f"GET {url}") 234 | return self.http.request("GET", url, headers=self.get_headers(requires_auth)) 235 | 236 | def post(self, api_path: str, payload: dict, requires_auth: bool = True): 237 | url = f"{self.url}/{api_path}" 238 | logger.debug(f"POST {url}") 239 | body = json.dumps(payload).encode("utf-8") 240 | return self.http.request( 241 | "POST", url, headers=self.get_headers(requires_auth), body=body 242 | ) 243 | 244 | def is_up(self) -> bool: 245 | logger.info(f"{SUBSTEP_INDENT}Checking EDA health") 246 | resp = self.get("core/about/health", requires_auth=False) 247 | if resp.status != 200: 248 | return False 249 | 250 | data = json.loads(resp.data.decode("utf-8")) 251 | return data.get("status") == "UP" 252 | 253 | def get_version(self) -> str: 254 | if self.version is not None: 255 | return self.version 256 | 257 | logger.debug("Retrieving EDA version") 258 | resp = self.get("core/about/version") 259 | if resp.status != 200: 260 | raise EDAConnectionError(f"Version check failed: {resp.data.decode()}") 261 | 262 | data = json.loads(resp.data.decode("utf-8")) 263 | raw_ver = data["eda"]["version"] 264 | self.version = raw_ver.split("-")[0] 265 | logger.debug(f"EDA version: {self.version}") 266 | return self.version 267 | 268 | def is_authenticated(self) -> bool: 269 | try: 270 | self.get_version() 271 | return True 272 | except EDAConnectionError: 273 | return False 274 | 275 | def add_to_transaction(self, cr_type: str, payload: dict) -> dict: 276 | item = {"type": {cr_type: payload}} 277 | self.transactions.append(item) 278 | logger.debug(f"Adding item to transaction: {json.dumps(item, indent=2)}") 279 | return item 280 | 281 | def add_create_to_transaction(self, resource_yaml: str) -> dict: 282 | return self.add_to_transaction( 283 | "create", {"value": yaml.safe_load(resource_yaml)} 284 | ) 285 | 286 | def add_replace_to_transaction(self, resource_yaml: str) -> dict: 287 | return self.add_to_transaction( 288 | "replace", {"value": yaml.safe_load(resource_yaml)} 289 | ) 290 | 291 | def add_delete_to_transaction( 292 | self, 293 | namespace: str, 294 | kind: str, 295 | name: str, 296 | group: str = None, 297 | version: str = None, 298 | ): 299 | group = group or self.CORE_GROUP 300 | version = version or self.CORE_VERSION 301 | self.add_to_transaction( 302 | "delete", 303 | { 304 | "gvk": { 305 | "group": group, 306 | "version": version, 307 | "kind": kind, 308 | }, 309 | "name": name, 310 | "namespace": namespace, 311 | }, 312 | ) 313 | 314 | def is_transaction_item_valid(self, item: dict) -> bool: 315 | logger.debug("Validating transaction item") 316 | 317 | # Check version to determine which endpoint to use 318 | version = self.get_version() 319 | if version.startswith('v'): 320 | version = version[1:] # Remove 'v' prefix 321 | 322 | version_parts = version.split('.') 323 | major = int(version_parts[0]) 324 | minor = int(version_parts[1]) if len(version_parts) > 1 else 0 325 | 326 | # For version 25.4 and newer, use v2 endpoint (with list wrapping) 327 | # For older versions, use v1 endpoint 328 | if major > 25 or (major == 25 and minor >= 4): 329 | logger.debug("Using v2 transaction validation endpoint") 330 | resp = self.post("core/transaction/v2/validate", [item]) 331 | else: 332 | logger.debug("Using v1 transaction validation endpoint") 333 | resp = self.post("core/transaction/v1/validate", item) 334 | 335 | if resp.status == 204: 336 | logger.debug("Transaction item validation success.") 337 | return True 338 | 339 | data = json.loads(resp.data.decode("utf-8")) 340 | logger.warning(f"{SUBSTEP_INDENT}Validation error: {data}") 341 | return False 342 | 343 | def commit_transaction( 344 | self, 345 | description: str, 346 | dryrun: bool = False, 347 | resultType: str = "normal", 348 | retain: bool = True, 349 | ) -> str: 350 | payload = { 351 | "description": description, 352 | "dryrun": dryrun, 353 | "resultType": resultType, 354 | "retain": retain, 355 | "crs": self.transactions, 356 | } 357 | logger.info( 358 | f"{SUBSTEP_INDENT}Committing transaction: {description}, {len(self.transactions)} items" 359 | ) 360 | resp = self.post("core/transaction/v1", payload) 361 | if resp.status != 200: 362 | raise EDAConnectionError( 363 | f"Transaction request failed: {resp.data.decode()}" 364 | ) 365 | 366 | data = json.loads(resp.data.decode("utf-8")) 367 | tx_id = data.get("id") 368 | if not tx_id: 369 | raise EDAConnectionError(f"No transaction ID in response: {data}") 370 | 371 | logger.info(f"{SUBSTEP_INDENT}Waiting for transaction {tx_id} to complete...") 372 | details_path = f"core/transaction/v1/details/{tx_id}?waitForComplete=true&failOnErrors=true" 373 | details_resp = self.get(details_path) 374 | if details_resp.status != 200: 375 | raise EDAConnectionError( 376 | f"Transaction detail request failed: {details_resp.data.decode()}" 377 | ) 378 | 379 | details = json.loads(details_resp.data.decode("utf-8")) 380 | if "code" in details: 381 | logger.error(f"Transaction commit failed: {details}") 382 | raise EDAConnectionError(f"Transaction commit failed: {details}") 383 | 384 | logger.info(f"{SUBSTEP_INDENT}Commit successful.") 385 | self.transactions = [] 386 | return tx_id 387 | -------------------------------------------------------------------------------- /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 | import urllib3 7 | from urllib.parse import urlparse 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get_proxy_settings(): 13 | """ 14 | Read proxy environment variables. 15 | 16 | Returns 17 | ------- 18 | tuple 19 | (http_proxy, https_proxy, no_proxy). 20 | """ 21 | http_upper = os.environ.get("HTTP_PROXY") 22 | http_lower = os.environ.get("http_proxy") 23 | https_upper = os.environ.get("HTTPS_PROXY") 24 | https_lower = os.environ.get("https_proxy") 25 | no_upper = os.environ.get("NO_PROXY") 26 | no_lower = os.environ.get("no_proxy") 27 | 28 | if http_upper and http_lower and http_upper != http_lower: 29 | logger.warning("Both HTTP_PROXY and http_proxy are set. Using HTTP_PROXY.") 30 | if https_upper and https_lower and https_upper != https_lower: 31 | logger.warning("Both HTTPS_PROXY and https_proxy are set. Using HTTPS_PROXY.") 32 | if no_upper and no_lower and no_upper != no_lower: 33 | logger.warning("Both NO_PROXY and no_proxy are set. Using NO_PROXY.") 34 | 35 | http_proxy = http_upper if http_upper else http_lower 36 | https_proxy = https_upper if https_upper else https_lower 37 | no_proxy = no_upper if no_upper else no_lower or "" 38 | return http_proxy, https_proxy, no_proxy 39 | 40 | 41 | def should_bypass_proxy(url, no_proxy=None): 42 | """ 43 | Check if a URL should bypass proxy based on NO_PROXY settings. 44 | 45 | Parameters 46 | ---------- 47 | url : str 48 | The URL to check. 49 | no_proxy : str, optional 50 | NO_PROXY environment variable content. 51 | 52 | Returns 53 | ------- 54 | bool 55 | True if the URL is matched by no_proxy patterns, False otherwise. 56 | """ 57 | if no_proxy is None: 58 | _, _, no_proxy = get_proxy_settings() 59 | if not no_proxy: 60 | return False 61 | 62 | parsed_url = urlparse(url if "//" in url else f"http://{url}") 63 | hostname = parsed_url.hostname 64 | if not hostname: 65 | return False 66 | 67 | no_proxy_parts = [p.strip() for p in no_proxy.split(",") if p.strip()] 68 | 69 | for np_val in no_proxy_parts: 70 | if np_val.startswith("."): 71 | np_val = np_val[1:] 72 | # Convert wildcard to regex 73 | pattern = re.escape(np_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/clients/kubernetes/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/kubernetes/__init__.py 2 | 3 | """Kubernetes client package (kubectl interactions, etc.).""" 4 | -------------------------------------------------------------------------------- /clab_connector/clients/kubernetes/client.py: -------------------------------------------------------------------------------- 1 | # clab_connector/clients/kubernetes/client.py 2 | 3 | import logging 4 | import re 5 | import time 6 | import yaml 7 | from typing import Optional 8 | 9 | from kubernetes import client, config 10 | from kubernetes.client.rest import ApiException 11 | from kubernetes.stream import stream 12 | from kubernetes.utils import create_from_yaml 13 | 14 | from clab_connector.utils.constants import SUBSTEP_INDENT 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # Attempt to load config: 19 | # 1) If in a Kubernetes pod, load in-cluster config 20 | # 2) Otherwise load local kube config 21 | try: 22 | config.load_incluster_config() 23 | logger.debug("Using in-cluster Kubernetes config.") 24 | except Exception: 25 | config.load_kube_config() 26 | logger.debug("Using local kubeconfig.") 27 | 28 | 29 | def get_toolbox_pod() -> str: 30 | """ 31 | Retrieves the name of the toolbox pod in the eda-system namespace, 32 | identified by labelSelector: eda.nokia.com/app=eda-toolbox. 33 | 34 | Returns 35 | ------- 36 | str 37 | The name of the first matching toolbox pod. 38 | 39 | Raises 40 | ------ 41 | RuntimeError 42 | If no toolbox pod is found. 43 | """ 44 | v1 = client.CoreV1Api() 45 | label_selector = "eda.nokia.com/app=eda-toolbox" 46 | pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) 47 | if not pods.items: 48 | raise RuntimeError("No toolbox pod found in 'eda-system' namespace.") 49 | return pods.items[0].metadata.name 50 | 51 | 52 | def get_bsvr_pod() -> str: 53 | """ 54 | Retrieves the name of the bootstrapserver (bsvr) pod in eda-system, 55 | identified by labelSelector: eda.nokia.com/app=bootstrapserver. 56 | 57 | Returns 58 | ------- 59 | str 60 | The name of the first matching bsvr pod. 61 | 62 | Raises 63 | ------ 64 | RuntimeError 65 | If no bsvr pod is found. 66 | """ 67 | v1 = client.CoreV1Api() 68 | label_selector = "eda.nokia.com/app=bootstrapserver" 69 | pods = v1.list_namespaced_pod("eda-system", label_selector=label_selector) 70 | if not pods.items: 71 | raise RuntimeError("No bsvr pod found in 'eda-system' namespace.") 72 | return pods.items[0].metadata.name 73 | 74 | 75 | def ping_from_bsvr(target_ip: str) -> bool: 76 | """ 77 | Ping a target IP from the bsvr pod. 78 | 79 | Parameters 80 | ---------- 81 | target_ip : str 82 | IP address to ping. 83 | 84 | Returns 85 | ------- 86 | bool 87 | True if ping indicates success, False otherwise. 88 | """ 89 | logger.debug(f"Pinging '{target_ip}' from the bsvr pod...") 90 | bsvr_name = get_bsvr_pod() 91 | core_api = client.CoreV1Api() 92 | command = ["ping", "-c", "1", target_ip] 93 | try: 94 | resp = stream( 95 | core_api.connect_get_namespaced_pod_exec, 96 | name=bsvr_name, 97 | namespace="eda-system", 98 | command=command, 99 | stderr=True, 100 | stdin=False, 101 | stdout=True, 102 | tty=False, 103 | ) 104 | # A quick check for "1 packets transmitted, 1 received" 105 | if "1 packets transmitted, 1 received" in resp: 106 | logger.info(f"{SUBSTEP_INDENT}Ping from bsvr to {target_ip} succeeded") 107 | return True 108 | else: 109 | logger.error(f"{SUBSTEP_INDENT}Ping from bsvr to {target_ip} failed:\n{resp}") 110 | return False 111 | except ApiException as exc: 112 | logger.error(f"{SUBSTEP_INDENT}API error during ping: {exc}") 113 | return False 114 | 115 | 116 | def apply_manifest(yaml_str: str, namespace: str = "eda-system") -> None: 117 | """ 118 | Apply a YAML manifest using Python's create_from_yaml(). 119 | 120 | Parameters 121 | ---------- 122 | yaml_str : str 123 | The YAML content to apply. 124 | namespace : str 125 | The namespace into which to apply this resource. 126 | 127 | Raises 128 | ------ 129 | RuntimeError 130 | If applying the manifest fails. 131 | """ 132 | try: 133 | # Parse the YAML string into a dict 134 | manifest = yaml.safe_load(yaml_str) 135 | 136 | # Get the API version and kind 137 | api_version = manifest.get("apiVersion") 138 | kind = manifest.get("kind") 139 | 140 | if not api_version or not kind: 141 | raise RuntimeError("YAML manifest must specify apiVersion and kind") 142 | 143 | # Split API version into group and version 144 | if "/" in api_version: 145 | group, version = api_version.split("/") 146 | else: 147 | group = "" 148 | version = api_version 149 | 150 | # Use CustomObjectsApi for custom resources 151 | custom_api = client.CustomObjectsApi() 152 | 153 | try: 154 | if group: 155 | # For custom resources (like Artifact) 156 | custom_api.create_namespaced_custom_object( 157 | group=group, 158 | version=version, 159 | namespace=namespace, 160 | plural=f"{kind.lower()}s", # Convention is to use lowercase plural 161 | body=manifest, 162 | ) 163 | else: 164 | # For core resources 165 | create_from_yaml( 166 | k8s_client=client.ApiClient(), 167 | yaml_file=yaml.dump(manifest), 168 | namespace=namespace, 169 | ) 170 | logger.info( 171 | f"{SUBSTEP_INDENT}Successfully applied {kind} to namespace '{namespace}'" 172 | ) 173 | except ApiException as e: 174 | if e.status == 409: # Already exists 175 | logger.info( 176 | f"{SUBSTEP_INDENT}{kind} already exists in namespace '{namespace}'" 177 | ) 178 | else: 179 | raise 180 | 181 | except Exception as exc: 182 | logger.error(f"Failed to apply manifest: {exc}") 183 | raise RuntimeError(f"Failed to apply manifest: {exc}") 184 | 185 | 186 | def edactl_namespace_bootstrap(namespace: str) -> Optional[int]: 187 | """ 188 | Emulate `kubectl exec -- edactl namespace bootstrap ` 189 | by streaming an exec call into the toolbox pod. 190 | 191 | Parameters 192 | ---------- 193 | namespace : str 194 | Namespace to bootstrap in EDA. 195 | 196 | Returns 197 | ------- 198 | Optional[int] 199 | The transaction ID if found, or None if skipping/existing. 200 | """ 201 | toolbox = get_toolbox_pod() 202 | core_api = client.CoreV1Api() 203 | cmd = ["edactl", "namespace", "bootstrap", namespace] 204 | try: 205 | resp = stream( 206 | core_api.connect_get_namespaced_pod_exec, 207 | name=toolbox, 208 | namespace="eda-system", 209 | command=cmd, 210 | stderr=True, 211 | stdin=False, 212 | stdout=True, 213 | tty=False, 214 | ) 215 | if "already exists" in resp: 216 | logger.info( 217 | f"{SUBSTEP_INDENT}Namespace {namespace} already exists, skipping bootstrap." 218 | ) 219 | return None 220 | 221 | match = re.search(r"Transaction (\d+)", resp) 222 | if match: 223 | tx_id = int(match.group(1)) 224 | logger.info( 225 | f"{SUBSTEP_INDENT}Created namespace {namespace} (Transaction: {tx_id})" 226 | ) 227 | return tx_id 228 | 229 | logger.info( 230 | f"{SUBSTEP_INDENT}Created namespace {namespace}, no transaction ID found." 231 | ) 232 | return None 233 | except ApiException as exc: 234 | logger.error(f"Failed to bootstrap namespace {namespace}: {exc}") 235 | raise 236 | 237 | 238 | def wait_for_namespace( 239 | namespace: str, max_retries: int = 10, retry_delay: int = 1 240 | ) -> bool: 241 | """ 242 | Wait for a namespace to exist in Kubernetes. 243 | 244 | Parameters 245 | ---------- 246 | namespace : str 247 | Namespace to wait for. 248 | max_retries : int 249 | Maximum number of attempts. 250 | retry_delay : int 251 | Delay (seconds) between attempts. 252 | 253 | Returns 254 | ------- 255 | bool 256 | True if the namespace is found, else raises. 257 | 258 | Raises 259 | ------ 260 | RuntimeError 261 | If the namespace is not found within the given attempts. 262 | """ 263 | v1 = client.CoreV1Api() 264 | for attempt in range(max_retries): 265 | try: 266 | v1.read_namespace(name=namespace) 267 | logger.info( 268 | f"{SUBSTEP_INDENT}Namespace {namespace} is available" 269 | ) 270 | return True 271 | except ApiException as exc: 272 | if exc.status == 404: 273 | logger.debug( 274 | f"Waiting for namespace '{namespace}' (attempt {attempt + 1}/{max_retries})" 275 | ) 276 | time.sleep(retry_delay) 277 | else: 278 | logger.error(f"Error retrieving namespace {namespace}: {exc}") 279 | raise 280 | raise RuntimeError(f"Timed out waiting for namespace {namespace}") 281 | 282 | 283 | def update_namespace_description(namespace: str, description: str, max_retries: int = 5, retry_delay: int = 2) -> bool: 284 | """ 285 | Patch a namespace's description. For EDA, this may be a custom CRD 286 | (group=core.eda.nokia.com, version=v1, plural=namespaces). 287 | Handles 404 errors with retries if the namespace is not yet available. 288 | 289 | Parameters 290 | ---------- 291 | namespace : str 292 | The namespace to patch. 293 | description : str 294 | The new description. 295 | max_retries : int 296 | Maximum number of retry attempts. 297 | retry_delay : int 298 | Delay in seconds between retries. 299 | 300 | Returns 301 | ------- 302 | bool 303 | True if successful, False if couldn't update after retries. 304 | """ 305 | crd_api = client.CustomObjectsApi() 306 | group = "core.eda.nokia.com" 307 | version = "v1" 308 | plural = "namespaces" 309 | 310 | patch_body = {"spec": {"description": description}} 311 | 312 | # Check if namespace exists in Kubernetes first 313 | v1 = client.CoreV1Api() 314 | try: 315 | v1.read_namespace(name=namespace) 316 | except ApiException as exc: 317 | if exc.status == 404: 318 | logger.warning( 319 | f"{SUBSTEP_INDENT}Kubernetes namespace '{namespace}' does not exist. Cannot update EDA description." 320 | ) 321 | return False 322 | else: 323 | logger.error(f"Error checking namespace '{namespace}': {exc}") 324 | raise 325 | 326 | # Try to update the EDA namespace description with retries 327 | for attempt in range(max_retries): 328 | try: 329 | resp = crd_api.patch_namespaced_custom_object( 330 | group=group, 331 | version=version, 332 | namespace="eda-system", 333 | plural=plural, 334 | name=namespace, 335 | body=patch_body, 336 | ) 337 | logger.debug(f"Namespace '{namespace}' patched with description. resp={resp}") 338 | return True 339 | except ApiException as exc: 340 | if exc.status == 404: 341 | logger.info( 342 | f"{SUBSTEP_INDENT}EDA namespace '{namespace}' not found (attempt {attempt+1}/{max_retries}). Retrying in {retry_delay}s..." 343 | ) 344 | time.sleep(retry_delay) 345 | else: 346 | logger.error(f"Failed to patch namespace '{namespace}': {exc}") 347 | raise 348 | 349 | logger.warning( 350 | f"{SUBSTEP_INDENT}Could not update description for namespace '{namespace}' after {max_retries} attempts." 351 | ) 352 | return False 353 | 354 | 355 | def edactl_revert_commit(commit_hash: str) -> bool: 356 | """ 357 | Revert an EDA commit by running `edactl git revert ` in the toolbox pod. 358 | 359 | Parameters 360 | ---------- 361 | commit_hash : str 362 | The commit hash to revert. 363 | 364 | Returns 365 | ------- 366 | bool 367 | True if revert is successful, False otherwise. 368 | """ 369 | toolbox = get_toolbox_pod() 370 | core_api = client.CoreV1Api() 371 | cmd = ["edactl", "git", "revert", commit_hash] 372 | try: 373 | resp = stream( 374 | core_api.connect_get_namespaced_pod_exec, 375 | name=toolbox, 376 | namespace="eda-system", 377 | command=cmd, 378 | stderr=True, 379 | stdin=False, 380 | stdout=True, 381 | tty=False, 382 | ) 383 | if "Successfully reverted commit" in resp: 384 | logger.info(f"Successfully reverted commit {commit_hash}") 385 | return True 386 | else: 387 | logger.error(f"Failed to revert commit {commit_hash}: {resp}") 388 | return False 389 | except ApiException as exc: 390 | logger.error(f"Failed to revert commit {commit_hash}: {exc}") 391 | return False 392 | 393 | 394 | def list_toponodes_in_namespace(namespace: str): 395 | crd_api = client.CustomObjectsApi() 396 | group = "core.eda.nokia.com" 397 | version = "v1" 398 | plural = "toponodes" 399 | # We do a namespaced call 400 | toponodes = crd_api.list_namespaced_custom_object( 401 | group=group, version=version, namespace=namespace, plural=plural 402 | ) 403 | # returns a dict with "items": [...] 404 | return toponodes.get("items", []) 405 | 406 | 407 | def list_topolinks_in_namespace(namespace: str): 408 | crd_api = client.CustomObjectsApi() 409 | group = "core.eda.nokia.com" 410 | version = "v1" 411 | plural = "topolinks" 412 | topolinks = crd_api.list_namespaced_custom_object( 413 | group=group, version=version, namespace=namespace, plural=plural 414 | ) 415 | return topolinks.get("items", []) 416 | -------------------------------------------------------------------------------- /clab_connector/models/link.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/link.py 2 | 3 | import logging 4 | from clab_connector.utils import helpers 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Link: 10 | """ 11 | Represents a bidirectional link between two nodes. 12 | 13 | Parameters 14 | ---------- 15 | node_1 : Node 16 | The first node in the link. 17 | intf_1 : str 18 | The interface name on the first node. 19 | node_2 : Node 20 | The second node in the link. 21 | intf_2 : str 22 | The interface name on the second node. 23 | """ 24 | 25 | def __init__(self, node_1, intf_1, node_2, intf_2): 26 | self.node_1 = node_1 27 | self.intf_1 = intf_1 28 | self.node_2 = node_2 29 | self.intf_2 = intf_2 30 | 31 | def __repr__(self): 32 | """ 33 | Return a string representation of the link. 34 | 35 | Returns 36 | ------- 37 | str 38 | A description of the link endpoints. 39 | """ 40 | return f"Link({self.node_1}-{self.intf_1}, {self.node_2}-{self.intf_2})" 41 | 42 | def is_topolink(self): 43 | """ 44 | Check if both endpoints are EDA-supported nodes. 45 | 46 | Returns 47 | ------- 48 | bool 49 | True if both nodes support EDA, False otherwise. 50 | """ 51 | if self.node_1 is None or not self.node_1.is_eda_supported(): 52 | return False 53 | if self.node_2 is None or not self.node_2.is_eda_supported(): 54 | return False 55 | return True 56 | 57 | def is_edge_link(self): 58 | """Check if exactly one endpoint is EDA-supported and the other is a linux node.""" 59 | if not self.node_1 or not self.node_2: 60 | return False 61 | if self.node_1.is_eda_supported() and self.node_2.kind == "linux": 62 | return True 63 | if self.node_2.is_eda_supported() and self.node_1.kind == "linux": 64 | return True 65 | return False 66 | 67 | def get_link_name(self, topology): 68 | """ 69 | Create a unique name for the link resource. 70 | 71 | Parameters 72 | ---------- 73 | topology : Topology 74 | The topology that owns this link. 75 | 76 | Returns 77 | ------- 78 | str 79 | A link name safe for EDA. 80 | """ 81 | return f"{self.node_1.get_node_name(topology)}-{self.intf_1}-{self.node_2.get_node_name(topology)}-{self.intf_2}" 82 | 83 | def get_topolink_yaml(self, topology): 84 | """ 85 | Render and return the TopoLink YAML if the link is EDA-supported. 86 | 87 | Parameters 88 | ---------- 89 | topology : Topology 90 | The topology that owns this link. 91 | 92 | Returns 93 | ------- 94 | str or None 95 | The rendered TopoLink CR YAML, or None if not EDA-supported. 96 | """ 97 | if self.is_topolink(): 98 | role = "interSwitch" 99 | elif self.is_edge_link(): 100 | role = "edge" 101 | else: 102 | return None 103 | data = { 104 | "namespace": f"clab-{topology.name}", 105 | "link_role": role, 106 | "link_name": self.get_link_name(topology), 107 | "local_node": self.node_1.get_node_name(topology), 108 | "local_interface": self.node_1.get_interface_name_for_kind(self.intf_1), 109 | "remote_node": self.node_2.get_node_name(topology), 110 | "remote_interface": self.node_2.get_interface_name_for_kind(self.intf_2), 111 | } 112 | return helpers.render_template("topolink.j2", data) 113 | 114 | 115 | def create_link(endpoints: list, nodes: list) -> Link: 116 | """ 117 | Create a Link object from two endpoint definitions and a list of Node objects. 118 | 119 | Parameters 120 | ---------- 121 | endpoints : list 122 | A list of exactly two endpoint strings, e.g. ["nodeA:e1-1", "nodeB:e1-1"]. 123 | nodes : list 124 | A list of Node objects in the topology. 125 | 126 | Returns 127 | ------- 128 | Link 129 | A Link object representing the connection. 130 | 131 | Raises 132 | ------ 133 | ValueError 134 | If the endpoint format is invalid or length is not 2. 135 | """ 136 | 137 | if len(endpoints) != 2: 138 | raise ValueError("Link endpoints must be a list of length 2") 139 | 140 | def parse_endpoint(ep): 141 | parts = ep.split(":") 142 | if len(parts) != 2: 143 | raise ValueError(f"Invalid endpoint '{ep}', must be 'node:iface'") 144 | return parts[0], parts[1] 145 | 146 | nodeA, ifA = parse_endpoint(endpoints[0]) 147 | nodeB, ifB = parse_endpoint(endpoints[1]) 148 | 149 | nA = next((n for n in nodes if n.name == nodeA), None) 150 | nB = next((n for n in nodes if n.name == nodeB), None) 151 | 152 | return Link(nA, ifA, nB, ifB) 153 | -------------------------------------------------------------------------------- /clab_connector/models/node/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/__init__.py 2 | 3 | """Node package for domain models related to nodes.""" 4 | -------------------------------------------------------------------------------- /clab_connector/models/node/base.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/base.py 2 | 3 | import logging 4 | 5 | from clab_connector.utils import helpers 6 | from clab_connector.clients.kubernetes.client import ping_from_bsvr 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Node: 12 | """ 13 | Base Node class for representing a generic containerlab node. 14 | 15 | Parameters 16 | ---------- 17 | name : str 18 | The name of the node. 19 | kind : str 20 | The kind of the node (e.g. nokia_srlinux). 21 | node_type : str 22 | The specific node type (e.g. ixrd2). 23 | version : str 24 | The software version of the node. 25 | mgmt_ipv4 : str 26 | The management IPv4 address of the node. 27 | """ 28 | 29 | def __init__(self, name, kind, node_type, version, mgmt_ipv4): 30 | self.name = name 31 | self.kind = kind 32 | self.node_type = node_type or self.get_default_node_type() 33 | self.version = version 34 | self.mgmt_ipv4 = mgmt_ipv4 35 | 36 | def __repr__(self): 37 | """ 38 | Return a string representation of the node. 39 | 40 | Returns 41 | ------- 42 | str 43 | A string describing the node and its parameters. 44 | """ 45 | return ( 46 | f"Node(name={self.name}, kind={self.kind}, type={self.node_type}, " 47 | f"version={self.version}, mgmt_ipv4={self.mgmt_ipv4})" 48 | ) 49 | 50 | def ping(self): 51 | """ 52 | Attempt to ping the node from the EDA bootstrap server (bsvr). 53 | 54 | Returns 55 | ------- 56 | bool 57 | True if the ping is successful, raises a RuntimeError otherwise. 58 | """ 59 | logger.debug(f"Pinging node '{self.name}' IP {self.mgmt_ipv4}") 60 | if ping_from_bsvr(self.mgmt_ipv4): 61 | logger.debug(f"Ping to '{self.name}' ({self.mgmt_ipv4}) successful") 62 | return True 63 | else: 64 | msg = f"Ping to '{self.name}' ({self.mgmt_ipv4}) failed" 65 | logger.error(msg) 66 | raise RuntimeError(msg) 67 | 68 | def get_node_name(self, topology): 69 | """ 70 | Generate a name suitable for EDA resources, based on the node name. 71 | 72 | Parameters 73 | ---------- 74 | topology : Topology 75 | The topology the node belongs to. 76 | 77 | Returns 78 | ------- 79 | str 80 | A normalized node name safe for EDA. 81 | """ 82 | return helpers.normalize_name(self.name) 83 | 84 | def get_default_node_type(self): 85 | """ 86 | Get the default node type if none is specified. 87 | 88 | Returns 89 | ------- 90 | str or None 91 | A default node type or None. 92 | """ 93 | return None 94 | 95 | def get_platform(self): 96 | """ 97 | Return the platform name for the node. 98 | 99 | Returns 100 | ------- 101 | str 102 | The platform name (default 'UNKNOWN'). 103 | """ 104 | return "UNKNOWN" 105 | 106 | def is_eda_supported(self): 107 | """ 108 | Check whether the node kind is supported by EDA. 109 | 110 | Returns 111 | ------- 112 | bool 113 | True if supported, False otherwise. 114 | """ 115 | return False 116 | 117 | def get_profile_name(self, topology): 118 | """ 119 | Get the name of the NodeProfile for this node. 120 | 121 | Parameters 122 | ---------- 123 | topology : Topology 124 | The topology this node belongs to. 125 | 126 | Returns 127 | ------- 128 | str 129 | The NodeProfile name for EDA resource creation. 130 | 131 | Raises 132 | ------ 133 | NotImplementedError 134 | Must be implemented by subclasses. 135 | """ 136 | raise NotImplementedError("Must be implemented by subclass") 137 | 138 | def get_node_profile(self, topology): 139 | """ 140 | Render and return NodeProfile YAML for the node. 141 | 142 | Parameters 143 | ---------- 144 | topology : Topology 145 | The topology the node belongs to. 146 | 147 | Returns 148 | ------- 149 | str or None 150 | The rendered NodeProfile YAML, or None if not applicable. 151 | """ 152 | return None 153 | 154 | def get_toponode(self, topology): 155 | """ 156 | Render and return TopoNode YAML for the node. 157 | 158 | Parameters 159 | ---------- 160 | topology : Topology 161 | The topology the node belongs to. 162 | 163 | Returns 164 | ------- 165 | str or None 166 | The rendered TopoNode YAML, or None if not applicable. 167 | """ 168 | return None 169 | 170 | def get_interface_name_for_kind(self, ifname): 171 | """ 172 | Convert an interface name from a containerlab style to EDA style. 173 | 174 | Parameters 175 | ---------- 176 | ifname : str 177 | The interface name in containerlab format. 178 | 179 | Returns 180 | ------- 181 | str 182 | A suitable interface name for EDA. 183 | """ 184 | return ifname 185 | 186 | def get_topolink_interface_name(self, topology, ifname): 187 | """ 188 | Generate a unique interface resource name for a link. 189 | 190 | Parameters 191 | ---------- 192 | topology : Topology 193 | The topology that this node belongs to. 194 | ifname : str 195 | The interface name (containerlab style). 196 | 197 | Returns 198 | ------- 199 | str 200 | The name that EDA will use for this interface resource. 201 | """ 202 | return ( 203 | f"{self.get_node_name(topology)}-{self.get_interface_name_for_kind(ifname)}" 204 | ) 205 | 206 | def get_topolink_interface(self, topology, ifname, other_node): 207 | """ 208 | Render and return the interface resource YAML (Interface CR) for a link endpoint. 209 | 210 | Parameters 211 | ---------- 212 | topology : Topology 213 | The topology that this node belongs to. 214 | ifname : str 215 | The interface name on this node (containerlab style). 216 | other_node : Node 217 | The peer node at the other end of the link. 218 | 219 | Returns 220 | ------- 221 | str or None 222 | The rendered Interface CR YAML, or None if not applicable. 223 | """ 224 | return None 225 | 226 | def needs_artifact(self): 227 | """ 228 | Determine if this node requires a schema or binary artifact in EDA. 229 | 230 | Returns 231 | ------- 232 | bool 233 | True if an artifact is needed, False otherwise. 234 | """ 235 | return False 236 | 237 | def get_artifact_name(self): 238 | """ 239 | Return the artifact name if needed by the node. 240 | 241 | Returns 242 | ------- 243 | str or None 244 | The artifact name, or None if not needed. 245 | """ 246 | return None 247 | 248 | def get_artifact_info(self): 249 | """ 250 | Return the artifact name, filename, and download URL if needed. 251 | 252 | Returns 253 | ------- 254 | tuple 255 | (artifact_name, filename, download_url) or (None, None, None). 256 | """ 257 | return (None, None, None) 258 | 259 | def get_artifact_yaml(self, artifact_name, filename, download_url): 260 | """ 261 | Render and return an Artifact CR YAML for this node. 262 | 263 | Parameters 264 | ---------- 265 | artifact_name : str 266 | The name of the artifact in EDA. 267 | filename : str 268 | The artifact file name. 269 | download_url : str 270 | The source URL of the artifact file. 271 | 272 | Returns 273 | ------- 274 | str or None 275 | The rendered Artifact CR YAML, or None if not applicable. 276 | """ 277 | return None 278 | -------------------------------------------------------------------------------- /clab_connector/models/node/factory.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/factory.py 2 | 3 | import logging 4 | from .base import Node 5 | from .nokia_srl import NokiaSRLinuxNode 6 | from .nokia_sros import NokiaSROSNode 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | KIND_MAPPING = { 11 | "nokia_srlinux": NokiaSRLinuxNode, 12 | "nokia_sros": NokiaSROSNode, 13 | } 14 | 15 | 16 | def create_node(name: str, config: dict) -> Node: 17 | """ 18 | Create a node instance based on the kind specified in config. 19 | 20 | Parameters 21 | ---------- 22 | name : str 23 | The name of the node. 24 | config : dict 25 | A dictionary containing 'kind', 'type', 'version', 'mgmt_ipv4', etc. 26 | 27 | Returns 28 | ------- 29 | Node or None 30 | An appropriate Node subclass instance if supported; otherwise None. 31 | """ 32 | kind = config.get("kind") 33 | if not kind: 34 | logger.error(f"No 'kind' in config for node '{name}'") 35 | return None 36 | 37 | cls = KIND_MAPPING.get(kind) 38 | if cls is None: 39 | logger.info(f"Unsupported kind '{kind}' for node '{name}'") 40 | return None 41 | 42 | return cls( 43 | name=name, 44 | kind=kind, 45 | node_type=config.get("type"), 46 | version=config.get("version"), 47 | mgmt_ipv4=config.get("mgmt_ipv4"), 48 | ) 49 | -------------------------------------------------------------------------------- /clab_connector/models/node/nokia_srl.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/node/nokia_srl.py 2 | 3 | import logging 4 | import re 5 | 6 | from .base import Node 7 | from clab_connector.utils import helpers 8 | from clab_connector.utils.constants import SUBSTEP_INDENT 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | class NokiaSRLinuxNode(Node): 13 | """ 14 | Nokia SR Linux Node representation. 15 | 16 | This subclass implements specific logic for SR Linux nodes, including 17 | naming, interface mapping, and EDA resource generation. 18 | """ 19 | 20 | SRL_USERNAME = "admin" 21 | SRL_PASSWORD = "NokiaSrl1!" 22 | NODE_TYPE = "srlinux" 23 | GNMI_PORT = "57410" 24 | VERSION_PATH = ".system.information.version" 25 | YANG_PATH = "https://eda-asvr.eda-system.svc/eda-system/clab-schemaprofiles/{artifact_name}/{filename}" 26 | SRL_IMAGE = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin" 27 | SRL_IMAGE_MD5 = "eda-system/srlimages/srlinux-{version}-bin/srlinux.bin.md5" 28 | 29 | # Mapping for EDA operating system 30 | EDA_OPERATING_SYSTEM = "srl" 31 | 32 | SUPPORTED_SCHEMA_PROFILES = { 33 | "24.10.1": ( 34 | "https://github.com/nokia/srlinux-yang-models/" 35 | "releases/download/v24.10.1/srlinux-24.10.1-492.zip" 36 | ), 37 | "24.10.2": ( 38 | "https://github.com/nokia/srlinux-yang-models/" 39 | "releases/download/v24.10.2/srlinux-24.10.2-357.zip" 40 | ), 41 | "24.10.3": ( 42 | "https://github.com/nokia/srlinux-yang-models/" 43 | "releases/download/v24.10.3/srlinux-24.10.3-201.zip" 44 | ), 45 | "24.10.4": ( 46 | "https://github.com/nokia/srlinux-yang-models/" 47 | "releases/download/v24.10.4/srlinux-24.10.4-244.zip" 48 | ), 49 | "25.3.1": ( 50 | "https://github.com/nokia/srlinux-yang-models/" 51 | "releases/download/v25.3.1/srlinux-25.3.1-149.zip" 52 | ) 53 | } 54 | 55 | def get_default_node_type(self): 56 | """ 57 | Return the default node type for an SR Linux node. 58 | 59 | Returns 60 | ------- 61 | str 62 | The default node type (e.g., "ixrd3l"). 63 | """ 64 | return "ixrd3l" 65 | 66 | def get_platform(self): 67 | """ 68 | Return the platform name based on node type. 69 | 70 | Returns 71 | ------- 72 | str 73 | The platform name (e.g. '7220 IXR-D3L'). 74 | """ 75 | t = self.node_type.replace("ixr", "") 76 | return f"7220 IXR-{t.upper()}" 77 | 78 | def is_eda_supported(self): 79 | """ 80 | Indicates SR Linux nodes are EDA-supported. 81 | 82 | Returns 83 | ------- 84 | bool 85 | True for SR Linux. 86 | """ 87 | return True 88 | 89 | def get_profile_name(self, topology): 90 | """ 91 | Generate a NodeProfile name specific to this SR Linux node. 92 | 93 | Parameters 94 | ---------- 95 | topology : Topology 96 | The topology object. 97 | 98 | Returns 99 | ------- 100 | str 101 | The NodeProfile name for EDA. 102 | """ 103 | return f"{topology.get_eda_safe_name()}-{self.NODE_TYPE}-{self.version}" 104 | 105 | def get_node_profile(self, topology): 106 | """ 107 | Render the NodeProfile YAML for this SR Linux node. 108 | """ 109 | logger.debug(f"Rendering node profile for {self.name}") 110 | artifact_name = self.get_artifact_name() 111 | filename = f"srlinux-{self.version}.zip" 112 | 113 | data = { 114 | "namespace": f"clab-{topology.name}", 115 | "profile_name": self.get_profile_name(topology), 116 | "sw_version": self.version, 117 | "gnmi_port": self.GNMI_PORT, 118 | "operating_system": self.EDA_OPERATING_SYSTEM, 119 | "version_path": self.VERSION_PATH, 120 | "version_match": "v{}.*".format(self.version.replace(".", "\\.")), 121 | "yang_path": self.YANG_PATH.format( 122 | artifact_name=artifact_name, filename=filename 123 | ), 124 | "node_user": "admin", 125 | "onboarding_password": self.SRL_PASSWORD, 126 | "onboarding_username": self.SRL_USERNAME, 127 | "sw_image": self.SRL_IMAGE.format(version=self.version), 128 | "sw_image_md5": self.SRL_IMAGE_MD5.format(version=self.version), 129 | } 130 | return helpers.render_template("node-profile.j2", data) 131 | 132 | def get_toponode(self, topology): 133 | """ 134 | Render the TopoNode YAML for this SR Linux node. 135 | """ 136 | logger.info(f"{SUBSTEP_INDENT}Creating toponode for {self.name}") 137 | role_value = "leaf" 138 | nl = self.name.lower() 139 | if "spine" in nl: 140 | role_value = "spine" 141 | elif "borderleaf" in nl or "bl" in nl: 142 | role_value = "borderleaf" 143 | elif "dcgw" in nl: 144 | role_value = "dcgw" 145 | 146 | data = { 147 | "namespace": f"clab-{topology.name}", 148 | "node_name": self.get_node_name(topology), 149 | "topology_name": topology.get_eda_safe_name(), 150 | "role_value": role_value, 151 | "node_profile": self.get_profile_name(topology), 152 | "kind": self.EDA_OPERATING_SYSTEM, 153 | "platform": self.get_platform(), 154 | "sw_version": self.version, 155 | "mgmt_ip": self.mgmt_ipv4, 156 | "containerlab_label": "managedSrl" 157 | } 158 | return helpers.render_template("toponode.j2", data) 159 | 160 | def get_interface_name_for_kind(self, ifname): 161 | """ 162 | Convert a containerlab interface name to an SR Linux style interface. 163 | 164 | Parameters 165 | ---------- 166 | ifname : str 167 | Containerlab interface name, e.g., 'e1-1'. 168 | 169 | Returns 170 | ------- 171 | str 172 | SR Linux style name, e.g. 'ethernet-1-1'. 173 | """ 174 | pattern = re.compile(r"^e(\d+)-(\d+)$") 175 | match = pattern.match(ifname) 176 | if match: 177 | return f"ethernet-{match.group(1)}-{match.group(2)}" 178 | return ifname 179 | 180 | def get_topolink_interface(self, topology, ifname, other_node): 181 | """ 182 | Render the Interface CR YAML for an SR Linux link endpoint. 183 | 184 | Parameters 185 | ---------- 186 | topology : Topology 187 | The topology object. 188 | ifname : str 189 | The containerlab interface name on this node. 190 | other_node : Node 191 | The peer node. 192 | 193 | Returns 194 | ------- 195 | str 196 | The rendered Interface CR YAML. 197 | """ 198 | logger.debug(f"{SUBSTEP_INDENT}Creating topolink interface for {self.name}") 199 | role = "interSwitch" 200 | if other_node is None or not other_node.is_eda_supported(): 201 | role = "edge" 202 | data = { 203 | "namespace": f"clab-{topology.name}", 204 | "interface_name": self.get_topolink_interface_name(topology, ifname), 205 | "label_key": "eda.nokia.com/role", 206 | "label_value": role, 207 | "encap_type": "'null'", 208 | "node_name": self.get_node_name(topology), 209 | "interface": self.get_interface_name_for_kind(ifname), 210 | "description": f"{role} link to {other_node.get_node_name(topology)}", 211 | } 212 | return helpers.render_template("interface.j2", data) 213 | 214 | def needs_artifact(self): 215 | """ 216 | SR Linux nodes may require a YANG artifact. 217 | 218 | Returns 219 | ------- 220 | bool 221 | True if an artifact is needed based on the version. 222 | """ 223 | return True 224 | 225 | def get_artifact_name(self): 226 | """ 227 | Return a name for the SR Linux schema artifact. 228 | 229 | Returns 230 | ------- 231 | str 232 | A string such as 'clab-srlinux-24.10.1'. 233 | """ 234 | return f"clab-srlinux-{self.version}" 235 | 236 | def get_artifact_info(self): 237 | """ 238 | Return artifact metadata for the SR Linux YANG schema file. 239 | 240 | Returns 241 | ------- 242 | tuple 243 | (artifact_name, filename, download_url) 244 | """ 245 | if self.version not in self.SUPPORTED_SCHEMA_PROFILES: 246 | logger.warning(f"{SUBSTEP_INDENT}No schema profile for version {self.version}") 247 | return (None, None, None) 248 | artifact_name = self.get_artifact_name() 249 | filename = f"srlinux-{self.version}.zip" 250 | download_url = self.SUPPORTED_SCHEMA_PROFILES[self.version] 251 | return (artifact_name, filename, download_url) 252 | 253 | def get_artifact_yaml(self, artifact_name, filename, download_url): 254 | """ 255 | Render the Artifact CR YAML for the SR Linux YANG schema. 256 | 257 | Parameters 258 | ---------- 259 | artifact_name : str 260 | The name of the artifact in EDA. 261 | filename : str 262 | The artifact file name. 263 | download_url : str 264 | The download URL of the artifact file. 265 | 266 | Returns 267 | ------- 268 | str 269 | The rendered Artifact CR YAML. 270 | """ 271 | data = { 272 | "artifact_name": artifact_name, 273 | "namespace": "eda-system", 274 | "artifact_filename": filename, 275 | "artifact_url": download_url, 276 | } 277 | return helpers.render_template("artifact.j2", data) 278 | -------------------------------------------------------------------------------- /clab_connector/models/node/nokia_sros.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from .base import Node 5 | from clab_connector.utils import helpers 6 | from clab_connector.utils.constants import SUBSTEP_INDENT 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class NokiaSROSNode(Node): 12 | """ 13 | Nokia SROS Node representation. 14 | 15 | This subclass implements specific logic for SROS nodes, including 16 | naming, interface mapping, and EDA resource generation. 17 | """ 18 | 19 | SROS_USERNAME = "admin" 20 | SROS_PASSWORD = "NokiaSros1!" 21 | NODE_TYPE = "sros" 22 | GNMI_PORT = "57400" 23 | VERSION_PATH = ".system.information.version" 24 | YANG_PATH = "https://eda-asvr.eda-system.svc/eda-system/clab-schemaprofiles/{artifact_name}/{filename}" 25 | 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" 26 | 27 | # Mapping for EDA operating system 28 | EDA_OPERATING_SYSTEM = "sros" 29 | 30 | SUPPORTED_SCHEMA_PROFILES = { 31 | "25.3.r2": ( 32 | "https://github.com/nokia-eda/schema-profiles/" 33 | "releases/download/nokia-sros-v25.3.r2/sros-25.3.r2.zip" 34 | ), 35 | } 36 | 37 | # Map of node types to their line card and MDA components 38 | SROS_COMPONENTS = { 39 | "sr-1": { 40 | "lineCard": {"slot": "1", "type": "iom-1"}, 41 | "mda": {"slot": "1-a", "type": "me12-100gb-qsfp28"}, 42 | "connectors": 12, # Number of connectors 43 | }, 44 | "sr-1s": { 45 | "lineCard": {"slot": "1", "type": "xcm-1s"}, 46 | "mda": {"slot": "1-a", "type": "s36-100gb-qsfp28"}, 47 | "connectors": 36, 48 | }, 49 | "sr-2s": { 50 | "lineCard": {"slot": "1", "type": "xcm-2s"}, 51 | "mda": {"slot": "1-a", "type": "ms8-100gb-sfpdd+2-100gb-qsfp28"}, 52 | "connectors": 10, 53 | }, 54 | "sr-7s": { 55 | "lineCard": {"slot": "1", "type": "xcm-7s"}, 56 | "mda": {"slot": "1-a", "type": "s36-100gb-qsfp28"}, 57 | "connectors": 36, 58 | }, 59 | } 60 | 61 | def _get_components(self): 62 | """ 63 | Generate component information based on the node type. 64 | 65 | Returns 66 | ------- 67 | list 68 | A list of component dictionaries for the TopoNode resource. 69 | """ 70 | # Default to empty component list 71 | components = [] 72 | 73 | # Normalize node type for lookup 74 | node_type = self.node_type.lower() if self.node_type else "" 75 | 76 | # Check if node type is in the mapping 77 | if node_type in self.SROS_COMPONENTS: 78 | # Get component info for this node type 79 | component_info = self.SROS_COMPONENTS[node_type] 80 | 81 | # Add line card component 82 | if "lineCard" in component_info: 83 | lc = component_info["lineCard"] 84 | components.append({ 85 | "kind": "lineCard", 86 | "slot": lc["slot"], 87 | "type": lc["type"] 88 | }) 89 | 90 | # Add MDA component 91 | if "mda" in component_info: 92 | mda = component_info["mda"] 93 | components.append({ 94 | "kind": "mda", 95 | "slot": mda["slot"], 96 | "type": mda["type"] 97 | }) 98 | 99 | # Add connector components 100 | if "connectors" in component_info: 101 | num_connectors = component_info["connectors"] 102 | for i in range(1, num_connectors + 1): 103 | components.append({ 104 | "kind": "connector", 105 | "slot": f"1-a-{i}", 106 | "type": "c1-100g" # Default connector type 107 | }) 108 | 109 | return components 110 | 111 | def get_default_node_type(self): 112 | """ 113 | Return the default node type for an SROS node. 114 | """ 115 | return "sr7750" # Default to 7750 SR router type 116 | 117 | def get_platform(self): 118 | """ 119 | Return the platform name based on node type. 120 | 121 | Returns 122 | ------- 123 | str 124 | The platform name (e.g. '7750 SR-1'). 125 | """ 126 | if self.node_type and self.node_type.lower().startswith("sr-"): 127 | # For SR-1, SR-7, etc. 128 | return f"7750 {self.node_type.upper()}" 129 | return "7750 SR" # Default fallback 130 | 131 | def is_eda_supported(self): 132 | """ 133 | Indicates SROS nodes are EDA-supported. 134 | """ 135 | return True 136 | 137 | def _normalize_version(self, version): 138 | """ 139 | Normalize version string to ensure consistent format between TopoNode and NodeProfile. 140 | """ 141 | # Convert to lowercase for consistent handling 142 | normalized = version.lower() 143 | return normalized 144 | 145 | def get_profile_name(self, topology): 146 | """ 147 | Generate a NodeProfile name specific to this SROS node. 148 | Make sure it follows Kubernetes naming conventions (lowercase) 149 | and includes the topology name to ensure uniqueness. 150 | """ 151 | # Convert version to lowercase to comply with K8s naming rules 152 | normalized_version = self._normalize_version(self.version) 153 | # Include the topology name in the profile name for uniqueness 154 | return f"{topology.get_eda_safe_name()}-sros-{normalized_version}" 155 | 156 | def get_node_profile(self, topology): 157 | """ 158 | Render the NodeProfile YAML for this SROS node. 159 | """ 160 | logger.debug(f"Rendering node profile for {self.name}") 161 | artifact_name = self.get_artifact_name() 162 | normalized_version = self._normalize_version(self.version) 163 | filename = f"sros-{normalized_version}.zip" 164 | 165 | # Extract version parts for LLM path, ensure consistent formatting 166 | version_short = normalized_version.replace(".", "-") 167 | 168 | data = { 169 | "namespace": f"clab-{topology.name}", 170 | "profile_name": self.get_profile_name(topology), 171 | "sw_version": normalized_version, # Use normalized version consistently 172 | "gnmi_port": self.GNMI_PORT, 173 | "operating_system": self.EDA_OPERATING_SYSTEM, 174 | "version_path": "", 175 | "version_match": "", 176 | "yang_path": self.YANG_PATH.format( 177 | artifact_name=artifact_name, filename=filename 178 | ), 179 | "annotate": "false", 180 | "node_user": "admin-sros", 181 | "onboarding_password": "NokiaSros1!", 182 | "onboarding_username": "admin", 183 | "license": f"sros-ghcr-{normalized_version}-dummy-license", 184 | "llm_db": self.LLM_DB_PATH.format( 185 | version=normalized_version, version_short=version_short 186 | ), 187 | } 188 | return helpers.render_template("node-profile.j2", data) 189 | 190 | def get_toponode(self, topology): 191 | """ 192 | Render the TopoNode YAML for this SROS node. 193 | """ 194 | logger.info(f"{SUBSTEP_INDENT}Creating toponode for {self.name}") 195 | role_value = "backbone" 196 | 197 | # Ensure all values are lowercase and valid 198 | node_name = self.get_node_name(topology) 199 | topo_name = topology.get_eda_safe_name() 200 | normalized_version = self._normalize_version(self.version) 201 | 202 | # Generate component information based on node type 203 | components = self._get_components() 204 | 205 | data = { 206 | "namespace": f"clab-{topology.name}", 207 | "node_name": node_name, 208 | "topology_name": topo_name, 209 | "role_value": role_value, 210 | "node_profile": self.get_profile_name(topology), 211 | "kind": self.EDA_OPERATING_SYSTEM, 212 | "platform": self.get_platform(), 213 | "sw_version": normalized_version, 214 | "mgmt_ip": self.mgmt_ipv4, 215 | "containerlab_label": "managedSros", 216 | "components": components # Add component information 217 | } 218 | return helpers.render_template("toponode.j2", data) 219 | 220 | def get_interface_name_for_kind(self, ifname): 221 | """ 222 | Convert a containerlab interface name to an SR OS EDA-compatible interface name. 223 | 224 | Supports all SR OS interface naming conventions: 225 | - 1/1/1 → ethernet-1-a-1 (first linecard, MDA 'a', port 1) 226 | - 2/2/1 → ethernet-2-b-1 (second linecard, MDA 'b', port 1) 227 | - 1/1/c1/1 → ethernet-1-1-1 (breakout port with implicit MDA) 228 | - 1/1/c2/1 → ethernet-1-a-2-1 (breakout port with explicit MDA 'a') 229 | - 1/x1/1/1 → ethernet-1-1-a-1 (XIOM MDA) 230 | - lo0 → loopback-0 231 | - lag-10 → lag-10 232 | - eth3 → ethernet-1-a-3-1 (containerlab format) 233 | - e1-1 → ethernet-1-a-1-1 (containerlab format) 234 | """ 235 | # Handle native SR OS port format with slashes: "1/1/1" 236 | slot_mda_port = re.compile(r"^(\d+)/(\d+)/(\d+)$") 237 | match = slot_mda_port.match(ifname) 238 | if match: 239 | slot = match.group(1) 240 | mda_num = int(match.group(2)) 241 | port = match.group(3) 242 | 243 | # Convert MDA number to letter (1→'a', 2→'b', etc.) 244 | mda_letter = chr(96 + mda_num) # ASCII 'a' is 97 245 | return f"ethernet-{slot}-{mda_letter}-{port}-1" 246 | 247 | # Handle breakout ports with implicit MDA: "1/1/c1/1" 248 | breakout_implicit = re.compile(r"^(\d+)/(\d+)/c(\d+)/(\d+)$") 249 | match = breakout_implicit.match(ifname) 250 | if match and match.group(2) == "1": # Check for implicit MDA (1) 251 | slot = match.group(1) 252 | channel = match.group(3) 253 | port = match.group(4) 254 | return f"ethernet-{slot}-{channel}-{port}" 255 | 256 | # Handle breakout ports with explicit MDA: "1/1/c2/1" 257 | breakout_explicit = re.compile(r"^(\d+)/(\d+)/c(\d+)/(\d+)$") 258 | match = breakout_explicit.match(ifname) 259 | if match: 260 | slot = match.group(1) 261 | mda_num = int(match.group(2)) 262 | channel = match.group(3) 263 | port = match.group(4) 264 | 265 | # Convert MDA number to letter 266 | mda_letter = chr(96 + mda_num) # ASCII 'a' is 97 267 | return f"ethernet-{slot}-{mda_letter}-{channel}-{port}" 268 | 269 | # Handle XIOM MDA: "1/x1/1/1" 270 | xiom = re.compile(r"^(\d+)/x(\d+)/(\d+)/(\d+)$") 271 | match = xiom.match(ifname) 272 | if match: 273 | slot = match.group(1) 274 | xiom_id = match.group(2) 275 | mda_num = int(match.group(3)) 276 | port = match.group(4) 277 | 278 | # Convert MDA number to letter 279 | mda_letter = chr(96 + mda_num) # ASCII 'a' is 97 280 | return f"ethernet-{slot}-{xiom_id}-{mda_letter}-{port}" 281 | 282 | # Handle ethX format (commonly used in containerlab for SR OS) 283 | eth_pattern = re.compile(r"^eth(\d+)$") 284 | match = eth_pattern.match(ifname) 285 | if match: 286 | port_num = match.group(1) 287 | # Map to ethernet-1-a-{port}-1 format with connector 288 | return f"ethernet-1-a-{port_num}-1" 289 | 290 | # Handle standard containerlab eX-Y format 291 | e_pattern = re.compile(r"^e(\d+)-(\d+)$") 292 | match = e_pattern.match(ifname) 293 | if match: 294 | slot = match.group(1) 295 | port = match.group(2) 296 | # Add MDA 'a' and connector number 297 | return f"ethernet-{slot}-a-{port}-1" 298 | 299 | # Handle loopback interfaces 300 | lo_pattern = re.compile(r"^lo(\d+)$") 301 | match = lo_pattern.match(ifname) 302 | if match: 303 | return f"loopback-{match.group(1)}" # Fixed: using match instead of lo_match 304 | 305 | # Handle LAG interfaces (already named correctly) 306 | lag_pattern = re.compile(r"^lag-\d+$") 307 | if lag_pattern.match(ifname): 308 | return ifname 309 | 310 | # If not matching any pattern, return the original 311 | return ifname 312 | 313 | def get_topolink_interface_name(self, topology, ifname): 314 | """ 315 | Generate a unique interface resource name for a link in EDA. 316 | Creates a valid Kubernetes resource name based on the EDA interface format. 317 | 318 | This normalizes complex interface names into valid resource names. 319 | """ 320 | node_name = self.get_node_name(topology) 321 | eda_ifname = self.get_interface_name_for_kind(ifname) 322 | 323 | # No longer strip out the 'ethernet-' prefix to maintain consistency with SR Linux 324 | return f"{node_name}-{eda_ifname}" 325 | 326 | def get_topolink_interface(self, topology, ifname, other_node): 327 | """ 328 | Render the Interface CR YAML for an SROS link endpoint. 329 | """ 330 | logger.debug(f"{SUBSTEP_INDENT}Creating topolink interface for {self.name}") 331 | role = "interSwitch" 332 | if other_node is None or not other_node.is_eda_supported(): 333 | role = "edge" 334 | data = { 335 | "namespace": f"clab-{topology.name}", 336 | "interface_name": self.get_topolink_interface_name(topology, ifname), 337 | "label_key": "eda.nokia.com/role", 338 | "label_value": role, 339 | "encap_type": "'null'", 340 | "node_name": self.get_node_name(topology), 341 | "interface": self.get_interface_name_for_kind(ifname), 342 | "description": f"{role} link to {other_node.get_node_name(topology)}", 343 | } 344 | return helpers.render_template("interface.j2", data) 345 | 346 | def needs_artifact(self): 347 | """ 348 | SROS nodes may require a YANG artifact. 349 | """ 350 | return True 351 | 352 | def get_artifact_name(self): 353 | """ 354 | Return a name for the SROS schema artifact. 355 | """ 356 | normalized_version = self._normalize_version(self.version) 357 | return f"clab-sros-ghcr-{normalized_version}" 358 | 359 | def get_artifact_info(self): 360 | """ 361 | Return artifact metadata for the SROS YANG schema file. 362 | """ 363 | normalized_version = self._normalize_version(self.version) 364 | # Check if we have a supported schema for this normalized version 365 | if normalized_version not in self.SUPPORTED_SCHEMA_PROFILES: 366 | logger.warning(f"{SUBSTEP_INDENT}No schema profile for version {normalized_version}") 367 | return (None, None, None) 368 | 369 | artifact_name = self.get_artifact_name() 370 | filename = f"sros-{normalized_version}.zip" 371 | download_url = self.SUPPORTED_SCHEMA_PROFILES[normalized_version] 372 | return (artifact_name, filename, download_url) 373 | 374 | def get_artifact_yaml(self, artifact_name, filename, download_url): 375 | """ 376 | Render the Artifact CR YAML for the SROS YANG schema. 377 | """ 378 | data = { 379 | "artifact_name": artifact_name, 380 | "namespace": "eda-system", 381 | "artifact_filename": filename, 382 | "artifact_url": download_url, 383 | } 384 | return helpers.render_template("artifact.j2", data) -------------------------------------------------------------------------------- /clab_connector/models/topology.py: -------------------------------------------------------------------------------- 1 | # clab_connector/models/topology.py 2 | 3 | import logging 4 | import os 5 | import json 6 | 7 | from clab_connector.utils.exceptions import TopologyFileError 8 | 9 | from clab_connector.models.node.factory import create_node 10 | from clab_connector.models.node.base import Node 11 | from clab_connector.models.link import create_link 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Topology: 17 | """ 18 | Represents a containerlab topology. 19 | 20 | Parameters 21 | ---------- 22 | name : str 23 | The name of the topology. 24 | mgmt_subnet : str 25 | The management IPv4 subnet for the topology. 26 | ssh_keys : list 27 | A list of SSH public keys. 28 | nodes : list 29 | A list of Node objects in the topology. 30 | links : list 31 | A list of Link objects in the topology. 32 | clab_file_path : str 33 | Path to the original containerlab file if available. 34 | """ 35 | 36 | def __init__(self, name, mgmt_subnet, ssh_keys, nodes, links, clab_file_path=""): 37 | self.name = name 38 | self.mgmt_ipv4_subnet = mgmt_subnet 39 | self.ssh_pub_keys = ssh_keys 40 | self.nodes = nodes 41 | self.links = links 42 | self.clab_file_path = clab_file_path 43 | 44 | def __repr__(self): 45 | """ 46 | Return a string representation of the topology. 47 | 48 | Returns 49 | ------- 50 | str 51 | Description of the topology name, mgmt_subnet, number of nodes and links. 52 | """ 53 | return ( 54 | f"Topology(name={self.name}, mgmt_subnet={self.mgmt_ipv4_subnet}, " 55 | f"nodes={len(self.nodes)}, links={len(self.links)})" 56 | ) 57 | 58 | def get_eda_safe_name(self): 59 | """ 60 | Convert the topology name into a format safe for use in EDA. 61 | 62 | Returns 63 | ------- 64 | str 65 | A name suitable for EDA resource naming. 66 | """ 67 | safe = self.name.lower().replace("_", "-").replace(" ", "-") 68 | safe = "".join(c for c in safe if c.isalnum() or c in ".-").strip(".-") 69 | if not safe or not safe[0].isalnum(): 70 | safe = "x" + safe 71 | if not safe[-1].isalnum(): 72 | safe += "0" 73 | return safe 74 | 75 | def check_connectivity(self): 76 | """ 77 | Attempt to ping each node's management IP from the bootstrap server. 78 | 79 | Raises 80 | ------ 81 | RuntimeError 82 | If any node fails to respond to ping. 83 | """ 84 | for node in self.nodes: 85 | node.ping() 86 | 87 | def get_node_profiles(self): 88 | """ 89 | Generate NodeProfile YAML for all nodes that produce them. 90 | 91 | Returns 92 | ------- 93 | list 94 | A list of node profile YAML strings. 95 | """ 96 | profiles = {} 97 | for n in self.nodes: 98 | prof = n.get_node_profile(self) 99 | if prof: 100 | key = f"{n.kind}-{n.version}" 101 | profiles[key] = prof 102 | return profiles.values() 103 | 104 | def get_toponodes(self): 105 | """ 106 | Generate TopoNode YAML for all EDA-supported nodes. 107 | 108 | Returns 109 | ------- 110 | list 111 | A list of toponode YAML strings. 112 | """ 113 | tnodes = [] 114 | for n in self.nodes: 115 | tn = n.get_toponode(self) 116 | if tn: 117 | tnodes.append(tn) 118 | return tnodes 119 | 120 | def get_topolinks(self, skip_edge_links: bool = False): 121 | """Generate TopoLink YAML for all EDA-supported links. 122 | 123 | Parameters 124 | ---------- 125 | skip_edge_links : bool, optional 126 | When True, omit TopoLink resources for edge links (links with only 127 | one EDA supported endpoint). Defaults to False. 128 | 129 | Returns 130 | ------- 131 | list 132 | A list of topolink YAML strings. 133 | """ 134 | links = [] 135 | for ln in self.links: 136 | if skip_edge_links and ln.is_edge_link(): 137 | continue 138 | if ln.is_topolink() or ln.is_edge_link(): 139 | link_yaml = ln.get_topolink_yaml(self) 140 | if link_yaml: 141 | links.append(link_yaml) 142 | return links 143 | 144 | def get_topolink_interfaces(self, skip_edge_link_interfaces: bool = False): 145 | """ 146 | Generate Interface YAML for each link endpoint (if EDA-supported). 147 | 148 | Parameters 149 | ---------- 150 | skip_edge_link_interfaces : bool, optional 151 | When True, interface resources for edge links (links where only one 152 | side is EDA-supported) are omitted. Defaults to False. 153 | 154 | Returns 155 | ------- 156 | list 157 | A list of interface YAML strings for the link endpoints. 158 | """ 159 | interfaces = [] 160 | for ln in self.links: 161 | is_edge = ln.is_edge_link() 162 | for node, ifname, peer in ( 163 | (ln.node_1, ln.intf_1, ln.node_2), 164 | (ln.node_2, ln.intf_2, ln.node_1), 165 | ): 166 | if node is None or not node.is_eda_supported(): 167 | continue 168 | if skip_edge_link_interfaces and is_edge and ( 169 | peer is None or not peer.is_eda_supported() 170 | ): 171 | continue 172 | intf_yaml = node.get_topolink_interface(self, ifname, peer) 173 | if intf_yaml: 174 | interfaces.append(intf_yaml) 175 | return interfaces 176 | 177 | 178 | def parse_topology_file(path: str) -> Topology: 179 | """ 180 | Parse a containerlab topology JSON file and return a Topology object. 181 | 182 | Parameters 183 | ---------- 184 | path : str 185 | Path to the containerlab topology JSON file. 186 | 187 | Returns 188 | ------- 189 | Topology 190 | A populated Topology object. 191 | 192 | Raises 193 | ------ 194 | TopologyFileError 195 | If the file does not exist or cannot be parsed. 196 | ValueError 197 | If the file is not recognized as a containerlab topology. 198 | """ 199 | logger.info(f"Parsing topology file '{path}'") 200 | if not os.path.isfile(path): 201 | logger.critical(f"Topology file '{path}' does not exist!") 202 | raise TopologyFileError(f"Topology file '{path}' does not exist!") 203 | 204 | try: 205 | with open(path, "r") as f: 206 | data = json.load(f) 207 | except json.JSONDecodeError as e: 208 | logger.critical(f"File '{path}' is not valid JSON.") 209 | raise TopologyFileError(f"File '{path}' is not valid JSON.") from e 210 | except OSError as e: 211 | logger.critical(f"Failed to read topology file '{path}': {e}") 212 | raise TopologyFileError(f"Failed to read topology file '{path}': {e}") from e 213 | 214 | if data.get("type") != "clab": 215 | raise ValueError("Not a valid containerlab topology file (missing 'type=clab')") 216 | 217 | name = data["name"] 218 | mgmt_subnet = data["clab"]["config"]["mgmt"].get("ipv4-subnet") 219 | ssh_keys = data.get("ssh-pub-keys", []) 220 | file_path = "" 221 | 222 | if data["nodes"]: 223 | first_key = next(iter(data["nodes"])) 224 | file_path = data["nodes"][first_key]["labels"].get("clab-topo-file", "") 225 | 226 | # Create node objects 227 | node_objects = [] # only nodes supported by EDA 228 | all_nodes = {} 229 | for node_name, node_data in data["nodes"].items(): 230 | image = node_data.get("image") 231 | version = None 232 | if image and ":" in image: 233 | version = image.split(":")[-1] 234 | config = { 235 | "kind": node_data["kind"], 236 | "type": node_data["labels"].get("clab-node-type", "ixrd2"), 237 | "version": version, 238 | "mgmt_ipv4": node_data.get("mgmt-ipv4-address"), 239 | } 240 | node_obj = create_node(node_name, config) 241 | if node_obj is None: 242 | node_obj = Node( 243 | name=node_name, 244 | kind=node_data["kind"], 245 | node_type=config.get("type"), 246 | version=version, 247 | mgmt_ipv4=node_data.get("mgmt-ipv4-address"), 248 | ) 249 | if node_obj.is_eda_supported(): 250 | node_objects.append(node_obj) 251 | all_nodes[node_name] = node_obj 252 | 253 | # Create link objects 254 | link_objects = [] 255 | for link_info in data["links"]: 256 | a_name = link_info["a"]["node"] 257 | z_name = link_info["z"]["node"] 258 | if a_name not in all_nodes or z_name not in all_nodes: 259 | continue 260 | node_a = all_nodes[a_name] 261 | node_z = all_nodes[z_name] 262 | # Only consider links where at least one endpoint is EDA-supported 263 | if not (node_a.is_eda_supported() or node_z.is_eda_supported()): 264 | continue 265 | endpoints = [ 266 | f"{a_name}:{link_info['a']['interface']}", 267 | f"{z_name}:{link_info['z']['interface']}", 268 | ] 269 | ln = create_link(endpoints, list(all_nodes.values())) 270 | link_objects.append(ln) 271 | 272 | topo = Topology( 273 | name=name, 274 | mgmt_subnet=mgmt_subnet, 275 | ssh_keys=ssh_keys, 276 | nodes=node_objects, 277 | links=link_objects, 278 | clab_file_path=file_path, 279 | ) 280 | 281 | original = topo.name 282 | topo.name = topo.get_eda_safe_name() 283 | if topo.name != original: 284 | logger.debug(f"Renamed topology '{original}' -> '{topo.name}' for EDA safety") 285 | return topo 286 | -------------------------------------------------------------------------------- /clab_connector/services/export/topology_exporter.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/export/topology_exporter.py 2 | 3 | import logging 4 | from ipaddress import IPv4Network, IPv4Address 5 | from clab_connector.clients.kubernetes.client import ( 6 | list_toponodes_in_namespace, 7 | list_topolinks_in_namespace, 8 | ) 9 | from clab_connector.utils.yaml_processor import YAMLProcessor 10 | from clab_connector.utils.constants import SUBSTEP_INDENT 11 | 12 | 13 | class TopologyExporter: 14 | """ 15 | TopologyExporter retrieves EDA toponodes/topolinks from a namespace 16 | and converts them to a .clab.yaml data structure. 17 | 18 | Parameters 19 | ---------- 20 | namespace : str 21 | The Kubernetes namespace that contains EDA toponodes/topolinks. 22 | output_file : str 23 | The path where the .clab.yaml file will be written. 24 | logger : logging.Logger 25 | A logger instance for output/diagnostics. 26 | """ 27 | 28 | def __init__(self, namespace: str, output_file: str, logger: logging.Logger): 29 | self.namespace = namespace 30 | self.output_file = output_file 31 | self.logger = logger 32 | 33 | def run(self): 34 | """ 35 | Fetch the nodes and links, build containerlab YAML, and write to output_file. 36 | """ 37 | # 1. Fetch data 38 | try: 39 | node_items = list_toponodes_in_namespace(self.namespace) 40 | link_items = list_topolinks_in_namespace(self.namespace) 41 | except Exception as e: 42 | self.logger.error(f"Failed to list toponodes/topolinks: {e}") 43 | raise 44 | 45 | # 2. Gather mgmt IP addresses for optional mgmt subnet 46 | mgmt_ips = self._collect_management_ips(node_items) 47 | 48 | mgmt_subnet = self._derive_mgmt_subnet(mgmt_ips) 49 | 50 | clab_data = { 51 | "name": self.namespace, # Use namespace as "lab name" 52 | "mgmt": {"network": f"{self.namespace}-mgmt", "ipv4-subnet": mgmt_subnet}, 53 | "topology": { 54 | "nodes": {}, 55 | "links": [], 56 | }, 57 | } 58 | 59 | # 3. Convert each toponode into containerlab node config 60 | for node_item in node_items: 61 | node_name, node_def = self._build_node_definition(node_item) 62 | if node_name and node_def: 63 | clab_data["topology"]["nodes"][node_name] = node_def 64 | 65 | # 4. Convert each topolink into containerlab link config 66 | for link_item in link_items: 67 | self._build_link_definitions(link_item, clab_data["topology"]["links"]) 68 | 69 | # 5. Write the .clab.yaml 70 | self._write_clab_yaml(clab_data) 71 | 72 | def _collect_management_ips(self, node_items): 73 | ips = [] 74 | for node_item in node_items: 75 | spec = node_item.get("spec", {}) 76 | status = node_item.get("status", {}) 77 | production_addr = ( 78 | spec.get("productionAddress") or status.get("productionAddress") or {} 79 | ) 80 | mgmt_ip = production_addr.get("ipv4") 81 | 82 | if not mgmt_ip and "node-details" in status: 83 | node_details = status["node-details"] 84 | mgmt_ip = node_details.split(":")[0] 85 | 86 | if mgmt_ip: 87 | try: 88 | ips.append(IPv4Address(mgmt_ip)) 89 | except ValueError: 90 | self.logger.warning( 91 | f"{SUBSTEP_INDENT}Invalid IP address found: {mgmt_ip}" 92 | ) 93 | return ips 94 | 95 | def _derive_mgmt_subnet(self, mgmt_ips): 96 | """ 97 | Given a list of IPv4Addresses, compute a smallest common subnet. 98 | If none, fallback to '172.80.80.0/24'. 99 | """ 100 | if not mgmt_ips: 101 | self.logger.warning( 102 | f"{SUBSTEP_INDENT}No valid management IPs found, using default subnet" 103 | ) 104 | return "172.80.80.0/24" 105 | 106 | min_ip = min(mgmt_ips) 107 | max_ip = max(mgmt_ips) 108 | 109 | min_bits = format(int(min_ip), "032b") 110 | max_bits = format(int(max_ip), "032b") 111 | 112 | common_prefix = 0 113 | for i in range(32): 114 | if min_bits[i] == max_bits[i]: 115 | common_prefix += 1 116 | else: 117 | break 118 | 119 | subnet = IPv4Network(f"{min_ip}/{common_prefix}", strict=False) 120 | return str(subnet) 121 | 122 | def _build_node_definition(self, node_item): 123 | """ 124 | Convert an EDA toponode item into a containerlab 'node definition'. 125 | Returns (node_name, node_def) or (None, None) if skipped. 126 | """ 127 | meta = node_item.get("metadata", {}) 128 | spec = node_item.get("spec", {}) 129 | status = node_item.get("status", {}) 130 | 131 | node_name = meta.get("name") 132 | if not node_name: 133 | self.logger.warning( 134 | f"{SUBSTEP_INDENT}Node item missing metadata.name, skipping." 135 | ) 136 | return None, None 137 | 138 | operating_system = ( 139 | spec.get("operatingSystem") or status.get("operatingSystem") or "" 140 | ) 141 | version = spec.get("version") or status.get("version") or "" 142 | 143 | production_addr = ( 144 | spec.get("productionAddress") or status.get("productionAddress") or {} 145 | ) 146 | mgmt_ip = production_addr.get("ipv4") 147 | 148 | # If no productionAddress IP, try node-details 149 | if not mgmt_ip and "node-details" in status: 150 | node_details = status["node-details"] 151 | mgmt_ip = node_details.split(":")[0] 152 | 153 | if not mgmt_ip: 154 | self.logger.warning( 155 | f"{SUBSTEP_INDENT}No mgmt IP found for node '{node_name}', skipping." 156 | ) 157 | return None, None 158 | 159 | # guess 'nokia_srlinux' if operating_system is 'srl*' 160 | kind = "nokia_srlinux" 161 | if operating_system.lower().startswith("sros"): 162 | kind = "nokia_sros" 163 | 164 | node_def = { 165 | "kind": kind, 166 | "mgmt-ipv4": mgmt_ip, 167 | } 168 | if version: 169 | node_def["image"] = f"ghcr.io/nokia/srlinux:{version}" 170 | 171 | return node_name, node_def 172 | 173 | def _build_link_definitions(self, link_item, links_array): 174 | link_spec = link_item.get("spec", {}) 175 | link_entries = link_spec.get("links", []) 176 | meta = link_item.get("metadata", {}) 177 | link_name = meta.get("name", "unknown-link") 178 | 179 | for entry in link_entries: 180 | local_node = entry.get("local", {}).get("node") 181 | local_intf = entry.get("local", {}).get("interface") 182 | remote_node = entry.get("remote", {}).get("node") 183 | remote_intf = entry.get("remote", {}).get("interface") 184 | if local_node and local_intf and remote_node and remote_intf: 185 | links_array.append( 186 | { 187 | "endpoints": [ 188 | f"{local_node}:{local_intf}", 189 | f"{remote_node}:{remote_intf}", 190 | ] 191 | } 192 | ) 193 | else: 194 | self.logger.warning( 195 | f"{SUBSTEP_INDENT}Incomplete link entry in {link_name}, skipping that entry." 196 | ) 197 | 198 | def _write_clab_yaml(self, clab_data): 199 | """ 200 | Save the final containerlab data structure as YAML to self.output_file. 201 | """ 202 | processor = YAMLProcessor() 203 | try: 204 | processor.save_yaml(clab_data, self.output_file) 205 | self.logger.info( 206 | f"{SUBSTEP_INDENT}Exported containerlab file: {self.output_file}" 207 | ) 208 | except IOError as e: 209 | self.logger.error(f"Failed to write containerlab file: {e}") 210 | raise 211 | -------------------------------------------------------------------------------- /clab_connector/services/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/integration/__init__.py 2 | 3 | """Integration services (Onboarding topology).""" 4 | -------------------------------------------------------------------------------- /clab_connector/services/integration/sros_post_integration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | sros_post_integration.py – SROS post-integration helpers 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import logging 9 | import re 10 | import socket 11 | import subprocess 12 | import tempfile 13 | from pathlib import Path 14 | from typing import List, Optional 15 | 16 | import paramiko 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | # --------------------------------------------------------------------------- # 21 | # SSH helpers # 22 | # --------------------------------------------------------------------------- # 23 | def verify_ssh_credentials( 24 | mgmt_ip: str, 25 | username: str, 26 | passwords: List[str], 27 | quiet: bool = False, 28 | ) -> Optional[str]: 29 | """ 30 | Return the first password that opens an SSH session, else None. 31 | """ 32 | for pw in passwords: 33 | client = paramiko.SSHClient() 34 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 35 | try: 36 | if not quiet: 37 | logger.debug( 38 | "Trying SSH to %s with user '%s' and password '%s'", 39 | mgmt_ip, 40 | username, 41 | pw, 42 | ) 43 | 44 | client.connect( 45 | hostname=mgmt_ip, 46 | port=22, 47 | username=username, 48 | password=pw, 49 | timeout=10, 50 | banner_timeout=10, 51 | allow_agent=False, 52 | look_for_keys=False, 53 | ) 54 | 55 | # If we reach this point authentication succeeded. 56 | if not quiet: 57 | logger.info("Password '%s' works for %s", pw, mgmt_ip) 58 | return pw 59 | 60 | except paramiko.AuthenticationException: 61 | if not quiet: 62 | logger.debug("Password '%s' rejected for %s", pw, mgmt_ip) 63 | except (paramiko.SSHException, socket.timeout, socket.error) as e: 64 | if not quiet: 65 | logger.debug("SSH connection problem with %s: %s", mgmt_ip, e) 66 | finally: 67 | try: 68 | client.close() 69 | except Exception: 70 | pass 71 | 72 | return None 73 | 74 | 75 | def transfer_file( 76 | src_path: Path, 77 | dest_path: str, 78 | username: str, 79 | mgmt_ip: str, 80 | password: str, 81 | quiet: bool = False, 82 | ) -> bool: 83 | """ 84 | SCP file to the target node using Paramiko SFTP. 85 | """ 86 | try: 87 | if not quiet: 88 | logger.debug( 89 | "SCP %s → %s@%s:%s", src_path, username, mgmt_ip, dest_path 90 | ) 91 | 92 | transport = paramiko.Transport((mgmt_ip, 22)) 93 | transport.connect(username=username, password=password) 94 | 95 | sftp = paramiko.SFTPClient.from_transport(transport) 96 | sftp.put(str(src_path), dest_path) 97 | sftp.close() 98 | transport.close() 99 | return True 100 | except Exception as e: 101 | if not quiet: 102 | logger.debug("SCP failed: %s", e) 103 | return False 104 | 105 | 106 | def execute_ssh_commands( 107 | script_path: Path, 108 | username: str, 109 | mgmt_ip: str, 110 | node_name: str, 111 | password: str, 112 | quiet: bool = False, 113 | ) -> bool: 114 | """ 115 | Push the command file line-by-line over an interactive shell. 116 | No timeouts version that will wait as long as needed for each command. 117 | """ 118 | try: 119 | commands = script_path.read_text().splitlines() 120 | 121 | client = paramiko.SSHClient() 122 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 123 | client.connect( 124 | hostname=mgmt_ip, 125 | username=username, 126 | password=password, 127 | allow_agent=False, 128 | look_for_keys=False, 129 | ) 130 | 131 | chan = client.invoke_shell() 132 | output = [] 133 | 134 | for cmd in commands: 135 | if cmd.strip() == "commit": 136 | import time 137 | time.sleep(2) # Wait 2 seconds before sending commit 138 | 139 | chan.send(cmd + "\n") 140 | while not chan.recv_ready(): 141 | pass 142 | 143 | buffer = "" 144 | while chan.recv_ready(): 145 | buffer += chan.recv(4096).decode() 146 | output.append(buffer) 147 | 148 | # Get any remaining output 149 | while chan.recv_ready(): 150 | output.append(chan.recv(4096).decode()) 151 | 152 | chan.close() 153 | client.close() 154 | 155 | if not quiet: 156 | logger.info( 157 | "Configuration of %s completed (output %d chars)", 158 | node_name, 159 | sum(map(len, output)), 160 | ) 161 | return True 162 | except Exception as e: 163 | logger.error("SSH exec error on %s: %s", node_name, e) 164 | return False 165 | 166 | 167 | # --------------------------------------------------------------------------- # 168 | # High-level workflow # 169 | # --------------------------------------------------------------------------- # 170 | def prepare_sros_node( 171 | node_name: str, 172 | namespace: str, 173 | version: str, 174 | mgmt_ip: str, 175 | username: str = "admin", 176 | password: Optional[str] = None, 177 | quiet: bool = False, 178 | ) -> bool: 179 | """ 180 | Perform SROS-specific post-integration steps. 181 | """ 182 | # First check if we can login with admin:admin 183 | # If we can't, assume the node is already bootstrapped 184 | admin_pwd = "admin" 185 | can_login = verify_ssh_credentials(mgmt_ip, username, [admin_pwd], quiet) 186 | 187 | if not can_login: 188 | logger.info("Node: %s already bootstrapped", node_name) 189 | return True 190 | 191 | # Proceed with original logic if admin:admin works 192 | # 1. determine password list (keep provided one first if present) 193 | pwd_list: List[str] = [] 194 | if password: 195 | pwd_list.append(password) 196 | pwd_list.append("admin") 197 | 198 | logger.info("Verifying SSH credentials for %s …", node_name) 199 | working_pw = verify_ssh_credentials(mgmt_ip, username, pwd_list, quiet) 200 | 201 | if not working_pw: 202 | logger.error("No valid password found – aborting") 203 | return False 204 | 205 | # 2. create temp artefacts 206 | with tempfile.TemporaryDirectory() as tdir: 207 | tdir_path = Path(tdir) 208 | cert_p = tdir_path / "edaboot.crt" 209 | key_p = tdir_path / "edaboot.key" 210 | cfg_p = tdir_path / "config.cfg" 211 | script_p = tdir_path / "sros_commands.txt" 212 | 213 | try: 214 | # ------------------------------------------------------------------ 215 | # kubectl extractions 216 | # ------------------------------------------------------------------ 217 | def _run(cmd: str) -> None: 218 | subprocess.check_call( 219 | cmd, 220 | shell=True, 221 | stdout=subprocess.DEVNULL if quiet else None, 222 | stderr=subprocess.DEVNULL if quiet else None, 223 | ) 224 | 225 | logger.info("Extracting TLS cert / key …") 226 | _run( 227 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 228 | f"-n eda-system -o jsonpath='{{.data.tls\\.crt}}' " 229 | f"| base64 -d > {cert_p}" 230 | ) 231 | _run( 232 | f"kubectl get secret {namespace}--{node_name}-cert-tls " 233 | f"-n eda-system -o jsonpath='{{.data.tls\\.key}}' " 234 | f"| base64 -d > {key_p}" 235 | ) 236 | 237 | logger.info("Extracting initial config …") 238 | _run( 239 | f"kubectl get artifact initcfg-{node_name}-{version} -n {namespace} " 240 | f"-o jsonpath='{{.spec.textFile.content}}' " 241 | f"| sed 's/\\\\n/\\n/g' > {cfg_p}" 242 | ) 243 | 244 | cfg_text = cfg_p.read_text() 245 | m = re.search(r"configure\s*\{(.*)\}", cfg_text, re.DOTALL) 246 | if not m or not m.group(1).strip(): 247 | raise ValueError("Could not find inner config block") 248 | inner_cfg = m.group(1).strip() 249 | 250 | # ------------------------------------------------------------------ 251 | # copy certs (try cf3:/ then /) 252 | # ------------------------------------------------------------------ 253 | logger.info("Copying certificates to device …") 254 | dest_root = None 255 | for root in ("cf3:/", "/"): 256 | if transfer_file(cert_p, root + "edaboot.crt", username, mgmt_ip, working_pw, quiet) and \ 257 | transfer_file(key_p, root + "edaboot.key", username, mgmt_ip, working_pw, quiet): 258 | dest_root = root 259 | break 260 | if not dest_root: 261 | raise RuntimeError("Failed to copy certificate/key to device") 262 | 263 | # ------------------------------------------------------------------ 264 | # build command script 265 | # ------------------------------------------------------------------ 266 | with script_p.open("w") as f: 267 | f.write("environment more false\n") 268 | f.write("environment print-detail false\n") 269 | f.write("environment confirmations false\n") 270 | f.write( 271 | f"admin system security pki import type certificate input-url {dest_root}edaboot.crt output-file edaboot.crt format pem\n" 272 | ) 273 | f.write( 274 | f"admin system security pki import type key input-url {dest_root}edaboot.key output-file edaboot.key format pem\n" 275 | ) 276 | f.write("configure global\n") 277 | f.write(inner_cfg + "\n") 278 | f.write("commit\n") 279 | f.write("exit all\n") 280 | 281 | # ------------------------------------------------------------------ 282 | # execute 283 | # ------------------------------------------------------------------ 284 | logger.info("Pushing configuration to %s …", node_name) 285 | return execute_ssh_commands( 286 | script_p, username, mgmt_ip, node_name, working_pw, quiet 287 | ) 288 | 289 | except (subprocess.CalledProcessError, FileNotFoundError, ValueError, RuntimeError) as e: 290 | logger.error("Post-integration failed: %s", e) 291 | return False 292 | except Exception as e: 293 | logger.exception("Unexpected error: %s", e) 294 | return False -------------------------------------------------------------------------------- /clab_connector/services/integration/topology_integrator.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/integration/topology_integrator.py (updated) 2 | 3 | import logging 4 | 5 | from clab_connector.utils.constants import SUBSTEP_INDENT 6 | 7 | from clab_connector.models.topology import parse_topology_file 8 | from clab_connector.clients.eda.client import EDAClient 9 | from clab_connector.clients.kubernetes.client import ( 10 | apply_manifest, 11 | edactl_namespace_bootstrap, 12 | wait_for_namespace, 13 | update_namespace_description, 14 | ) 15 | from clab_connector.utils import helpers 16 | from clab_connector.utils.exceptions import EDAConnectionError, ClabConnectorError 17 | from clab_connector.services.integration.sros_post_integration import prepare_sros_node 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class TopologyIntegrator: 23 | """ 24 | Handles creation of EDA resources for a given containerlab topology. 25 | 26 | Parameters 27 | ---------- 28 | eda_client : EDAClient 29 | A connected EDAClient used to submit resources to the EDA cluster. 30 | """ 31 | 32 | def __init__(self, eda_client: EDAClient): 33 | self.eda_client = eda_client 34 | self.topology = None 35 | 36 | def run( 37 | self, 38 | topology_file, 39 | eda_url, 40 | eda_user, 41 | eda_password, 42 | verify, 43 | skip_edge_intfs: bool = False, 44 | ): 45 | """ 46 | Parse the topology, run connectivity checks, and create EDA resources. 47 | 48 | Parameters 49 | ---------- 50 | topology_file : str or Path 51 | Path to the containerlab topology JSON file. 52 | eda_url : str 53 | EDA hostname/IP. 54 | eda_user : str 55 | EDA username. 56 | eda_password : str 57 | EDA password. 58 | verify : bool 59 | Certificate verification flag. 60 | skip_edge_intfs : bool 61 | When True, omit edge link resources and their interfaces from the 62 | integration. 63 | 64 | Returns 65 | ------- 66 | None 67 | 68 | Raises 69 | ------ 70 | EDAConnectionError 71 | If EDA is unreachable or credentials are invalid. 72 | ClabConnectorError 73 | If any resource fails validation. 74 | """ 75 | logger.info("Parsing topology for integration") 76 | self.topology = parse_topology_file(str(topology_file)) 77 | 78 | logger.info("== Running pre-checks ==") 79 | self.prechecks() 80 | 81 | # Verify connectivity to each node's management interface 82 | self.topology.check_connectivity() 83 | 84 | logger.info("== Creating namespace ==") 85 | self.create_namespace() 86 | 87 | logger.info("== Creating artifacts ==") 88 | self.create_artifacts() 89 | 90 | logger.info("== Creating init ==") 91 | self.create_init() 92 | self.eda_client.commit_transaction("create init (bootstrap)") 93 | 94 | logger.info("== Creating node security profile ==") 95 | self.create_node_security_profile() 96 | 97 | logger.info("== Creating node users ==") 98 | self.create_node_user_groups() 99 | self.create_node_users() 100 | self.eda_client.commit_transaction("create node users and groups") 101 | 102 | logger.info("== Creating node profiles ==") 103 | self.create_node_profiles() 104 | self.eda_client.commit_transaction("create node profiles") 105 | 106 | logger.info("== Onboarding nodes ==") 107 | self.create_toponodes() 108 | self.eda_client.commit_transaction("create nodes") 109 | 110 | logger.info("== Adding topolink interfaces ==") 111 | self.create_topolink_interfaces(skip_edge_intfs) 112 | # Only commit if there are transactions 113 | if self.eda_client.transactions: 114 | self.eda_client.commit_transaction("create topolink interfaces") 115 | else: 116 | logger.info(f"{SUBSTEP_INDENT}No topolink interfaces to create, skipping.") 117 | 118 | logger.info("== Creating topolinks ==") 119 | self.create_topolinks(skip_edge_intfs) 120 | # Only commit if there are transactions 121 | if self.eda_client.transactions: 122 | self.eda_client.commit_transaction("create topolinks") 123 | else: 124 | logger.info(f"{SUBSTEP_INDENT}No topolinks to create, skipping.") 125 | 126 | logger.info("== Running post-integration steps ==") 127 | self.run_post_integration() 128 | 129 | logger.info("Done!") 130 | 131 | def prechecks(self): 132 | """ 133 | Verify that EDA is up and credentials are valid. 134 | 135 | Raises 136 | ------ 137 | EDAConnectionError 138 | If EDA is not reachable or not authenticated. 139 | """ 140 | if not self.eda_client.is_up(): 141 | raise EDAConnectionError("EDA not up or unreachable") 142 | if not self.eda_client.is_authenticated(): 143 | raise EDAConnectionError("EDA credentials invalid") 144 | 145 | def create_namespace(self): 146 | """ 147 | Create and bootstrap a namespace for the topology in EDA. 148 | """ 149 | ns = f"clab-{self.topology.name}" 150 | try: 151 | edactl_namespace_bootstrap(ns) 152 | wait_for_namespace(ns) 153 | desc = f"Containerlab {self.topology.name}: {self.topology.clab_file_path}" 154 | success = update_namespace_description(ns, desc) 155 | if not success: 156 | logger.warning( 157 | f"{SUBSTEP_INDENT}Created namespace '{ns}' but could not update its description. Continuing with integration." 158 | ) 159 | except Exception as e: 160 | # If namespace creation itself fails, we should stop the process 161 | logger.error(f"Failed to create namespace '{ns}': {e}") 162 | raise 163 | 164 | def create_artifacts(self): 165 | """ 166 | Create Artifact resources for nodes that need them. 167 | 168 | Skips creation if already exists or no artifact data is available. 169 | """ 170 | logger.info(f"{SUBSTEP_INDENT}Creating artifacts for nodes that need them") 171 | nodes_by_artifact = {} 172 | for node in self.topology.nodes: 173 | if not node.needs_artifact(): 174 | continue 175 | artifact_name, filename, download_url = node.get_artifact_info() 176 | if not artifact_name or not filename or not download_url: 177 | logger.warning( 178 | f"{SUBSTEP_INDENT}No artifact info for node {node.name}; skipping." 179 | ) 180 | continue 181 | if artifact_name not in nodes_by_artifact: 182 | nodes_by_artifact[artifact_name] = { 183 | "nodes": [], 184 | "filename": filename, 185 | "download_url": download_url, 186 | "version": node.version, 187 | } 188 | nodes_by_artifact[artifact_name]["nodes"].append(node.name) 189 | 190 | for artifact_name, info in nodes_by_artifact.items(): 191 | first_node = info["nodes"][0] 192 | logger.info( 193 | f"{SUBSTEP_INDENT}Creating YANG artifact for node: {first_node} (version={info['version']})" 194 | ) 195 | artifact_yaml = self.topology.nodes[0].get_artifact_yaml( 196 | artifact_name, info["filename"], info["download_url"] 197 | ) 198 | if not artifact_yaml: 199 | logger.warning( 200 | f"{SUBSTEP_INDENT}Could not generate artifact YAML for {first_node}" 201 | ) 202 | continue 203 | try: 204 | apply_manifest(artifact_yaml, namespace="eda-system") 205 | logger.info(f"{SUBSTEP_INDENT}Artifact '{artifact_name}' created.") 206 | other_nodes = info["nodes"][1:] 207 | if other_nodes: 208 | logger.info( 209 | f"{SUBSTEP_INDENT}Using same artifact for nodes: {', '.join(other_nodes)}" 210 | ) 211 | except RuntimeError as ex: 212 | if "AlreadyExists" in str(ex): 213 | logger.info(f"{SUBSTEP_INDENT}Artifact '{artifact_name}' already exists.") 214 | else: 215 | logger.error(f"Error creating artifact '{artifact_name}': {ex}") 216 | 217 | def create_init(self): 218 | """ 219 | Create an Init resource in the namespace to bootstrap additional resources. 220 | """ 221 | data = {"namespace": f"clab-{self.topology.name}"} 222 | yml = helpers.render_template("init.yaml.j2", data) 223 | item = self.eda_client.add_replace_to_transaction(yml) 224 | if not self.eda_client.is_transaction_item_valid(item): 225 | raise ClabConnectorError("Validation error for init resource") 226 | 227 | def create_node_security_profile(self): 228 | """ 229 | Create a NodeSecurityProfile resource that references an EDA node issuer. 230 | """ 231 | data = {"namespace": f"clab-{self.topology.name}"} 232 | yaml_str = helpers.render_template("nodesecurityprofile.yaml.j2", data) 233 | try: 234 | apply_manifest(yaml_str, namespace=f"clab-{self.topology.name}") 235 | logger.info(f"{SUBSTEP_INDENT}Node security profile created.") 236 | except RuntimeError as ex: 237 | if "AlreadyExists" in str(ex): 238 | logger.info(f"{SUBSTEP_INDENT}Node security profile already exists, skipping.") 239 | else: 240 | raise 241 | 242 | def create_node_user_groups(self): 243 | """ 244 | Create a NodeGroup resource for user groups (like 'sudo'). 245 | """ 246 | data = {"namespace": f"clab-{self.topology.name}"} 247 | node_user_group = helpers.render_template("node-user-group.yaml.j2", data) 248 | item = self.eda_client.add_replace_to_transaction(node_user_group) 249 | if not self.eda_client.is_transaction_item_valid(item): 250 | raise ClabConnectorError("Validation error for node user group") 251 | 252 | def create_node_users(self): 253 | """ 254 | Create NodeUser resources with SSH pub keys for SRL and SROS nodes. 255 | """ 256 | ssh_pub_keys = getattr(self.topology, "ssh_pub_keys", []) 257 | if not ssh_pub_keys: 258 | logger.warning( 259 | f"{SUBSTEP_INDENT}No SSH public keys found. Proceeding with an empty key list." 260 | ) 261 | 262 | # Create SRL node user 263 | srl_data = { 264 | "namespace": f"clab-{self.topology.name}", 265 | "node_user": "admin", 266 | "username": "admin", 267 | "password": "NokiaSrl1!", 268 | "ssh_pub_keys": ssh_pub_keys, 269 | "node_selector": "containerlab=managedSrl" 270 | } 271 | srl_node_user = helpers.render_template("node-user.j2", srl_data) 272 | item_srl = self.eda_client.add_replace_to_transaction(srl_node_user) 273 | if not self.eda_client.is_transaction_item_valid(item_srl): 274 | raise ClabConnectorError("Validation error for SRL node user") 275 | 276 | # Create SROS node user 277 | sros_data = { 278 | "namespace": f"clab-{self.topology.name}", 279 | "node_user": "admin-sros", 280 | "username": "admin", 281 | "password": "NokiaSros1!", 282 | "ssh_pub_keys": ssh_pub_keys, 283 | "node_selector": "containerlab=managedSros" 284 | } 285 | sros_node_user = helpers.render_template("node-user.j2", sros_data) 286 | item_sros = self.eda_client.add_replace_to_transaction(sros_node_user) 287 | if not self.eda_client.is_transaction_item_valid(item_sros): 288 | raise ClabConnectorError("Validation error for SROS node user") 289 | 290 | def create_node_profiles(self): 291 | """ 292 | Create NodeProfile resources for each EDA-supported node version-kind combo. 293 | """ 294 | profiles = self.topology.get_node_profiles() 295 | for prof_yaml in profiles: 296 | item = self.eda_client.add_replace_to_transaction(prof_yaml) 297 | if not self.eda_client.is_transaction_item_valid(item): 298 | raise ClabConnectorError("Validation error creating node profile") 299 | 300 | def create_toponodes(self): 301 | """ 302 | Create TopoNode resources for each node. 303 | """ 304 | tnodes = self.topology.get_toponodes() 305 | for node_yaml in tnodes: 306 | item = self.eda_client.add_replace_to_transaction(node_yaml) 307 | if not self.eda_client.is_transaction_item_valid(item): 308 | raise ClabConnectorError("Validation error creating toponode") 309 | 310 | def create_topolink_interfaces(self, skip_edge_intfs: bool = False): 311 | """ 312 | Create Interface resources for each link endpoint in the topology. 313 | """ 314 | interfaces = self.topology.get_topolink_interfaces( 315 | skip_edge_link_interfaces=skip_edge_intfs 316 | ) 317 | for intf_yaml in interfaces: 318 | item = self.eda_client.add_replace_to_transaction(intf_yaml) 319 | if not self.eda_client.is_transaction_item_valid(item): 320 | raise ClabConnectorError("Validation error creating topolink interface") 321 | 322 | def create_topolinks(self, skip_edge_links: bool = False): 323 | """Create TopoLink resources for each EDA-supported link in the topology. 324 | 325 | Parameters 326 | ---------- 327 | skip_edge_links : bool, optional 328 | When True, omit TopoLink resources for edge links. Defaults to False. 329 | """ 330 | links = self.topology.get_topolinks(skip_edge_links=skip_edge_links) 331 | for l_yaml in links: 332 | item = self.eda_client.add_replace_to_transaction(l_yaml) 333 | if not self.eda_client.is_transaction_item_valid(item): 334 | raise ClabConnectorError("Validation error creating topolink") 335 | 336 | def run_post_integration(self): 337 | """ 338 | Run any post-integration steps required for specific node types. 339 | """ 340 | namespace = f"clab-{self.topology.name}" 341 | # Determine if we should be quiet based on the current log level 342 | quiet = logging.getLogger().getEffectiveLevel() > logging.INFO 343 | 344 | # Look for SROS nodes and run post-integration for them 345 | for node in self.topology.nodes: 346 | if node.kind == "nokia_sros": 347 | logger.info(f"{SUBSTEP_INDENT}Running SROS post-integration for node {node.name}") 348 | try: 349 | # Get normalized version from the node 350 | normalized_version = node._normalize_version(node.version) 351 | success = prepare_sros_node( 352 | node_name=node.get_node_name(self.topology), 353 | namespace=namespace, 354 | version=normalized_version, 355 | mgmt_ip=node.mgmt_ipv4, 356 | username="admin", 357 | password="admin", 358 | quiet=quiet # Pass quiet parameter 359 | ) 360 | if success: 361 | logger.info(f"{SUBSTEP_INDENT}SROS post-integration for {node.name} completed successfully") 362 | else: 363 | logger.error(f"SROS post-integration for {node.name} failed") 364 | except Exception as e: 365 | logger.error(f"Error during SROS post-integration for {node.name}: {e}") -------------------------------------------------------------------------------- /clab_connector/services/manifest/manifest_generator.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/manifest/manifest_generator.py 2 | 3 | import os 4 | import logging 5 | from clab_connector.models.topology import parse_topology_file 6 | from clab_connector.utils import helpers 7 | from clab_connector.utils.constants import SUBSTEP_INDENT 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class ManifestGenerator: 12 | """ 13 | Generate YAML manifests (CR definitions) from a containerlab topology. 14 | The CRs include (if applicable): 15 | - Artifacts (grouped by artifact name) 16 | - Init 17 | - NodeSecurityProfile 18 | - NodeUserGroup 19 | - NodeUser 20 | - NodeProfiles 21 | - TopoNodes 22 | - Topolink Interfaces 23 | - Topolinks 24 | 25 | When the --separate option is used, the manifests are output as one file per category 26 | (e.g. artifacts.yaml, init.yaml, etc.). Otherwise all CRs are concatenated into one YAML file. 27 | """ 28 | 29 | def __init__( 30 | self, 31 | topology_file: str, 32 | output: str = None, 33 | separate: bool = False, 34 | skip_edge_intfs: bool = False, 35 | ): 36 | """ 37 | Parameters 38 | ---------- 39 | topology_file : str 40 | Path to the containerlab topology JSON file. 41 | output : str 42 | If separate is False: path to the combined output file. 43 | If separate is True: path to an output directory where each category file is written. 44 | separate : bool 45 | If True, generate separate YAML files per CR category. 46 | Otherwise, generate one combined YAML file. 47 | skip_edge_intfs : bool 48 | When True, omit edge link resources and their interfaces from the 49 | generated manifests. 50 | """ 51 | self.topology_file = topology_file 52 | self.output = output 53 | self.separate = separate 54 | self.skip_edge_intfs = skip_edge_intfs 55 | self.topology = None 56 | # Dictionary mapping category name to a list of YAML document strings. 57 | self.cr_groups = {} 58 | 59 | def generate(self): 60 | """Parse the topology and generate the CR YAML documents grouped by category.""" 61 | self.topology = parse_topology_file(self.topology_file) 62 | namespace = f"clab-{self.topology.name}" 63 | logger.info(f"Generating manifests for namespace: {namespace}") 64 | 65 | # --- Artifacts: Group each unique artifact into one document per artifact. 66 | artifacts = [] 67 | seen_artifacts = set() 68 | for node in self.topology.nodes: 69 | if not node.needs_artifact(): 70 | continue 71 | artifact_name, filename, download_url = node.get_artifact_info() 72 | if not artifact_name or not filename or not download_url: 73 | logger.warning( 74 | f"{SUBSTEP_INDENT}No artifact info for node {node.name}; skipping." 75 | ) 76 | continue 77 | if artifact_name in seen_artifacts: 78 | continue 79 | seen_artifacts.add(artifact_name) 80 | artifact_yaml = node.get_artifact_yaml(artifact_name, filename, download_url) 81 | if artifact_yaml: 82 | artifacts.append(artifact_yaml) 83 | if artifacts: 84 | self.cr_groups["artifacts"] = artifacts 85 | 86 | # --- Init resource 87 | init_yaml = helpers.render_template("init.yaml.j2", {"namespace": namespace}) 88 | self.cr_groups["init"] = [init_yaml] 89 | 90 | # --- Node Security Profile 91 | nsp_yaml = helpers.render_template("nodesecurityprofile.yaml.j2", {"namespace": namespace}) 92 | self.cr_groups["node-security-profile"] = [nsp_yaml] 93 | 94 | # --- Node User Group 95 | nug_yaml = helpers.render_template("node-user-group.yaml.j2", {"namespace": namespace}) 96 | self.cr_groups["node-user-group"] = [nug_yaml] 97 | 98 | # --- Node User 99 | # Create SRL node user 100 | srl_data = { 101 | "namespace": namespace, 102 | "node_user": "admin", 103 | "username": "admin", 104 | "password": "NokiaSrl1!", 105 | "ssh_pub_keys": self.topology.ssh_pub_keys or [], 106 | "node_selector": "containerlab=managedSrl" 107 | } 108 | srl_node_user = helpers.render_template("node-user.j2", srl_data) 109 | 110 | # Create SROS node user 111 | sros_data = { 112 | "namespace": namespace, 113 | "node_user": "admin-sros", 114 | "username": "admin", 115 | "password": "NokiaSros1!", 116 | "ssh_pub_keys": self.topology.ssh_pub_keys or [], 117 | "node_selector": "containerlab=managedSros" 118 | } 119 | sros_node_user = helpers.render_template("node-user.j2", sros_data) 120 | 121 | # Add both node users to the manifest 122 | self.cr_groups["node-user"] = [srl_node_user, sros_node_user] 123 | 124 | # --- Node Profiles 125 | profiles = self.topology.get_node_profiles() 126 | if profiles: 127 | self.cr_groups["node-profiles"] = list(profiles) 128 | 129 | # --- TopoNodes 130 | toponodes = self.topology.get_toponodes() 131 | if toponodes: 132 | self.cr_groups["toponodes"] = list(toponodes) 133 | 134 | # --- Topolink Interfaces 135 | intfs = self.topology.get_topolink_interfaces( 136 | skip_edge_link_interfaces=self.skip_edge_intfs 137 | ) 138 | if intfs: 139 | self.cr_groups["topolink-interfaces"] = list(intfs) 140 | 141 | # --- Topolinks 142 | links = self.topology.get_topolinks( 143 | skip_edge_links=self.skip_edge_intfs 144 | ) 145 | if links: 146 | self.cr_groups["topolinks"] = list(links) 147 | 148 | return self.cr_groups 149 | 150 | def output_manifests(self): 151 | """Output the generated CR YAML documents either as one combined file or as separate files per category.""" 152 | if not self.cr_groups: 153 | logger.warning(f"{SUBSTEP_INDENT}No manifests were generated.") 154 | return 155 | 156 | if not self.separate: 157 | # One combined YAML file: concatenate all documents (across all groups) with separators. 158 | all_docs = [] 159 | for category, docs in self.cr_groups.items(): 160 | header = f"# --- {category.upper()} ---" 161 | all_docs.append(header) 162 | all_docs.extend(docs) 163 | combined = "\n---\n".join(all_docs) 164 | if self.output: 165 | with open(self.output, "w") as f: 166 | f.write(combined) 167 | logger.info( 168 | f"{SUBSTEP_INDENT}Combined manifest written to {self.output}" 169 | ) 170 | else: 171 | logger.info("\n" + combined) 172 | else: 173 | # Separate files per category: self.output must be a directory. 174 | output_dir = self.output or "manifests" 175 | os.makedirs(output_dir, exist_ok=True) 176 | for category, docs in self.cr_groups.items(): 177 | combined = "\n---\n".join(docs) 178 | file_path = os.path.join(output_dir, f"{category}.yaml") 179 | with open(file_path, "w") as f: 180 | f.write(combined) 181 | logger.info( 182 | f"{SUBSTEP_INDENT}Manifest for '{category}' written to {file_path}" 183 | ) -------------------------------------------------------------------------------- /clab_connector/services/removal/__init__.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/removal/__init__.py 2 | 3 | """Removal services (Uninstall/teardown of topology).""" 4 | -------------------------------------------------------------------------------- /clab_connector/services/removal/topology_remover.py: -------------------------------------------------------------------------------- 1 | # clab_connector/services/removal/topology_remover.py 2 | 3 | import logging 4 | 5 | from clab_connector.models.topology import parse_topology_file 6 | from clab_connector.clients.eda.client import EDAClient 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): 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 | 35 | Returns 36 | ------- 37 | None 38 | """ 39 | self.topology = parse_topology_file(str(topology_file)) 40 | 41 | logger.info("== Removing namespace ==") 42 | self.remove_namespace() 43 | self.eda_client.commit_transaction("remove namespace") 44 | 45 | logger.info("Done!") 46 | 47 | def remove_namespace(self): 48 | """ 49 | Delete the EDA namespace corresponding to this topology. 50 | """ 51 | ns = f"clab-{self.topology.name}" 52 | logger.info(f"{SUBSTEP_INDENT}Removing namespace {ns}") 53 | self.eda_client.add_delete_to_transaction( 54 | namespace="", kind="Namespace", name=ns 55 | ) 56 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /clab_connector/templates/init.yaml.j2: -------------------------------------------------------------------------------- 1 | apiVersion: bootstrap.eda.nokia.com/v1alpha1 2 | kind: Init 3 | metadata: 4 | name: init-base 5 | namespace: {{ namespace }} 6 | spec: 7 | commitSave: true 8 | mgmt: 9 | ipv4DHCP: true 10 | ipv6DHCP: true -------------------------------------------------------------------------------- /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 | encapType: {{ encap_type}} 13 | ethernet: 14 | stormControl: 15 | enabled: false 16 | lldp: true 17 | members: 18 | - enabled: true 19 | interface: {{ interface }} 20 | lacpPortPriority: 32768 21 | node: {{ node_name }} 22 | type: interface 23 | description: '{{ description }}' 24 | -------------------------------------------------------------------------------- /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 %} -------------------------------------------------------------------------------- /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 | superuser: true 13 | -------------------------------------------------------------------------------- /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 | sshPublicKeys: 15 | {%- if ssh_pub_keys and ssh_pub_keys|length > 0 %} 16 | {% for key in ssh_pub_keys %} 17 | - {{ key }} 18 | {% endfor %} 19 | {%- else %} 20 | [] 21 | {%- endif %} -------------------------------------------------------------------------------- /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 | tls: 11 | csrParams: 12 | certificateValidity: 2160h 13 | city: Sunnyvale 14 | country: US 15 | csrSuite: CSRSUITE_X509_KEY_TYPE_RSA_2048_SIGNATURE_ALGORITHM_SHA_2_256 16 | org: NI 17 | orgUnit: EDA 18 | state: California 19 | issuerRef: eda-node-issuer -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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: '' -------------------------------------------------------------------------------- /clab_connector/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eda-labs/clab-connector/91ca30e85c9278454419875621bb4ed070164c82/clab_connector/utils/__init__.py -------------------------------------------------------------------------------- /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/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 | pass 10 | 11 | 12 | class EDAConnectionError(ClabConnectorError): 13 | """ 14 | Raised when the EDA client cannot connect or authenticate. 15 | """ 16 | 17 | pass 18 | 19 | 20 | class TopologyFileError(ClabConnectorError): 21 | """Raised when a topology file is missing or invalid.""" 22 | 23 | pass 24 | -------------------------------------------------------------------------------- /clab_connector/utils/helpers.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/helpers.py 2 | 3 | import os 4 | import logging 5 | from jinja2 import Environment, FileSystemLoader, select_autoescape 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 10 | TEMPLATE_DIR = os.path.join(PACKAGE_ROOT, "templates") 11 | 12 | template_environment = Environment( 13 | loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape() 14 | ) 15 | 16 | 17 | def render_template(template_name: str, data: dict) -> str: 18 | """ 19 | Render a Jinja2 template by name, using a data dictionary. 20 | 21 | Parameters 22 | ---------- 23 | template_name : str 24 | The name of the template file (e.g., "node-profile.j2"). 25 | data : dict 26 | A dictionary of values to substitute into the template. 27 | 28 | Returns 29 | ------- 30 | str 31 | The rendered template as a string. 32 | """ 33 | template = template_environment.get_template(template_name) 34 | return template.render(data) 35 | 36 | 37 | def normalize_name(name: str) -> str: 38 | """ 39 | Convert a name to a normalized, EDA-safe format. 40 | 41 | Parameters 42 | ---------- 43 | name : str 44 | The original name. 45 | 46 | Returns 47 | ------- 48 | str 49 | The normalized name. 50 | """ 51 | safe_name = name.lower().replace("_", "-").replace(" ", "-") 52 | safe_name = "".join(c for c in safe_name if c.isalnum() or c in ".-").strip(".-") 53 | if not safe_name or not safe_name[0].isalnum(): 54 | safe_name = "x" + safe_name 55 | if not safe_name[-1].isalnum(): 56 | safe_name += "0" 57 | return safe_name 58 | -------------------------------------------------------------------------------- /clab_connector/utils/logging_config.py: -------------------------------------------------------------------------------- 1 | # clab_connector/utils/logging_config.py 2 | 3 | import logging.config 4 | from typing import Optional 5 | from pathlib import Path 6 | 7 | 8 | def setup_logging(log_level: str = "INFO", log_file: Optional[str] = None): 9 | """ 10 | Set up logging configuration with optional file output. 11 | 12 | Parameters 13 | ---------- 14 | log_level : str 15 | Desired logging level (e.g. "WARNING", "INFO", "DEBUG"). 16 | log_file : Optional[str] 17 | Path to the log file. If None, logs are not written to a file. 18 | 19 | Returns 20 | ------- 21 | None 22 | """ 23 | logging_config = { 24 | "version": 1, 25 | "disable_existing_loggers": False, 26 | "formatters": { 27 | "console": { 28 | "format": "%(message)s", 29 | }, 30 | "file": { 31 | "format": "%(asctime)s %(levelname)-8s %(message)s", 32 | "datefmt": "%Y-%m-%d %H:%M:%S", 33 | }, 34 | }, 35 | "handlers": { 36 | "console": { 37 | "class": "rich.logging.RichHandler", 38 | "level": log_level, 39 | "formatter": "console", 40 | "rich_tracebacks": True, 41 | "show_path": True, 42 | "markup": True, 43 | "log_time_format": "[%X]", 44 | }, 45 | }, 46 | "loggers": { 47 | "": { # Root logger 48 | "handlers": ["console"], 49 | "level": log_level, 50 | }, 51 | }, 52 | } 53 | 54 | if log_file: 55 | log_path = Path(log_file) 56 | log_path.parent.mkdir(parents=True, exist_ok=True) 57 | 58 | logging_config["handlers"]["file"] = { 59 | "class": "logging.FileHandler", 60 | "filename": str(log_path), 61 | "level": log_level, 62 | "formatter": "file", 63 | } 64 | logging_config["loggers"][""]["handlers"].append("file") 65 | 66 | logging.config.dictConfig(logging_config) 67 | 68 | # Reduce verbosity from lower-level clients when using INFO level 69 | if logging.getLevelName(log_level) == "INFO": 70 | # Apply to entire clients package to catch any submodules 71 | logging.getLogger("clab_connector.clients").setLevel(logging.WARNING) 72 | -------------------------------------------------------------------------------- /clab_connector/utils/yaml_processor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import yaml 3 | 4 | from clab_connector.utils.constants import SUBSTEP_INDENT 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class YAMLProcessor: 10 | class CustomDumper(yaml.SafeDumper): 11 | """ 12 | Custom YAML dumper that adjusts the indentation for lists and maintains certain lists in inline format. 13 | """ 14 | 15 | pass 16 | 17 | def custom_list_representer(self, dumper, data): 18 | # Check if we are at the specific list under 'links' with 'endpoints' 19 | if len(data) == 2 and isinstance(data[0], str) and ":" in data[0]: 20 | return dumper.represent_sequence( 21 | "tag:yaml.org,2002:seq", data, flow_style=True 22 | ) 23 | else: 24 | return dumper.represent_sequence( 25 | "tag:yaml.org,2002:seq", data, flow_style=False 26 | ) 27 | 28 | def custom_dict_representer(self, dumper, data): 29 | return dumper.represent_dict(data.items()) 30 | 31 | def __init__(self): 32 | # Assign custom representers to the CustomDumper class 33 | self.CustomDumper.add_representer(list, self.custom_list_representer) 34 | self.CustomDumper.add_representer(dict, self.custom_dict_representer) 35 | 36 | def load_yaml(self, yaml_str): 37 | try: 38 | # Load YAML data 39 | data = yaml.safe_load(yaml_str) 40 | return data 41 | 42 | except yaml.YAMLError as e: 43 | logger.error(f"Error loading YAML: {str(e)}") 44 | raise 45 | 46 | def save_yaml(self, data, output_file, flow_style=None): 47 | try: 48 | # Save YAML data 49 | with open(output_file, "w") as file: 50 | if flow_style is None: 51 | yaml.dump( 52 | data, 53 | file, 54 | Dumper=self.CustomDumper, 55 | sort_keys=False, 56 | default_flow_style=False, 57 | indent=2, 58 | ) 59 | else: 60 | yaml.dump(data, file, default_flow_style=False, sort_keys=False) 61 | 62 | logger.info(f"{SUBSTEP_INDENT}YAML file saved as '{output_file}'.") 63 | 64 | except IOError as e: 65 | logger.error(f"Error saving YAML file: {str(e)}") 66 | raise 67 | -------------------------------------------------------------------------------- /docs/connector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eda-labs/clab-connector/91ca30e85c9278454419875621bb4ed070164c82/docs/connector.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /example-topologies/EDA-sros.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"] -------------------------------------------------------------------------------- /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" ] -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "clab-connector" 7 | version = "0.5.0" 8 | description = "EDA Containerlab Connector" 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | dependencies = [ 12 | "bcrypt==4.3.0", 13 | "certifi==2025.4.26", 14 | "cffi==1.17.1", 15 | "charset-normalizer==3.4.2", 16 | "cryptography==45.0.2", 17 | "idna==3.10", 18 | "jinja2==3.1.6", 19 | "kubernetes==32.0.1", 20 | "markupsafe==3.0.2", 21 | "paramiko>=3.5.1", 22 | "pycparser==2.22", 23 | "pynacl==1.5.0", 24 | "pyyaml==6.0.2", 25 | "requests==2.32.3", 26 | "typer==0.15.4", 27 | "urllib3==2.4.0", 28 | "click==8.1.8", 29 | ] 30 | 31 | [project.scripts] 32 | clab-connector = "clab_connector.cli.main:app" 33 | 34 | [tool.hatch.build] 35 | include = [ 36 | "clab_connector/**/*.py", 37 | "clab_connector/**/*.j2", 38 | "clab_connector/templates/*" 39 | ] 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["clab_connector"] 43 | --------------------------------------------------------------------------------