├── .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 |
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 |
--------------------------------------------------------------------------------