From 1cfb5ffe0d6ac3d410ffd08899b39a8b5610ab2d Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:44:53 +0100 Subject: [PATCH 001/281] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6162fa5..57c2f66 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Federation-SDK -Repo WIP highly influenced by https://gitlab.i2cat.net/areas/software-networks/6g-diferente/camara-edgecloud/-/tree/main?ref_type=heads +WIP -- GitLab From ef716f7dd753141ba84f3d1bfa05025161779eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Feb 2025 17:17:15 +0100 Subject: [PATCH 002/281] Add get av. zones workflow --- edgecloud/doc/workflows/get_av_zones.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 edgecloud/doc/workflows/get_av_zones.md diff --git a/edgecloud/doc/workflows/get_av_zones.md b/edgecloud/doc/workflows/get_av_zones.md new file mode 100644 index 0000000..73be045 --- /dev/null +++ b/edgecloud/doc/workflows/get_av_zones.md @@ -0,0 +1,17 @@ +```mermaid +sequenceDiagram +title Retrieve Edge Cloud Zones (i2Edge EdgeCloud Platform) +actor AP as App Vertical Provider +participant CE as Capabilities Exposure +box Service Resource Manager + participant API + participant SDK as EdgeCloudSDK +end +participant i2Edge + +AP ->> CE: GET /edge-cloud-zones +CE ->> API: GET /av. zones +API ->> SDK: sdk.i2edge.get_zones() + +SDK ->> i2Edge: GET /zones/list +``` \ No newline at end of file -- GitLab From c61890aa7ca18b35ee5146b94cd9b3c11dd6e657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Feb 2025 17:22:25 +0100 Subject: [PATCH 003/281] Extend the get av. zones seq diagram --- edgecloud/doc/workflows/get_av_zones.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/edgecloud/doc/workflows/get_av_zones.md b/edgecloud/doc/workflows/get_av_zones.md index 73be045..b6732e0 100644 --- a/edgecloud/doc/workflows/get_av_zones.md +++ b/edgecloud/doc/workflows/get_av_zones.md @@ -1,6 +1,6 @@ ```mermaid sequenceDiagram -title Retrieve Edge Cloud Zones (i2Edge EdgeCloud Platform) +title Retrieve Edge Cloud Zones actor AP as App Vertical Provider participant CE as Capabilities Exposure box Service Resource Manager @@ -8,10 +8,12 @@ box Service Resource Manager participant SDK as EdgeCloudSDK end participant i2Edge +participant PiEdge AP ->> CE: GET /edge-cloud-zones CE ->> API: GET /av. zones API ->> SDK: sdk.i2edge.get_zones() - SDK ->> i2Edge: GET /zones/list +API ->> SDK: sdk.piedge.get_zones() +SDK ->> PiEdge: GET /nodes ``` \ No newline at end of file -- GitLab From 940fb402aa2564ccc15570d6546de7714641cb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Feb 2025 17:25:07 +0100 Subject: [PATCH 004/281] Improve (again) the get av. zones diagram --- edgecloud/doc/workflows/get_av_zones.md | 1 + 1 file changed, 1 insertion(+) diff --git a/edgecloud/doc/workflows/get_av_zones.md b/edgecloud/doc/workflows/get_av_zones.md index b6732e0..68cd9f7 100644 --- a/edgecloud/doc/workflows/get_av_zones.md +++ b/edgecloud/doc/workflows/get_av_zones.md @@ -10,6 +10,7 @@ end participant i2Edge participant PiEdge +note over AP,CE: CAMARA EdgeCloud API AP ->> CE: GET /edge-cloud-zones CE ->> API: GET /av. zones API ->> SDK: sdk.i2edge.get_zones() -- GitLab From 139e5b1cb53e96512ea29d6ff785ef96c94ba810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Feb 2025 18:20:41 +0100 Subject: [PATCH 005/281] Rename EdgeApplicationManagementInterface to EdgeCloudManagementInterface --- edgecloud/clients/aeros/client.py | 4 ++-- edgecloud/clients/eurecom_platform/client.py | 4 ++-- edgecloud/clients/i2edge/client.py | 4 ++-- edgecloud/clients/isi_platform/client.py | 4 ++-- edgecloud/clients/piedge/client.py | 4 ++-- edgecloud/core/edgecloud_interface.py | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/edgecloud/clients/aeros/client.py b/edgecloud/clients/aeros/client.py index ccbd9d7..2d47c44 100644 --- a/edgecloud/clients/aeros/client.py +++ b/edgecloud/clients/aeros/client.py @@ -1,8 +1,8 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeApplicationManagementInterface +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -class EdgeApplicationManager(EdgeApplicationManagementInterface): +class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/edgecloud/clients/eurecom_platform/client.py b/edgecloud/clients/eurecom_platform/client.py index ccbd9d7..2d47c44 100644 --- a/edgecloud/clients/eurecom_platform/client.py +++ b/edgecloud/clients/eurecom_platform/client.py @@ -1,8 +1,8 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeApplicationManagementInterface +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -class EdgeApplicationManager(EdgeApplicationManagementInterface): +class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/edgecloud/clients/i2edge/client.py b/edgecloud/clients/i2edge/client.py index ccbd9d7..2d47c44 100644 --- a/edgecloud/clients/i2edge/client.py +++ b/edgecloud/clients/i2edge/client.py @@ -1,8 +1,8 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeApplicationManagementInterface +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -class EdgeApplicationManager(EdgeApplicationManagementInterface): +class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/edgecloud/clients/isi_platform/client.py b/edgecloud/clients/isi_platform/client.py index ccbd9d7..2d47c44 100644 --- a/edgecloud/clients/isi_platform/client.py +++ b/edgecloud/clients/isi_platform/client.py @@ -1,8 +1,8 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeApplicationManagementInterface +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -class EdgeApplicationManager(EdgeApplicationManagementInterface): +class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/edgecloud/clients/piedge/client.py b/edgecloud/clients/piedge/client.py index ccbd9d7..2d47c44 100644 --- a/edgecloud/clients/piedge/client.py +++ b/edgecloud/clients/piedge/client.py @@ -1,8 +1,8 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeApplicationManagementInterface +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -class EdgeApplicationManager(EdgeApplicationManagementInterface): +class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/edgecloud/core/edgecloud_interface.py b/edgecloud/core/edgecloud_interface.py index 85d3457..9e53723 100644 --- a/edgecloud/core/edgecloud_interface.py +++ b/edgecloud/core/edgecloud_interface.py @@ -14,7 +14,7 @@ from abc import ABC, abstractmethod from typing import List, Dict, Optional -class EdgeApplicationManagementInterface(ABC): +class EdgeCloudManagementInterface(ABC): """ Abstract Base Class for Edge Application Management. """ -- GitLab From 16e4624a663b266af4934ece3ccb4b43a7b54cff Mon Sep 17 00:00:00 2001 From: dlaskaratos <79975730+dlaskaratos@users.noreply.github.com> Date: Tue, 11 Mar 2025 18:06:31 +0200 Subject: [PATCH 006/281] Create LICENSE --- LICENSE | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ca24c7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,51 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS -- GitLab From 93f4f7038ae0577bbc9a65d55bdb92d7cfc6c4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 13 Mar 2025 15:27:35 +0100 Subject: [PATCH 007/281] Polish the edgecloud structure --- artifact/clients/skopeo/.gitkeep | 0 .../{eurecom_platform => dmo}/__init__.py | 0 .../{eurecom_platform => dmo}/client.py | 0 edgecloud/clients/isi_platform/__init__.py | 0 edgecloud/clients/isi_platform/client.py | 29 ------------------- edgecloud/core/edgecloud_interface.py | 13 --------- 6 files changed, 42 deletions(-) delete mode 100644 artifact/clients/skopeo/.gitkeep rename edgecloud/clients/{eurecom_platform => dmo}/__init__.py (100%) rename edgecloud/clients/{eurecom_platform => dmo}/client.py (100%) delete mode 100644 edgecloud/clients/isi_platform/__init__.py delete mode 100644 edgecloud/clients/isi_platform/client.py diff --git a/artifact/clients/skopeo/.gitkeep b/artifact/clients/skopeo/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/edgecloud/clients/eurecom_platform/__init__.py b/edgecloud/clients/dmo/__init__.py similarity index 100% rename from edgecloud/clients/eurecom_platform/__init__.py rename to edgecloud/clients/dmo/__init__.py diff --git a/edgecloud/clients/eurecom_platform/client.py b/edgecloud/clients/dmo/client.py similarity index 100% rename from edgecloud/clients/eurecom_platform/client.py rename to edgecloud/clients/dmo/client.py diff --git a/edgecloud/clients/isi_platform/__init__.py b/edgecloud/clients/isi_platform/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/edgecloud/clients/isi_platform/client.py b/edgecloud/clients/isi_platform/client.py deleted file mode 100644 index 2d47c44..0000000 --- a/edgecloud/clients/isi_platform/client.py +++ /dev/null @@ -1,29 +0,0 @@ -# Mocked API for testing purposes -from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface - -class EdgeApplicationManager(EdgeCloudManagementInterface): - def onboard_app(self, app_manifest: Dict) -> Dict: - print(f"Submitting application: {app_manifest}") - return {"appId": "1234-5678"} - - def get_all_onboarded_apps(self) -> List[Dict]: - return [{"appId": "1234-5678", "name": "TestApp"}] - - def get_onboarded_app(self, app_id: str) -> Dict: - return {"appId": app_id, "name": "TestApp"} - - def delete_onboarded_app(self, app_id: str) -> None: - print(f"Deleting application: {app_id}") - - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - return {"appInstanceId": "abcd-efgh"} - - def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: - return [{"appInstanceId": "abcd-efgh", "status": "ready"}] - - def undeploy_app(self, app_instance_id: str) -> None: - print(f"Deleting app instance: {app_instance_id}") - - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: - return [{"edgeCloudZoneId": "zone-1", "status": "active"}] diff --git a/edgecloud/core/edgecloud_interface.py b/edgecloud/core/edgecloud_interface.py index 9e53723..97095c4 100644 --- a/edgecloud/core/edgecloud_interface.py +++ b/edgecloud/core/edgecloud_interface.py @@ -1,16 +1,3 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. -# -# Contributors: -# - Adrián Pino (adrian.pino@i2cat.net) -## - from abc import ABC, abstractmethod from typing import List, Dict, Optional -- GitLab From 4a5a3310a26ef4a5139678519fd3d45bbfe8296e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 13 Mar 2025 15:27:54 +0100 Subject: [PATCH 008/281] Delete artefact-related part --- artifact/clients/skopeo/__init__.py | 0 artifact/core/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 artifact/clients/skopeo/__init__.py delete mode 100644 artifact/core/.gitkeep diff --git a/artifact/clients/skopeo/__init__.py b/artifact/clients/skopeo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/artifact/core/.gitkeep b/artifact/core/.gitkeep deleted file mode 100644 index e69de29..0000000 -- GitLab From d0f62bcd382abc51231758bbb908504e32ce3455 Mon Sep 17 00:00:00 2001 From: adrian-pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:45:44 +0100 Subject: [PATCH 009/281] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 57c2f66..52a8661 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# Federation-SDK +# SUNRISE-6G Open-SDK WIP -- GitLab From ed4d3aff749851ee1b130918f335c665db9c6175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:23:21 +0100 Subject: [PATCH 010/281] Add /src folder structure and missing __init__.py files --- .../workflows/edgecloud}/get_av_zones.md | 0 {edgecloud/clients/aeros => src}/__init__.py | 0 {edgecloud => src/edgecloud}/.env | 0 .../clients/dmo => src/edgecloud}/__init__.py | 0 .../edgecloud/clients}/__init__.py | 0 .../edgecloud/clients/aeros}/__init__.py | 0 .../edgecloud}/clients/aeros/client.py | 5 ++- .../edgecloud/clients/dmo}/__init__.py | 0 .../edgecloud}/clients/dmo/client.py | 5 ++- .../edgecloud/clients/i2edge}/__init__.py | 0 .../edgecloud}/clients/i2edge/client.py | 5 ++- .../edgecloud/clients/piedge/__init__.py | 0 .../edgecloud}/clients/piedge/client.py | 5 ++- .../edgecloud/core/__init__.py | 0 .../edgecloud}/core/edgecloud_interface.py | 0 src/main.py | 36 +++++++++++++++++++ .../core/.gitkeep => src/network/__init__.py | 0 src/network/clients/__init__.py | 0 src/network/clients/oai/__init__.py | 0 src/network/clients/open5gs/.gitkeep | 0 src/network/clients/open5gs/__init__.py | 0 src/network/core/__init__.py | 0 22 files changed, 52 insertions(+), 4 deletions(-) rename {edgecloud/doc/workflows => docs/workflows/edgecloud}/get_av_zones.md (100%) rename {edgecloud/clients/aeros => src}/__init__.py (100%) rename {edgecloud => src/edgecloud}/.env (100%) rename {edgecloud/clients/dmo => src/edgecloud}/__init__.py (100%) rename {edgecloud/clients/i2edge => src/edgecloud/clients}/__init__.py (100%) rename {edgecloud/clients/piedge => src/edgecloud/clients/aeros}/__init__.py (100%) rename {edgecloud => src/edgecloud}/clients/aeros/client.py (89%) rename {network/clients/oai => src/edgecloud/clients/dmo}/__init__.py (100%) rename {edgecloud => src/edgecloud}/clients/dmo/client.py (89%) rename {network/clients/open5gs => src/edgecloud/clients/i2edge}/__init__.py (100%) rename {edgecloud => src/edgecloud}/clients/i2edge/client.py (89%) rename network/clients/oai/.gitkeep => src/edgecloud/clients/piedge/__init__.py (100%) rename {edgecloud => src/edgecloud}/clients/piedge/client.py (89%) rename network/clients/open5gs/.gitkeep => src/edgecloud/core/__init__.py (100%) rename {edgecloud => src/edgecloud}/core/edgecloud_interface.py (100%) create mode 100644 src/main.py rename network/core/.gitkeep => src/network/__init__.py (100%) create mode 100644 src/network/clients/__init__.py create mode 100644 src/network/clients/oai/__init__.py create mode 100644 src/network/clients/open5gs/.gitkeep create mode 100644 src/network/clients/open5gs/__init__.py create mode 100644 src/network/core/__init__.py diff --git a/edgecloud/doc/workflows/get_av_zones.md b/docs/workflows/edgecloud/get_av_zones.md similarity index 100% rename from edgecloud/doc/workflows/get_av_zones.md rename to docs/workflows/edgecloud/get_av_zones.md diff --git a/edgecloud/clients/aeros/__init__.py b/src/__init__.py similarity index 100% rename from edgecloud/clients/aeros/__init__.py rename to src/__init__.py diff --git a/edgecloud/.env b/src/edgecloud/.env similarity index 100% rename from edgecloud/.env rename to src/edgecloud/.env diff --git a/edgecloud/clients/dmo/__init__.py b/src/edgecloud/__init__.py similarity index 100% rename from edgecloud/clients/dmo/__init__.py rename to src/edgecloud/__init__.py diff --git a/edgecloud/clients/i2edge/__init__.py b/src/edgecloud/clients/__init__.py similarity index 100% rename from edgecloud/clients/i2edge/__init__.py rename to src/edgecloud/clients/__init__.py diff --git a/edgecloud/clients/piedge/__init__.py b/src/edgecloud/clients/aeros/__init__.py similarity index 100% rename from edgecloud/clients/piedge/__init__.py rename to src/edgecloud/clients/aeros/__init__.py diff --git a/edgecloud/clients/aeros/client.py b/src/edgecloud/clients/aeros/client.py similarity index 89% rename from edgecloud/clients/aeros/client.py rename to src/edgecloud/clients/aeros/client.py index 2d47c44..985ca54 100644 --- a/edgecloud/clients/aeros/client.py +++ b/src/edgecloud/clients/aeros/client.py @@ -1,8 +1,11 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface class EdgeApplicationManager(EdgeCloudManagementInterface): + def __init__(self, base_url: str): + self.base_url = base_url + def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/network/clients/oai/__init__.py b/src/edgecloud/clients/dmo/__init__.py similarity index 100% rename from network/clients/oai/__init__.py rename to src/edgecloud/clients/dmo/__init__.py diff --git a/edgecloud/clients/dmo/client.py b/src/edgecloud/clients/dmo/client.py similarity index 89% rename from edgecloud/clients/dmo/client.py rename to src/edgecloud/clients/dmo/client.py index 2d47c44..985ca54 100644 --- a/edgecloud/clients/dmo/client.py +++ b/src/edgecloud/clients/dmo/client.py @@ -1,8 +1,11 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface class EdgeApplicationManager(EdgeCloudManagementInterface): + def __init__(self, base_url: str): + self.base_url = base_url + def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/network/clients/open5gs/__init__.py b/src/edgecloud/clients/i2edge/__init__.py similarity index 100% rename from network/clients/open5gs/__init__.py rename to src/edgecloud/clients/i2edge/__init__.py diff --git a/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py similarity index 89% rename from edgecloud/clients/i2edge/client.py rename to src/edgecloud/clients/i2edge/client.py index 2d47c44..985ca54 100644 --- a/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -1,8 +1,11 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface class EdgeApplicationManager(EdgeCloudManagementInterface): + def __init__(self, base_url: str): + self.base_url = base_url + def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/network/clients/oai/.gitkeep b/src/edgecloud/clients/piedge/__init__.py similarity index 100% rename from network/clients/oai/.gitkeep rename to src/edgecloud/clients/piedge/__init__.py diff --git a/edgecloud/clients/piedge/client.py b/src/edgecloud/clients/piedge/client.py similarity index 89% rename from edgecloud/clients/piedge/client.py rename to src/edgecloud/clients/piedge/client.py index 2d47c44..985ca54 100644 --- a/edgecloud/clients/piedge/client.py +++ b/src/edgecloud/clients/piedge/client.py @@ -1,8 +1,11 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface class EdgeApplicationManager(EdgeCloudManagementInterface): + def __init__(self, base_url: str): + self.base_url = base_url + def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} diff --git a/network/clients/open5gs/.gitkeep b/src/edgecloud/core/__init__.py similarity index 100% rename from network/clients/open5gs/.gitkeep rename to src/edgecloud/core/__init__.py diff --git a/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py similarity index 100% rename from edgecloud/core/edgecloud_interface.py rename to src/edgecloud/core/edgecloud_interface.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..5c03b20 --- /dev/null +++ b/src/main.py @@ -0,0 +1,36 @@ +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory + +# def create_edgecloud_client(client_name: str, base_url: str): +# """ +# Create and return an edgecloud client. + +# Args: +# client_name (str): The name of the client (e.g., "i2edge"). +# base_url (str): The base URL for the client. + +# Returns: +# The created edgecloud client. +# """ +# return EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + +#################################################################################################### +# Temporal code - testing purposes +#################################################################################################### +if __name__ == "__main__": + # Define the client name and base URL + client_name = "i2edge" + base_url = "http://192.168.123.237:30769/" + + # Create the edgecloud client + sbi = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + + # Print the edgecloud client being used and its URL + print(f"Using edgecloud client: {sbi}") + print(f"URL: {sbi.base_url}") + + # Call the get_edge_cloud_zones function + zones = sbi.get_edge_cloud_zones() + print(f"Edge Cloud Zones: {zones}") +#################################################################################################### +# End of Temporal code +#################################################################################################### diff --git a/network/core/.gitkeep b/src/network/__init__.py similarity index 100% rename from network/core/.gitkeep rename to src/network/__init__.py diff --git a/src/network/clients/__init__.py b/src/network/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/clients/oai/__init__.py b/src/network/clients/oai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/clients/open5gs/.gitkeep b/src/network/clients/open5gs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/network/clients/open5gs/__init__.py b/src/network/clients/open5gs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/core/__init__.py b/src/network/core/__init__.py new file mode 100644 index 0000000..e69de29 -- GitLab From ebc278814103e7d617865ca63e6db19528041bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:27:04 +0100 Subject: [PATCH 011/281] Move missing file --- {edgecloud => src/edgecloud}/core/schemas.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {edgecloud => src/edgecloud}/core/schemas.py (100%) diff --git a/edgecloud/core/schemas.py b/src/edgecloud/core/schemas.py similarity index 100% rename from edgecloud/core/schemas.py rename to src/edgecloud/core/schemas.py -- GitLab From 2e7ecdbaeae436dd134b564398d9bca94a9c4a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:31:18 +0100 Subject: [PATCH 012/281] Add test_factory_edgecloud & test_edge_cloud_zones --- tests/__init__.py | 0 tests/test_edgecloud_clients.py | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_edgecloud_clients.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py new file mode 100644 index 0000000..2af8a61 --- /dev/null +++ b/tests/test_edgecloud_clients.py @@ -0,0 +1,51 @@ +import pytest + +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient + +# Define common test cases for all tests +test_cases = [ + ("i2edge", "http://192.168.123.237:30769/"), + ("aeros", "http://aeros.example.com/"), + ("piedge", "http://piedge.example.com/"), + ("dmo", "http://dmo.example.com/") +] + +# Add an invalid client test case +invalid_test_case = [("invalid_client", "http://invalid.url/")] + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_factory_edgecloud(client_name, base_url): + """ + Test the factory pattern for the edgecloud client. + """ + # Map client names to their corresponding client classes + client_class_map = { + "i2edge": I2EdgeClient, + "aeros": AerosClient, + "piedge": PiEdgeClient, + "dmo": DmoClient, + } + + expected_client_class = client_class_map[client_name] + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + assert isinstance(edgecloud_platform, expected_client_class) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_edge_cloud_zones_return_list(client_name, base_url): + """ + Test the get_edge_cloud_zones method for each client. + """ + # Create the edgecloud client + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + + # Call the get_edge_cloud_zones function + zones = edgecloud_platform.get_edge_cloud_zones() + + # Assert that the result is a list (or whatever the expected type is) + assert isinstance(zones, list), f"Expected a list of zones for {client_name}, but got {type(zones)}" -- GitLab From bd248eef4609f619d9ce7044850999ac2c8ff21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:56:16 +0100 Subject: [PATCH 013/281] Add common functionality to abstract API operations --- src/edgecloud/clients/i2edge/common.py | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/edgecloud/clients/i2edge/common.py diff --git a/src/edgecloud/clients/i2edge/common.py b/src/edgecloud/clients/i2edge/common.py new file mode 100644 index 0000000..2f65cba --- /dev/null +++ b/src/edgecloud/clients/i2edge/common.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## +import json +from typing import Optional + +import requests +from pydantic import BaseModel + +from src import logger + +log = logger.get_logger(__name__) + + +class I2EdgeError(Exception): + pass + + +class I2EdgeErrorResponse(BaseModel): + message: str + detail: dict + + +def get_error_message_from(response: requests.Response) -> str: + try: + error_response = I2EdgeErrorResponse(**response.json()) + return error_response.message + except Exception as e: + log.error("Failed to parse error response from i2edge: {}".format(e)) + return response.text + + +def i2edge_post(url: str, model_payload: BaseModel) -> dict: + headers = {"Content-Type": "application/json", "accept": "application/json"} + json_payload = json.dumps(model_payload.model_dump(mode="json")) + try: + response = requests.post(url, data=json_payload, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) + log.error(err_msg) + raise I2EdgeError(err_msg) + + +def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: + headers = { + "accept": "application/json", + } + payload_dict = model_payload.model_dump(mode="json") + payload_in_str = {k: str(v) for k, v in payload_dict.items()} + try: + response = requests.post(url, data=payload_in_str, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) + log.error(err_msg) + raise I2EdgeError(err_msg) + + +def i2edge_delete(url: str, id: str) -> dict: + headers = {"accept": "application/json"} + try: + query = "{}/{}".format(url, id) + response = requests.delete(query, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to undeploy app: {}. Detail: {}".format(i2edge_err_msg, e) + log.error(err_msg) + raise I2EdgeError(err_msg) + + +def i2edge_get(url: str, params: Optional[dict]): + headers = {"accept": "application/json"} + try: + response = requests.get(url, params=params, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to get apps: {}. Detail: {}".format(i2edge_err_msg, e) + log.error(err_msg) + raise I2EdgeError(err_msg) -- GitLab From 83dcf903dbd3590c2318aac08449e00859850002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:56:33 +0100 Subject: [PATCH 014/281] Add get_edge_cloud_zones endpoint --- src/edgecloud/clients/i2edge/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 985ca54..7486e24 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -1,11 +1,20 @@ -# Mocked API for testing purposes -from typing import Dict, List, Optional +from typing import Optional from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from typing import Dict, List, Optional +from .common import I2EdgeError, i2edge_delete, i2edge_get, i2edge_post, i2edge_post_multiform_data class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): self.base_url = base_url + def get_edge_cloud_zones(self) -> list[dict]: + url = "{}/zones/list".format(self.base_url) + try: + response = i2edge_get(url, params=None) + return response + except I2EdgeError as e: + raise e + def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") return {"appId": "1234-5678"} -- GitLab From 1ece50cc3a8c08d099a3e790218c64a0bb2f8828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:58:51 +0100 Subject: [PATCH 015/281] Add factory class that contains the edge cloud platform clients --- src/edgecloud/core/edgecloud_factory.py | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/edgecloud/core/edgecloud_factory.py diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py new file mode 100644 index 0000000..e59cb2d --- /dev/null +++ b/src/edgecloud/core/edgecloud_factory.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from enum import Enum +from typing import TYPE_CHECKING + +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient + +if TYPE_CHECKING: + from .edgecloud_interface import EdgeCloudInterface + +class EdgeCloudFactory: + """ + Factory class for creating EdgeCloud Clients + """ + + @staticmethod + def create_edgecloud_client(client_name: str, base_url: str) -> EdgeCloudInterface: + try: + return EdgeCloudTypes.edgecloud_types[client_name](base_url) + except KeyError: + # Get the list of supported client names + supported_clients = list(EdgeCloudTypes.edgecloud_types.keys()) + raise ValueError( + f"Invalid edgecloud client name: '{client_name}'. " + f"Supported clients are: {', '.join(supported_clients)}" + ) + +class EdgeCloudTypes(): + """ + Class dedicated for the different types of edgecloud clients. + """ + I2EDGE = "i2edge" + AEROS = "aeros" + DMO = "dmo" + PIEDGE="piedge" + + edgecloud_types = { + I2EDGE: lambda url: I2EdgeClient(base_url=url), + AEROS: lambda url: AerosClient(base_url=url), + DMO: lambda url: DmoClient(base_url=url), + PIEDGE: lambda url: PiEdgeClient(base_url=url) + } -- GitLab From 6dd33e52dab8e8fe8c201a70586cc18f098a6913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:59:00 +0100 Subject: [PATCH 016/281] Update gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 274d721..f943311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .venv .mypy_cache -.pytest_cache \ No newline at end of file +.pytest_cache +.pyc +__pycache__/ +tmp/ -- GitLab From 4ced4a2167a886c92f3449977577eb588509e3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:58:51 +0100 Subject: [PATCH 017/281] Add factory class that contains the edge cloud platform clients --- src/edgecloud/core/edgecloud_factory.py | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/edgecloud/core/edgecloud_factory.py diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py new file mode 100644 index 0000000..e59cb2d --- /dev/null +++ b/src/edgecloud/core/edgecloud_factory.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from enum import Enum +from typing import TYPE_CHECKING + +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient + +if TYPE_CHECKING: + from .edgecloud_interface import EdgeCloudInterface + +class EdgeCloudFactory: + """ + Factory class for creating EdgeCloud Clients + """ + + @staticmethod + def create_edgecloud_client(client_name: str, base_url: str) -> EdgeCloudInterface: + try: + return EdgeCloudTypes.edgecloud_types[client_name](base_url) + except KeyError: + # Get the list of supported client names + supported_clients = list(EdgeCloudTypes.edgecloud_types.keys()) + raise ValueError( + f"Invalid edgecloud client name: '{client_name}'. " + f"Supported clients are: {', '.join(supported_clients)}" + ) + +class EdgeCloudTypes(): + """ + Class dedicated for the different types of edgecloud clients. + """ + I2EDGE = "i2edge" + AEROS = "aeros" + DMO = "dmo" + PIEDGE="piedge" + + edgecloud_types = { + I2EDGE: lambda url: I2EdgeClient(base_url=url), + AEROS: lambda url: AerosClient(base_url=url), + DMO: lambda url: DmoClient(base_url=url), + PIEDGE: lambda url: PiEdgeClient(base_url=url) + } -- GitLab From 99448f9eb8fe27fd1e83884ba91888c0cdc9ff45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 17:59:00 +0100 Subject: [PATCH 018/281] Update gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 274d721..f943311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .venv .mypy_cache -.pytest_cache \ No newline at end of file +.pytest_cache +.pyc +__pycache__/ +tmp/ -- GitLab From e8c511bd0554acfb22d47500d9110ebe72dad0a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 19 Mar 2025 18:21:08 +0100 Subject: [PATCH 019/281] Delete duplicated get_edge_cloud_zones --- src/edgecloud/clients/i2edge/client.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 7486e24..032adea 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -36,6 +36,3 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def undeploy_app(self, app_instance_id: str) -> None: print(f"Deleting app instance: {app_instance_id}") - - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: - return [{"edgeCloudZoneId": "zone-1", "status": "active"}] -- GitLab From f119c3457f04fd6ce0ee1fd81eb207f61734dc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 11:50:28 +0100 Subject: [PATCH 020/281] Add region & status support for get_edge_cloud_zones. Add tests --- src/edgecloud/clients/i2edge/client.py | 24 ++++++++++++++++++++-- tests/test_edgecloud_clients.py | 28 +++++++++++++++++++------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 032adea..5023144 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -1,14 +1,34 @@ from typing import Optional from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface from typing import Dict, List, Optional +from . import schemas from .common import I2EdgeError, i2edge_delete, i2edge_get, i2edge_post, i2edge_post_multiform_data class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): self.base_url = base_url - def get_edge_cloud_zones(self) -> list[dict]: - url = "{}/zones/list".format(self.base_url) + def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> list[dict]: + # Note: status is not supported by i2Edge; won't be used + try: + params = {} + if region is not None: + # Use the /zone/{region} endpoint + url = "{}/zone/{}".format(self.base_url, region) + if status is not None: + params['status'] = status + response = i2edge_get(url, params=params) + else: + # Use the /zones/list endpoint + url = "{}/zones/list".format(self.base_url) + if status is not None: + params['status'] = status + response = i2edge_get(url, params=params) + + return response + except I2EdgeError as e: + raise e + try: response = i2edge_get(url, params=None) return response diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 2af8a61..b2b015b 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -9,9 +9,9 @@ from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient # Define common test cases for all tests test_cases = [ ("i2edge", "http://192.168.123.237:30769/"), - ("aeros", "http://aeros.example.com/"), - ("piedge", "http://piedge.example.com/"), - ("dmo", "http://dmo.example.com/") + # ("aeros", "http://aeros.example.com/"), + # ("piedge", "http://piedge.example.com/"), + # ("dmo", "http://dmo.example.com/") ] # Add an invalid client test case @@ -37,15 +37,29 @@ def test_factory_edgecloud(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones_return_list(client_name, base_url): +def test_get_edge_cloud_zones(client_name, base_url): """ - Test the get_edge_cloud_zones method for each client. + Test the format of the response from get_edge_cloud_zones for each client. """ # Create the edgecloud client edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - # Call the get_edge_cloud_zones function + # Case 1: status & region (which are optional) not specified zones = edgecloud_platform.get_edge_cloud_zones() + assert isinstance(zones, list), f"Expected a list of zones for {client_name}, but got {type(zones)}" + if zones: # Check content if the list is not empty + assert all(isinstance(zone, dict) for zone in zones), "Each zone should be a dictionary" + + # Case 2: region specified + zones = edgecloud_platform.get_edge_cloud_zones(region="Omega") + assert isinstance(zones, dict), f"Expected a dict for {client_name} when region is specified, but got {type(zones)}" - # Assert that the result is a list (or whatever the expected type is) + # Case 3: status specified + zones = edgecloud_platform.get_edge_cloud_zones(status="active") assert isinstance(zones, list), f"Expected a list of zones for {client_name}, but got {type(zones)}" + if zones: # Check content if the list is not empty + assert all(isinstance(zone, dict) for zone in zones), "Each zone should be a dictionary" + + # Case 4: status & region specified + zones = edgecloud_platform.get_edge_cloud_zones(region="Omega", status="active") + assert isinstance(zones, dict), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" -- GitLab From bab29843249831bc25832f186d83e8df896b4603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 11:50:40 +0100 Subject: [PATCH 021/281] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f943311..39b1186 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .pyc __pycache__/ tmp/ +.vscode/ \ No newline at end of file -- GitLab From b26ace4c4764dd845bc8e94987978aa6007d5e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 11:51:02 +0100 Subject: [PATCH 022/281] Update pip requirements (add pydantic) --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 9c92b0f..9dec5e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +annotated-types==0.7.0 certifi==2025.1.31 charset-normalizer==3.4.1 colorlog==6.8.2 @@ -6,7 +7,10 @@ idna==3.10 iniconfig==2.0.0 packaging==24.2 pluggy==1.5.0 +pydantic==2.10.6 +pydantic_core==2.27.2 pytest==8.3.2 requests==2.32.3 tomli==2.2.1 +typing_extensions==4.12.2 urllib3==2.3.0 -- GitLab From 204fede2c37a8a3c3f952c3907bf9706f8bc5e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 11:53:48 +0100 Subject: [PATCH 023/281] Polish src/main --- src/main.py | 46 ++++++++++++---------------------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/src/main.py b/src/main.py index 5c03b20..04bd20b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,36 +1,14 @@ from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -# def create_edgecloud_client(client_name: str, base_url: str): -# """ -# Create and return an edgecloud client. - -# Args: -# client_name (str): The name of the client (e.g., "i2edge"). -# base_url (str): The base URL for the client. - -# Returns: -# The created edgecloud client. -# """ -# return EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - -#################################################################################################### -# Temporal code - testing purposes -#################################################################################################### -if __name__ == "__main__": - # Define the client name and base URL - client_name = "i2edge" - base_url = "http://192.168.123.237:30769/" - - # Create the edgecloud client - sbi = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - - # Print the edgecloud client being used and its URL - print(f"Using edgecloud client: {sbi}") - print(f"URL: {sbi.base_url}") - - # Call the get_edge_cloud_zones function - zones = sbi.get_edge_cloud_zones() - print(f"Edge Cloud Zones: {zones}") -#################################################################################################### -# End of Temporal code -#################################################################################################### +def create_edgecloud_client(client_name: str, base_url: str): + """ + Create and return an edgecloud client. + + Args: + client_name (str): The name of the client (e.g., "i2edge"). + base_url (str): The base URL for the client. + + Returns: + The created edgecloud client. + """ + return EdgeCloudFactory.create_edgecloud_client(client_name, base_url) -- GitLab From 96afbc17899fcc9fb599969b37cfbc5508e391b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 11:55:15 +0100 Subject: [PATCH 024/281] Add utils file to manage API calls --- src/edgecloud/clients/i2edge/utils.py | 151 ++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/edgecloud/clients/i2edge/utils.py diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py new file mode 100644 index 0000000..4c34d52 --- /dev/null +++ b/src/edgecloud/clients/i2edge/utils.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Sergio Giménez (sergio.gimenez@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) +## + +import uuid +from typing import Optional, Union +from uuid import UUID + +from edgecloud import logger +from edgecloud.api.routers.lcm.schemas import RequiredResources +from edgecloud.core import utils as core_utils + +from .client import I2EdgeClient +from .common import I2EdgeError + +log = logger.get_logger(__name__) + + +def generate_namespace_name_from(app_id: str, app_instance_id: str) -> str: + max_length = 63 + combined_name = "{}-{}".format(app_id, app_instance_id) + if len(combined_name) > max_length: + combined_name = combined_name[:max_length] + return combined_name + + +def generate_unique_id() -> UUID: + return uuid.uuid4() + + +def instantiate_app_with( + camara_app_id: UUID, + zone_id: str, + required_resources: RequiredResources, + i2edge: I2EdgeClient, +) -> tuple[str, str]: + memory_size_str = "{}GB".format(required_resources.memory + 1) + num_gpus = core_utils.get_num_gpus_from(required_resources) + try: + flavour_id = i2edge.create_flavour( + zone_id=zone_id, + memory_size=memory_size_str, + num_cpu=required_resources.numCPU, + num_gpus=num_gpus, + ) + i2edge_instance_id = generate_unique_id() + application_k8s_namespace = generate_namespace_name_from( + str(camara_app_id), str(i2edge_instance_id) + ) + i2edge.deploy_app( + appId=str(camara_app_id), + zoneId=zone_id, + flavourId=flavour_id, + namespace=application_k8s_namespace, + ) + return flavour_id, application_k8s_namespace + except I2EdgeError as e: + err_msg = "Error instantiating app {} in zone {}".format(camara_app_id, zone_id) + log.error("{}. Detailed error: {}".format(err_msg, e)) + raise e + + +def onboard_app_with( + application_id: UUID, + artefact_id: UUID, + app_name: str, + app_version: Optional[str], # TODO pass this to i2edge + repo_type: str, + app_repo: str, + user_name: Optional[str], + password: Optional[str], + token: Optional[str], + i2edge: I2EdgeClient, +): + try: + # TODO Come back to handle errors when onboarding and perform rollbacks + i2edge.create_artefact( + artefact_id=str(artefact_id), + artefact_name=app_name, + repo_name=app_name, + repo_type=repo_type, + repo_url=app_repo, + user_name=user_name, + password=password, + token=token, + ) + + i2edge.onboard_app(app_id=str(application_id), artefact_id=str(application_id)) + except I2EdgeError as e: + err_msg = "Error onboarding app {} in i2edge".format(app_name) + log.error("{}. Detailed error: {}".format(err_msg, e)) + raise e + + +def delete_app_instance_by(namespace: str, flavour_id: str, zone_id: str, i2edge: I2EdgeClient): + i2edge_app_instance_name = get_app_name_from(namespace, i2edge) + if i2edge_app_instance_name is None: + err_msg = "Couldn't retrieve app instance from I2Edge." + log.error(err_msg) + raise I2EdgeError(err_msg) + i2edge.undeploy_app(i2edge_app_instance_name) + i2edge.delete_flavour(flavour_id=str(flavour_id), zone_id=zone_id) + + +def get_app_name_from(namespace: str, i2edge: I2EdgeClient) -> Union[str, None]: + try: + response = i2edge.get_all_deployed_apps() + for deployment in response: + if deployment.get("bodytosend", {}).get("namespace") == namespace: + return deployment.get("name") + return None + except I2EdgeError as e: + err_msg = "Error getting app name for namespace {}".format(namespace) + log.error("{}. Detailed error: {}".format(err_msg, e)) + raise e + + +def delete_app_by(app_id: UUID, artefact_id: UUID, i2edge: I2EdgeClient): + try: + i2edge.delete_onboarded_app(app_id=str(app_id)) + i2edge.delete_artefact(artefact_id=str(artefact_id)) + except I2EdgeError as e: + err_msg = "Error deleting app {}".format(app_id) + log.error("{}. Detailed error: {}".format(err_msg, e)) + raise e + + +def get_edgecloud_zones(i2edge: I2EdgeClient) -> list[str]: + try: + zone_ids = [] + response = i2edge.get_zones_list() + for zone in response: + zone_id = zone.get("zoneId") + if zone_id is not None: + zone_ids.append(zone_id) + return zone_ids + + except I2EdgeError as e: + err_msg = "Error getting zones from i2edge" + log.error("{}. Detailed error: {}".format(err_msg, e)) + raise e -- GitLab From 831a4227a2e78deb05694882f99ebea2c5c51c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 12:58:15 +0100 Subject: [PATCH 025/281] Add pydantic schemas --- src/edgecloud/clients/i2edge/schemas.py | 166 ++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/edgecloud/clients/i2edge/schemas.py diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/edgecloud/clients/i2edge/schemas.py new file mode 100644 index 0000000..33e1abc --- /dev/null +++ b/src/edgecloud/clients/i2edge/schemas.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Sergio Giménez (sergio.gimenez@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) +## +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class ZoneInfo(BaseModel): + flavourId: str + zoneId: str + + +class AppParameters(BaseModel): + namespace: Optional[str] = None + + +class AppDeployData(BaseModel): + appId: str + appProviderId: str + appVersion: str + zoneInfo: ZoneInfo + + +class AppDeploy(BaseModel): + app_deploy_data: AppDeployData + app_parameters: Optional[AppParameters] = None + + +# Artefact + + +class RepoType(str, Enum): + UPLOAD = "UPLOAD" + PUBLICREPO = "PUBLICREPO" + PRIVATEREPO = "PRIVATEREPO" + + +class ArtefactOnboarding(BaseModel): + artefact_id: str + name: str + # chart: Optional[bytes] = Field(default=None) # XXX AFAIK not supported by CAMARA. + repo_password: Optional[str] = None + repo_name: Optional[str] = None + repo_type: RepoType + repo_url: Optional[str] = None + repo_token: Optional[str] = None + repo_user_name: Optional[str] = None + model_config = ConfigDict(use_enum_values=True) + + +# Application Onboarding + +# XXX Leaving default values since i2edge only cares about appid and artifactid, at least for now. + + +class AppComponentSpec(BaseModel): + artefactId: str + componentName: str = Field(default="default_component") + serviceNameEW: str = Field(default="default_ew_service") + serviceNameNB: str = Field(default="default_nb_service") + + +class AppMetaData(BaseModel): + appDescription: str = Field(default="Default app description") + appName: str = Field(default="Default App") + category: str = Field(default="DEFAULT") + mobilitySupport: bool = Field(default=False) + version: str = Field(default="1.0") + + +class AppQoSProfile(BaseModel): + appProvisioning: bool = Field(default=True) + bandwidthRequired: int = Field(default=1) + latencyConstraints: str = Field(default="NONE") + multiUserClients: str = Field(default="APP_TYPE_SINGLE_USER") + noOfUsersPerAppInst: int = Field(default=1) + + +class ApplicationOnboardingData(BaseModel): + appComponentSpecs: List[AppComponentSpec] + appDeploymentZones: List[str] = Field(default=["default_zone"]) + app_id: str + appMetaData: AppMetaData = Field(default_factory=AppMetaData) + appProviderId: str = Field(default="default_provider") + appQoSProfile: AppQoSProfile = Field(default_factory=AppQoSProfile) + appStatusCallbackLink: Optional[str] = None + + +class ApplicationOnboardingRequest(BaseModel): + profile_data: ApplicationOnboardingData + + +# Flavour + + +class GPU(BaseModel): + gpuMemory: int = Field(default=0, description="GPU memory in MB") + gpuModeName: str = Field(default="", description="GPU mode name") + gpuVendorType: str = Field(default="GPU_PROVIDER_NVIDIA", description="GPU vendor type") + numGPU: int = Field(..., description="Number of GPUs") + + +class Hugepages(BaseModel): + number: int = Field(default=0, description="Number of hugepages") + pageSize: str = Field(default="2MB", description="Size of hugepages") + + +class SupportedOSTypes(BaseModel): + architecture: str = Field(default="x86_64", description="OS architecture") + distribution: str = Field(default="RHEL", description="OS distribution") + license: str = Field(default="OS_LICENSE_TYPE_FREE", description="OS license type") + version: str = Field(default="OS_VERSION_UBUNTU_2204_LTS", description="OS version") + + +class FlavourSupported(BaseModel): + cpuArchType: str = Field(default="ISA_X86", description="CPU architecture type") + cpuExclusivity: bool = Field(default=True, description="CPU exclusivity") + fpga: int = Field(default=0, description="Number of FPGAs") + gpu: Optional[List[GPU]] = Field(default=None, description="List of GPUs") + hugepages: List[Hugepages] = Field( + default_factory=lambda: [Hugepages()], description="List of hugepages" + ) + memorySize: str = Field(..., description="Memory size (e.g., '1024MB' or '2GB')") + numCPU: int = Field(..., description="Number of CPUs") + storageSize: int = Field(default=0, description="Storage size in GB") + supportedOSTypes: List[SupportedOSTypes] = Field( + default_factory=lambda: [SupportedOSTypes()], + description="List of supported OS types", + ) + vpu: int = Field(default=0, description="Number of VPUs") + + @field_validator("memorySize") + @classmethod + def validate_memory_size(cls, v): + if not (v.endswith("MB") or v.endswith("GB")): + raise ValueError("memorySize must end with MB or GB") + try: + int(v[:-2]) + except ValueError: + raise ValueError("memorySize must be a number followed by MB or GB") + return v + + +class Flavour(BaseModel): + flavour_supported: FlavourSupported + + +# EdgeCloud Zones + + +class Zone(BaseModel): + geographyDetails: str + geolocation: str + zoneId: str -- GitLab From 84e6e24b331289b2a105d230fe975be190afd9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 12:58:25 +0100 Subject: [PATCH 026/281] Add contributors disclaimer --- src/edgecloud/clients/i2edge/client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 5023144..c9803ff 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -1,3 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## from typing import Optional from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface from typing import Dict, List, Optional -- GitLab From a71ab68967c98b35c0f18ddccfb2d49340bb225f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 21 Mar 2025 14:01:50 +0100 Subject: [PATCH 027/281] Add missing disclaimers. Delete unused file --- src/edgecloud/clients/i2edge/utils.py | 1 - src/edgecloud/core/edgecloud_factory.py | 13 +++++++++++++ src/edgecloud/core/edgecloud_interface.py | 12 ++++++++++++ src/edgecloud/core/schemas.py | 1 - 4 files changed, 25 insertions(+), 2 deletions(-) delete mode 100644 src/edgecloud/core/schemas.py diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py index 4c34d52..9b6051b 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/edgecloud/clients/i2edge/utils.py @@ -11,7 +11,6 @@ # - Sergio Giménez (sergio.gimenez@i2cat.net) # - César Cajas (cesar.cajas@i2cat.net) ## - import uuid from typing import Optional, Union from uuid import UUID diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index e59cb2d..f39c732 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -1,3 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py index 97095c4..6470b89 100644 --- a/src/edgecloud/core/edgecloud_interface.py +++ b/src/edgecloud/core/edgecloud_interface.py @@ -1,3 +1,15 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +## from abc import ABC, abstractmethod from typing import List, Dict, Optional diff --git a/src/edgecloud/core/schemas.py b/src/edgecloud/core/schemas.py deleted file mode 100644 index f87f5c1..0000000 --- a/src/edgecloud/core/schemas.py +++ /dev/null @@ -1 +0,0 @@ -# TODO \ No newline at end of file -- GitLab From a6bba30994ff514c89e1d879c8f85d706ff90722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:24:58 +0100 Subject: [PATCH 028/281] Add logger file --- src/logger.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/logger.py diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..b47ef1e --- /dev/null +++ b/src/logger.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Federation SDK +# Unauthorized copying of this file, via any medium is strictly prohibited. +# +# Contributors: +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## +import logging +import sys + +from colorlog import ColoredFormatter + +APP_LOGGER_NAME = "edgecloud" +COLORED_FORMATERR = ( + "%(log_color)s%(levelname)s%(reset)s | " + "[%(log_color)s%(name)s%(reset)s:%(log_color)s%(lineno)d%(reset)s] " + "%(log_color)s%(message)s%(reset)s" +) +FILE_FORMATTER = "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message)s" + + +def setup_logger(logger_name=APP_LOGGER_NAME, is_debug=True, file_name=None): + + logger = logging.getLogger(logger_name) + logger.setLevel(logging.DEBUG if is_debug else logging.INFO) + + colored_formatter = ColoredFormatter(COLORED_FORMATERR) + file_formatter = logging.Formatter(FILE_FORMATTER) + + sh = logging.StreamHandler(sys.stdout) + sh.setFormatter(colored_formatter) + logger.handlers.clear() + logger.addHandler(sh) + + if file_name: + fh = logging.FileHandler(file_name) + fh.setFormatter(file_formatter) + logger.addHandler(fh) + + return logger + + +def get_logger(module_name): + return logging.getLogger(APP_LOGGER_NAME).getChild(module_name) -- GitLab From 737ca624999c6f1d232378a106c3b3495bc3403c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:26:22 +0100 Subject: [PATCH 029/281] Update format: isort --- src/edgecloud/core/edgecloud_factory.py | 13 +++++++++---- src/edgecloud/core/edgecloud_interface.py | 3 ++- tests/test_edgecloud_clients.py | 12 ++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index f39c732..0ab5aca 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -12,13 +12,18 @@ # - Sergio Giménez (sergio.gimenez@i2cat.net) ## from __future__ import annotations + from enum import Enum from typing import TYPE_CHECKING -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient -from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient +from src.edgecloud.clients.aeros.client import \ + EdgeApplicationManager as AerosClient +from src.edgecloud.clients.dmo.client import \ + EdgeApplicationManager as DmoClient +from src.edgecloud.clients.i2edge.client import \ + EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.piedge.client import \ + EdgeApplicationManager as PiEdgeClient if TYPE_CHECKING: from .edgecloud_interface import EdgeCloudInterface diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py index 6470b89..0128c99 100644 --- a/src/edgecloud/core/edgecloud_interface.py +++ b/src/edgecloud/core/edgecloud_interface.py @@ -11,7 +11,8 @@ # - Adrián Pino Martínez (adrian.pino@i2cat.net) ## from abc import ABC, abstractmethod -from typing import List, Dict, Optional +from typing import Dict, List, Optional + class EdgeCloudManagementInterface(ABC): """ diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index b2b015b..6f41034 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -1,10 +1,14 @@ import pytest +from src.edgecloud.clients.aeros.client import \ + EdgeApplicationManager as AerosClient +from src.edgecloud.clients.dmo.client import \ + EdgeApplicationManager as DmoClient +from src.edgecloud.clients.i2edge.client import \ + EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.piedge.client import \ + EdgeApplicationManager as PiEdgeClient from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient -from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient # Define common test cases for all tests test_cases = [ -- GitLab From cdfaa016fd67c20ce71a51e792491be076c570ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:27:21 +0100 Subject: [PATCH 030/281] Update i2edge client: isort & black --- src/edgecloud/clients/i2edge/client.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index c9803ff..af551ec 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -11,11 +11,15 @@ # - Adrián Pino Martínez (adrian.pino@i2cat.net) # - Sergio Giménez (sergio.gimenez@i2cat.net) ## -from typing import Optional -from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface from typing import Dict, List, Optional + +from src import logger +from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface + from . import schemas -from .common import I2EdgeError, i2edge_delete, i2edge_get, i2edge_post, i2edge_post_multiform_data +from .common import (I2EdgeError, i2edge_delete, i2edge_get, i2edge_post, + i2edge_post_multiform_data) + class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): @@ -23,16 +27,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> list[dict]: # Note: status is not supported by i2Edge; won't be used + # Up to now; region == av_zone (so if region is specified, that zone will be returned) try: params = {} if region is not None: - # Use the /zone/{region} endpoint url = "{}/zone/{}".format(self.base_url, region) if status is not None: params['status'] = status response = i2edge_get(url, params=params) else: - # Use the /zones/list endpoint url = "{}/zones/list".format(self.base_url) if status is not None: params['status'] = status -- GitLab From d585501ee450adce08ba16f47957024a4d0aeaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:27:51 +0100 Subject: [PATCH 031/281] Update wrong paths for i2edge's utils --- src/edgecloud/clients/i2edge/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py index 9b6051b..f0d9661 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/edgecloud/clients/i2edge/utils.py @@ -15,9 +15,9 @@ import uuid from typing import Optional, Union from uuid import UUID -from edgecloud import logger -from edgecloud.api.routers.lcm.schemas import RequiredResources -from edgecloud.core import utils as core_utils +from src.edgecloud import logger +from src.edgecloud.api.routers.lcm.schemas import RequiredResources +from src.edgecloud.core import utils as core_utils from .client import I2EdgeClient from .common import I2EdgeError -- GitLab From a79238e618c42bfe78b2eedde6a3fc88e63faf0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:32:13 +0100 Subject: [PATCH 032/281] Add artifact mgmt-related endpoints --- src/edgecloud/clients/i2edge/client.py | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index af551ec..7f4e06b 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -45,9 +45,54 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e + def _create_artefact( + self, + artefact_id: str, + artefact_name: str, + repo_name: str, + repo_type: str, + repo_url: str, + password: Optional[str] = None, + token: Optional[str] = None, + user_name: Optional[str] = None, + ): + repo_type = schemas.RepoType(repo_type) + url = "{}/artefact".format(self.base_url) + payload = schemas.ArtefactOnboarding( + artefact_id=artefact_id, + name=artefact_name, + repo_password=password, + repo_name=repo_name, + repo_type=repo_type, + repo_url=repo_url, + repo_token=token, + repo_user_name=user_name, + ) try: - response = i2edge_get(url, params=None) + i2edge_post_multiform_data(url, payload) + except I2EdgeError as e: + raise e + + def _get_artefact(self, artefact_id: str) -> Dict: + url = "{}/artefact/{}".format(self.base_url, artefact_id) + try: + response = i2edge_get(url, artefact_id) + return response + except I2EdgeError as e: + raise e + + def _get_all_artefacts(self) -> List[Dict]: + url = "{}/artefact".format(self.base_url) + try: + response = i2edge_get(url, {}) return response + except I2EdgeError as e: + raise + + def _delete_artefact(self, artefact_id: str): + url = "{}/artefact".format(self.base_url) + try: + i2edge_delete(url, artefact_id) except I2EdgeError as e: raise e -- GitLab From cb60c4f7a065e17130a765033f883701a806e3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 13:36:05 +0100 Subject: [PATCH 033/281] Update copyright message --- src/edgecloud/clients/i2edge/client.py | 3 +-- src/edgecloud/clients/i2edge/common.py | 3 +-- src/edgecloud/core/edgecloud_factory.py | 3 +-- src/edgecloud/core/edgecloud_interface.py | 3 +-- src/logger.py | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 7f4e06b..e0f9150 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) diff --git a/src/edgecloud/clients/i2edge/common.py b/src/edgecloud/clients/i2edge/common.py index 2f65cba..8f1a1fc 100644 --- a/src/edgecloud/clients/i2edge/common.py +++ b/src/edgecloud/clients/i2edge/common.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index 0ab5aca..2f893a9 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py index 0128c99..2375575 100644 --- a/src/edgecloud/core/edgecloud_interface.py +++ b/src/edgecloud/core/edgecloud_interface.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) diff --git a/src/logger.py b/src/logger.py index b47ef1e..4fb7825 100644 --- a/src/logger.py +++ b/src/logger.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) -- GitLab From 4e6ad1e37149d006993f193036f1b52705340bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 16:30:52 +0100 Subject: [PATCH 034/281] Satisfy isort & black linters --- src/edgecloud/clients/i2edge/client.py | 19 ++++++++---- src/edgecloud/clients/i2edge/schemas.py | 7 +++-- src/edgecloud/clients/i2edge/utils.py | 7 +++-- src/edgecloud/core/edgecloud_factory.py | 24 +++++++-------- src/edgecloud/core/edgecloud_interface.py | 25 ++++++++++------ tests/test_edgecloud_clients.py | 36 ++++++++++++++--------- 6 files changed, 70 insertions(+), 48 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index e0f9150..79fa668 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -16,15 +16,22 @@ from src import logger from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface from . import schemas -from .common import (I2EdgeError, i2edge_delete, i2edge_get, i2edge_post, - i2edge_post_multiform_data) +from .common import ( + I2EdgeError, + i2edge_delete, + i2edge_get, + i2edge_post, + i2edge_post_multiform_data, +) class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): self.base_url = base_url - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> list[dict]: + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> list[dict]: # Note: status is not supported by i2Edge; won't be used # Up to now; region == av_zone (so if region is specified, that zone will be returned) try: @@ -32,14 +39,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if region is not None: url = "{}/zone/{}".format(self.base_url, region) if status is not None: - params['status'] = status + params["status"] = status response = i2edge_get(url, params=params) else: url = "{}/zones/list".format(self.base_url) if status is not None: - params['status'] = status + params["status"] = status response = i2edge_get(url, params=params) - + return response except I2EdgeError as e: raise e diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/edgecloud/clients/i2edge/schemas.py index 33e1abc..ed1a15d 100644 --- a/src/edgecloud/clients/i2edge/schemas.py +++ b/src/edgecloud/clients/i2edge/schemas.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) @@ -108,7 +107,9 @@ class ApplicationOnboardingRequest(BaseModel): class GPU(BaseModel): gpuMemory: int = Field(default=0, description="GPU memory in MB") gpuModeName: str = Field(default="", description="GPU mode name") - gpuVendorType: str = Field(default="GPU_PROVIDER_NVIDIA", description="GPU vendor type") + gpuVendorType: str = Field( + default="GPU_PROVIDER_NVIDIA", description="GPU vendor type" + ) numGPU: int = Field(..., description="Number of GPUs") diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py index f0d9661..bee5e94 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/edgecloud/clients/i2edge/utils.py @@ -4,8 +4,7 @@ # Copyright 2025-present by Software Networks Area, i2CAT. # All rights reserved. # -# This file is part of the Federation SDK -# Unauthorized copying of this file, via any medium is strictly prohibited. +# This file is part of the Open SDK # # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) @@ -101,7 +100,9 @@ def onboard_app_with( raise e -def delete_app_instance_by(namespace: str, flavour_id: str, zone_id: str, i2edge: I2EdgeClient): +def delete_app_instance_by( + namespace: str, flavour_id: str, zone_id: str, i2edge: I2EdgeClient +): i2edge_app_instance_name = get_app_name_from(namespace, i2edge) if i2edge_app_instance_name is None: err_msg = "Couldn't retrieve app instance from I2Edge." diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index 2f893a9..36e733d 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -12,21 +12,17 @@ ## from __future__ import annotations -from enum import Enum from typing import TYPE_CHECKING -from src.edgecloud.clients.aeros.client import \ - EdgeApplicationManager as AerosClient -from src.edgecloud.clients.dmo.client import \ - EdgeApplicationManager as DmoClient -from src.edgecloud.clients.i2edge.client import \ - EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.piedge.client import \ - EdgeApplicationManager as PiEdgeClient +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient if TYPE_CHECKING: from .edgecloud_interface import EdgeCloudInterface + class EdgeCloudFactory: """ Factory class for creating EdgeCloud Clients @@ -43,19 +39,21 @@ class EdgeCloudFactory: f"Invalid edgecloud client name: '{client_name}'. " f"Supported clients are: {', '.join(supported_clients)}" ) - -class EdgeCloudTypes(): + + +class EdgeCloudTypes: """ Class dedicated for the different types of edgecloud clients. """ + I2EDGE = "i2edge" AEROS = "aeros" DMO = "dmo" - PIEDGE="piedge" + PIEDGE = "piedge" edgecloud_types = { I2EDGE: lambda url: I2EdgeClient(base_url=url), AEROS: lambda url: AerosClient(base_url=url), DMO: lambda url: DmoClient(base_url=url), - PIEDGE: lambda url: PiEdgeClient(base_url=url) + PIEDGE: lambda url: PiEdgeClient(base_url=url), } diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py index 2375575..5731535 100644 --- a/src/edgecloud/core/edgecloud_interface.py +++ b/src/edgecloud/core/edgecloud_interface.py @@ -21,8 +21,9 @@ class EdgeCloudManagementInterface(ABC): @abstractmethod def onboard_app(self, app_manifest: Dict) -> Dict: """ - Onboards an app, submitting application metadata to the Edge Cloud Provider. - + Onboards an app, submitting application metadata + to the Edge Cloud Provider. + :param app_manifest: Application metadata in dictionary format. :return: Dictionary containing created application details. """ @@ -32,7 +33,7 @@ class EdgeCloudManagementInterface(ABC): def get_all_onboarded_apps(self) -> List[Dict]: """ Retrieves a list of onboarded applications. - + :return: List of application metadata dictionaries. """ pass @@ -41,7 +42,7 @@ class EdgeCloudManagementInterface(ABC): def get_onboarded_app(self, app_id: str) -> Dict: """ Retrieves information of a specific onboarded application. - + :param app_id: Unique identifier of the application. :return: Dictionary with application details. """ @@ -51,7 +52,7 @@ class EdgeCloudManagementInterface(ABC): def delete_onboarded_app(self, app_id: str) -> None: """ Deletes an application onboarded from the Edge Cloud Provider. - + :param app_id: Unique identifier of the application. """ pass @@ -60,16 +61,20 @@ class EdgeCloudManagementInterface(ABC): def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: """ Requests the instantiation of an application instance. - + :param app_id: Unique identifier of the application. - :param app_zones: List of Edge Cloud Zones where the app should be instantiated. + :param app_zones: List of Edge Cloud Zones where the app should be + instantiated. :return: Dictionary with instance details. """ pass @abstractmethod def get_all_deployed_apps( - self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, ) -> List[Dict]: """ Retrieves information of application instances. @@ -91,7 +96,9 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> List[Dict]: """ Retrieves a list of available Edge Cloud Zones. diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 6f41034..5af4eff 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -1,13 +1,9 @@ import pytest -from src.edgecloud.clients.aeros.client import \ - EdgeApplicationManager as AerosClient -from src.edgecloud.clients.dmo.client import \ - EdgeApplicationManager as DmoClient -from src.edgecloud.clients.i2edge.client import \ - EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.piedge.client import \ - EdgeApplicationManager as PiEdgeClient +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory # Define common test cases for all tests @@ -50,20 +46,32 @@ def test_get_edge_cloud_zones(client_name, base_url): # Case 1: status & region (which are optional) not specified zones = edgecloud_platform.get_edge_cloud_zones() - assert isinstance(zones, list), f"Expected a list of zones for {client_name}, but got {type(zones)}" + assert isinstance( + zones, list + ), f"Expected a list of zones for {client_name}, but got {type(zones)}" if zones: # Check content if the list is not empty - assert all(isinstance(zone, dict) for zone in zones), "Each zone should be a dictionary" + assert all( + isinstance(zone, dict) for zone in zones + ), "Each zone should be a dictionary" # Case 2: region specified zones = edgecloud_platform.get_edge_cloud_zones(region="Omega") - assert isinstance(zones, dict), f"Expected a dict for {client_name} when region is specified, but got {type(zones)}" + assert isinstance( + zones, dict + ), f"Expected a dict for {client_name} when region is specified, but got {type(zones)}" # Case 3: status specified zones = edgecloud_platform.get_edge_cloud_zones(status="active") - assert isinstance(zones, list), f"Expected a list of zones for {client_name}, but got {type(zones)}" + assert isinstance( + zones, list + ), f"Expected a list of zones for {client_name}, but got {type(zones)}" if zones: # Check content if the list is not empty - assert all(isinstance(zone, dict) for zone in zones), "Each zone should be a dictionary" + assert all( + isinstance(zone, dict) for zone in zones + ), "Each zone should be a dictionary" # Case 4: status & region specified zones = edgecloud_platform.get_edge_cloud_zones(region="Omega", status="active") - assert isinstance(zones, dict), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" + assert isinstance( + zones, dict + ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" -- GitLab From 1b0ad0b010baecc65c4daf8b450ecf9655e3ee33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 16:42:43 +0100 Subject: [PATCH 035/281] Fix format - satisfy linters (black, isort, flake8) --- src/edgecloud/clients/i2edge/common.py | 21 ++++++++++++++++----- src/edgecloud/clients/i2edge/schemas.py | 20 +++++++++++++++----- src/edgecloud/clients/i2edge/utils.py | 12 +++++++++--- src/edgecloud/clients/piedge/client.py | 1 + src/edgecloud/core/edgecloud_factory.py | 4 +++- src/logger.py | 4 +++- tests/test_edgecloud_clients.py | 14 ++++++++++---- 7 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/edgecloud/clients/i2edge/common.py b/src/edgecloud/clients/i2edge/common.py index 8f1a1fc..c526977 100644 --- a/src/edgecloud/clients/i2edge/common.py +++ b/src/edgecloud/clients/i2edge/common.py @@ -39,7 +39,10 @@ def get_error_message_from(response: requests.Response) -> str: def i2edge_post(url: str, model_payload: BaseModel) -> dict: - headers = {"Content-Type": "application/json", "accept": "application/json"} + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } json_payload = json.dumps(model_payload.model_dump(mode="json")) try: response = requests.post(url, data=json_payload, headers=headers) @@ -47,7 +50,9 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) + err_msg = "Failed to deploy app: {}. Detail: {}".format( + i2edge_err_msg, e + ) log.error(err_msg) raise I2EdgeError(err_msg) @@ -64,7 +69,9 @@ def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) + err_msg = "Failed to deploy app: {}. Detail: {}".format( + i2edge_err_msg, e + ) log.error(err_msg) raise I2EdgeError(err_msg) @@ -78,7 +85,9 @@ def i2edge_delete(url: str, id: str) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to undeploy app: {}. Detail: {}".format(i2edge_err_msg, e) + err_msg = "Failed to undeploy app: {}. Detail: {}".format( + i2edge_err_msg, e + ) log.error(err_msg) raise I2EdgeError(err_msg) @@ -91,6 +100,8 @@ def i2edge_get(url: str, params: Optional[dict]): return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to get apps: {}. Detail: {}".format(i2edge_err_msg, e) + err_msg = "Failed to get apps: {}. Detail: {}".format( + i2edge_err_msg, e + ) log.error(err_msg) raise I2EdgeError(err_msg) diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/edgecloud/clients/i2edge/schemas.py index ed1a15d..2112ede 100644 --- a/src/edgecloud/clients/i2edge/schemas.py +++ b/src/edgecloud/clients/i2edge/schemas.py @@ -121,19 +121,27 @@ class Hugepages(BaseModel): class SupportedOSTypes(BaseModel): architecture: str = Field(default="x86_64", description="OS architecture") distribution: str = Field(default="RHEL", description="OS distribution") - license: str = Field(default="OS_LICENSE_TYPE_FREE", description="OS license type") - version: str = Field(default="OS_VERSION_UBUNTU_2204_LTS", description="OS version") + license: str = Field( + default="OS_LICENSE_TYPE_FREE", description="OS license type" + ) + version: str = Field( + default="OS_VERSION_UBUNTU_2204_LTS", description="OS version" + ) class FlavourSupported(BaseModel): - cpuArchType: str = Field(default="ISA_X86", description="CPU architecture type") + cpuArchType: str = Field( + default="ISA_X86", description="CPU architecture type" + ) cpuExclusivity: bool = Field(default=True, description="CPU exclusivity") fpga: int = Field(default=0, description="Number of FPGAs") gpu: Optional[List[GPU]] = Field(default=None, description="List of GPUs") hugepages: List[Hugepages] = Field( default_factory=lambda: [Hugepages()], description="List of hugepages" ) - memorySize: str = Field(..., description="Memory size (e.g., '1024MB' or '2GB')") + memorySize: str = Field( + ..., description="Memory size (e.g., '1024MB' or '2GB')" + ) numCPU: int = Field(..., description="Number of CPUs") storageSize: int = Field(default=0, description="Storage size in GB") supportedOSTypes: List[SupportedOSTypes] = Field( @@ -150,7 +158,9 @@ class FlavourSupported(BaseModel): try: int(v[:-2]) except ValueError: - raise ValueError("memorySize must be a number followed by MB or GB") + raise ValueError( + "memorySize must be a number followed by MB or GB" + ) return v diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py index bee5e94..e4af749 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/edgecloud/clients/i2edge/utils.py @@ -63,7 +63,9 @@ def instantiate_app_with( ) return flavour_id, application_k8s_namespace except I2EdgeError as e: - err_msg = "Error instantiating app {} in zone {}".format(camara_app_id, zone_id) + err_msg = "Error instantiating app {} in zone {}".format( + camara_app_id, zone_id + ) log.error("{}. Detailed error: {}".format(err_msg, e)) raise e @@ -93,7 +95,9 @@ def onboard_app_with( token=token, ) - i2edge.onboard_app(app_id=str(application_id), artefact_id=str(application_id)) + i2edge.onboard_app( + app_id=str(application_id), artefact_id=str(application_id) + ) except I2EdgeError as e: err_msg = "Error onboarding app {} in i2edge".format(app_name) log.error("{}. Detailed error: {}".format(err_msg, e)) @@ -112,7 +116,9 @@ def delete_app_instance_by( i2edge.delete_flavour(flavour_id=str(flavour_id), zone_id=zone_id) -def get_app_name_from(namespace: str, i2edge: I2EdgeClient) -> Union[str, None]: +def get_app_name_from( + namespace: str, i2edge: I2EdgeClient +) -> Union[str, None]: try: response = i2edge.get_all_deployed_apps() for deployment in response: diff --git a/src/edgecloud/clients/piedge/client.py b/src/edgecloud/clients/piedge/client.py index 985ca54..f8af39a 100644 --- a/src/edgecloud/clients/piedge/client.py +++ b/src/edgecloud/clients/piedge/client.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface + class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): self.base_url = base_url diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index 36e733d..69991c0 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -29,7 +29,9 @@ class EdgeCloudFactory: """ @staticmethod - def create_edgecloud_client(client_name: str, base_url: str) -> EdgeCloudInterface: + def create_edgecloud_client( + client_name: str, base_url: str + ) -> EdgeCloudInterface: try: return EdgeCloudTypes.edgecloud_types[client_name](base_url) except KeyError: diff --git a/src/logger.py b/src/logger.py index 4fb7825..1a8b07d 100644 --- a/src/logger.py +++ b/src/logger.py @@ -20,7 +20,9 @@ COLORED_FORMATERR = ( "[%(log_color)s%(name)s%(reset)s:%(log_color)s%(lineno)d%(reset)s] " "%(log_color)s%(message)s%(reset)s" ) -FILE_FORMATTER = "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message)s" +FILE_FORMATTER = ( + "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message)s" +) def setup_logger(logger_name=APP_LOGGER_NAME, is_debug=True, file_name=None): diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 5af4eff..9ee03e4 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -32,7 +32,9 @@ def test_factory_edgecloud(client_name, base_url): } expected_client_class = client_class_map[client_name] - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) assert isinstance(edgecloud_platform, expected_client_class) @@ -42,7 +44,9 @@ def test_get_edge_cloud_zones(client_name, base_url): Test the format of the response from get_edge_cloud_zones for each client. """ # Create the edgecloud client - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) # Case 1: status & region (which are optional) not specified zones = edgecloud_platform.get_edge_cloud_zones() @@ -71,7 +75,9 @@ def test_get_edge_cloud_zones(client_name, base_url): ), "Each zone should be a dictionary" # Case 4: status & region specified - zones = edgecloud_platform.get_edge_cloud_zones(region="Omega", status="active") + zones = edgecloud_platform.get_edge_cloud_zones( + region="Omega", status="active" + ) assert isinstance( zones, dict - ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" + ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" \ No newline at end of file -- GitLab From 766f9b5ec0de4679f64581e5ac41b2ba733beb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 16:56:00 +0100 Subject: [PATCH 036/281] Add logger into i2edge methods --- src/edgecloud/clients/i2edge/client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 79fa668..9303c3e 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -24,6 +24,8 @@ from .common import ( i2edge_post_multiform_data, ) +log = logger.get_logger(__name__) + class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): @@ -33,7 +35,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self, region: Optional[str] = None, status: Optional[str] = None ) -> list[dict]: # Note: status is not supported by i2Edge; won't be used - # Up to now; region == av_zone (so if region is specified, that zone will be returned) + # XXX Currently coded: region == av_zone. Is this correct? try: params = {} if region is not None: @@ -46,7 +48,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if status is not None: params["status"] = status response = i2edge_get(url, params=params) - + log.info("Availability zones retrieved successfully") return response except I2EdgeError as e: raise e @@ -76,6 +78,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) try: i2edge_post_multiform_data(url, payload) + log.info("Artifact added successfully") except I2EdgeError as e: raise e @@ -83,6 +86,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/artefact/{}".format(self.base_url, artefact_id) try: response = i2edge_get(url, artefact_id) + log.info("Artifact retrieved successfully") return response except I2EdgeError as e: raise e @@ -91,14 +95,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/artefact".format(self.base_url) try: response = i2edge_get(url, {}) + log.info("Artifacts retrieved successfully") return response except I2EdgeError as e: - raise + raise e def _delete_artefact(self, artefact_id: str): url = "{}/artefact".format(self.base_url) try: i2edge_delete(url, artefact_id) + log.info("Artifact deleted successfully") except I2EdgeError as e: raise e -- GitLab From a9ebb43c4ebfb35918c1d73bd2e0b8eb657db344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 16:56:20 +0100 Subject: [PATCH 037/281] Add flake8 config file --- .flake8 | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..1d36346 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 \ No newline at end of file -- GitLab From fd12cb8b502897622f2d5a158bc06b924467f861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 26 Mar 2025 17:28:01 +0100 Subject: [PATCH 038/281] Improve tests readability --- tests/test_edgecloud_clients.py | 35 ++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 9ee03e4..62285ab 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -14,10 +14,10 @@ test_cases = [ # ("dmo", "http://dmo.example.com/") ] -# Add an invalid client test case -invalid_test_case = [("invalid_client", "http://invalid.url/")] - +####################################### +# EDGECLOUD CLIENT'S INSTANTIATION +####################################### @pytest.mark.parametrize("client_name, base_url", test_cases) def test_factory_edgecloud(client_name, base_url): """ @@ -38,6 +38,9 @@ def test_factory_edgecloud(client_name, base_url): assert isinstance(edgecloud_platform, expected_client_class) +####################################### +# GET EDGE CLOUD ZONES +####################################### @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_edge_cloud_zones(client_name, base_url): """ @@ -62,7 +65,12 @@ def test_get_edge_cloud_zones(client_name, base_url): zones = edgecloud_platform.get_edge_cloud_zones(region="Omega") assert isinstance( zones, dict - ), f"Expected a dict for {client_name} when region is specified, but got {type(zones)}" + ), ( + ( + f"Expected a dict for {client_name} when region is specified, " + f"but got {type(zones)}" + ) + ) # Case 3: status specified zones = edgecloud_platform.get_edge_cloud_zones(status="active") @@ -80,4 +88,21 @@ def test_get_edge_cloud_zones(client_name, base_url): ) assert isinstance( zones, dict - ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" \ No newline at end of file + ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" + + +####################################### +# ARTIFACT MANAGEMENT (only for i2Edge) +####################################### + + +####################################### +# APP ONBOARDING +####################################### +# TODO + + +####################################### +# APP MANAGEMENT +####################################### +# TODO -- GitLab From bf7850b46893f1c7588e83168b2b9f4a8025e6c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 11:49:29 +0100 Subject: [PATCH 039/281] Add tests for artifact mgmt --- tests/test_edgecloud_clients.py | 65 ++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 62285ab..7dabdcb 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -3,6 +3,7 @@ import pytest from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.i2edge.client import I2EdgeError from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory @@ -88,12 +89,74 @@ def test_get_edge_cloud_zones(client_name, base_url): ) assert isinstance( zones, dict - ), f"Expected a dict for {client_name} when region & status is specified, but got {type(zones)}" + ), ( + f"Expected a dict for {client_name} when region & status is specified, " + f"but got {type(zones)}" + ) ####################################### # ARTIFACT MANAGEMENT (only for i2Edge) ####################################### +artefact_id = "hello-world-from-sdk" + + +# Test create artefact success +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_create_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._create_artefact( + artefact_id=artefact_id, + artefact_name="hello-world", + repo_name="dummy-repo", + repo_type="PUBLICREPO", + repo_url="https://helm.github.io/examples", + password=None, + token=None, + user_name=None + ) + except I2EdgeError as e: + pytest.fail(f"Artefact creation failed unexpectedly: {e}") + + +# Test create artefact failure +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_create_artefact_failure(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform._create_artefact( + artefact_id=artefact_id, + artefact_name="test-artefact", + repo_name="test-repo", + repo_type="PUBLICREPO", + repo_url="http://invalid.url", + password=None, + token=None, + user_name=None + ) + + +# Test artefact deletion success +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._delete_artefact(artefact_id=artefact_id) + except I2EdgeError as e: + pytest.fail(f"Artefact deletion failed unexpectedly: {e}") ####################################### -- GitLab From d00cff57140fc1c084fd35733c4dc9f392586d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 13:49:49 +0100 Subject: [PATCH 040/281] Add artefact tests --- tests/test_edgecloud_clients.py | 38 ++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 7dabdcb..5b4d0e1 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -101,7 +101,6 @@ def test_get_edge_cloud_zones(client_name, base_url): artefact_id = "hello-world-from-sdk" -# Test create artefact success @pytest.mark.parametrize("client_name, base_url", test_cases) def test_create_artefact_success(client_name, base_url): if client_name == "i2edge": @@ -124,7 +123,6 @@ def test_create_artefact_success(client_name, base_url): pytest.fail(f"Artefact creation failed unexpectedly: {e}") -# Test create artefact failure @pytest.mark.parametrize("client_name, base_url", test_cases) def test_create_artefact_failure(client_name, base_url): if client_name == "i2edge": @@ -145,7 +143,30 @@ def test_create_artefact_failure(client_name, base_url): ) -# Test artefact deletion success +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._get_artefact(artefact_id=artefact_id) + except I2EdgeError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_artefact_failure(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") + + @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_success(client_name, base_url): if client_name == "i2edge": @@ -159,6 +180,17 @@ def test_delete_artefact_success(client_name, base_url): pytest.fail(f"Artefact deletion failed unexpectedly: {e}") +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_artefact_failure(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") + + ####################################### # APP ONBOARDING ####################################### -- GitLab From 2b2187046fc7c870b2d7fddb3e015fdc81321cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 13:56:21 +0100 Subject: [PATCH 041/281] Update main.py --- src/main.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 04bd20b..4f7a6a3 100644 --- a/src/main.py +++ b/src/main.py @@ -1,14 +1,46 @@ +from src import logger from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +logger.setup_logger(is_debug=True, file_name="sdk.log") + + def create_edgecloud_client(client_name: str, base_url: str): """ Create and return an edgecloud client. Args: - client_name (str): The name of the client (e.g., "i2edge"). + client_name (str): Name of the edge cloud platform. Must be one of: + 'i2edge', 'aeros', 'dmo', 'piedge' base_url (str): The base URL for the client. Returns: The created edgecloud client. + + Example: + >>> client = create_edgecloud_client('i2edge', 'http://localhost:8080') """ return EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + + +# ########################################### +# # Temporal code - Testing purposes +# ########################################### +# if __name__ == "__main__": +# # Define the client name and base URL +# client_name = "i2edge" +# base_url = "http://192.168.123.237:30769/" + +# # Create the edgecloud client +# sbi = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + +# # Print the edgecloud client being used and its URL +# print(f"Using edgecloud client: {sbi}") +# print(f"URL: {sbi.base_url}") +# print("") + +# # Get all availability zones +# zones = sbi.get_edge_cloud_zones() +# print(zones) +# ########################################### +# # End of temporal code +# ########################################### -- GitLab From 7d6df3bc0e5a80130de2566c50643bfe84af6833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 14:06:27 +0100 Subject: [PATCH 042/281] Add app onboarding-related endpoints & tests --- src/edgecloud/clients/i2edge/client.py | 42 ++++++-- tests/test_edgecloud_clients.py | 134 ++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 9303c3e..153b35b 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -109,17 +109,45 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def onboard_app(self, app_manifest: Dict) -> Dict: - print(f"Submitting application: {app_manifest}") - return {"appId": "1234-5678"} + try: + app_id = app_manifest["appId"] + artefact_id = app_id + + app_component_spec = schemas.AppComponentSpec(artefactId=artefact_id) + data = schemas.ApplicationOnboardingData( + app_id=app_id, appComponentSpecs=[app_component_spec] + ) + payload = schemas.ApplicationOnboardingRequest(profile_data=data) + url = "{}/application/onboarding".format(self.base_url) + i2edge_post(url, payload) + except I2EdgeError as e: + raise e + except KeyError as e: + raise I2EdgeError("Missing required field in app_manifest: {}".format(e)) - def get_all_onboarded_apps(self) -> List[Dict]: - return [{"appId": "1234-5678", "name": "TestApp"}] + def delete_onboarded_app(self, app_id: str) -> None: + url = "{}/application/onboarding".format(self.base_url) + try: + i2edge_delete(url, app_id) + except I2EdgeError as e: + raise e def get_onboarded_app(self, app_id: str) -> Dict: - return {"appId": app_id, "name": "TestApp"} + url = "{}/application/onboarding/{}".format(self.base_url, app_id) + try: + response = i2edge_get(url, app_id) + return response + except I2EdgeError as e: + raise e - def delete_onboarded_app(self, app_id: str) -> None: - print(f"Deleting application: {app_id}") + def get_all_onboarded_apps(self) -> List[Dict]: + url = "{}/applications/onboarding".format(self.base_url) + params = {} + try: + response = i2edge_get(url, params) + return response + except I2EdgeError as e: + raise e def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: return {"appInstanceId": "abcd-efgh"} diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 5b4d0e1..cef0320 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -194,7 +194,139 @@ def test_delete_artefact_failure(client_name, base_url): ####################################### # APP ONBOARDING ####################################### -# TODO +# CAMARA app payload (only mandatory fields) +app_manifest = { + "appId": "test_app_from_SDK", + "name": "my-application", + "version": "1.0.0", + "appProvider": "MyAppProvider", + "packageType": "CONTAINER", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "https://example.com/my-app-image:1.0.0", + }, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 2048, + "topology": { + "minNumberOfNodes": 2, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + }, + "isStandalone": False, + "version": "1.29", + }, + "componentSpec": [ + { + "componentName": "my-component", + "networkInterfaces": [ + { + "interfaceId": "eth0", + "protocol": "TCP", + "port": 8080, + "visibilityType": "VISIBILITY_EXTERNAL", + } + ], + } + ], +} +# artefactId needs to be added; same ID as appId +app_manifest.update({"artefactId": app_manifest["appId"]}) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.onboard_app(app_manifest) + except I2EdgeError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform.onboard_app({}) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_failure_artefact_id_missing(client_name, base_url): + app_manifest.pop("artefactId") + + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform.onboard_app({}) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) + except I2EdgeError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_onboarded_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform.get_onboarded_app(app_id="non-existent-app") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_all_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.get_all_onboarded_apps() + except I2EdgeError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) + except I2EdgeError as e: + pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_onboarded_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(I2EdgeError): + edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") ####################################### -- GitLab From 2b4cafa3d42d422ef5e51050c4d6d1d2cbc44cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 14:28:44 +0100 Subject: [PATCH 043/281] Polish get_edge_cloud_zones --- src/edgecloud/clients/i2edge/client.py | 16 ++------ tests/test_edgecloud_clients.py | 51 ++++---------------------- 2 files changed, 11 insertions(+), 56 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 153b35b..47e52f2 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -34,20 +34,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None ) -> list[dict]: - # Note: status is not supported by i2Edge; won't be used - # XXX Currently coded: region == av_zone. Is this correct? + url = "{}/zones/list".format(self.base_url) + params = {} try: - params = {} - if region is not None: - url = "{}/zone/{}".format(self.base_url, region) - if status is not None: - params["status"] = status - response = i2edge_get(url, params=params) - else: - url = "{}/zones/list".format(self.base_url) - if status is not None: - params["status"] = status - response = i2edge_get(url, params=params) + response = i2edge_get(url, params=params) log.info("Availability zones retrieved successfully") return response except I2EdgeError as e: diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index cef0320..565fc49 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -24,7 +24,6 @@ def test_factory_edgecloud(client_name, base_url): """ Test the factory pattern for the edgecloud client. """ - # Map client names to their corresponding client classes client_class_map = { "i2edge": I2EdgeClient, "aeros": AerosClient, @@ -47,52 +46,18 @@ def test_get_edge_cloud_zones(client_name, base_url): """ Test the format of the response from get_edge_cloud_zones for each client. """ - # Create the edgecloud client edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - # Case 1: status & region (which are optional) not specified - zones = edgecloud_platform.get_edge_cloud_zones() - assert isinstance( - zones, list - ), f"Expected a list of zones for {client_name}, but got {type(zones)}" - if zones: # Check content if the list is not empty - assert all( - isinstance(zone, dict) for zone in zones - ), "Each zone should be a dictionary" - - # Case 2: region specified - zones = edgecloud_platform.get_edge_cloud_zones(region="Omega") - assert isinstance( - zones, dict - ), ( - ( - f"Expected a dict for {client_name} when region is specified, " - f"but got {type(zones)}" - ) - ) - - # Case 3: status specified - zones = edgecloud_platform.get_edge_cloud_zones(status="active") - assert isinstance( - zones, list - ), f"Expected a list of zones for {client_name}, but got {type(zones)}" - if zones: # Check content if the list is not empty - assert all( - isinstance(zone, dict) for zone in zones - ), "Each zone should be a dictionary" - - # Case 4: status & region specified - zones = edgecloud_platform.get_edge_cloud_zones( - region="Omega", status="active" - ) - assert isinstance( - zones, dict - ), ( - f"Expected a dict for {client_name} when region & status is specified, " - f"but got {type(zones)}" - ) + try: + zones = edgecloud_platform.get_edge_cloud_zones() + assert isinstance(zones, list) + for zone in zones: + assert "zoneId" in zone + assert "geographyDetails" in zone + except I2EdgeError as e: + pytest.fail(f"Failed to retrieve zones: {e}") ####################################### -- GitLab From cf5e9a83c3beafcf613a6f0a5e0efac1f1052e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 17:06:52 +0100 Subject: [PATCH 044/281] Update get_av_zones seq diagram to match the current code --- docs/workflows/edgecloud/get_av_zones.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/workflows/edgecloud/get_av_zones.md b/docs/workflows/edgecloud/get_av_zones.md index 68cd9f7..b33c24e 100644 --- a/docs/workflows/edgecloud/get_av_zones.md +++ b/docs/workflows/edgecloud/get_av_zones.md @@ -13,8 +13,10 @@ participant PiEdge note over AP,CE: CAMARA EdgeCloud API AP ->> CE: GET /edge-cloud-zones CE ->> API: GET /av. zones -API ->> SDK: sdk.i2edge.get_zones() +API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(i2Edge) +API ->> SDK: sbi.get_edge_cloud_zones() SDK ->> i2Edge: GET /zones/list -API ->> SDK: sdk.piedge.get_zones() +API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) +API ->> SDK: sbi.get_edge_cloud_zones() SDK ->> PiEdge: GET /nodes ``` \ No newline at end of file -- GitLab From 6bf1e312ea99d269728bc88a8e1a7a417a7e798d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 17:08:29 +0100 Subject: [PATCH 045/281] Add test for get_all_artefacts --- tests/test_edgecloud_clients.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_edgecloud_clients.py b/tests/test_edgecloud_clients.py index 565fc49..8fd5b9c 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/test_edgecloud_clients.py @@ -132,6 +132,18 @@ def test_get_artefact_failure(client_name, base_url): edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_all_artefacts_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._get_all_artefacts() + except I2EdgeError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_success(client_name, base_url): if client_name == "i2edge": -- GitLab From e3e64f271ed7d2c01ae2eb863d5d9ade0f225fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 17:17:37 +0100 Subject: [PATCH 046/281] Update gitignore; Add blank line at the end --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 39b1186..85e2f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ .pyc __pycache__/ tmp/ -.vscode/ \ No newline at end of file +.vscode/ -- GitLab From 19dbc981fb938e2b240a3ea3c40f5b5f6a1a0eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 27 Mar 2025 17:43:19 +0100 Subject: [PATCH 047/281] Update readme; add contribution guidelines --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52a8661..39fdb72 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,70 @@ -# SUNRISE-6G Open-SDK +# OpenSDK - Contribution Guidelines -WIP +Thank you for contributing to this project! Please follow the guidelines below to ensure a smooth collaboration. + +## Branch Naming Convention +Each partner should create a feature branch following the naming convention based on the type of adapter they are contributing: + + +### ☁️ EdgeCloud Adapters +Branch Name Format: +``` +feature/add-edgecloud- +``` +Example: +``` +feature/add-edgecloud-i2edge +``` + +### 🌐 Network Adapters +Branch Name Format: +``` +feature/add-network-<5G_CORE_NAME> +``` +Example: +``` +feature/add-network-open5gs +``` + +## Directory Structure +Each contribution should be made in the appropriate directory: +- **EdgeCloud Adapters** → `src/edgecloud/clients/` +- **Network Adapters** → `src/network/clients/` + +## Unit Tests Requirement +To merge a feature branch into `main`, the adapter **must include unit tests** under the `/tests` directory. +Ensure that your unit tests cover the main functionalities of the adapter. + +## Steps to Contribute +1. **Fork the Repository** (if applicable). +2. **Create a New Branch** following the naming convention. +3. **Develop Your Feature** inside the correct directory. +4. **Write Unit Tests** under `/tests`. +5. **Submit a Merge Request (MR)** to the `main` branch. +6. **Ensure All Tests Pass** before the merge. + +## Sequence Diagram Example +Refer to the sequence diagram example from `docs/workflows/edgecloud/get_av_zones.md` for guidance on workflow structure: + +```mermaid +sequenceDiagram +title Retrieve Edge Cloud Zones +actor AP as App Vertical Provider +participant CE as Capabilities Exposure +box Service Resource Manager + participant API + participant SDK as EdgeCloudSDK +end +participant i2Edge +participant PiEdge + +note over AP,CE: CAMARA EdgeCloud API +AP ->> CE: GET /edge-cloud-zones +CE ->> API: GET /av. zones +API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(i2Edge) +API ->> SDK: sbi.get_edge_cloud_zones() +SDK ->> i2Edge: GET /zones/list +API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) +API ->> SDK: sbi.get_edge_cloud_zones() +SDK ->> PiEdge: GET /nodes +``` -- GitLab From 60fad7908d7e6a305ddc5fc9e91ff657a1fd00e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 28 Mar 2025 10:35:59 +0100 Subject: [PATCH 048/281] Move i2edge test into /tests/edgecloud --- tests/edgecloud/__init__.py | 0 .../test_i2edge.py} | 12 +++++++----- tests/network/__init__.py | 0 3 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 tests/edgecloud/__init__.py rename tests/{test_edgecloud_clients.py => edgecloud/test_i2edge.py} (97%) create mode 100644 tests/network/__init__.py diff --git a/tests/edgecloud/__init__.py b/tests/edgecloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_edgecloud_clients.py b/tests/edgecloud/test_i2edge.py similarity index 97% rename from tests/test_edgecloud_clients.py rename to tests/edgecloud/test_i2edge.py index 8fd5b9c..865d700 100644 --- a/tests/test_edgecloud_clients.py +++ b/tests/edgecloud/test_i2edge.py @@ -63,7 +63,9 @@ def test_get_edge_cloud_zones(client_name, base_url): ####################################### # ARTIFACT MANAGEMENT (only for i2Edge) ####################################### -artefact_id = "hello-world-from-sdk" +artefact_id = "hello-world-from-sdk-2" +artefact_name = "hello-word-2" +repo_name = "dummy-repo-2" @pytest.mark.parametrize("client_name, base_url", test_cases) @@ -76,8 +78,8 @@ def test_create_artefact_success(client_name, base_url): try: edgecloud_platform._create_artefact( artefact_id=artefact_id, - artefact_name="hello-world", - repo_name="dummy-repo", + artefact_name=artefact_name, + repo_name=repo_name, repo_type="PUBLICREPO", repo_url="https://helm.github.io/examples", password=None, @@ -98,8 +100,8 @@ def test_create_artefact_failure(client_name, base_url): with pytest.raises(I2EdgeError): edgecloud_platform._create_artefact( artefact_id=artefact_id, - artefact_name="test-artefact", - repo_name="test-repo", + artefact_name=artefact_name, + repo_name=repo_name, repo_type="PUBLICREPO", repo_url="http://invalid.url", password=None, diff --git a/tests/network/__init__.py b/tests/network/__init__.py new file mode 100644 index 0000000..e69de29 -- GitLab From e1c13c989646b2aab6628c09e503cea44626e1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 28 Mar 2025 10:41:43 +0100 Subject: [PATCH 049/281] Update readme. Create placeholder test files --- README.md | 2 +- tests/edgecloud/test_aerOS.py | 0 tests/edgecloud/test_dmo.py | 0 tests/edgecloud/test_piedge.py | 0 tests/network/test_oai.py | 0 tests/network/test_open5gcore.py | 0 tests/network/test_open5gs.py | 0 7 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/edgecloud/test_aerOS.py create mode 100644 tests/edgecloud/test_dmo.py create mode 100644 tests/edgecloud/test_piedge.py create mode 100644 tests/network/test_oai.py create mode 100644 tests/network/test_open5gcore.py create mode 100644 tests/network/test_open5gs.py diff --git a/README.md b/README.md index 39fdb72..81fe24b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Each contribution should be made in the appropriate directory: - **Network Adapters** → `src/network/clients/` ## Unit Tests Requirement -To merge a feature branch into `main`, the adapter **must include unit tests** under the `/tests` directory. +To merge a feature branch into `main`, the adapter **must include unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores) Ensure that your unit tests cover the main functionalities of the adapter. ## Steps to Contribute diff --git a/tests/edgecloud/test_aerOS.py b/tests/edgecloud/test_aerOS.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/edgecloud/test_dmo.py b/tests/edgecloud/test_dmo.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/edgecloud/test_piedge.py b/tests/edgecloud/test_piedge.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/network/test_oai.py b/tests/network/test_oai.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/network/test_open5gcore.py b/tests/network/test_open5gcore.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/network/test_open5gs.py b/tests/network/test_open5gs.py new file mode 100644 index 0000000..e69de29 -- GitLab From 4b0454dc05d7d0f0013b5bb9af3c72b810688005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 28 Mar 2025 10:44:17 +0100 Subject: [PATCH 050/281] Polish i2edge test file --- tests/edgecloud/test_i2edge.py | 115 ++++++++++++++------------------- 1 file changed, 50 insertions(+), 65 deletions(-) diff --git a/tests/edgecloud/test_i2edge.py b/tests/edgecloud/test_i2edge.py index 865d700..c9255ec 100644 --- a/tests/edgecloud/test_i2edge.py +++ b/tests/edgecloud/test_i2edge.py @@ -1,34 +1,24 @@ import pytest -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient from src.edgecloud.clients.i2edge.client import I2EdgeError -from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -# Define common test cases for all tests -test_cases = [ - ("i2edge", "http://192.168.123.237:30769/"), - # ("aeros", "http://aeros.example.com/"), - # ("piedge", "http://piedge.example.com/"), - # ("dmo", "http://dmo.example.com/") +i2edge_testbed = [ + ("i2edge", "http://192.168.123.237:30769/") ] ####################################### # EDGECLOUD CLIENT'S INSTANTIATION ####################################### -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_factory_edgecloud(client_name, base_url): """ Test the factory pattern for the edgecloud client. """ client_class_map = { "i2edge": I2EdgeClient, - "aeros": AerosClient, - "piedge": PiEdgeClient, - "dmo": DmoClient, } expected_client_class = client_class_map[client_name] @@ -41,7 +31,7 @@ def test_factory_edgecloud(client_name, base_url): ####################################### # GET EDGE CLOUD ZONES ####################################### -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_edge_cloud_zones(client_name, base_url): """ Test the format of the response from get_edge_cloud_zones for each client. @@ -68,7 +58,7 @@ artefact_name = "hello-word-2" repo_name = "dummy-repo-2" -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_create_artefact_success(client_name, base_url): if client_name == "i2edge": edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( @@ -90,7 +80,7 @@ def test_create_artefact_success(client_name, base_url): pytest.fail(f"Artefact creation failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_create_artefact_failure(client_name, base_url): if client_name == "i2edge": edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( @@ -110,64 +100,59 @@ def test_create_artefact_failure(client_name, base_url): ) -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) - try: - edgecloud_platform._get_artefact(artefact_id=artefact_id) - except I2EdgeError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + try: + edgecloud_platform._get_artefact(artefact_id=artefact_id) + except I2EdgeError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) - with pytest.raises(I2EdgeError): - edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") + with pytest.raises(I2EdgeError): + edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_all_artefacts_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) - try: - edgecloud_platform._get_all_artefacts() - except I2EdgeError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + try: + edgecloud_platform._get_all_artefacts() + except I2EdgeError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_delete_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) - try: - edgecloud_platform._delete_artefact(artefact_id=artefact_id) - except I2EdgeError as e: - pytest.fail(f"Artefact deletion failed unexpectedly: {e}") + try: + edgecloud_platform._delete_artefact(artefact_id=artefact_id) + except I2EdgeError as e: + pytest.fail(f"Artefact deletion failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_delete_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) - with pytest.raises(I2EdgeError): - edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") + with pytest.raises(I2EdgeError): + edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") ####################################### @@ -218,7 +203,7 @@ app_manifest = { app_manifest.update({"artefactId": app_manifest["appId"]}) -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_onboard_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -230,7 +215,7 @@ def test_onboard_app_success(client_name, base_url): pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_onboard_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -240,7 +225,7 @@ def test_onboard_app_failure(client_name, base_url): edgecloud_platform.onboard_app({}) -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_onboard_app_failure_artefact_id_missing(client_name, base_url): app_manifest.pop("artefactId") @@ -252,7 +237,7 @@ def test_onboard_app_failure_artefact_id_missing(client_name, base_url): edgecloud_platform.onboard_app({}) -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -264,7 +249,7 @@ def test_get_onboarded_app_success(client_name, base_url): pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_onboarded_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -274,7 +259,7 @@ def test_get_onboarded_app_failure(client_name, base_url): edgecloud_platform.get_onboarded_app(app_id="non-existent-app") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_get_all_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -286,7 +271,7 @@ def test_get_all_onboarded_app_success(client_name, base_url): pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_delete_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url @@ -298,7 +283,7 @@ def test_delete_onboarded_app_success(client_name, base_url): pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) +@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) def test_delete_onboarded_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url -- GitLab From 7d1d4a7aba80b2a8e2e1c379b62f397884613d8e Mon Sep 17 00:00:00 2001 From: Laskaratos Dimitris Date: Fri, 28 Mar 2025 12:41:10 +0200 Subject: [PATCH 051/281] Implemented pi-edge edge cloud functions --- src/edgecloud/clients/piedge/client.py | 73 ++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/src/edgecloud/clients/piedge/client.py b/src/edgecloud/clients/piedge/client.py index f8af39a..17d0dc9 100644 --- a/src/edgecloud/clients/piedge/client.py +++ b/src/edgecloud/clients/piedge/client.py @@ -1,33 +1,86 @@ # Mocked API for testing purposes from typing import Dict, List, Optional -from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +import os +import logging +import requests +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from swagger_server.utils import kubernetes_connector, connector_db +from swagger_server.models.service_function_registration_request import ServiceFunctionRegistrationRequest +from swagger_server.models.deploy_service_function import DeployServiceFunction +from swagger_server.core.piedge_encoder import deploy_service_function +piedge_ip = os.environ['EDGE_CLOUD_ADAPTER'] +edge_cloud_provider = os.environ['PLATFORM_PROVIDER'] class EdgeApplicationManager(EdgeCloudManagementInterface): - def __init__(self, base_url: str): - self.base_url = base_url - def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") - return {"appId": "1234-5678"} + logging.info('Extracting variables from payload...') + app_name = app_manifest.get('name') + image = app_manifest.get('appRepo').get('imagePath') + sf = ServiceFunctionRegistrationRequest(service_function_image=image, service_function_name=app_name) + return sf def get_all_onboarded_apps(self) -> List[Dict]: - return [{"appId": "1234-5678", "name": "TestApp"}] + logging.info('Retrieving all registered apps from database...') + app_list = connector_db.get_documents_from_collection(collection_input="service_functions") + return app_list + # return [{"appId": "1234-5678", "name": "TestApp"}] def get_onboarded_app(self, app_id: str) -> Dict: - return {"appId": app_id, "name": "TestApp"} + logging.info('Searching for registered app with ID: '+ app_id+' in database...') + app = connector_db.get_documents_from_collection("service_functions", input_type="_id", input_value=app_id) + return app def delete_onboarded_app(self, app_id: str) -> None: - print(f"Deleting application: {app_id}") + logging.info('Deleting registered app with ID: '+ app_id+' from database...') + result = connector_db.delete_document_service_function(app_id) + return result + # print(f"Deleting application: {app_id}") def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - return {"appInstanceId": "abcd-efgh"} + logging.info('Searching for registered app with ID: '+ app_id+' in database...') + app = connector_db.get_documents_from_collection("service_functions", input_type="_id", input_value=app_id) + success_response = [] + if app is not None: + for zone in app_zones: + sf = DeployServiceFunction(service_function_name=app.get('name'), + service_function_instance_name=app.get('name')+zone.get('edgeCloudZoneName'), + location=zone.get('edgeCloudZoneName')) + result = deploy_service_function(service_function=sf) + success_response.append(result) + # return {"appInstanceId": "abcd-efgh"} + return success_response def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: + logging.info('Retreiving all deployed apps in the edge cloud platform') + response = kubernetes_connector.get_deployed_service_functions() return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: + logging.info('Searching for deployed app with ID: '+ app_instance_id+' in database...') print(f"Deleting app instance: {app_instance_id}") + # deployed_service_function_name_=auxiliary_functions.prepare_name_for_k8s(deployed_service_function_name) + sfs=kubernetes_connector.get_deployed_service_functions() + response = 'App instance with ID ['+app_instance_id+'] not found' + for service_fun in sfs.items: + if service_fun["uid"]==app_instance_id: + response = kubernetes_connector.delete_service_function(service_fun['service_function_instance_name']) + return response + def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: - return [{"edgeCloudZoneId": "zone-1", "status": "active"}] + + nodes_response = kubernetes_connector.get_PoPs() + zone_list =[] + + for node in nodes_response.json().get('nodes'): + zone = {} + zone['edgeCloudZoneId'] = node.get('uid') + zone['edgeCloudZoneName'] = node.get('name') + zone['edgeCloudZoneStatus'] = node.get('status') + zone['edgeCloudProvider'] = edge_cloud_provider + zone['edgeCloudRegion'] = node.get('location') + zone_list.append(zone) + return zone_list + -- GitLab From b2a2b3ffecb1507ed0697444a173d670b6bf1244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 28 Mar 2025 11:56:31 +0100 Subject: [PATCH 052/281] Delete DMO-related files as it finally won't be implemented --- src/edgecloud/clients/dmo/__init__.py | 0 src/edgecloud/clients/dmo/client.py | 32 ------------------------- src/edgecloud/core/edgecloud_factory.py | 3 --- src/main.py | 2 +- tests/edgecloud/test_dmo.py | 0 5 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 src/edgecloud/clients/dmo/__init__.py delete mode 100644 src/edgecloud/clients/dmo/client.py delete mode 100644 tests/edgecloud/test_dmo.py diff --git a/src/edgecloud/clients/dmo/__init__.py b/src/edgecloud/clients/dmo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/edgecloud/clients/dmo/client.py b/src/edgecloud/clients/dmo/client.py deleted file mode 100644 index 985ca54..0000000 --- a/src/edgecloud/clients/dmo/client.py +++ /dev/null @@ -1,32 +0,0 @@ -# Mocked API for testing purposes -from typing import Dict, List, Optional -from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface - -class EdgeApplicationManager(EdgeCloudManagementInterface): - def __init__(self, base_url: str): - self.base_url = base_url - - def onboard_app(self, app_manifest: Dict) -> Dict: - print(f"Submitting application: {app_manifest}") - return {"appId": "1234-5678"} - - def get_all_onboarded_apps(self) -> List[Dict]: - return [{"appId": "1234-5678", "name": "TestApp"}] - - def get_onboarded_app(self, app_id: str) -> Dict: - return {"appId": app_id, "name": "TestApp"} - - def delete_onboarded_app(self, app_id: str) -> None: - print(f"Deleting application: {app_id}") - - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - return {"appInstanceId": "abcd-efgh"} - - def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: - return [{"appInstanceId": "abcd-efgh", "status": "ready"}] - - def undeploy_app(self, app_instance_id: str) -> None: - print(f"Deleting app instance: {app_instance_id}") - - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: - return [{"edgeCloudZoneId": "zone-1", "status": "active"}] diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index 69991c0..61aaf10 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -15,7 +15,6 @@ from __future__ import annotations from typing import TYPE_CHECKING from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.dmo.client import EdgeApplicationManager as DmoClient from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient @@ -50,12 +49,10 @@ class EdgeCloudTypes: I2EDGE = "i2edge" AEROS = "aeros" - DMO = "dmo" PIEDGE = "piedge" edgecloud_types = { I2EDGE: lambda url: I2EdgeClient(base_url=url), AEROS: lambda url: AerosClient(base_url=url), - DMO: lambda url: DmoClient(base_url=url), PIEDGE: lambda url: PiEdgeClient(base_url=url), } diff --git a/src/main.py b/src/main.py index 4f7a6a3..d87d7fe 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,7 @@ def create_edgecloud_client(client_name: str, base_url: str): Args: client_name (str): Name of the edge cloud platform. Must be one of: - 'i2edge', 'aeros', 'dmo', 'piedge' + 'i2edge', 'aeros', 'piedge' base_url (str): The base URL for the client. Returns: diff --git a/tests/edgecloud/test_dmo.py b/tests/edgecloud/test_dmo.py deleted file mode 100644 index e69de29..0000000 -- GitLab From 9a95a24d1a188ed94c8264d882e91f4995412b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 2 Apr 2025 13:58:10 +0200 Subject: [PATCH 053/281] Update i2edge error to inherit from EdgeCloudPlatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sergio Giménez Antón --- src/edgecloud/clients/errors.py | 3 +++ src/edgecloud/clients/i2edge/common.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 src/edgecloud/clients/errors.py diff --git a/src/edgecloud/clients/errors.py b/src/edgecloud/clients/errors.py new file mode 100644 index 0000000..34ef28b --- /dev/null +++ b/src/edgecloud/clients/errors.py @@ -0,0 +1,3 @@ + +class EdgeCloudPlatformError(Exception): + pass diff --git a/src/edgecloud/clients/i2edge/common.py b/src/edgecloud/clients/i2edge/common.py index c526977..8af9d31 100644 --- a/src/edgecloud/clients/i2edge/common.py +++ b/src/edgecloud/clients/i2edge/common.py @@ -13,6 +13,7 @@ import json from typing import Optional import requests +from src.edgecloud.clients.errors import EdgeCloudPlatformError from pydantic import BaseModel from src import logger @@ -20,7 +21,7 @@ from src import logger log = logger.get_logger(__name__) -class I2EdgeError(Exception): +class I2EdgeError(EdgeCloudPlatformError): pass -- GitLab From 30f80316463d8b74a9e9501eac1d528b7bd38a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 2 Apr 2025 13:59:04 +0200 Subject: [PATCH 054/281] Refactor: set common tests to all platforms. Add test_cases file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sergio Giménez Antón --- tests/edgecloud/test_aerOS.py | 0 tests/edgecloud/test_app_deployment.py | 1 + tests/edgecloud/test_app_onboarding.py | 126 +++++++++++ tests/edgecloud/test_artefact.py | 111 +++++++++ tests/edgecloud/test_av_zones.py | 24 ++ tests/edgecloud/test_cases.py | 5 + tests/edgecloud/test_factory.py | 25 +++ tests/edgecloud/test_i2edge.py | 299 ------------------------- tests/edgecloud/test_piedge.py | 0 9 files changed, 292 insertions(+), 299 deletions(-) delete mode 100644 tests/edgecloud/test_aerOS.py create mode 100644 tests/edgecloud/test_app_deployment.py create mode 100644 tests/edgecloud/test_app_onboarding.py create mode 100644 tests/edgecloud/test_artefact.py create mode 100644 tests/edgecloud/test_av_zones.py create mode 100644 tests/edgecloud/test_cases.py create mode 100644 tests/edgecloud/test_factory.py delete mode 100644 tests/edgecloud/test_i2edge.py delete mode 100644 tests/edgecloud/test_piedge.py diff --git a/tests/edgecloud/test_aerOS.py b/tests/edgecloud/test_aerOS.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/edgecloud/test_app_deployment.py b/tests/edgecloud/test_app_deployment.py new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/tests/edgecloud/test_app_deployment.py @@ -0,0 +1 @@ +# TODO diff --git a/tests/edgecloud/test_app_onboarding.py b/tests/edgecloud/test_app_onboarding.py new file mode 100644 index 0000000..4ac701e --- /dev/null +++ b/tests/edgecloud/test_app_onboarding.py @@ -0,0 +1,126 @@ +import pytest + +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from src.edgecloud.clients.errors import EdgeCloudPlatformError +from tests.edgecloud.test_cases import test_cases + +# CAMARA app payload (only mandatory fields) +app_manifest = { + "appId": "test_app_from_SDK", + "name": "my-application", + "version": "1.0.0", + "appProvider": "MyAppProvider", + "packageType": "CONTAINER", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "https://example.com/my-app-image:1.0.0", + }, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 2048, + "topology": { + "minNumberOfNodes": 2, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + }, + "isStandalone": False, + "version": "1.29", + }, + "componentSpec": [ + { + "componentName": "my-component", + "networkInterfaces": [ + { + "interfaceId": "eth0", + "protocol": "TCP", + "port": 8080, + "visibilityType": "VISIBILITY_EXTERNAL", + } + ], + } + ], +} + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.onboard_app(app_manifest) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform.onboard_app({}) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_onboarded_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform.get_onboarded_app(app_id="non-existent-app") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_all_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.get_all_onboarded_apps() + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_onboarded_app_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") + diff --git a/tests/edgecloud/test_artefact.py b/tests/edgecloud/test_artefact.py new file mode 100644 index 0000000..fc988da --- /dev/null +++ b/tests/edgecloud/test_artefact.py @@ -0,0 +1,111 @@ +import pytest + +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from src.edgecloud.clients.errors import EdgeCloudPlatformError + +# Note: artifact mgmt is only supported by i2Edge + +test_cases = [ + ("i2edge", "http://192.168.123.237:30769/"), +] + +artefact_id = "hello-world-from-sdk-2" +artefact_name = "hello-word-2" +repo_name = "dummy-repo-2" + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_create_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._create_artefact( + artefact_id=artefact_id, + artefact_name=artefact_name, + repo_name=repo_name, + repo_type="PUBLICREPO", + repo_url="https://helm.github.io/examples", + password=None, + token=None, + user_name=None + ) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact creation failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_create_artefact_failure(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform._create_artefact( + artefact_id=artefact_id, + artefact_name=artefact_name, + repo_name=repo_name, + repo_type="PUBLICREPO", + repo_url="http://invalid.url", + password=None, + token=None, + user_name=None + ) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_artefact_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._get_artefact(artefact_id=artefact_id) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_artefact_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_all_artefacts_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._get_all_artefacts() + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_artefact_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + edgecloud_platform._delete_artefact(artefact_id=artefact_id) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact deletion failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_artefact_failure(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") diff --git a/tests/edgecloud/test_av_zones.py b/tests/edgecloud/test_av_zones.py new file mode 100644 index 0000000..14fd453 --- /dev/null +++ b/tests/edgecloud/test_av_zones.py @@ -0,0 +1,24 @@ +import pytest + +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from src.edgecloud.clients.errors import EdgeCloudPlatformError +from tests.edgecloud.test_cases import test_cases + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_edge_cloud_zones(client_name, base_url): + """ + Test the format of the response from get_edge_cloud_zones for each client. + """ + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + + try: + zones = edgecloud_platform.get_edge_cloud_zones() + assert isinstance(zones, list) + for zone in zones: + assert "zoneId" in zone + assert "geographyDetails" in zone + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to retrieve zones: {e}") diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py new file mode 100644 index 0000000..ea1c93d --- /dev/null +++ b/tests/edgecloud/test_cases.py @@ -0,0 +1,5 @@ +test_cases = [ + ("i2edge", "http://192.168.123.237:30769/"), + # ("aeros", "http://aeros.example.com/"), + # ("piedge", "http://piedge.example.com/"), +] diff --git a/tests/edgecloud/test_factory.py b/tests/edgecloud/test_factory.py new file mode 100644 index 0000000..5a133f1 --- /dev/null +++ b/tests/edgecloud/test_factory.py @@ -0,0 +1,25 @@ +import pytest + +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from tests.edgecloud.test_cases import test_cases + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_factory_edgecloud(client_name, base_url): + """ + Test the factory pattern for the edgecloud client. + """ + client_class_map = { + "i2edge": I2EdgeClient, + "aeros": AerosClient, + "piedge": PiEdgeClient, + } + + expected_client_class = client_class_map[client_name] + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + assert isinstance(edgecloud_platform, expected_client_class) diff --git a/tests/edgecloud/test_i2edge.py b/tests/edgecloud/test_i2edge.py deleted file mode 100644 index c9255ec..0000000 --- a/tests/edgecloud/test_i2edge.py +++ /dev/null @@ -1,299 +0,0 @@ -import pytest - -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.i2edge.client import I2EdgeError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory - -i2edge_testbed = [ - ("i2edge", "http://192.168.123.237:30769/") -] - - -####################################### -# EDGECLOUD CLIENT'S INSTANTIATION -####################################### -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_factory_edgecloud(client_name, base_url): - """ - Test the factory pattern for the edgecloud client. - """ - client_class_map = { - "i2edge": I2EdgeClient, - } - - expected_client_class = client_class_map[client_name] - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - assert isinstance(edgecloud_platform, expected_client_class) - - -####################################### -# GET EDGE CLOUD ZONES -####################################### -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_edge_cloud_zones(client_name, base_url): - """ - Test the format of the response from get_edge_cloud_zones for each client. - """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - zones = edgecloud_platform.get_edge_cloud_zones() - assert isinstance(zones, list) - for zone in zones: - assert "zoneId" in zone - assert "geographyDetails" in zone - except I2EdgeError as e: - pytest.fail(f"Failed to retrieve zones: {e}") - - -####################################### -# ARTIFACT MANAGEMENT (only for i2Edge) -####################################### -artefact_id = "hello-world-from-sdk-2" -artefact_name = "hello-word-2" -repo_name = "dummy-repo-2" - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_create_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform._create_artefact( - artefact_id=artefact_id, - artefact_name=artefact_name, - repo_name=repo_name, - repo_type="PUBLICREPO", - repo_url="https://helm.github.io/examples", - password=None, - token=None, - user_name=None - ) - except I2EdgeError as e: - pytest.fail(f"Artefact creation failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_create_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform._create_artefact( - artefact_id=artefact_id, - artefact_name=artefact_name, - repo_name=repo_name, - repo_type="PUBLICREPO", - repo_url="http://invalid.url", - password=None, - token=None, - user_name=None - ) - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform._get_artefact(artefact_id=artefact_id) - except I2EdgeError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_all_artefacts_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform._get_all_artefacts() - except I2EdgeError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_delete_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform._delete_artefact(artefact_id=artefact_id) - except I2EdgeError as e: - pytest.fail(f"Artefact deletion failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_delete_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") - - -####################################### -# APP ONBOARDING -####################################### -# CAMARA app payload (only mandatory fields) -app_manifest = { - "appId": "test_app_from_SDK", - "name": "my-application", - "version": "1.0.0", - "appProvider": "MyAppProvider", - "packageType": "CONTAINER", - "appRepo": { - "type": "PUBLICREPO", - "imagePath": "https://example.com/my-app-image:1.0.0", - }, - "requiredResources": { - "infraKind": "kubernetes", - "applicationResources": { - "cpuPool": { - "numCPU": 2, - "memory": 2048, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 1, - "minNodeMemory": 1024, - }, - } - }, - "isStandalone": False, - "version": "1.29", - }, - "componentSpec": [ - { - "componentName": "my-component", - "networkInterfaces": [ - { - "interfaceId": "eth0", - "protocol": "TCP", - "port": 8080, - "visibilityType": "VISIBILITY_EXTERNAL", - } - ], - } - ], -} -# artefactId needs to be added; same ID as appId -app_manifest.update({"artefactId": app_manifest["appId"]}) - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_onboard_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform.onboard_app(app_manifest) - except I2EdgeError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_onboard_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform.onboard_app({}) - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_onboard_app_failure_artefact_id_missing(client_name, base_url): - app_manifest.pop("artefactId") - - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform.onboard_app({}) - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) - except I2EdgeError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform.get_onboarded_app(app_id="non-existent-app") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_get_all_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform.get_all_onboarded_apps() - except I2EdgeError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_delete_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - try: - edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) - except I2EdgeError as e: - pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", i2edge_testbed) -def test_delete_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - - with pytest.raises(I2EdgeError): - edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") - - -####################################### -# APP MANAGEMENT -####################################### -# TODO diff --git a/tests/edgecloud/test_piedge.py b/tests/edgecloud/test_piedge.py deleted file mode 100644 index e69de29..0000000 -- GitLab From c2067699188d156260e0c598f3044a0a07d49e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 2 Apr 2025 14:01:30 +0200 Subject: [PATCH 055/281] Delete placeholder tests for the network side --- tests/network/test_oai.py | 0 tests/network/test_open5gcore.py | 0 tests/network/test_open5gs.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/network/test_oai.py delete mode 100644 tests/network/test_open5gcore.py delete mode 100644 tests/network/test_open5gs.py diff --git a/tests/network/test_oai.py b/tests/network/test_oai.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/network/test_open5gcore.py b/tests/network/test_open5gcore.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/network/test_open5gs.py b/tests/network/test_open5gs.py deleted file mode 100644 index e69de29..0000000 -- GitLab From bff2fea0137353992126f47b3d9d8351be5fabe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 2 Apr 2025 14:01:54 +0200 Subject: [PATCH 056/281] Update readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 81fe24b..831268d 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,7 @@ Each contribution should be made in the appropriate directory: - **Network Adapters** → `src/network/clients/` ## Unit Tests Requirement -To merge a feature branch into `main`, the adapter **must include unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores) -Ensure that your unit tests cover the main functionalities of the adapter. +To merge a feature branch into `main`, the adapter **must pass the unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores) ## Steps to Contribute 1. **Fork the Repository** (if applicable). -- GitLab From 72d1461a728b7ec1254020896c7bf08193557c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 3 Apr 2025 13:06:58 +0200 Subject: [PATCH 057/281] Polish tests (delete unneeded blank spaces, etc) --- tests/edgecloud/{test_factory.py => test_1_factory.py} | 4 ++-- tests/edgecloud/{test_av_zones.py => test_2_av_zones.py} | 1 - tests/edgecloud/{test_artefact.py => test_3_artefact.py} | 8 +------- .../{test_app_onboarding.py => test_4_app_onboarding.py} | 8 -------- .../{test_app_deployment.py => test_5_app_deployment.py} | 0 5 files changed, 3 insertions(+), 18 deletions(-) rename tests/edgecloud/{test_factory.py => test_1_factory.py} (95%) rename tests/edgecloud/{test_av_zones.py => test_2_av_zones.py} (99%) rename tests/edgecloud/{test_artefact.py => test_3_artefact.py} (99%) rename tests/edgecloud/{test_app_onboarding.py => test_4_app_onboarding.py} (99%) rename tests/edgecloud/{test_app_deployment.py => test_5_app_deployment.py} (100%) diff --git a/tests/edgecloud/test_factory.py b/tests/edgecloud/test_1_factory.py similarity index 95% rename from tests/edgecloud/test_factory.py rename to tests/edgecloud/test_1_factory.py index 5a133f1..4cd0dfd 100644 --- a/tests/edgecloud/test_factory.py +++ b/tests/edgecloud/test_1_factory.py @@ -17,9 +17,9 @@ def test_factory_edgecloud(client_name, base_url): "aeros": AerosClient, "piedge": PiEdgeClient, } - expected_client_class = client_class_map[client_name] edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url + client_name, + base_url ) assert isinstance(edgecloud_platform, expected_client_class) diff --git a/tests/edgecloud/test_av_zones.py b/tests/edgecloud/test_2_av_zones.py similarity index 99% rename from tests/edgecloud/test_av_zones.py rename to tests/edgecloud/test_2_av_zones.py index 14fd453..1e3f253 100644 --- a/tests/edgecloud/test_av_zones.py +++ b/tests/edgecloud/test_2_av_zones.py @@ -13,7 +13,6 @@ def test_get_edge_cloud_zones(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: zones = edgecloud_platform.get_edge_cloud_zones() assert isinstance(zones, list) diff --git a/tests/edgecloud/test_artefact.py b/tests/edgecloud/test_3_artefact.py similarity index 99% rename from tests/edgecloud/test_artefact.py rename to tests/edgecloud/test_3_artefact.py index fc988da..8b1dffa 100644 --- a/tests/edgecloud/test_artefact.py +++ b/tests/edgecloud/test_3_artefact.py @@ -20,7 +20,6 @@ def test_create_artefact_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform._create_artefact( artefact_id=artefact_id, @@ -42,7 +41,6 @@ def test_create_artefact_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform._create_artefact( artefact_id=artefact_id, @@ -61,7 +59,6 @@ def test_get_artefact_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform._get_artefact(artefact_id=artefact_id) except EdgeCloudPlatformError as e: @@ -73,7 +70,6 @@ def test_get_artefact_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") @@ -83,18 +79,17 @@ def test_get_all_artefacts_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform._get_all_artefacts() except EdgeCloudPlatformError as e: pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform._delete_artefact(artefact_id=artefact_id) except EdgeCloudPlatformError as e: @@ -106,6 +101,5 @@ def test_delete_artefact_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") diff --git a/tests/edgecloud/test_app_onboarding.py b/tests/edgecloud/test_4_app_onboarding.py similarity index 99% rename from tests/edgecloud/test_app_onboarding.py rename to tests/edgecloud/test_4_app_onboarding.py index 4ac701e..cdf3800 100644 --- a/tests/edgecloud/test_app_onboarding.py +++ b/tests/edgecloud/test_4_app_onboarding.py @@ -52,7 +52,6 @@ def test_onboard_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform.onboard_app(app_manifest) except EdgeCloudPlatformError as e: @@ -64,7 +63,6 @@ def test_onboard_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.onboard_app({}) @@ -74,7 +72,6 @@ def test_get_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) except EdgeCloudPlatformError as e: @@ -86,7 +83,6 @@ def test_get_onboarded_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.get_onboarded_app(app_id="non-existent-app") @@ -96,7 +92,6 @@ def test_get_all_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform.get_all_onboarded_apps() except EdgeCloudPlatformError as e: @@ -108,7 +103,6 @@ def test_delete_onboarded_app_success(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - try: edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) except EdgeCloudPlatformError as e: @@ -120,7 +114,5 @@ def test_delete_onboarded_app_failure(client_name, base_url): edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( client_name, base_url ) - with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") - diff --git a/tests/edgecloud/test_app_deployment.py b/tests/edgecloud/test_5_app_deployment.py similarity index 100% rename from tests/edgecloud/test_app_deployment.py rename to tests/edgecloud/test_5_app_deployment.py -- GitLab From 7b847e13b783605f4cbbda75bc65a6b6bbba0799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 3 Apr 2025 13:07:24 +0200 Subject: [PATCH 058/281] Update gitignore, exclude .log files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 85e2f6d..3c71d16 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ tmp/ .vscode/ +.log -- GitLab From 8e6502fbf1949b556e8bc8b254cd3e8307de024c Mon Sep 17 00:00:00 2001 From: reza2cat Date: Mon, 7 Apr 2025 07:39:52 +0000 Subject: [PATCH 059/281] Add custom error class for Network SDK --- src/network/clients/errors.py | 2 + src/network/clients/open5gs/client.py | 57 +++++++++++++++++++++ src/network/clients/open5gs/common.py | 38 ++++++++++++++ src/network/clients/open5gs/schemas.py | 19 +++++++ src/network/core/network_factory.py | 59 ++++++++++++++++++++++ src/network/core/network_interface.py | 69 ++++++++++++++++++++++++++ 6 files changed, 244 insertions(+) create mode 100644 src/network/clients/errors.py create mode 100644 src/network/clients/open5gs/client.py create mode 100644 src/network/clients/open5gs/common.py create mode 100644 src/network/clients/open5gs/schemas.py create mode 100644 src/network/core/network_factory.py create mode 100644 src/network/core/network_interface.py diff --git a/src/network/clients/errors.py b/src/network/clients/errors.py new file mode 100644 index 0000000..1fd3ed6 --- /dev/null +++ b/src/network/clients/errors.py @@ -0,0 +1,2 @@ +class NetworkPlatformError(Exception): + pass diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py new file mode 100644 index 0000000..696ddcf --- /dev/null +++ b/src/network/clients/open5gs/client.py @@ -0,0 +1,57 @@ +from typing import Dict +from src import logger +from src.network.core.network_interface import NetworkManagementInterface +from . import common +from . import schemas + +log = logger.get_logger(__name__) + +class Open5GSClient(NetworkManagementInterface): + """ + This client implements the NetworkManagementInterface and translates the + CAMARA APIs into specific HTTP requests understandable by the Open5GS NEF API. + + Invloved partners and their roles in this implementation: + - I2CAT: Responsible for the CAMARA QoD API and its mapping to the 3GPP AsSessionWithQoS API exposed by Open5GS NEF. + - NCSRD: Responsible for the CAMARA Location API and its mapping to the 3GPP Monitoring Even API exposed Open5GS NEF. + """ + + def __init__(self, base_url: str, scs_as_id: str): + """ + Initializes the Open5GS Client. + """ + try: + self.base_url = base_url + self.scs_as_id = scs_as_id + log.info(f"Initialized Open5GSClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}") + except Exception as e: + log.error(f"Failed to initialize Open5GSClient: {e}") + raise e + + + # --- Implementation of NetworkManagementInterface methods --- + def create_qod_session(self, session_info: Dict) -> Dict: + """ + Creates a QoD session based on the CAMARA QoD API input. + Maps the CAMARA QoD POST /sessions to Open5GS NEF POST /{scsAsId}/subscriptions. + """ + pass + + + def get_qod_session(self, session_id: str) -> Dict: + """ + Retrieves a specific Open5GS QoS Subscription details. + Maps CAMARA QoD GET /sessions/{sessionId} to Open5GS NEF GET /{scsAsId}/subscriptions/{subscriptionId}. + """ + pass + + + def delete_qod_session(self, session_id: str) -> None: + """ + Deletes a specific Open5GS QoS Subscription. + Maps CAMARA QoD DELETE /sessions/{sessionId} to Open5GS NEF DELETE /{scsAsId}/subscriptions/{subscriptionId}. + """ + pass + + + diff --git a/src/network/clients/open5gs/common.py b/src/network/clients/open5gs/common.py new file mode 100644 index 0000000..6677e4b --- /dev/null +++ b/src/network/clients/open5gs/common.py @@ -0,0 +1,38 @@ +# Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). +import json +from typing import Optional + +import requests +from pydantic import BaseModel, ValidationError +from src.network.clients.errors import NetworkPlatformError +from src import logger + +log = logger.get_logger(__name__) + + +class Open5GSError(NetworkPlatformError): + pass + +class Open5GSErrorResponse(BaseModel): + message: str + detail: dict + +# --- HTTP Request Helper Functions --- +def open5gs_post(url: str, model_payload: BaseModel) -> dict: + """ + Placeholder for the POST request function.""" + pass + +def open5gs_get(url: str, params: Optional[dict] = None) -> dict: + """ + Placeholder for the GET request function. + """ + pass + +def open5gs_delete(url: str) -> None: + """ + Placeholder for the DELETE request function. + """ + pass + + diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py new file mode 100644 index 0000000..4824019 --- /dev/null +++ b/src/network/clients/open5gs/schemas.py @@ -0,0 +1,19 @@ +# This file defines the Pydantic models that represent the data structures (schemas) +# for the requests sent to and responses received from the Open5GS NEF API, +# specifically focusing on the APIs needed to support CAMARA QoD. + +from pydantic import BaseModel + +# Dummy examples of Pydantic models for the Open5GS NEF API. +class Open5GSQoSSubscription(BaseModel): + """ + Represents the payload for creating a QoS subscription in Open5GS. + """ + pass + +class CamaraQoDSessionInfo(BaseModel): + """ + Represents the input data for creating a QoD session. + """ + pass + diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py new file mode 100644 index 0000000..26f1481 --- /dev/null +++ b/src/network/core/network_factory.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Reza Mosahebfard (reza.mosahebfard@i2cat.net) +## +from __future__ import annotations + +from typing import TYPE_CHECKING + +from src.network.clients.open5gs.client import Open5GSClient +from src.network.clients.oai.client import OaiNefClient +from src.network.clients.open5gcore.client import Open5GCoreClient + +if TYPE_CHECKING: + from .network_interface import NetworkManagementInterface + +class NetworkClientFactory: + """ + Factory class for creating Network Management Clients. + """ + + @staticmethod + def create_network_client(client_name: str, base_url: str) -> NetworkManagementInterface: + """ + Creates and returns an instance of the specified Network Client. + """ + try: + constructor = NetworkClientFactory.network_client_constructors[client_name] + network_client_instance = constructor(base_url) + return network_client_instance + except KeyError: + # Get the list of supported client names + supported_clients = list(NetworkClientFactory.network_client_constructors.keys()) + raise ValueError( + f"Invalid network client name: '{client_name}'. " + f"Supported clients are: {', '.join(supported_clients)}" + ) + +class NetworkClientTypes: + """ + Class for creating Network Clients. + """ + + OPEN5GS = "open5gs" + OAI = "oai" + OPEN5GCORE = "open5gcore" + + # --- Dictionary mapping type constants to constructors --- + network_types = { + OPEN5GS: lambda url: Open5GSClient(base_url=url), + OAI: lambda url: OaiNefClient(base_url=url), + OPEN5GCORE: lambda url: Open5GCoreClient(base_url=url), + } diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py new file mode 100644 index 0000000..75f8544 --- /dev/null +++ b/src/network/core/network_interface.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Reza Mosahebfard (reza.mosahebfard@i2cat.net) +## +from abc import ABC, abstractmethod +from typing import Dict + +class NetworkManagementInterface(ABC): + """ + Abstract Base Class for Network Resource Management. + + This interface defines the standard methods that all + Network Clients (Open5GS, OAI, Open5GCoe) must implement. + + Partners implementing a new network client should inherit from this class + and provide concrete implementations for all abstract methods relevant + to their specific NEF capabilities. + """ + + @abstractmethod + def create_qod_session(self, session_info: Dict) -> Dict: + """ + Creates a QoS session based on CAMARA QoD API input. + + args: + session_info: Dictionary containing session details conforming to the CAMARA QoD session creation parameters. + + returns: + dictionary containing the created session details, including its ID. + """ + pass + + + @abstractmethod + def get_qod_session(self, session_id: str) -> Dict: + """ + Retrieves details of a specific Quality on Demand (QoS) session. + + args: + session_id: The unique identifier of the QoS session. + + returns: + Dictionary containing the details of the requested QoS session. + """ + pass + + + @abstractmethod + def delete_qod_session(self, session_id: str) -> None: + """ + Deletes a specific Quality on Demand (QoS) session. + + args: + session_id: The unique identifier of the QoS session to delete. + + returns: + None + """ + pass + + # Placeholder for other CAMARA APIs (e.g., Traffic Influence, Location-retrieval, etc.) + -- GitLab From 12b002bbe12231e7f2b51cf385fc1359bd9f97a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 7 Apr 2025 10:09:05 +0200 Subject: [PATCH 060/281] Update format: apply Flake8 linter --- src/network/core/network_factory.py | 17 ++++++++++++----- src/network/core/network_interface.py | 14 +++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py index 26f1481..4ce0131 100644 --- a/src/network/core/network_factory.py +++ b/src/network/core/network_factory.py @@ -14,19 +14,22 @@ from __future__ import annotations from typing import TYPE_CHECKING from src.network.clients.open5gs.client import Open5GSClient -from src.network.clients.oai.client import OaiNefClient +from src.network.clients.oai.client import OaiNefClient from src.network.clients.open5gcore.client import Open5GCoreClient if TYPE_CHECKING: from .network_interface import NetworkManagementInterface + class NetworkClientFactory: """ Factory class for creating Network Management Clients. """ @staticmethod - def create_network_client(client_name: str, base_url: str) -> NetworkManagementInterface: + def create_network_client( + client_name: str, base_url: str + ) -> NetworkManagementInterface: """ Creates and returns an instance of the specified Network Client. """ @@ -36,19 +39,23 @@ class NetworkClientFactory: return network_client_instance except KeyError: # Get the list of supported client names - supported_clients = list(NetworkClientFactory.network_client_constructors.keys()) + supported_clients = list( + NetworkClientFactory.network_client_constructors.keys() + ) raise ValueError( f"Invalid network client name: '{client_name}'. " - f"Supported clients are: {', '.join(supported_clients)}" + "Supported clients are: " + f"{', '.join(supported_clients)}" ) + class NetworkClientTypes: """ Class for creating Network Clients. """ OPEN5GS = "open5gs" - OAI = "oai" + OAI = "oai" OPEN5GCORE = "open5gcore" # --- Dictionary mapping type constants to constructors --- diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 75f8544..7be1449 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -12,11 +12,12 @@ from abc import ABC, abstractmethod from typing import Dict + class NetworkManagementInterface(ABC): """ Abstract Base Class for Network Resource Management. - This interface defines the standard methods that all + This interface defines the standard methods that all Network Clients (Open5GS, OAI, Open5GCoe) must implement. Partners implementing a new network client should inherit from this class @@ -29,15 +30,15 @@ class NetworkManagementInterface(ABC): """ Creates a QoS session based on CAMARA QoD API input. - args: - session_info: Dictionary containing session details conforming to the CAMARA QoD session creation parameters. + args: + session_info: Dictionary containing session details conforming to + the CAMARA QoD session creation parameters. returns: dictionary containing the created session details, including its ID. """ pass - @abstractmethod def get_qod_session(self, session_id: str) -> Dict: """ @@ -51,7 +52,6 @@ class NetworkManagementInterface(ABC): """ pass - @abstractmethod def delete_qod_session(self, session_id: str) -> None: """ @@ -65,5 +65,5 @@ class NetworkManagementInterface(ABC): """ pass - # Placeholder for other CAMARA APIs (e.g., Traffic Influence, Location-retrieval, etc.) - + # Placeholder for other CAMARA APIs (e.g., Traffic Influence, + # Location-retrieval, etc.) -- GitLab From 5edf8eca5855bb9c6908fe53cbc6a4588bc2e35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 7 Apr 2025 10:10:54 +0200 Subject: [PATCH 061/281] Fix typo --- src/network/core/network_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 7be1449..0628ca9 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -18,7 +18,7 @@ class NetworkManagementInterface(ABC): Abstract Base Class for Network Resource Management. This interface defines the standard methods that all - Network Clients (Open5GS, OAI, Open5GCoe) must implement. + Network Clients (Open5GS, OAI, Open5GCore) must implement. Partners implementing a new network client should inherit from this class and provide concrete implementations for all abstract methods relevant -- GitLab From 624994e4176699ae0f5e00ff8b0d57dbda595212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 7 Apr 2025 10:24:12 +0200 Subject: [PATCH 062/281] Fix format in open5gs client - Flake8 linter --- src/network/clients/open5gs/client.py | 33 ++++++++++++++++---------- src/network/clients/open5gs/common.py | 10 ++++---- src/network/clients/open5gs/schemas.py | 9 +++---- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index 696ddcf..dc5ddf6 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -6,14 +6,17 @@ from . import schemas log = logger.get_logger(__name__) -class Open5GSClient(NetworkManagementInterface): + +class NetworkManager(NetworkManagementInterface): """ - This client implements the NetworkManagementInterface and translates the + This client implements the NetworkManagementInterface and translates the CAMARA APIs into specific HTTP requests understandable by the Open5GS NEF API. Invloved partners and their roles in this implementation: - - I2CAT: Responsible for the CAMARA QoD API and its mapping to the 3GPP AsSessionWithQoS API exposed by Open5GS NEF. - - NCSRD: Responsible for the CAMARA Location API and its mapping to the 3GPP Monitoring Even API exposed Open5GS NEF. + - I2CAT: Responsible for the CAMARA QoD API and its mapping to the + 3GPP AsSessionWithQoS API exposed by Open5GS NEF. + - NCSRD: Responsible for the CAMARA Location API and its mapping to the + 3GPP Monitoring Event API exposed Open5GS NEF. """ def __init__(self, base_url: str, scs_as_id: str): @@ -23,12 +26,14 @@ class Open5GSClient(NetworkManagementInterface): try: self.base_url = base_url self.scs_as_id = scs_as_id - log.info(f"Initialized Open5GSClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}") + log.info( + f"Initialized Open5GSClient with base_url: {self.base_url} " + f"and scs_as_id: {self.scs_as_id}" + ) except Exception as e: log.error(f"Failed to initialize Open5GSClient: {e}") raise e - # --- Implementation of NetworkManagementInterface methods --- def create_qod_session(self, session_info: Dict) -> Dict: """ @@ -37,21 +42,25 @@ class Open5GSClient(NetworkManagementInterface): """ pass - def get_qod_session(self, session_id: str) -> Dict: """ Retrieves a specific Open5GS QoS Subscription details. - Maps CAMARA QoD GET /sessions/{sessionId} to Open5GS NEF GET /{scsAsId}/subscriptions/{subscriptionId}. + Maps CAMARA QoD GET /sessions/{sessionId} to Open5GS NEF GET / + {scsAsId}/subscriptions/{subscriptionId}. """ pass - def delete_qod_session(self, session_id: str) -> None: """ Deletes a specific Open5GS QoS Subscription. - Maps CAMARA QoD DELETE /sessions/{sessionId} to Open5GS NEF DELETE /{scsAsId}/subscriptions/{subscriptionId}. + Maps CAMARA QoD DELETE /sessions/{sessionId} to Open5GS NEF DELETE / + {scsAsId}/subscriptions/{subscriptionId}. """ pass - - +# Note: +# As this class is inheriting from NetworkManagementInterface, it is +# expected to implement all the abstract methods defined in that interface. +# +# In case this network adapter doesn't support a specific method, it should +# be marked as NotImplementedError. diff --git a/src/network/clients/open5gs/common.py b/src/network/clients/open5gs/common.py index 6677e4b..067c756 100644 --- a/src/network/clients/open5gs/common.py +++ b/src/network/clients/open5gs/common.py @@ -13,26 +13,28 @@ log = logger.get_logger(__name__) class Open5GSError(NetworkPlatformError): pass + class Open5GSErrorResponse(BaseModel): message: str detail: dict + # --- HTTP Request Helper Functions --- def open5gs_post(url: str, model_payload: BaseModel) -> dict: """ Placeholder for the POST request function.""" pass + def open5gs_get(url: str, params: Optional[dict] = None) -> dict: """ Placeholder for the GET request function. """ - pass + pass + def open5gs_delete(url: str) -> None: """ Placeholder for the DELETE request function. """ - pass - - + pass diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py index 4824019..1f13918 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/clients/open5gs/schemas.py @@ -1,19 +1,20 @@ # This file defines the Pydantic models that represent the data structures (schemas) -# for the requests sent to and responses received from the Open5GS NEF API, +# for the requests sent to and responses received from the Open5GS NEF API, # specifically focusing on the APIs needed to support CAMARA QoD. from pydantic import BaseModel + # Dummy examples of Pydantic models for the Open5GS NEF API. class Open5GSQoSSubscription(BaseModel): """ Represents the payload for creating a QoS subscription in Open5GS. """ - pass + pass + class CamaraQoDSessionInfo(BaseModel): """ Represents the input data for creating a QoD session. """ - pass - + pass -- GitLab From 814ad32daa474702a2eb918535d77b1c346e7ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 7 Apr 2025 10:24:48 +0200 Subject: [PATCH 063/281] Add placeholders for oai and open5gcore --- src/network/clients/oai/client.py | 29 +++++++++++++++++++ .../.gitkeep => open5gcore/__init__.py} | 0 src/network/clients/open5gcore/client.py | 29 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/network/clients/oai/client.py rename src/network/clients/{open5gs/.gitkeep => open5gcore/__init__.py} (100%) create mode 100644 src/network/clients/open5gcore/client.py diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py new file mode 100644 index 0000000..ce08471 --- /dev/null +++ b/src/network/clients/oai/client.py @@ -0,0 +1,29 @@ +from typing import Dict +from src import logger +from src.network.core.network_interface import NetworkManagementInterface +from . import common +from . import schemas + +log = logger.get_logger(__name__) + + +# Placeholder for the OAI Network Management Client +class NetworkManager(NetworkManagementInterface): + def __init__(self, base_url: str, scs_as_id: str): + pass + + def create_qod_session(self, session_info: Dict) -> Dict: + pass + + def get_qod_session(self, session_id: str) -> Dict: + pass + + def delete_qod_session(self, session_id: str) -> None: + pass + +# Note: +# As this class is inheriting from NetworkManagementInterface, it is +# expected to implement all the abstract methods defined in that interface. +# +# In case this network adapter doesn't support a specific method, it should +# be marked as NotImplementedError. diff --git a/src/network/clients/open5gs/.gitkeep b/src/network/clients/open5gcore/__init__.py similarity index 100% rename from src/network/clients/open5gs/.gitkeep rename to src/network/clients/open5gcore/__init__.py diff --git a/src/network/clients/open5gcore/client.py b/src/network/clients/open5gcore/client.py new file mode 100644 index 0000000..7d51c52 --- /dev/null +++ b/src/network/clients/open5gcore/client.py @@ -0,0 +1,29 @@ +from typing import Dict +from src import logger +from src.network.core.network_interface import NetworkManagementInterface +from . import common +from . import schemas + +log = logger.get_logger(__name__) + + +# Placeholder for the Open5gcore Network Management Client +class NetworkManager(NetworkManagementInterface): + def __init__(self, base_url: str, scs_as_id: str): + pass + + def create_qod_session(self, session_info: Dict) -> Dict: + pass + + def get_qod_session(self, session_id: str) -> Dict: + pass + + def delete_qod_session(self, session_id: str) -> None: + pass + +# Note: +# As this class is inheriting from NetworkManagementInterface, it is +# expected to implement all the abstract methods defined in that interface. +# +# In case this network adapter doesn't support a specific method, it should +# be marked as NotImplementedError. -- GitLab From 0820f347f893bcee80bdaf8a6144430a18c48522 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Tue, 8 Apr 2025 11:09:02 +0200 Subject: [PATCH 064/281] Extend Network Management Interface for traffic influence support --- src/network/core/network_interface.py | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 0628ca9..3ff6491 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -65,5 +65,46 @@ class NetworkManagementInterface(ABC): """ pass + @abstractmethod + def create_traffic_influence_resource(self, traffic_influence_info: Dict) -> Dict: + """ + Creates a Traffic Influence resource based on CAMARA TI API input. + + args: + traffic_influence_info: Dictionary containing traffic influence details conforming to + the CAMARA TI resource creation parameters. + + returns: + dictionary containing the created traffic influence resource details, including its ID. + """ + pass + + + @abstractmethod + def get_traffic_influence_resource(self, resource_id: str) -> Dict: + """ + Retrieves details of a specific Traffic Influence resource. + + args: + resource_id: The unique identifier of the Traffic Influence resource. + + returns: + Dictionary containing the details of the requested Traffic Influence resource. + """ + pass + + @abstractmethod + def delete_traffic_influence_resource(self, resource_id: str) -> None: + """ + Deletes a specific Traffic Influence resource. + + args: + resource_id: The unique identifier of the Traffic Influence resource to delete. + + returns: + None + """ + pass + # Placeholder for other CAMARA APIs (e.g., Traffic Influence, # Location-retrieval, etc.) -- GitLab From 9d6b04662ca678c07c9b617860f377303ae60748 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Wed, 9 Apr 2025 10:20:33 +0200 Subject: [PATCH 065/281] fix: correct object and method call --- src/network/core/network_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py index 4ce0131..2659fa2 100644 --- a/src/network/core/network_factory.py +++ b/src/network/core/network_factory.py @@ -34,13 +34,13 @@ class NetworkClientFactory: Creates and returns an instance of the specified Network Client. """ try: - constructor = NetworkClientFactory.network_client_constructors[client_name] + constructor = NetworkClientTypes.network_types[client_name] network_client_instance = constructor(base_url) return network_client_instance except KeyError: # Get the list of supported client names supported_clients = list( - NetworkClientFactory.network_client_constructors.keys() + NetworkClientTypes.network_types.keys() ) raise ValueError( f"Invalid network client name: '{client_name}'. " -- GitLab From 848f8b1fa3540f979e1df5974981de47d0818462 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Wed, 9 Apr 2025 15:17:33 +0200 Subject: [PATCH 066/281] feat: QoD support in oai client --- requirements.txt | 1 + src/network/clients/oai/client.py | 129 +++++++++++++++++++++++++++++ src/network/clients/oai/common.py | 56 +++++++++++++ src/network/clients/oai/schemas.py | 110 ++++++++++++++++++++++++ src/network/clients/oai/utils.py | 44 ++++++++++ 5 files changed, 340 insertions(+) create mode 100644 src/network/clients/oai/client.py create mode 100644 src/network/clients/oai/common.py create mode 100644 src/network/clients/oai/schemas.py create mode 100644 src/network/clients/oai/utils.py diff --git a/requirements.txt b/requirements.txt index 9dec5e2..04fa16f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ pydantic==2.10.6 pydantic_core==2.27.2 pytest==8.3.2 requests==2.32.3 +shortuuid==1.0.13 tomli==2.2.1 typing_extensions==4.12.2 urllib3==2.3.0 diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py new file mode 100644 index 0000000..2ba1c46 --- /dev/null +++ b/src/network/clients/oai/client.py @@ -0,0 +1,129 @@ +from typing import Dict +from src import logger +import shortuuid +import time +from pydantic import ValidationError +from src.network.core.network_interface import NetworkManagementInterface +from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription +from src.network.clients.oai.common import ( + oai_as_session_with_qos_post, + oai_as_session_with_qos_get, + oai_as_session_with_qos_delete, + OaiHttpError, + OaiNetworkError +) + +from src.network.clients.oai.utils import camara_qod_to_as_session_with_qos, as_session_with_qos_to_camara_qod + +log = logger.get_logger(__name__) + +class OaiNefClient(NetworkManagementInterface): + def __init__(self, base_url: str, scs_as_id: str = None): + """ + Initialize Network Client for OAI Core Network + The currently supported features are: + - QoD + - Traffic Influence + """ + try: + super().__init__() + self.base_url = base_url + self.scs_as_id = shortuuid.uuid() + log.info(f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}") + except Exception as e: + log.error(f"Failed to initialize OaiNefClient: {e}") + raise e + + #implementation of the NetworkManagementInterface QoD Methods + def create_qod_session(self, session_info: Dict) -> Dict: + """ + Creates a QoS session based on CAMARA QoD API input. + It maps CAMARA QoD API POST /sessions to + OAI NEF POST /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions + """ + try: + qod_input = CamaraQoDSessionInfo(**session_info) + + #convert CAMARA QoD to NEF AsSessionWithQos model and do POST + nef_req = camara_qod_to_as_session_with_qos(qod_input) + nef_res = oai_as_session_with_qos_post(self.base_url, self.scs_as_id, nef_req) + + #retrieve the NEF resource id + if "self" in nef_res.keys(): + nef_url = nef_res["self"] + nef_id = nef_url.split("subscriptions/")[1] + else: + raise OaiNetworkError("No valid ID for the created resource was returned") + + #create QoD session detail and return info with resource Id + qod_input.sessionId = nef_id + + log.info(f"QoD session activated successfully [id={nef_id}]") + + return qod_input + + except ValidationError as e: + raise OaiNetworkError("Could not validate QoD Session Info data") from e + except KeyError as e: + raise OaiNetworkError(f"Missing field in QoD Session Info data: {e}") from e + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + except OaiNetworkError as e: + raise + + + def get_qod_session(self, session_id: str) -> Dict: + """ + Retrieves details of a specific Quality on Demand (QoS) session. + It maps CAMARA QoD API GET /sessions/{sessionId} to + OAI NEF GET /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} + """ + try: + res = oai_as_session_with_qos_get(self.base_url, self.scs_as_id, session_id=session_id) + nef_res = OaiAsSessionWithQosSubscription(**res) + qod_info = as_session_with_qos_to_camara_qod(nef_res) + + log.info(f"QoD session retrived successfully [id={session_id}]") + + return qod_info + except ValidationError as e: + raise OaiNetworkError("Could not validate network response data") from e + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + except OaiNetworkError as e: + raise + + def delete_qod_session(self, session_id: str) -> None: + """ + Deletes a specific Quality on Demand (QoS) session. + It maps CAMARA QoD API DELETE /sessions/{sessionId} to + OAI NEF DELETE /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} + """ + try: + oai_as_session_with_qos_delete(self.base_url, self.scs_as_id, session_id=session_id) + + log.info(f"QoD session deleted successfully [id={session_id}]") + + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + except OaiNetworkError as e: + raise + + #implementation of the NetworkManagementInterface Traffic Influence Methods + def create_traffic_influence_resource(self, traffic_influence_info): + + log.error(f"create_traffic_influence_resource not implemented yet") + + raise NotImplementedError() + + def delete_traffic_influence_resource(self, resource_id): + + log.error(f"delete_traffic_influence_resource not implemented yet") + + raise NotImplementedError() + + def get_traffic_influence_resource(self, resource_id): + + log.error(f"get_traffic_influence_resource not implemented yet") + + raise NotImplementedError() \ No newline at end of file diff --git a/src/network/clients/oai/common.py b/src/network/clients/oai/common.py new file mode 100644 index 0000000..a601c8b --- /dev/null +++ b/src/network/clients/oai/common.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel +from src.network.clients.errors import NetworkPlatformError + +import json +import requests + +def _make_request(method: str, url: str, data=None): + try: + headers = None + if method == 'POST': + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + elif method == 'GET': + headers = { + "accept": "application/json", + } + response = requests.request(method, url, headers=headers, data=data) + response.raise_for_status() + if response.content: + return response.json() + except requests.exceptions.HTTPError as e: + raise OaiHttpError(e) from e + except requests.exceptions.ConnectionError as e: + raise OaiHttpError("connection error") from e + + +#QoD methods +def oai_as_session_with_qos_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = oai_as_session_with_qos_build_url(base_url, scs_as_id) + return _make_request("POST", url, data=data) + + +def oai_as_session_with_qos_get(base_url: str, scs_as_id: str, session_id: str) -> dict: + url = oai_as_session_with_qos_build_url(base_url, scs_as_id, session_id) + return _make_request("GET", url) + + +def oai_as_session_with_qos_delete(base_url: str, scs_as_id: str, session_id: str): + url = oai_as_session_with_qos_build_url(base_url, scs_as_id, session_id) + return _make_request("DELETE", url) + +def oai_as_session_with_qos_build_url(base_url: str, scs_as_id: str, session_id: str = None): + url = f"{base_url}/3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions" + if session_id != None and len(session_id) > 0: + return f"{url}/{session_id}" + else: + return url + +class OaiHttpError(Exception): + pass + +class OaiNetworkError(NetworkPlatformError): + pass \ No newline at end of file diff --git a/src/network/clients/oai/schemas.py b/src/network/clients/oai/schemas.py new file mode 100644 index 0000000..c91547b --- /dev/null +++ b/src/network/clients/oai/schemas.py @@ -0,0 +1,110 @@ +from pydantic import BaseModel, Field, AnyHttpUrl +from typing import List, Optional + + +class Snssai(BaseModel): + sst: int = Field(default=1) + sd: str = Field(default="FFFFFF") + +class FlowInfoItem(BaseModel): + flowId: int + flowDescriptions: List[str] + +class OaiAsSessionWithQosSubscription(BaseModel): + """ + Represents the model to create an AsSessionWithQoS resource inside the OAI NEF. + """ + supportedFeatures: str = Field(default="12") + dnn: str = Field(default="oai") + snssai: Snssai + flowInfo: List[FlowInfoItem] + ueIpv4Addr: str + notificationDestination: str + qosReference: str + self: Optional[str] = None + qosDuration: Optional[int] = None + + def add_flow_descriptor(self, flow_desriptor: str): + self.flowInfo = list() + self.flowInfo.append(FlowInfoItem( + flowId=len(self.flowInfo)+1, + flowDescriptions=[flow_desriptor] + )) + + def add_snssai(self, sst: int, sd: str = None): + self.snssai = Snssai(sst=sst, sd=sd) + +class PortRange(BaseModel): + from_: int = Field(alias="from") + to: int + + class Config: + populate_by_name = True + +class Ports(BaseModel): + ranges: Optional[List[PortRange]] = None + ports: Optional[List[int]] = None + +class Ipv4Address(BaseModel): + publicAddress: str + publicPort: Optional[int] = None + +class Device(BaseModel): + phoneNumber: Optional[str] = None + networkAccessIdentifier: Optional[str] = None + ipv4Address: Optional[Ipv4Address] = None + ipv6Address: Optional[str] = None + +class ApplicationServer(BaseModel): + ipv4Address: Optional[str] = None + ipv6Address: Optional[str] = None + +class SinkCredential(BaseModel): + credentialType: Optional[str] = None + +class CamaraQoDSessionInfo(BaseModel): + """ + Represents the input data for creating a QoD session. + """ + duration: int + qosProfile: str + applicationServer: ApplicationServer + + device: Optional[Device] = None + devicePorts: Optional[Ports] = None + applicationServerPorts: Optional[Ports] = None + sink: Optional[str] = None + sinkCredential: Optional[SinkCredential] = None + + #fields only applicable to sessionInfo in responses: + sessionId: Optional[str] = None + startedAt: Optional[int] = None + expiresAt: Optional[int] = None + qosStatus: Optional[str] = None + statusInfo: Optional[str] = None + + + class Config: + populate_by_name = True + + def retrieve_ue_ipv4(self): + if self.device is not None and self.device.ipv4Address is not None: + return self.device.ipv4Address.publicAddress + else: + raise KeyError("device.ipv4Address.publicAddress") + + def retrieve_app_ipv4(self): + if self.applicationServer.ipv4Address is not None: + return self.applicationServer.ipv4Address + else: + raise KeyError("applicationServer.ipv4Address") + + def add_server_ipv4(self, ipv4: str): + self.applicationServer = ApplicationServer(ipv4Address = ipv4) + + + def add_ue_ipv4(self, ipv4: str): + if self.device is None: + self.device = Device() + if self.device.ipv4Address is None: + self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) \ No newline at end of file diff --git a/src/network/clients/oai/utils.py b/src/network/clients/oai/utils.py new file mode 100644 index 0000000..719d83e --- /dev/null +++ b/src/network/clients/oai/utils.py @@ -0,0 +1,44 @@ +from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription +from pydantic import BaseModel + +def camara_qod_to_as_session_with_qos(qod_input: CamaraQoDSessionInfo) -> OaiAsSessionWithQosSubscription : + device_ip = qod_input.retrieve_ue_ipv4() + server_ip = qod_input.retrieve_app_ipv4() + + # Extract callback sink and QoS profile + sink_url = qod_input.sink + qos_profile = qod_input.qosProfile + + #build flow descriptor in oai format using device ip and server ip + flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" + + #create the nef request model + nef_req = OaiAsSessionWithQosSubscription.construct() + nef_req.ueIpv4Addr = device_ip + nef_req.notificationDestination = sink_url + nef_req.add_flow_descriptor(flow_desriptor=flow_descriptor) + nef_req.qosReference = qos_profile + nef_req.add_snssai(1, "FFFFFF") + + #the qos duration feature is not available yet in oai + #nef_req.qosDuration = qod_input.duration + + return nef_req + + +def as_session_with_qos_to_camara_qod(nef_input: OaiAsSessionWithQosSubscription) -> CamaraQoDSessionInfo : + #create the camara qod model + + qod_info = CamaraQoDSessionInfo.construct() + + flowDesc = nef_input.flowInfo[0].flowDescriptions[0] + serverIp = flowDesc.split("to ")[1].split("/32")[0] + + qod_info.add_server_ipv4(serverIp) + qod_info.qosProfile = nef_input.qosReference + qod_info.add_ue_ipv4(nef_input.ueIpv4Addr) + qod_info.sink = nef_input.notificationDestination + qod_info.duration = nef_input.qosDuration + + return qod_info + -- GitLab From e0bfcf0f614672d4c24639a65baa3e39c5b01f31 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Wed, 9 Apr 2025 15:22:47 +0200 Subject: [PATCH 067/281] update contributor information --- src/network/clients/oai/client.py | 10 ++++++++++ src/network/clients/oai/common.py | 11 +++++++++++ src/network/clients/oai/schemas.py | 10 ++++++++++ src/network/clients/oai/utils.py | 11 +++++++++++ 4 files changed, 42 insertions(+) diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py index 2ba1c46..b5ca6d0 100644 --- a/src/network/clients/oai/client.py +++ b/src/network/clients/oai/client.py @@ -1,3 +1,13 @@ +## +# Copyright (c) 2025 Netsoft Group, EURECOM. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Giulio Carota (giulio.carota@eurecom.fr) +## + from typing import Dict from src import logger import shortuuid diff --git a/src/network/clients/oai/common.py b/src/network/clients/oai/common.py index a601c8b..62eab05 100644 --- a/src/network/clients/oai/common.py +++ b/src/network/clients/oai/common.py @@ -1,3 +1,14 @@ +## +# Copyright (c) 2025 Netsoft Group, EURECOM. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Giulio Carota (giulio.carota@eurecom.fr) +## + + from pydantic import BaseModel from src.network.clients.errors import NetworkPlatformError diff --git a/src/network/clients/oai/schemas.py b/src/network/clients/oai/schemas.py index c91547b..5cea63b 100644 --- a/src/network/clients/oai/schemas.py +++ b/src/network/clients/oai/schemas.py @@ -1,3 +1,13 @@ +## +# Copyright (c) 2025 Netsoft Group, EURECOM. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Giulio Carota (giulio.carota@eurecom.fr) +## + from pydantic import BaseModel, Field, AnyHttpUrl from typing import List, Optional diff --git a/src/network/clients/oai/utils.py b/src/network/clients/oai/utils.py index 719d83e..96936e3 100644 --- a/src/network/clients/oai/utils.py +++ b/src/network/clients/oai/utils.py @@ -1,3 +1,14 @@ +## +# Copyright (c) 2025 Netsoft Group, EURECOM. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Giulio Carota (giulio.carota@eurecom.fr) +## + + from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription from pydantic import BaseModel -- GitLab From 3690e9e5f25a687100249b58079a14bc736de8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=C3=B1ellas=2C=20Ferran?= Date: Thu, 10 Apr 2025 11:50:56 +0200 Subject: [PATCH 068/281] Adds AsSessionWithQoSSubscription schema definition Also adds pydantic-extra-types to define MAC addresses. --- requirements.txt | 1 + src/network/clients/open5gs/schemas.py | 154 ++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9dec5e2..f29da27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ iniconfig==2.0.0 packaging==24.2 pluggy==1.5.0 pydantic==2.10.6 +pydantic-extra-types==2.10.3 pydantic_core==2.27.2 pytest==8.3.2 requests==2.32.3 diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py index 1f13918..69fa37e 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/clients/open5gs/schemas.py @@ -2,19 +2,163 @@ # for the requests sent to and responses received from the Open5GS NEF API, # specifically focusing on the APIs needed to support CAMARA QoD. -from pydantic import BaseModel +import ipaddress +from enum import Enum +from typing import Annotated +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, RootModel +from pydantic_extra_types.mac_address import MacAddress -# Dummy examples of Pydantic models for the Open5GS NEF API. -class Open5GSQoSSubscription(BaseModel): + +class FlowDirection(Enum): """ - Represents the payload for creating a QoS subscription in Open5GS. + DOWNLINK: The corresponding filter applies for traffic to the UE. + UPLINK: The corresponding filter applies for traffic from the UE. + BIDIRECTIONAL: The corresponding filter applies for traffic both to and from the UE. + UNSPECIFIED: The corresponding filter applies for traffic to the UE (downlink), but has no specific direction declared. The service data flow detection shall apply the filter for uplink traffic as if the filter was bidirectional. The PCF shall not use the value UNSPECIFIED in filters created by the network in NW-initiated procedures. The PCF shall only include the value UNSPECIFIED in filters in UE-initiated procedures if the same value is received from the SMF. """ - pass + + DOWNLINK = "DOWNLINK" + UPLINK = "UPLINK" + BIDIRECTIONAL = "BIDIRECTIONAL" + UNSPECIFIED = "UNSPECIFIED" + + +class RequestedQosMonitoringParameter(Enum): + DOWNLINK = "DOWNLINK" + UPLINK = "UPLINK" + ROUND_TRIP = "ROUND_TRIP" + + +class ReportingFrequency(Enum): + EVENT_TRIGGERED = "EVENT_TRIGGERED" + PERIODIC = "PERIODIC" + SESSION_RELEASE = "SESSION_RELEASE" + + +Uinteger = Annotated[int, Field(ge=0)] + + +class DurationSec(RootModel[NonNegativeInt]): + root: NonNegativeInt = Field( + ..., + description="Unsigned integer identifying a period of time in units of seconds.", + ) + + +class Volume(RootModel[NonNegativeInt]): + root: NonNegativeInt = Field( + ..., description="Unsigned integer identifying a volume in units of bytes." + ) + + +class SupportedFeatures(RootModel[str]): + root: str = Field( + ..., + pattern=r"^[A-Fa-f0-9]*$", + description="Hexadecimal string representing supported features.", + ) + + +class Link(RootModel[str]): + root: str = Field( + ..., + description="String formatted according to IETF RFC 3986 identifying a referenced resource.", + ) + + +class FlowDescriptionModel(RootModel[str]): + root: str = Field(..., description="Defines a packet filter of an IP flow.") + + +class EthFlowDescription(BaseModel): + destMacAddr: MacAddress | None = None + ethType: str + fDesc: FlowDescriptionModel | None = None + fDir: FlowDirection | None = None + sourceMacAddr: MacAddress | None = None + vlanTags: list[str] | None = Field(None, max_items=2, min_items=1) + srcMacAddrEnd: MacAddress | None = None + destMacAddrEnd: MacAddress | None = None + + +class UsageThreshold(BaseModel): + duration: DurationSec | None = None + totalVolume: Volume | None = None + downlinkVolume: Volume | None = None + uplinkVolume: Volume | None = None + + +class SponsorInformation(BaseModel): + sponsorId: str = Field(..., description="It indicates Sponsor ID.") + aspId: str = Field(..., description="It indicates Application Service Provider ID.") + + +class WebsockNotifConfig(BaseModel): + websocketUri: Link | None = None + requestWebsocketUri: bool | None = Field( + None, + description="Set by the SCS/AS to indicate that the Websocket delivery is requested.", + ) + + +class QosMonitoringInformationModel(BaseModel): + reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field( + None, min_items=1 + ) + repFreqs: list[ReportingFrequency] | None = Field(None, min_items=1) + repThreshDl: Uinteger | None = None + repThreshUl: Uinteger | None = None + repThreshRp: Uinteger | None = None + waitTime: int | None = None + repPeriod: int | None = None + + +class FlowInfo(BaseModel): + flowId: int = Field(..., description="Indicates the IP flow.") + flowDescriptions: list[str] | None = Field( + None, + description="Indicates the packet filters of the IP flow. Refer to subclause 5.3.8 of 3GPP TS 29.214 for encoding. It shall contain UL and/or DL IP flow description.", + max_items=2, + min_items=1, + ) + + +class AsSessionWithQoSSubscription(BaseModel): + model_config = ConfigDict(serialize_by_alias=True) + self_: Link | None = Field(None, alias="self") + supportedFeatures: SupportedFeatures | None = None + notificationDestination: Link + flowInfo: list[FlowInfo] | None = Field( + None, description="Describe the data flow which requires QoS.", min_items=1 + ) + ethFlowInfo: list[EthFlowDescription] | None = Field( + None, description="Identifies Ethernet packet flows.", min_items=1 + ) + qosReference: str | None = Field( + None, description="Identifies a pre-defined QoS information" + ) + altQoSReferences: list[str] | None = Field( + None, + description="Identifies an ordered list of pre-defined QoS information. The lower the index of the array for a given entry, the higher the priority.", + min_items=1, + ) + ueIpv4Addr: ipaddress.Ipv4Addr | None = None + ueIpv6Addr: ipaddress.Ipv6Addr | None = None + macAddr: MacAddress | None = None + usageThreshold: UsageThreshold | None = None + sponsorInfo: SponsorInformation | None = None + qosMonInfo: QosMonitoringInformationModel | None = None + requestTestNotification: bool | None = Field( + None, + description="Set to true by the SCS/AS to request the SCEF to send a test notification as defined in subclause 5.2.5.3. Set to false or omitted otherwise.", + ) + websockNotifConfig: WebsockNotifConfig | None = None class CamaraQoDSessionInfo(BaseModel): """ Represents the input data for creating a QoD session. """ + pass -- GitLab From 2fb8a8b1f5692a34e98a735e273f024da89ce58a Mon Sep 17 00:00:00 2001 From: reza2cat Date: Thu, 10 Apr 2025 15:21:07 +0000 Subject: [PATCH 069/281] Fix format in open5gs schemas - Flake8 linter --- src/network/clients/open5gs/schemas.py | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py index 69fa37e..c945b92 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/clients/open5gs/schemas.py @@ -15,7 +15,12 @@ class FlowDirection(Enum): DOWNLINK: The corresponding filter applies for traffic to the UE. UPLINK: The corresponding filter applies for traffic from the UE. BIDIRECTIONAL: The corresponding filter applies for traffic both to and from the UE. - UNSPECIFIED: The corresponding filter applies for traffic to the UE (downlink), but has no specific direction declared. The service data flow detection shall apply the filter for uplink traffic as if the filter was bidirectional. The PCF shall not use the value UNSPECIFIED in filters created by the network in NW-initiated procedures. The PCF shall only include the value UNSPECIFIED in filters in UE-initiated procedures if the same value is received from the SMF. + UNSPECIFIED: The corresponding filter applies for traffic to the UE (downlink), but + has no specific direction declared. The service data flow detection shall apply the + filter for uplink traffic as if the filter was bidirectional. The PCF shall not use + the value UNSPECIFIED in filters created by the network in NW-initiated procedures. + The PCF shall only include the value UNSPECIFIED in filters in UE-initiated + procedures if the same value is received from the SMF. """ DOWNLINK = "DOWNLINK" @@ -42,7 +47,8 @@ Uinteger = Annotated[int, Field(ge=0)] class DurationSec(RootModel[NonNegativeInt]): root: NonNegativeInt = Field( ..., - description="Unsigned integer identifying a period of time in units of seconds.", + description="Unsigned integer identifying a period of time in units of \ + seconds.", ) @@ -63,7 +69,8 @@ class SupportedFeatures(RootModel[str]): class Link(RootModel[str]): root: str = Field( ..., - description="String formatted according to IETF RFC 3986 identifying a referenced resource.", + description="String formatted according to IETF RFC 3986 identifying a \ + referenced resource.", ) @@ -98,7 +105,8 @@ class WebsockNotifConfig(BaseModel): websocketUri: Link | None = None requestWebsocketUri: bool | None = Field( None, - description="Set by the SCS/AS to indicate that the Websocket delivery is requested.", + description="Set by the SCS/AS to indicate that the Websocket delivery is \ + requested.", ) @@ -118,7 +126,9 @@ class FlowInfo(BaseModel): flowId: int = Field(..., description="Indicates the IP flow.") flowDescriptions: list[str] | None = Field( None, - description="Indicates the packet filters of the IP flow. Refer to subclause 5.3.8 of 3GPP TS 29.214 for encoding. It shall contain UL and/or DL IP flow description.", + description="Indicates the packet filters of the IP flow. Refer to subclause \ + 5.3.8 of 3GPP TS 29.214 for encoding. It shall contain UL and/or DL IP \ + flow description.", max_items=2, min_items=1, ) @@ -140,7 +150,8 @@ class AsSessionWithQoSSubscription(BaseModel): ) altQoSReferences: list[str] | None = Field( None, - description="Identifies an ordered list of pre-defined QoS information. The lower the index of the array for a given entry, the higher the priority.", + description="Identifies an ordered list of pre-defined QoS information. The \ + lower the index of the array for a given entry, the higher the priority.", min_items=1, ) ueIpv4Addr: ipaddress.Ipv4Addr | None = None @@ -151,7 +162,9 @@ class AsSessionWithQoSSubscription(BaseModel): qosMonInfo: QosMonitoringInformationModel | None = None requestTestNotification: bool | None = Field( None, - description="Set to true by the SCS/AS to request the SCEF to send a test notification as defined in subclause 5.2.5.3. Set to false or omitted otherwise.", + description="Set to true by the SCS/AS to request the SCEF to send a test \ + notification as defined in subclause 5.2.5.3. Set to false or omitted \ + otherwise.", ) websockNotifConfig: WebsockNotifConfig | None = None -- GitLab From 9ed39593f2cafc91c7a47df463e88e850991821e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Wed, 16 Apr 2025 12:03:34 +0200 Subject: [PATCH 070/281] Adds CAMARA CreateSession Model Removes parameters referring to events and notifications. --- src/network/clients/open5gs/schemas.py | 167 +++++++++++++++++++++---- 1 file changed, 145 insertions(+), 22 deletions(-) diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py index c945b92..d98ecb7 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/clients/open5gs/schemas.py @@ -8,6 +8,7 @@ from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress +from ipaddress import IPv4Address, IPv6Address class FlowDirection(Enum): @@ -101,15 +102,6 @@ class SponsorInformation(BaseModel): aspId: str = Field(..., description="It indicates Application Service Provider ID.") -class WebsockNotifConfig(BaseModel): - websocketUri: Link | None = None - requestWebsocketUri: bool | None = Field( - None, - description="Set by the SCS/AS to indicate that the Websocket delivery is \ - requested.", - ) - - class QosMonitoringInformationModel(BaseModel): reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field( None, min_items=1 @@ -160,18 +152,149 @@ class AsSessionWithQoSSubscription(BaseModel): usageThreshold: UsageThreshold | None = None sponsorInfo: SponsorInformation | None = None qosMonInfo: QosMonitoringInformationModel | None = None - requestTestNotification: bool | None = Field( - None, - description="Set to true by the SCS/AS to request the SCEF to send a test \ - notification as defined in subclause 5.2.5.3. Set to false or omitted \ - otherwise.", - ) - websockNotifConfig: WebsockNotifConfig | None = None - -class CamaraQoDSessionInfo(BaseModel): - """ - Represents the input data for creating a QoD session. - """ - pass +############################################################### +############################################################### +# CAMARA Models + +class PhoneNumber(RootModel[str]): + root: Annotated[ + str, + Field( + description="A public identifier addressing a telephone subscription. In mobile networks it corresponds to the MSISDN (Mobile Station International Subscriber Directory Number). In order to be globally unique it has to be formatted in international format, according to E.164 standard, prefixed with '+'.", + examples=['+123456789'], + pattern='^\\+[1-9][0-9]{4,14}$', + ), + ] + +class NetworkAccessIdentifier(RootModel[str]): + root: Annotated[ + str, + Field( + description='A public identifier addressing a subscription in a mobile network. In 3GPP terminology, it corresponds to the GPSI formatted with the External Identifier ({Local Identifier}@{Domain Identifier}). Unlike the telephone number, the network access identifier is not subjected to portability ruling in force, and is individually managed by each operator.', + examples=['123456789@domain.com'], + ), + ] + +class SingleIpv4Addr(RootModel[IPv4Address]): + root: Annotated[ + IPv4Address, + Field( + description='A single IPv4 address with no subnet mask', + examples=['203.0.113.0'], + ), + ] + +class Port(RootModel[int]): + root: Annotated[int, Field(description='TCP or UDP port number', ge=0, le=65535)] + +class DeviceIpv4Addr1(BaseModel): + publicAddress: SingleIpv4Addr + privateAddress: SingleIpv4Addr + publicPort: Port | None = None + + +class DeviceIpv4Addr2(BaseModel): + publicAddress: SingleIpv4Addr + privateAddress: SingleIpv4Addr | None = None + publicPort: Port + + +class DeviceIpv4Addr(RootModel[DeviceIpv4Addr1 | DeviceIpv4Addr2]): + root: Annotated[ + DeviceIpv4Addr1 | DeviceIpv4Addr2, + Field( + description='The device should be identified by either the public (observed) IP address and port as seen by the application server, or the private (local) and any public (observed) IP addresses in use by the device (this information can be obtained by various means, for example from some DNS servers).\n\nIf the allocated and observed IP addresses are the same (i.e. NAT is not in use) then the same address should be specified for both publicAddress and privateAddress.\n\nIf NAT64 is in use, the device should be identified by its publicAddress and publicPort, or separately by its allocated IPv6 address (field ipv6Address of the Device object)\n\nIn all cases, publicAddress must be specified, along with at least one of either privateAddress or publicPort, dependent upon which is known. In general, mobile devices cannot be identified by their public IPv4 address alone.\n', + examples=[{'publicAddress': '203.0.113.0', 'publicPort': 59765}], + ), + ] + +class DeviceIpv6Address(RootModel[IPv6Address]): + root: Annotated[ + IPv6Address, + Field( + description='The device should be identified by the observed IPv6 address, or by any single IPv6 address from within the subnet allocated to the device (e.g. adding ::0 to the /64 prefix).\n\nThe session shall apply to all IP flows between the device subnet and the specified application server, unless further restricted by the optional parameters devicePorts or applicationServerPorts.\n', + examples=['2001:db8:85a3:8d3:1319:8a2e:370:7344'], + ), + ] + +class Device(BaseModel): + phoneNumber: PhoneNumber | None = None + networkAccessIdentifier: NetworkAccessIdentifier | None = None + ipv4Address: DeviceIpv4Addr | None = None + ipv6Address: DeviceIpv6Address | None = None + +class ApplicationServerIpv4Address(RootModel[str]): + root: Annotated[ + str, + Field( + description='IPv4 address may be specified in form
as:\n - address - an IPv4 number in dotted-quad form 1.2.3.4. Only this exact IP number will match the flow control rule.\n - address/mask - an IP number as above with a mask width of the form 1.2.3.4/24.\n In this case, all IP numbers from 1.2.3.0 to 1.2.3.255 will match. The bit width MUST be valid for the IP version.\n', + examples=['198.51.100.0/24'], + ), + ] + +class ApplicationServerIpv6Address(RootModel[str]): + root: Annotated[ + str, + Field( + description='IPv6 address may be specified in form
as:\n - address - The /128 subnet is optional for single addresses:\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344/128\n - address/mask - an IP v6 number with a mask:\n - 2001:db8:85a3:8d3::0/64\n - 2001:db8:85a3:8d3::/64\n', + examples=['2001:db8:85a3:8d3:1319:8a2e:370:7344'], + ), + ] + +class ApplicationServer(BaseModel): + ipv4Address: ApplicationServerIpv4Address | None = None + ipv6Address: ApplicationServerIpv6Address | None = None + +class Range(BaseModel): + from_: Annotated[Port, Field(alias='from')] + to: Port + +class PortsSpec(BaseModel): + ranges: Annotated[ + list[Range] | None, Field(description='Range of TCP or UDP ports', min_length=1) + ] = None + ports: Annotated[ + list[Port] | None, Field(description='Array of TCP or UDP ports', min_length=1) + ] = None + +class QosProfileName(RootModel[str]): + root: Annotated[ + str, + Field( + description='A unique name for identifying a specific QoS profile.\nThis may follow different formats depending on the API provider implementation.\nSome options addresses:\n - A UUID style string\n - Support for predefined profiles QOS_S, QOS_M, QOS_L, and QOS_E\n - A searchable descriptive name\nThe set of QoS Profiles that an API provider is offering may be retrieved by means of the QoS Profile API (qos-profile) or agreed on onboarding time.\n', + examples=['voice'], + max_length=256, + min_length=3, + pattern='^[a-zA-Z0-9_.-]+$', + ), + ] + +class BaseSessionInfo(BaseModel): + device: Device | None = None + applicationServer: ApplicationServer + devicePorts: Annotated[ + PortsSpec | None, + Field( + description='The ports used locally by the device for flows to which the requested QoS profile should apply. If omitted, then the qosProfile will apply to all flows between the device and the specified application server address and ports' + ), + ] = None + applicationServerPorts: Annotated[ + PortsSpec | None, + Field( + description='A list of single ports or port ranges on the application server' + ), + ] = None + qosProfile: QosProfileName + + +class CreateSession(BaseSessionInfo): + duration: Annotated[ + int, + Field( + description='Requested session duration in seconds. Value may be explicitly limited for the QoS profile, as specified in the Qos Profile (see qos-profile API). Implementations can grant the requested session duration or set a different duration, based on network policies or conditions.\n', + examples=[3600], + ge=1, + ), + ] \ No newline at end of file -- GitLab From 3693d8391afc99b58b001b7ee7dac4a0b65df57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 23 Apr 2025 18:33:05 +0200 Subject: [PATCH 071/281] Add pre commit hook using isort, black, flake --- .flake8 | 18 +++++++++++- .pre-commit-config.yaml | 41 +++++++++++++++++++++++++++ requirements.txt | 61 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.flake8 b/.flake8 index 1d36346..4f05f66 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,18 @@ [flake8] -max-line-length = 88 \ No newline at end of file +max-line-length = 100 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 +ignore = E203, E266, E501, W503 +per-file-ignores = + __init__.py:F401 +exclude = + .git, + __pycache__, + build, + dist, + .venv, + .tox, + .mypy_cache, + .pytest_cache +extend-ignore = E203 +extend-select = B950 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c4c4baf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-executables-have-shebangs +# - id: check-yaml + - id: end-of-file-fixer + exclude: ^caas/cfg/ + - id: fix-encoding-pragma + - id: requirements-txt-fixer + - id: trailing-whitespace + # https://stackoverflow.com/a/76200334/2186237 +- repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + name: isort (python) + args: ["--profile", "black", "--filter-files"] +- repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + args: + # - "--check" + - "--target-version=py38" + - "--target-version=py39" + - "--target-version=py310" + - "--target-version=py311" + - "--target-version=py312" +- repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + additional_dependencies: [] +#- repo: https://github.com/hhatto/autopep8 +# rev: v2.0.4 +# hooks: +# - id: autopep8 diff --git a/requirements.txt b/requirements.txt index 9dec5e2..c08353a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,77 @@ annotated-types==0.7.0 +asttokens==3.0.0 +attrs==25.3.0 +backcall==0.2.0 +beautifulsoup4==4.13.3 +bleach==6.2.0 +build==1.2.2.post1 certifi==2025.1.31 +cfgv==3.4.0 charset-normalizer==3.4.1 +click==8.1.8 colorlog==6.8.2 +coverage==7.7.1 +decorator==5.2.1 +defusedxml==0.7.1 +distlib==0.3.9 +docopt==0.6.2 exceptiongroup==1.2.2 +executing==2.2.0 +fastjsonschema==2.21.1 +filelock==3.18.0 +identify==2.6.10 idna==3.10 iniconfig==2.0.0 +ipython==8.12.3 +jedi==0.19.2 +Jinja2==3.1.6 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 +jupyter_client==8.6.3 +jupyter_core==5.7.2 +jupyterlab_pygments==0.3.0 +MarkupSafe==3.0.2 +matplotlib-inline==0.1.7 +mistune==3.1.3 +nbclient==0.10.2 +nbconvert==7.16.6 +nbformat==5.10.4 +nodeenv==1.9.1 packaging==24.2 +pandocfilters==1.5.1 +parso==0.8.4 +pexpect==4.9.0 +pickleshare==0.7.5 +pip-tools==7.4.1 +pipreqs==0.5.0 +platformdirs==4.3.7 pluggy==1.5.0 +pre_commit==4.2.0 +prompt_toolkit==3.0.50 +ptyprocess==0.7.0 +pure_eval==0.2.3 pydantic==2.10.6 pydantic_core==2.27.2 +Pygments==2.19.1 +pyproject_hooks==1.2.0 pytest==8.3.2 +pytest-cov==6.0.0 +python-dateutil==2.9.0.post0 +PyYAML==6.0.2 +pyzmq==26.4.0 +referencing==0.36.2 requests==2.32.3 +rpds-py==0.24.0 +six==1.17.0 +soupsieve==2.6 +stack-data==0.6.3 +tinycss2==1.4.0 tomli==2.2.1 +tornado==6.4.2 +traitlets==5.14.3 typing_extensions==4.12.2 urllib3==2.3.0 +virtualenv==20.30.0 +wcwidth==0.2.13 +webencodings==0.5.1 +yarg==0.1.9 -- GitLab From e830b1056880a20548e908e8e267af73bb8fab66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 11:15:24 +0200 Subject: [PATCH 072/281] Satisfy linters (black, flake, isort) --- docs/workflows/edgecloud/get_av_zones.md | 2 +- src/edgecloud/clients/aeros/client.py | 14 ++- src/edgecloud/clients/errors.py | 2 + src/edgecloud/clients/i2edge/client.py | 7 +- src/edgecloud/clients/i2edge/common.py | 18 +--- src/edgecloud/clients/i2edge/schemas.py | 20 +--- src/edgecloud/clients/i2edge/utils.py | 12 +-- src/edgecloud/clients/piedge/client.py | 119 ++++++++++++++--------- src/edgecloud/core/edgecloud_factory.py | 4 +- src/logger.py | 4 +- src/main.py | 3 + src/network/clients/errors.py | 1 + src/network/clients/oai/client.py | 5 +- src/network/clients/open5gcore/client.py | 5 +- src/network/clients/open5gs/client.py | 5 +- src/network/clients/open5gs/common.py | 8 +- src/network/clients/open5gs/schemas.py | 3 + src/network/core/network_factory.py | 2 +- tests/edgecloud/test_1_factory.py | 6 +- tests/edgecloud/test_2_av_zones.py | 7 +- tests/edgecloud/test_3_artefact.py | 27 ++--- tests/edgecloud/test_4_app_onboarding.py | 31 ++---- tests/edgecloud/test_5_app_deployment.py | 1 + tests/edgecloud/test_cases.py | 1 + 24 files changed, 156 insertions(+), 151 deletions(-) diff --git a/docs/workflows/edgecloud/get_av_zones.md b/docs/workflows/edgecloud/get_av_zones.md index b33c24e..ad7258c 100644 --- a/docs/workflows/edgecloud/get_av_zones.md +++ b/docs/workflows/edgecloud/get_av_zones.md @@ -19,4 +19,4 @@ SDK ->> i2Edge: GET /zones/list API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) API ->> SDK: sbi.get_edge_cloud_zones() SDK ->> PiEdge: GET /nodes -``` \ No newline at end of file +``` diff --git a/src/edgecloud/clients/aeros/client.py b/src/edgecloud/clients/aeros/client.py index 985ca54..6c0fa08 100644 --- a/src/edgecloud/clients/aeros/client.py +++ b/src/edgecloud/clients/aeros/client.py @@ -1,7 +1,10 @@ +# -*- coding: utf-8 -*- # Mocked API for testing purposes from typing import Dict, List, Optional + from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface + class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str): self.base_url = base_url @@ -22,11 +25,18 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: return {"appInstanceId": "abcd-efgh"} - def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[Dict]: return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: print(f"Deleting app instance: {app_instance_id}") - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> List[Dict]: return [{"edgeCloudZoneId": "zone-1", "status": "active"}] diff --git a/src/edgecloud/clients/errors.py b/src/edgecloud/clients/errors.py index 34ef28b..97b14bc 100644 --- a/src/edgecloud/clients/errors.py +++ b/src/edgecloud/clients/errors.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + class EdgeCloudPlatformError(Exception): pass diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 47e52f2..a48fff2 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -142,7 +142,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: return {"appInstanceId": "abcd-efgh"} - def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[Dict]: return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: diff --git a/src/edgecloud/clients/i2edge/common.py b/src/edgecloud/clients/i2edge/common.py index 8af9d31..d4cda49 100644 --- a/src/edgecloud/clients/i2edge/common.py +++ b/src/edgecloud/clients/i2edge/common.py @@ -13,10 +13,10 @@ import json from typing import Optional import requests -from src.edgecloud.clients.errors import EdgeCloudPlatformError from pydantic import BaseModel from src import logger +from src.edgecloud.clients.errors import EdgeCloudPlatformError log = logger.get_logger(__name__) @@ -51,9 +51,7 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to deploy app: {}. Detail: {}".format( - i2edge_err_msg, e - ) + err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) log.error(err_msg) raise I2EdgeError(err_msg) @@ -70,9 +68,7 @@ def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to deploy app: {}. Detail: {}".format( - i2edge_err_msg, e - ) + err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) log.error(err_msg) raise I2EdgeError(err_msg) @@ -86,9 +82,7 @@ def i2edge_delete(url: str, id: str) -> dict: return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to undeploy app: {}. Detail: {}".format( - i2edge_err_msg, e - ) + err_msg = "Failed to undeploy app: {}. Detail: {}".format(i2edge_err_msg, e) log.error(err_msg) raise I2EdgeError(err_msg) @@ -101,8 +95,6 @@ def i2edge_get(url: str, params: Optional[dict]): return response.json() except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) - err_msg = "Failed to get apps: {}. Detail: {}".format( - i2edge_err_msg, e - ) + err_msg = "Failed to get apps: {}. Detail: {}".format(i2edge_err_msg, e) log.error(err_msg) raise I2EdgeError(err_msg) diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/edgecloud/clients/i2edge/schemas.py index 2112ede..ed1a15d 100644 --- a/src/edgecloud/clients/i2edge/schemas.py +++ b/src/edgecloud/clients/i2edge/schemas.py @@ -121,27 +121,19 @@ class Hugepages(BaseModel): class SupportedOSTypes(BaseModel): architecture: str = Field(default="x86_64", description="OS architecture") distribution: str = Field(default="RHEL", description="OS distribution") - license: str = Field( - default="OS_LICENSE_TYPE_FREE", description="OS license type" - ) - version: str = Field( - default="OS_VERSION_UBUNTU_2204_LTS", description="OS version" - ) + license: str = Field(default="OS_LICENSE_TYPE_FREE", description="OS license type") + version: str = Field(default="OS_VERSION_UBUNTU_2204_LTS", description="OS version") class FlavourSupported(BaseModel): - cpuArchType: str = Field( - default="ISA_X86", description="CPU architecture type" - ) + cpuArchType: str = Field(default="ISA_X86", description="CPU architecture type") cpuExclusivity: bool = Field(default=True, description="CPU exclusivity") fpga: int = Field(default=0, description="Number of FPGAs") gpu: Optional[List[GPU]] = Field(default=None, description="List of GPUs") hugepages: List[Hugepages] = Field( default_factory=lambda: [Hugepages()], description="List of hugepages" ) - memorySize: str = Field( - ..., description="Memory size (e.g., '1024MB' or '2GB')" - ) + memorySize: str = Field(..., description="Memory size (e.g., '1024MB' or '2GB')") numCPU: int = Field(..., description="Number of CPUs") storageSize: int = Field(default=0, description="Storage size in GB") supportedOSTypes: List[SupportedOSTypes] = Field( @@ -158,9 +150,7 @@ class FlavourSupported(BaseModel): try: int(v[:-2]) except ValueError: - raise ValueError( - "memorySize must be a number followed by MB or GB" - ) + raise ValueError("memorySize must be a number followed by MB or GB") return v diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/edgecloud/clients/i2edge/utils.py index e4af749..bee5e94 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/edgecloud/clients/i2edge/utils.py @@ -63,9 +63,7 @@ def instantiate_app_with( ) return flavour_id, application_k8s_namespace except I2EdgeError as e: - err_msg = "Error instantiating app {} in zone {}".format( - camara_app_id, zone_id - ) + err_msg = "Error instantiating app {} in zone {}".format(camara_app_id, zone_id) log.error("{}. Detailed error: {}".format(err_msg, e)) raise e @@ -95,9 +93,7 @@ def onboard_app_with( token=token, ) - i2edge.onboard_app( - app_id=str(application_id), artefact_id=str(application_id) - ) + i2edge.onboard_app(app_id=str(application_id), artefact_id=str(application_id)) except I2EdgeError as e: err_msg = "Error onboarding app {} in i2edge".format(app_name) log.error("{}. Detailed error: {}".format(err_msg, e)) @@ -116,9 +112,7 @@ def delete_app_instance_by( i2edge.delete_flavour(flavour_id=str(flavour_id), zone_id=zone_id) -def get_app_name_from( - namespace: str, i2edge: I2EdgeClient -) -> Union[str, None]: +def get_app_name_from(namespace: str, i2edge: I2EdgeClient) -> Union[str, None]: try: response = i2edge.get_all_deployed_apps() for deployment in response: diff --git a/src/edgecloud/clients/piedge/client.py b/src/edgecloud/clients/piedge/client.py index 17d0dc9..89b7b96 100644 --- a/src/edgecloud/clients/piedge/client.py +++ b/src/edgecloud/clients/piedge/client.py @@ -1,86 +1,115 @@ +# -*- coding: utf-8 -*- # Mocked API for testing purposes -from typing import Dict, List, Optional -import os import logging -import requests -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -from swagger_server.utils import kubernetes_connector, connector_db -from swagger_server.models.service_function_registration_request import ServiceFunctionRegistrationRequest -from swagger_server.models.deploy_service_function import DeployServiceFunction +import os +from typing import Dict, List, Optional + from swagger_server.core.piedge_encoder import deploy_service_function +from swagger_server.models.deploy_service_function import DeployServiceFunction +from swagger_server.models.service_function_registration_request import ( + ServiceFunctionRegistrationRequest, +) +from swagger_server.utils import connector_db, kubernetes_connector + +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface + +piedge_ip = os.environ["EDGE_CLOUD_ADAPTER"] +edge_cloud_provider = os.environ["PLATFORM_PROVIDER"] -piedge_ip = os.environ['EDGE_CLOUD_ADAPTER'] -edge_cloud_provider = os.environ['PLATFORM_PROVIDER'] class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: print(f"Submitting application: {app_manifest}") - logging.info('Extracting variables from payload...') - app_name = app_manifest.get('name') - image = app_manifest.get('appRepo').get('imagePath') - sf = ServiceFunctionRegistrationRequest(service_function_image=image, service_function_name=app_name) + logging.info("Extracting variables from payload...") + app_name = app_manifest.get("name") + image = app_manifest.get("appRepo").get("imagePath") + sf = ServiceFunctionRegistrationRequest( + service_function_image=image, service_function_name=app_name + ) return sf def get_all_onboarded_apps(self) -> List[Dict]: - logging.info('Retrieving all registered apps from database...') - app_list = connector_db.get_documents_from_collection(collection_input="service_functions") + logging.info("Retrieving all registered apps from database...") + app_list = connector_db.get_documents_from_collection( + collection_input="service_functions" + ) return app_list # return [{"appId": "1234-5678", "name": "TestApp"}] def get_onboarded_app(self, app_id: str) -> Dict: - logging.info('Searching for registered app with ID: '+ app_id+' in database...') - app = connector_db.get_documents_from_collection("service_functions", input_type="_id", input_value=app_id) + logging.info( + "Searching for registered app with ID: " + app_id + " in database..." + ) + app = connector_db.get_documents_from_collection( + "service_functions", input_type="_id", input_value=app_id + ) return app def delete_onboarded_app(self, app_id: str) -> None: - logging.info('Deleting registered app with ID: '+ app_id+' from database...') + logging.info("Deleting registered app with ID: " + app_id + " from database...") result = connector_db.delete_document_service_function(app_id) return result # print(f"Deleting application: {app_id}") def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - logging.info('Searching for registered app with ID: '+ app_id+' in database...') - app = connector_db.get_documents_from_collection("service_functions", input_type="_id", input_value=app_id) + logging.info( + "Searching for registered app with ID: " + app_id + " in database..." + ) + app = connector_db.get_documents_from_collection( + "service_functions", input_type="_id", input_value=app_id + ) success_response = [] if app is not None: for zone in app_zones: - sf = DeployServiceFunction(service_function_name=app.get('name'), - service_function_instance_name=app.get('name')+zone.get('edgeCloudZoneName'), - location=zone.get('edgeCloudZoneName')) + sf = DeployServiceFunction( + service_function_name=app.get("name"), + service_function_instance_name=app.get("name") + + zone.get("edgeCloudZoneName"), + location=zone.get("edgeCloudZoneName"), + ) result = deploy_service_function(service_function=sf) success_response.append(result) # return {"appInstanceId": "abcd-efgh"} return success_response - def get_all_deployed_apps(self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None) -> List[Dict]: - logging.info('Retreiving all deployed apps in the edge cloud platform') - response = kubernetes_connector.get_deployed_service_functions() + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[Dict]: + logging.info("Retreiving all deployed apps in the edge cloud platform") + # response = kubernetes_connector.get_deployed_service_functions() # Flake8 error: declared but not used return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: - logging.info('Searching for deployed app with ID: '+ app_instance_id+' in database...') + logging.info( + "Searching for deployed app with ID: " + app_instance_id + " in database..." + ) print(f"Deleting app instance: {app_instance_id}") # deployed_service_function_name_=auxiliary_functions.prepare_name_for_k8s(deployed_service_function_name) - sfs=kubernetes_connector.get_deployed_service_functions() - response = 'App instance with ID ['+app_instance_id+'] not found' + sfs = kubernetes_connector.get_deployed_service_functions() + response = "App instance with ID [" + app_instance_id + "] not found" for service_fun in sfs.items: - if service_fun["uid"]==app_instance_id: - response = kubernetes_connector.delete_service_function(service_fun['service_function_instance_name']) - return response + if service_fun["uid"] == app_instance_id: + response = kubernetes_connector.delete_service_function( + service_fun["service_function_instance_name"] + ) + return response + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> List[Dict]: - def get_edge_cloud_zones(self, region: Optional[str] = None, status: Optional[str] = None) -> List[Dict]: - nodes_response = kubernetes_connector.get_PoPs() - zone_list =[] - - for node in nodes_response.json().get('nodes'): - zone = {} - zone['edgeCloudZoneId'] = node.get('uid') - zone['edgeCloudZoneName'] = node.get('name') - zone['edgeCloudZoneStatus'] = node.get('status') - zone['edgeCloudProvider'] = edge_cloud_provider - zone['edgeCloudRegion'] = node.get('location') - zone_list.append(zone) + zone_list = [] + + for node in nodes_response.json().get("nodes"): + zone = {} + zone["edgeCloudZoneId"] = node.get("uid") + zone["edgeCloudZoneName"] = node.get("name") + zone["edgeCloudZoneStatus"] = node.get("status") + zone["edgeCloudProvider"] = edge_cloud_provider + zone["edgeCloudRegion"] = node.get("location") + zone_list.append(zone) return zone_list - diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py index 61aaf10..e2c3ca1 100644 --- a/src/edgecloud/core/edgecloud_factory.py +++ b/src/edgecloud/core/edgecloud_factory.py @@ -28,9 +28,7 @@ class EdgeCloudFactory: """ @staticmethod - def create_edgecloud_client( - client_name: str, base_url: str - ) -> EdgeCloudInterface: + def create_edgecloud_client(client_name: str, base_url: str) -> EdgeCloudInterface: try: return EdgeCloudTypes.edgecloud_types[client_name](base_url) except KeyError: diff --git a/src/logger.py b/src/logger.py index 1a8b07d..4fb7825 100644 --- a/src/logger.py +++ b/src/logger.py @@ -20,9 +20,7 @@ COLORED_FORMATERR = ( "[%(log_color)s%(name)s%(reset)s:%(log_color)s%(lineno)d%(reset)s] " "%(log_color)s%(message)s%(reset)s" ) -FILE_FORMATTER = ( - "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message)s" -) +FILE_FORMATTER = "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message)s" def setup_logger(logger_name=APP_LOGGER_NAME, is_debug=True, file_name=None): diff --git a/src/main.py b/src/main.py index d87d7fe..6fede35 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from src import logger from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory @@ -35,10 +36,12 @@ def create_edgecloud_client(client_name: str, base_url: str): # # Print the edgecloud client being used and its URL # print(f"Using edgecloud client: {sbi}") +# print(f"Edge Cloud Platform: {client_name}") # print(f"URL: {sbi.base_url}") # print("") # # Get all availability zones +# print("Running test endpoint: get_edge_cloud_zones:") # zones = sbi.get_edge_cloud_zones() # print(zones) # ########################################### diff --git a/src/network/clients/errors.py b/src/network/clients/errors.py index 1fd3ed6..6497bd8 100644 --- a/src/network/clients/errors.py +++ b/src/network/clients/errors.py @@ -1,2 +1,3 @@ +# -*- coding: utf-8 -*- class NetworkPlatformError(Exception): pass diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py index ce08471..44da698 100644 --- a/src/network/clients/oai/client.py +++ b/src/network/clients/oai/client.py @@ -1,8 +1,8 @@ +# -*- coding: utf-8 -*- from typing import Dict + from src import logger from src.network.core.network_interface import NetworkManagementInterface -from . import common -from . import schemas log = logger.get_logger(__name__) @@ -21,6 +21,7 @@ class NetworkManager(NetworkManagementInterface): def delete_qod_session(self, session_id: str) -> None: pass + # Note: # As this class is inheriting from NetworkManagementInterface, it is # expected to implement all the abstract methods defined in that interface. diff --git a/src/network/clients/open5gcore/client.py b/src/network/clients/open5gcore/client.py index 7d51c52..195e676 100644 --- a/src/network/clients/open5gcore/client.py +++ b/src/network/clients/open5gcore/client.py @@ -1,8 +1,8 @@ +# -*- coding: utf-8 -*- from typing import Dict + from src import logger from src.network.core.network_interface import NetworkManagementInterface -from . import common -from . import schemas log = logger.get_logger(__name__) @@ -21,6 +21,7 @@ class NetworkManager(NetworkManagementInterface): def delete_qod_session(self, session_id: str) -> None: pass + # Note: # As this class is inheriting from NetworkManagementInterface, it is # expected to implement all the abstract methods defined in that interface. diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index dc5ddf6..422892f 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -1,8 +1,8 @@ +# -*- coding: utf-8 -*- from typing import Dict + from src import logger from src.network.core.network_interface import NetworkManagementInterface -from . import common -from . import schemas log = logger.get_logger(__name__) @@ -58,6 +58,7 @@ class NetworkManager(NetworkManagementInterface): """ pass + # Note: # As this class is inheriting from NetworkManagementInterface, it is # expected to implement all the abstract methods defined in that interface. diff --git a/src/network/clients/open5gs/common.py b/src/network/clients/open5gs/common.py index 067c756..d0acc64 100644 --- a/src/network/clients/open5gs/common.py +++ b/src/network/clients/open5gs/common.py @@ -1,11 +1,11 @@ +# -*- coding: utf-8 -*- # Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). -import json from typing import Optional -import requests -from pydantic import BaseModel, ValidationError -from src.network.clients.errors import NetworkPlatformError +from pydantic import BaseModel + from src import logger +from src.network.clients.errors import NetworkPlatformError log = logger.get_logger(__name__) diff --git a/src/network/clients/open5gs/schemas.py b/src/network/clients/open5gs/schemas.py index 1f13918..b572d75 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/clients/open5gs/schemas.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file defines the Pydantic models that represent the data structures (schemas) # for the requests sent to and responses received from the Open5GS NEF API, # specifically focusing on the APIs needed to support CAMARA QoD. @@ -10,6 +11,7 @@ class Open5GSQoSSubscription(BaseModel): """ Represents the payload for creating a QoS subscription in Open5GS. """ + pass @@ -17,4 +19,5 @@ class CamaraQoDSessionInfo(BaseModel): """ Represents the input data for creating a QoD session. """ + pass diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py index 4ce0131..9669b6c 100644 --- a/src/network/core/network_factory.py +++ b/src/network/core/network_factory.py @@ -13,9 +13,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from src.network.clients.open5gs.client import Open5GSClient from src.network.clients.oai.client import OaiNefClient from src.network.clients.open5gcore.client import Open5GCoreClient +from src.network.clients.open5gs.client import Open5GSClient if TYPE_CHECKING: from .network_interface import NetworkManagementInterface diff --git a/tests/edgecloud/test_1_factory.py b/tests/edgecloud/test_1_factory.py index 4cd0dfd..5433f0e 100644 --- a/tests/edgecloud/test_1_factory.py +++ b/tests/edgecloud/test_1_factory.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import pytest from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient @@ -18,8 +19,5 @@ def test_factory_edgecloud(client_name, base_url): "piedge": PiEdgeClient, } expected_client_class = client_class_map[client_name] - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, - base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) assert isinstance(edgecloud_platform, expected_client_class) diff --git a/tests/edgecloud/test_2_av_zones.py b/tests/edgecloud/test_2_av_zones.py index 1e3f253..b06bb7d 100644 --- a/tests/edgecloud/test_2_av_zones.py +++ b/tests/edgecloud/test_2_av_zones.py @@ -1,7 +1,8 @@ +# -*- coding: utf-8 -*- import pytest -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from src.edgecloud.clients.errors import EdgeCloudPlatformError +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases @@ -10,9 +11,7 @@ def test_get_edge_cloud_zones(client_name, base_url): """ Test the format of the response from get_edge_cloud_zones for each client. """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: zones = edgecloud_platform.get_edge_cloud_zones() assert isinstance(zones, list) diff --git a/tests/edgecloud/test_3_artefact.py b/tests/edgecloud/test_3_artefact.py index 8b1dffa..77e345b 100644 --- a/tests/edgecloud/test_3_artefact.py +++ b/tests/edgecloud/test_3_artefact.py @@ -1,7 +1,8 @@ +# -*- coding: utf-8 -*- import pytest -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from src.edgecloud.clients.errors import EdgeCloudPlatformError +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory # Note: artifact mgmt is only supported by i2Edge @@ -29,7 +30,7 @@ def test_create_artefact_success(client_name, base_url): repo_url="https://helm.github.io/examples", password=None, token=None, - user_name=None + user_name=None, ) except EdgeCloudPlatformError as e: pytest.fail(f"Artefact creation failed unexpectedly: {e}") @@ -50,15 +51,13 @@ def test_create_artefact_failure(client_name, base_url): repo_url="http://invalid.url", password=None, token=None, - user_name=None + user_name=None, ) @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform._get_artefact(artefact_id=artefact_id) except EdgeCloudPlatformError as e: @@ -67,18 +66,14 @@ def test_get_artefact_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) with pytest.raises(EdgeCloudPlatformError): edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_all_artefacts_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform._get_all_artefacts() except EdgeCloudPlatformError as e: @@ -87,9 +82,7 @@ def test_get_all_artefacts_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform._delete_artefact(artefact_id=artefact_id) except EdgeCloudPlatformError as e: @@ -98,8 +91,6 @@ def test_delete_artefact_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) with pytest.raises(EdgeCloudPlatformError): edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") diff --git a/tests/edgecloud/test_4_app_onboarding.py b/tests/edgecloud/test_4_app_onboarding.py index cdf3800..0bd14d3 100644 --- a/tests/edgecloud/test_4_app_onboarding.py +++ b/tests/edgecloud/test_4_app_onboarding.py @@ -1,7 +1,8 @@ +# -*- coding: utf-8 -*- import pytest -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from src.edgecloud.clients.errors import EdgeCloudPlatformError +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases # CAMARA app payload (only mandatory fields) @@ -49,9 +50,7 @@ app_manifest = { @pytest.mark.parametrize("client_name, base_url", test_cases) def test_onboard_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.onboard_app(app_manifest) except EdgeCloudPlatformError as e: @@ -60,18 +59,14 @@ def test_onboard_app_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_onboard_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.onboard_app({}) @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) except EdgeCloudPlatformError as e: @@ -80,18 +75,14 @@ def test_get_onboarded_app_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.get_onboarded_app(app_id="non-existent-app") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_all_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.get_all_onboarded_apps() except EdgeCloudPlatformError as e: @@ -100,9 +91,7 @@ def test_get_all_onboarded_app_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) except EdgeCloudPlatformError as e: @@ -111,8 +100,6 @@ def test_delete_onboarded_app_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) with pytest.raises(EdgeCloudPlatformError): edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") diff --git a/tests/edgecloud/test_5_app_deployment.py b/tests/edgecloud/test_5_app_deployment.py index 4640904..6859c78 100644 --- a/tests/edgecloud/test_5_app_deployment.py +++ b/tests/edgecloud/test_5_app_deployment.py @@ -1 +1,2 @@ +# -*- coding: utf-8 -*- # TODO diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index ea1c93d..b9270e7 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- test_cases = [ ("i2edge", "http://192.168.123.237:30769/"), # ("aeros", "http://aeros.example.com/"), -- GitLab From d07f7bfce9e50a4ed2818e8f04ab2ce907a7c44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 11:42:16 +0200 Subject: [PATCH 073/281] Polish the precommit hooks config --- .pre-commit-config.yaml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4c4baf..1568060 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,11 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-added-large-files - - id: check-executables-have-shebangs -# - id: check-yaml - id: end-of-file-fixer - exclude: ^caas/cfg/ - - id: fix-encoding-pragma - id: requirements-txt-fixer - id: trailing-whitespace - # https://stackoverflow.com/a/76200334/2186237 - repo: https://github.com/pycqa/isort rev: 6.0.1 hooks: @@ -25,17 +18,9 @@ repos: - id: black args: # - "--check" - - "--target-version=py38" - - "--target-version=py39" - "--target-version=py310" - - "--target-version=py311" - - "--target-version=py312" - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 additional_dependencies: [] -#- repo: https://github.com/hhatto/autopep8 -# rev: v2.0.4 -# hooks: -# - id: autopep8 -- GitLab From 17829f19493f77fe3a31eca0eacc4c40392980f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 12:10:04 +0200 Subject: [PATCH 074/281] Add Contribute & Testing files. Update readme --- README.md | 10 +++++----- docs/CONTRIBUTING.md | 24 ++++++++++++++++++++++++ docs/TESTING.md | 14 ++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/TESTING.md diff --git a/README.md b/README.md index 831268d..dcc5299 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,14 @@ Each contribution should be made in the appropriate directory: - **EdgeCloud Adapters** → `src/edgecloud/clients/` - **Network Adapters** → `src/network/clients/` -## Unit Tests Requirement -To merge a feature branch into `main`, the adapter **must pass the unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores) +## Testing (Mandatory) +To merge a feature branch into `main`, the adapter **must pass the unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores). Instructions to do so available at [TESTING.md](docs/TESTING.MD) -## Steps to Contribute -1. **Fork the Repository** (if applicable). +## Contributing +1. **Check Guidelines at [CONTRIBUTING.md](docs/CONTRIBUTING.md).** 2. **Create a New Branch** following the naming convention. 3. **Develop Your Feature** inside the correct directory. -4. **Write Unit Tests** under `/tests`. +4. **Satisfy Unit Tests** located under `/tests`. 5. **Submit a Merge Request (MR)** to the `main` branch. 6. **Ensure All Tests Pass** before the merge. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..44e22db --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +To contribute with code, follow the instructions below to enforce automatic syntax formatting and linting. +Configuration is taken from https://www.pre-commit.com/#2-add-a-pre-commit-configuration and from git-hooks. + +*Note*: apply commands from the root of the repository. + +```bash +# Required: install the pre-commit hooks +pip3 install pre-commit +pre-commit install + +# Required: install the commit-msg git hook +cp -p ./dev/hooks/commit-msg .git/hooks/ + +# Optional: manual trigger (to know how validation will be applied or to force it manually on files before/after commit) +pre-commit run --all-files + +# Optional: to keep the pre-commit versions of validation up-to-date +pre-commit autoupdate + +# Optional: to remove the pre-commit git-hook binding +pre-commit uninstall +``` diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..cc0b857 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,14 @@ +# Testing + +*Note*: apply commands from the root of the repository. + +To run tests for the Edge Cloud adapters: + +```bash +pytest tests/edgecloud/ +``` + +To run tests for the Network adapters (WIP): +```bash +pytest tests/network/ +``` -- GitLab From 7f0ec4a088c7798bc6140a11d00f881cae6b24f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 12:23:22 +0200 Subject: [PATCH 075/281] Update README to provide more clarity --- README.md | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index dcc5299..7ef489a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,29 @@ -# OpenSDK - Contribution Guidelines +# OpenSDK -Thank you for contributing to this project! Please follow the guidelines below to ensure a smooth collaboration. +Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms & 5G network cores -## Branch Naming Convention -Each partner should create a feature branch following the naming convention based on the type of adapter they are contributing: +## Contributing Guidelines +Thank you for contributing to this project. Please follow the guidelines below to ensure a smooth collaboration. + +### Directory Structure +Each contribution should be made in the appropriate directory: +- **EdgeCloud Adapters** → `src/edgecloud/clients/` +- **Network Adapters** → `src/network/clients/` + +### Testing (Mandatory) +To merge a feature branch into `main`, the adapter **must pass the unit tests**. Instructions to do so available at [TESTING.md](docs/TESTING.md) + +### Contributing +1. **Check Guidelines at [CONTRIBUTING.md](docs/CONTRIBUTING.md).** +2. **Create a New Branch** following the naming convention. +3. **Develop Your Feature** inside the correct directory. +4. **Ensure All Tests Pass** before the merge. +5. **Submit a Merge Request (MR)** to the `main` branch. +### Branch Naming Convention +Each partner should create a feature branch following the naming convention based on the type of adapter they are contributing: -### ☁️ EdgeCloud Adapters +#### ☁️ EdgeCloud Adapters Branch Name Format: ``` feature/add-edgecloud- @@ -16,7 +33,7 @@ Example: feature/add-edgecloud-i2edge ``` -### 🌐 Network Adapters +#### 🌐 Network Adapters Branch Name Format: ``` feature/add-network-<5G_CORE_NAME> @@ -26,22 +43,6 @@ Example: feature/add-network-open5gs ``` -## Directory Structure -Each contribution should be made in the appropriate directory: -- **EdgeCloud Adapters** → `src/edgecloud/clients/` -- **Network Adapters** → `src/network/clients/` - -## Testing (Mandatory) -To merge a feature branch into `main`, the adapter **must pass the unit tests** under the `/tests` directory. Please use `/tests/edgecloud` for edgecloud, and `/tests/network` for the 5G cores). Instructions to do so available at [TESTING.md](docs/TESTING.MD) - -## Contributing -1. **Check Guidelines at [CONTRIBUTING.md](docs/CONTRIBUTING.md).** -2. **Create a New Branch** following the naming convention. -3. **Develop Your Feature** inside the correct directory. -4. **Satisfy Unit Tests** located under `/tests`. -5. **Submit a Merge Request (MR)** to the `main` branch. -6. **Ensure All Tests Pass** before the merge. - ## Sequence Diagram Example Refer to the sequence diagram example from `docs/workflows/edgecloud/get_av_zones.md` for guidance on workflow structure: -- GitLab From f0bb3da258f5f4e02c2d6573c2679cdfd4c6808f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 15:05:55 +0200 Subject: [PATCH 076/281] Update contributing guideline --- docs/CONTRIBUTING.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 44e22db..922fbe2 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -10,9 +10,6 @@ Configuration is taken from https://www.pre-commit.com/#2-add-a-pre-commit-confi pip3 install pre-commit pre-commit install -# Required: install the commit-msg git hook -cp -p ./dev/hooks/commit-msg .git/hooks/ - # Optional: manual trigger (to know how validation will be applied or to force it manually on files before/after commit) pre-commit run --all-files -- GitLab From 4f344d23b3a9fe1ff85b684b5574b0b4fc8074d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 15:32:36 +0200 Subject: [PATCH 077/281] Add ci pipeline --- .github/workflows/ci.yaml | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..022e6c9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,47 @@ +name: CI Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + # tests: + # name: Run tests + # runs-on: ubuntu-latest + # container: + # image: python:3.12-slim + + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + + # - name: Install dependencies + # run: pip install -r requirements.txt + + # - name: Run pytest + # run: pytest tests/edgecloud + + lint: + name: Run linters + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + container: + image: python:3.12-slim + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: isort check + run: isort src tests --check + + - name: black check + run: black src tests --check + + - name: flake8 check + run: flake8 src tests -- GitLab From 0e9716f729d6522f80dcdbf756154ff2a13902d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 15:37:29 +0200 Subject: [PATCH 078/281] Fix isort arguments --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 022e6c9..8520624 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,7 @@ jobs: run: pip install -r requirements.txt - name: isort check - run: isort src tests --check + run: isort src tests --check --profile black --filter-files - name: black check run: black src tests --check -- GitLab From 26916013f124810426d528cf125902e8f73e367f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Apr 2025 15:45:44 +0200 Subject: [PATCH 079/281] Add missing packages in requirements.txt --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index c08353a..2a651a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ asttokens==3.0.0 attrs==25.3.0 backcall==0.2.0 beautifulsoup4==4.13.3 +black==24.8.0 bleach==6.2.0 build==1.2.2.post1 certifi==2025.1.31 @@ -19,10 +20,12 @@ exceptiongroup==1.2.2 executing==2.2.0 fastjsonschema==2.21.1 filelock==3.18.0 +flake8==7.1.1 identify==2.6.10 idna==3.10 iniconfig==2.0.0 ipython==8.12.3 +isort==5.13.2 jedi==0.19.2 Jinja2==3.1.6 jsonschema==4.23.0 -- GitLab From 06b5727cb5e982916ce59422be95aba70db9c0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 25 Apr 2025 11:40:01 +0200 Subject: [PATCH 080/281] Add get_edge_cloud_zones_details function --- src/edgecloud/clients/aeros/client.py | 39 ++++++++++++++++++++ src/edgecloud/clients/i2edge/client.py | 43 +++++++++++++++++++++++ src/edgecloud/clients/piedge/client.py | 39 ++++++++++++++++++++ src/edgecloud/core/edgecloud_interface.py | 14 ++++++++ 4 files changed, 135 insertions(+) diff --git a/src/edgecloud/clients/aeros/client.py b/src/edgecloud/clients/aeros/client.py index 6c0fa08..3fa9a44 100644 --- a/src/edgecloud/clients/aeros/client.py +++ b/src/edgecloud/clients/aeros/client.py @@ -40,3 +40,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self, region: Optional[str] = None, status: Optional[str] = None ) -> List[Dict]: return [{"edgeCloudZoneId": "zone-1", "status": "active"}] + + def get_edge_cloud_zones_details( + self, zone_id: str, flavour_id: Optional[str] = None + ) -> Dict: + # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API + return { + "zoneId": zone_id, + "reservedComputeResources": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "4", + "memory": 8192, + } + ], + "computeResourceQuotaLimits": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "8", + "memory": 16384, + } + ], + "flavoursSupported": [ + { + "flavourId": "medium-x86", + "cpuArchType": "ISA_X86_64", + "supportedOSTypes": [ + { + "architecture": "x86_64", + "distribution": "UBUNTU", + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": 4, + "memorySize": 8192, + "storageSize": 100, + } + ], + } diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index a48fff2..5396fd6 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -43,6 +43,46 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e + # Harcoded + def get_edge_cloud_zones_details( + self, zone_id: str, flavour_id: Optional[str] = None + ) -> Dict: + # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API + return { + "zoneId": zone_id, + "reservedComputeResources": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "4", + "memory": 8192, + } + ], + "computeResourceQuotaLimits": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "8", + "memory": 16384, + } + ], + "flavoursSupported": [ + { + "flavourId": "medium-x86", + "cpuArchType": "ISA_X86_64", + "supportedOSTypes": [ + { + "architecture": "x86_64", + "distribution": "UBUNTU", + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": 4, + "memorySize": 8192, + "storageSize": 100, + } + ], + } + def _create_artefact( self, artefact_id: str, @@ -139,9 +179,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e + # Harcoded def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: return {"appInstanceId": "abcd-efgh"} + # Harcoded def get_all_deployed_apps( self, app_id: Optional[str] = None, @@ -150,5 +192,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) -> List[Dict]: return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + # Harcoded def undeploy_app(self, app_instance_id: str) -> None: print(f"Deleting app instance: {app_instance_id}") diff --git a/src/edgecloud/clients/piedge/client.py b/src/edgecloud/clients/piedge/client.py index 89b7b96..e6c767f 100644 --- a/src/edgecloud/clients/piedge/client.py +++ b/src/edgecloud/clients/piedge/client.py @@ -113,3 +113,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone["edgeCloudRegion"] = node.get("location") zone_list.append(zone) return zone_list + + def get_edge_cloud_zones_details( + self, zone_id: str, flavour_id: Optional[str] = None + ) -> Dict: + # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API + return { + "zoneId": zone_id, + "reservedComputeResources": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "4", + "memory": 8192, + } + ], + "computeResourceQuotaLimits": [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": "8", + "memory": 16384, + } + ], + "flavoursSupported": [ + { + "flavourId": "medium-x86", + "cpuArchType": "ISA_X86_64", + "supportedOSTypes": [ + { + "architecture": "x86_64", + "distribution": "UBUNTU", + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": 4, + "memorySize": 8192, + "storageSize": 100, + } + ], + } diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/edgecloud/core/edgecloud_interface.py index 5731535..7dd2c6f 100644 --- a/src/edgecloud/core/edgecloud_interface.py +++ b/src/edgecloud/core/edgecloud_interface.py @@ -107,3 +107,17 @@ class EdgeCloudManagementInterface(ABC): :return: List of Edge Cloud Zones. """ pass + + @abstractmethod + def get_edge_cloud_zones_details( + self, federation_context_id: str, zone_id: str + ) -> Dict: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP. + + :param federation_context_id: Identifier of the federation context. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with Edge Cloud Zone details. + """ + pass -- GitLab From 367480d47c43c21a4aab9903fbc67c6b01e63f06 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 27 Apr 2025 12:57:00 +0300 Subject: [PATCH 081/281] aerOS continuum exposure under OpenSDK. pytesting still needs to be alinged --- .pre-commit-config.yaml | 2 +- docs/workflows/edgecloud/get_av_zones.md | 4 + src/edgecloud/clients/aeros/__init__.py | 21 ++ src/edgecloud/clients/aeros/client.py | 235 +++++++++++++--- src/edgecloud/clients/aeros/config.py | 23 ++ .../clients/aeros/continuum_client.py | 170 +++++++++++ src/edgecloud/clients/aeros/utils.py | 43 +++ tests/edgecloud/test_aeros_edge_manager.py | 265 ++++++++++++++++++ tests/edgecloud/test_cases.py | 2 +- 9 files changed, 729 insertions(+), 36 deletions(-) create mode 100644 src/edgecloud/clients/aeros/config.py create mode 100644 src/edgecloud/clients/aeros/continuum_client.py create mode 100644 src/edgecloud/clients/aeros/utils.py create mode 100644 tests/edgecloud/test_aeros_edge_manager.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1568060..0757ac2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: # - "--check" - "--target-version=py310" - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.2.0 hooks: - id: flake8 additional_dependencies: [] diff --git a/docs/workflows/edgecloud/get_av_zones.md b/docs/workflows/edgecloud/get_av_zones.md index ad7258c..efa4d28 100644 --- a/docs/workflows/edgecloud/get_av_zones.md +++ b/docs/workflows/edgecloud/get_av_zones.md @@ -9,6 +9,7 @@ box Service Resource Manager end participant i2Edge participant PiEdge +participant aerOS note over AP,CE: CAMARA EdgeCloud API AP ->> CE: GET /edge-cloud-zones @@ -19,4 +20,7 @@ SDK ->> i2Edge: GET /zones/list API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) API ->> SDK: sbi.get_edge_cloud_zones() SDK ->> PiEdge: GET /nodes +API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(aerOS) +API ->> SDK: sbi.get_edge_cloud_zones() +SDK ->> aerOS: GET /entities?type=Domain ``` diff --git a/src/edgecloud/clients/aeros/__init__.py b/src/edgecloud/clients/aeros/__init__.py index e69de29..191fcb7 100644 --- a/src/edgecloud/clients/aeros/__init__.py +++ b/src/edgecloud/clients/aeros/__init__.py @@ -0,0 +1,21 @@ +""" +aerOS client + This module provides a client for interacting with the aerOS REST API. + It includes methods for onboarding/deploying applications, + and querying aerOS continuum entities + aerOS domain is exposed as zones + aerOS services and service components are exposed as applications + Client is initialized with a base URL for the aerOS API + and an access token for authentication. +""" + +from src.edgecloud.clients.aeros import config +from src.logger import setup_logger + +logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + +logger.info("aerOS client initialized") +logger.debug("aerOS API URL: %s", config.aerOS_API_URL) +logger.debug("aerOS access token: %s", config.aerOS_ACCESS_TOKEN) +logger.debug("aerOS debug mode: %s", config.DEBUG) +logger.debug("aerOS log file: %s", config.LOG_FILE) diff --git a/src/edgecloud/clients/aeros/client.py b/src/edgecloud/clients/aeros/client.py index 3fa9a44..ead33eb 100644 --- a/src/edgecloud/clients/aeros/client.py +++ b/src/edgecloud/clients/aeros/client.py @@ -1,29 +1,63 @@ -# -*- coding: utf-8 -*- -# Mocked API for testing purposes -from typing import Dict, List, Optional +## +# This file is part of the Open SDK +# +# Contributors: +# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) +# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) +## +from typing import Any, Dict, List, Optional +from src.edgecloud.clients.aeros import config +from src.edgecloud.clients.aeros.continuum_client import ContinuumClient from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from src.logger import setup_logger class EdgeApplicationManager(EdgeCloudManagementInterface): + """ + aerOS Continuum Client + FIXME: Handle None responses from continuum client + """ + def __init__(self, base_url: str): self.base_url = base_url + self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) def onboard_app(self, app_manifest: Dict) -> Dict: - print(f"Submitting application: {app_manifest}") - return {"appId": "1234-5678"} + # HLO-FE POST with TOSCA and app_id (service_id) + service_id = app_manifest.get("serviceId") + tosca_str = app_manifest.get("tosca") + aeros_client = ContinuumClient(self.base_url) + onboard_response = aeros_client.onboard_service( + service_id=service_id, tosca_str=tosca_str + ) + return {"appId": onboard_response["serviceId"]} def get_all_onboarded_apps(self) -> List[Dict]: - return [{"appId": "1234-5678", "name": "TestApp"}] + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Service&format=simplified" + aeros_apps = aeros_client.query_entities(ngsild_params) + return [ + {"appId": service["id"], "name": service["name"]} for service in aeros_apps + ] + # return [{"appId": "1234-5678", "name": "TestApp"}] def get_onboarded_app(self, app_id: str) -> Dict: - return {"appId": app_id, "name": "TestApp"} + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "format=simplified" + aeros_app = aeros_client.query_entity(app_id, ngsild_params) + return {"appId": aeros_app["id"], "name": aeros_app["name"]} def delete_onboarded_app(self, app_id: str) -> None: print(f"Deleting application: {app_id}") + # TBD: Purge from continuum (make all ngsil-ld calls for servieId connected entities) + # Should check if undeployed first def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - return {"appInstanceId": "abcd-efgh"} + # HLO-FE PUT with app_id (service_id) + aeros_client = ContinuumClient(self.base_url) + deploy_response = aeros_client.deploy_service(app_id) + return {"appInstanceId": deploy_response["serviceId"]} def get_all_deployed_apps( self, @@ -31,51 +65,184 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: Optional[str] = None, region: Optional[str] = None, ) -> List[Dict]: - return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + # FIXME: Get services in deployed state + aeros_client = ContinuumClient(self.base_url) + ngsild_params = 'type=Service&format=simplified&q=actionType=="DEPLOYED"' + if app_id: + ngsild_params += f'&q=service=="{app_id}"' + aeros_apps = aeros_client.query_entities(ngsild_params) + return [ + { + "appInstanceId": service["id"], + "status": + # scomponent["serviceComponentStatus"].split(":")[-1].lower() + service["actionType"], + } + for service in aeros_apps + ] + # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + + # def get_all_deployed_apps(self, + # app_id: Optional[str] = None, + # app_instance_id: Optional[str] = None, + # region: Optional[str] = None) -> List[Dict]: + # # FIXME: Get services in deployed state + # aeros_client = ContinuumClient(self.base_url) + # ngsild_params = "type=ServiceComponent&format=simplified" + # if app_id: + # ngsild_params += f'&q=service=="{app_id}"' + # aeros_apps = aeros_client.query_entities(ngsild_params) + # return [{ + # "appInstanceId": + # scomponent["id"], + # "status": + # scomponent["serviceComponentStatus"].split(":")[-1].lower() + # } for scomponent in aeros_apps] + # # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: - print(f"Deleting app instance: {app_instance_id}") + # HLO-FE DELETE with app_id (service_id) + aeros_client = ContinuumClient(self.base_url) + _ = aeros_client.undeploy_service(app_instance_id) def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None ) -> List[Dict]: - return [{"edgeCloudZoneId": "zone-1", "status": "active"}] + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Domain&format=simplified" + aeros_domains = aeros_client.query_entities(ngsild_params) + return [ + { + "edgeCloudZoneId": domain["id"], + "status": domain["domainStatus"].split(":")[-1].lower(), + } + for domain in aeros_domains + ] + + # return [{"edgeCloudZoneId": "zone-1", "status": "active"}] def get_edge_cloud_zones_details( self, zone_id: str, flavour_id: Optional[str] = None ) -> Dict: + """ + Get details of a specific edge cloud zone. + :param zone_id: The ID of the edge cloud zone + :param flavour_id: Optional flavour ID to filter the results + :return: Details of the edge cloud zone + """ # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API - return { - "zoneId": zone_id, + # return { + # "zoneId": + # zone_id, + # "reservedComputeResources": [{ + # "cpuArchType": "ISA_X86_64", + # "numCPU": "4", + # "memory": 8192, + # }], + # "computeResourceQuotaLimits": [{ + # "cpuArchType": "ISA_X86_64", + # "numCPU": "8", + # "memory": 16384, + # }], + # "flavoursSupported": [{ + # "flavourId": + # "medium-x86", + # "cpuArchType": + # "ISA_X86_64", + # "supportedOSTypes": [{ + # "architecture": "x86_64", + # "distribution": "UBUNTU", + # "version": "OS_VERSION_UBUNTU_2204_LTS", + # "license": "OS_LICENSE_TYPE_FREE", + # }], + # "numCPU": + # 4, + # "memorySize": + # 8192, + # "storageSize": + # 100, + # }], + # # + # } + aeros_client = ContinuumClient(self.base_url) + ngsild_params = ( + f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' + ) + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) + # Query the infrastructure elements for the specified zonese + aeros_domain_ies = aeros_client.query_entities(ngsild_params) + # Transform the infrastructure elements into the required format + # and return the details of the edge cloud zone + response = self.transform_infrastructure_elements( + domain_ies=aeros_domain_ies, domain=zone_id + ) + self.logger.debug("Transformed response: %s", response) + # Return the transformed response + return response + + def transform_infrastructure_elements( + self, domain_ies: List[Dict[str, Any]], domain: str + ) -> Dict[str, Any]: + """ + Transform the infrastructure elements into a format suitable for the + edge cloud zone details. + :param domain_ies: List of infrastructure elements + :param domain: The ID of the edge cloud zone + :return: Transformed details of the edge cloud zone + """ + total_cpu = 0 + total_ram = 0 + total_disk = 0 + total_available_ram = 0 + total_available_disk = 0 + + flavours_supported = [] + + for element in domain_ies: + total_cpu += element.get("cpuCores", 0) + total_ram += element.get("ramCapacity", 0) + total_available_ram += element.get("availableRam", 0) + total_disk += element.get("diskCapacity", 0) + total_available_disk += element.get("availableDisk", 0) + + # Create a flavour per machine + flavour = { + "flavourId": f"{element.get('hostname')}-{element.get('containerTechnology')}", + "cpuArchType": f"{element.get('cpuArchitecture')}", + "supportedOSTypes": [ + { + "architecture": f"{element.get('cpuArchitecture')}", + "distribution": f"{element.get('operatingSystem')}", # assume + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + } + ], + "numCPU": element.get("cpuCores", 0), + "memorySize": element.get("ramCapacity", 0), + "storageSize": element.get("diskCapacity", 0), + } + flavours_supported.append(flavour) + + result = { + "zoneId": domain, "reservedComputeResources": [ { "cpuArchType": "ISA_X86_64", - "numCPU": "4", - "memory": 8192, + "numCPU": str(total_cpu), + "memory": total_ram, } ], "computeResourceQuotaLimits": [ { "cpuArchType": "ISA_X86_64", - "numCPU": "8", - "memory": 16384, - } - ], - "flavoursSupported": [ - { - "flavourId": "medium-x86", - "cpuArchType": "ISA_X86_64", - "supportedOSTypes": [ - { - "architecture": "x86_64", - "distribution": "UBUNTU", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - } - ], - "numCPU": 4, - "memorySize": 8192, - "storageSize": 100, + "numCPU": str(total_cpu * 2), # Assume quota is 2x total? + "memory": total_ram * 2, } ], + "flavoursSupported": flavours_supported, } + return result diff --git a/src/edgecloud/clients/aeros/config.py b/src/edgecloud/clients/aeros/config.py new file mode 100644 index 0000000..ae28e3e --- /dev/null +++ b/src/edgecloud/clients/aeros/config.py @@ -0,0 +1,23 @@ +## +# This file is part of the Open SDK +# +# Contributors: +# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) +# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) +## +""" +aerOS access configuration +""" +import os + +aerOS_API_URL = os.environ.get("aerOS_API_URL") +if not aerOS_API_URL: + raise ValueError("Environment variable 'aerOS_API_URL' is not set.") +aerOS_ACCESS_TOKEN = os.environ.get("aerOS_ACCESS_TOKEN") +if not aerOS_ACCESS_TOKEN: + raise ValueError("Environment variable 'aerOS_ACCESS_TOKEN' is not set.") +aerOS_HLO_TOKEN = os.environ.get("aerOS_HLO_TOKEN") +if not aerOS_HLO_TOKEN: + raise ValueError("Environment variable 'aerOS_HLO_TOKEN' is not set.") +DEBUG = True +LOG_FILE = ".log/aeros_client.log" diff --git a/src/edgecloud/clients/aeros/continuum_client.py b/src/edgecloud/clients/aeros/continuum_client.py new file mode 100644 index 0000000..064ad6e --- /dev/null +++ b/src/edgecloud/clients/aeros/continuum_client.py @@ -0,0 +1,170 @@ +## +# This file is part of the Open SDK +# +# Contributors: +# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) +# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) +## +""" +aerOS REST API Client + This client is used to interact with the aerOS REST API. +""" + +import requests + +from src.edgecloud.clients.aeros import config +from src.edgecloud.clients.aeros.utils import catch_requests_exceptions +from src.logger import setup_logger + + +class ContinuumClient: + """ + Client to aerOS ngsi-ld based continuum exposure + """ + + def __init__(self, base_url: str = None): + """ + :param base_url: the base url of the aerOS API + """ + if base_url is None: + self.api_url = config.aerOS_API_URL + else: + self.api_url = base_url + self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + self.m2m_cb_token = config.aerOS_ACCESS_TOKEN + self.hlo_token = config.aerOS_HLO_TOKEN + self.headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "aerOS": "true", + "Authorization": f"Bearer {self.m2m_cb_token}", + } + self.hlo_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "aerOS": "true", + "Authorization": f"Bearer {self.hlo_token}", + } + self.hlo_onboard_headers = { + "Content-Type": "application/yaml", + "Authorization": f"Bearer {self.hlo_token}", + } + + @catch_requests_exceptions + def query_entity(self, entity_id, ngsild_params) -> dict: + """ + Query entity with ngsi-ld params + :input + @param entity_id: the id of the queried entity + @param ngsi-ld: the query params + :output + ngsi-ld object + """ + entity_url = f"{self.api_url}/entities/{entity_id}?{ngsild_params}" + response = requests.get(entity_url, headers=self.headers, timeout=15) + if response is None: + return None + else: + if config.DEBUG: + self.logger.debug("Query entity URL: %s", entity_url) + self.logger.debug( + "Query entity response: %s %s", response.status_code, response.text + ) + return response.json() + + @catch_requests_exceptions + def query_entities(self, ngsild_params): + """ + Query entities with ngsi-ld params + :input + @param ngsi-ld: the query params + :output + ngsi-ld object + """ + entities_url = f"{self.api_url}/entities?{ngsild_params}" + response = requests.get(entities_url, headers=self.headers, timeout=15) + if response is None: + return None + # else: + # if config.DEBUG: + # self.logger.debug("Query entities URL: %s", entities_url) + # self.logger.debug("Query entities response: %s %s", + # response.status_code, response.text) + return response.json() + + @catch_requests_exceptions + def deploy_service(self, service_id: str) -> dict: + """ + Re-allocate (deploy) service on aerOS continuum + :input + @param service_id: the id of the service to be re-allocated + :output + the re-allocated service json object + """ + re_allocate_url = f"{self.api_url}/hlo_fe/services/{service_id}" + response = requests.put(re_allocate_url, headers=self.hlo_headers, timeout=15) + if response is None: + return None + else: + if config.DEBUG: + self.logger.debug("Re-allocate service URL: %s", re_allocate_url) + self.logger.debug( + "Re-allocate service response: %s %s", + response.status_code, + response.text, + ) + return response.json() + + @catch_requests_exceptions + def undeploy_service(self, service_id: str) -> dict: + """ + Undeploy service + :input + @param service_id: the id of the service to be undeployed + :output + the undeployed service json object + """ + undeploy_url = f"{self.api_url}/hlo_fe/services/{service_id}" + response = requests.delete(undeploy_url, headers=self.hlo_headers, timeout=15) + if response is None: + return None + else: + if config.DEBUG: + self.logger.debug("Re-allocate service URL: %s", undeploy_url) + self.logger.debug( + "Undeploy service response: %s %s", + response.status_code, + response.text, + ) + return response.json() + + @catch_requests_exceptions + def onboard_service(self, service_id: str, tosca_str: str) -> dict: + """ + Onboard (& deploy) service on aerOS continuum + :input + @param service_id: the id of the service to onboarded (& deployed) + @param tosca_str: the tosca whith all orchestration information + :output + the allocated service json object + """ + onboard_url = f"{self.api_url}/hlo_fe/services/{service_id}" + if config.DEBUG: + self.logger.debug("Onboard service URL: %s", onboard_url) + self.logger.debug( + "Onboard service request body (TOSCA-YAML): %s", tosca_str + ) + response = requests.post( + onboard_url, data=tosca_str, headers=self.hlo_onboard_headers, timeout=15 + ) + if response is None: + return None + else: + if config.DEBUG: + self.logger.debug("Onboard service URL: %s", onboard_url) + self.logger.debug( + "Onboard service response: %s %s", + response.status_code, + response.text, + ) + return response.json() diff --git a/src/edgecloud/clients/aeros/utils.py b/src/edgecloud/clients/aeros/utils.py new file mode 100644 index 0000000..d4f5cf5 --- /dev/null +++ b/src/edgecloud/clients/aeros/utils.py @@ -0,0 +1,43 @@ +## +# This file is part of the Open SDK +# +# Contributors: +# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) +# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) +## +""" +Docstring +""" +from requests.exceptions import HTTPError, RequestException, Timeout + +import src.edgecloud.clients.aeros.config as config +from src.logger import setup_logger + + +def catch_requests_exceptions(func): + """ + Docstring + """ + logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + + def wrapper(*args, **kwargs): + try: + result = func(*args, **kwargs) + return result + except HTTPError as e: + logger.info("4xx or 5xx: %s \n", {e}) + return None # raise our custom exception or log, etc. + except ConnectionError as e: + logger.info( + "Raised for connection-related issues (e.g., DNS resolution failure, network issues): %s \n", + {e}, + ) + return None # raise our custom exception or log, etc. + except Timeout as e: + logger.info("Timeout occured: %s \n", {e}) + return None # raise our custom exception or log, etc. + except RequestException as e: + logger.info("Request failed: %s \n", {e}) + return None # raise our custom exception or log, etc. + + return wrapper diff --git a/tests/edgecloud/test_aeros_edge_manager.py b/tests/edgecloud/test_aeros_edge_manager.py new file mode 100644 index 0000000..c18b5cc --- /dev/null +++ b/tests/edgecloud/test_aeros_edge_manager.py @@ -0,0 +1,265 @@ +## +# This file is part of the Open SDK +# Temporary file for testing aerOS EdgeApplicationManager class +# +# Contributors: +# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) +# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) +## +""" +aerOS continuum, exposed under CAMARA APIs unit testing +""" +import unittest +from typing import Any, Dict + +from src.edgecloud.clients.aeros.client import EdgeApplicationManager + +TOSCA_YAML_EXAMPLE: str = """ +tosca_definitions_version: tosca_simple_yaml_1_3 + +description: TOSCA for network performance + +node_templates: + influxdb: + type: tosca.nodes.Container.Application + requirements: + - network: + properties: + ports: + fastapi: + properties: + protocol: [tcp] + source: 8086 + exposePorts: true + - host: + node_filter: + properties: + id: "urn:ngsi-ld:InfrastructureElement:CloudFerro:fa163e5e25ef" + artifacts: + influxdb-image: + file: p4lik4ri/influxdb + type: tosca.artifacts.Deployment.Image.Container.Docker + repository: docker_hub + interfaces: + Standard: + create: + implementation: influxdb-image + inputs: + envVars: + - ENV1: void + + +""" + + +class TestAerOSEdgeApplicationManager(unittest.TestCase): + """ + Test aerOS EdgeApplicationManager class + Test aerOS ContinuumClient class + """ + + def setUp(self): + self.manager = EdgeApplicationManager( + base_url="https://ncsrd-mvp-domain.aeros-project.eu" + ) + + # def test_get_all_onboarded_apps_returns_list_of_dicts(self): + # ''' + # Test if get_all_onboarded_apps returns a list of dictionaries + # Check if the list contains at least one known item. + # ''' + # result = self.manager.get_all_onboarded_apps() + + # # Check it's a list + # self.assertIsInstance(result, list) + # self.assertTrue(all(isinstance(entry, dict) for entry in result)) + + # # Check if at least one known item is in the list + # expected_entry = { + # "appId": "urn:ngsi-ld:Service:xai-service", + # "name": "aeros_service_urn:ngsi-ld:Service:xai-service" + # } + # self.assertIn(expected_entry, result) + + # def test_get_onboarded_app_returns_expected_keys(self): + # ''' + # Test if get_onboarded_app returns a dictionary with expected keys + # Check against an existing "onboarded/deployed" service. + # ''' + # # Use an existing app ID known to be "onboarded" in aerOS + # app_id = "urn:ngsi-ld:Service:xai-service" + + # result = self.manager.get_onboarded_app(app_id) + + # self.assertIsInstance(result, dict) + # self.assertIn("appId", result) + # self.assertIn("name", result) + + # # Check specific known values + # self.assertEqual(result["appId"], app_id) + # self.assertEqual(result["name"], + # "aeros_service_urn:ngsi-ld:Service:xai-service") + + # def test_get_all_deployed_apps_returns_list(self): + # ''' + # Test if get_all_deployed_apps returns a list of dictionaries + # Check if list items (dicts) contain CAMARA expected keys. + # ''' + # result = self.manager.get_all_deployed_apps() + + # self.assertIsInstance(result, list) + # self.assertGreater(len(result), + # 0) # Expecting at least one app instance + + # for item in result: + # self.assertIn("appInstanceId", item) + # self.assertIn("status", item) + + # def test_get_all_deployed_apps_filter_by_app_id(self): + # ''' + # Test if get_all_deployed_apps returns a list of dictionaries + # when providing an app_id. + # Check if list items (dicts) contain CAMARA expected keys + # and service component name (appId) is one of the two + # components of the provided service. + # ''' + # app_id = "urn:ngsi-ld:Service:xai-service" + + # result = self.manager.get_all_deployed_apps(app_id=app_id) + + # self.assertIsInstance(result, list) + + # for item in result: + # self.assertIn("appInstanceId", item) + # self.assertIn("status", item) + # self.assertIsInstance(item["status"], str) + # self.assertIn(item["appInstanceId"], [ + # "urn:ngsi-ld:Service:xai-service:Component:server-side", + # "urn:ngsi-ld:Service:xai-service:Component:broker-side" + # ]) + + # def test_get_edge_cloud_zones_returns_valid_list(self): + # ''' + # Test if get_edge_cloud_zones returns a list of dictionaries + # Check if item NCSRD aerOS domain is contained in return object. + # ''' + # result = self.manager.get_edge_cloud_zones() + + # self.assertIsInstance(result, list) + # self.assertTrue( + # all("edgeCloudZoneId" in zone and "status" in zone + # for zone in result)) + + # # Optional: check for known zone + # known_zone = { + # "edgeCloudZoneId": "urn:ngsi-ld:Domain:NCSRD", + # "status": "functional" + # } + # self.assertIn(known_zone, result) + + # def test_onboard_app_success(self): + # ''' + # Test if onboard_app returns a dictionary with appId + # Check if the appId is correct + # ''' + # tosca_str = TOSCA_YAML_EXAMPLE + + # app_manifest = { + # "serviceId": "urn:ngsi-ld:Service:cloud-edge-app", + # "tosca": tosca_str + # } + + # result = self.manager.onboard_app(app_manifest) + + # self.assertIsInstance(result, dict) + # self.assertIn("appId", result) + # self.assertEqual(result["appId"], "urn:ngsi-ld:Service:cloud-edge-app") + + # def test_undeploy_app_completes_successfully(self): + # ''' + # Test if undeploy_app completes successfully + # Check if the appInstanceId is a string and starts with "urn:ngsi-ld:Service:" + # ''' + # app_instance_id = "urn:ngsi-ld:Service:cloud-edge-app" + # self.manager.undeploy_app(app_instance_id) + + # def test_deploy_app_returns_app_instance_id(self): + # ''' + # Test if deploy_app returns a dictionary with appInstanceId + # Check if the appInstanceId is a string and starts with "urn:ngsi-ld:Service:" + # ''' + # app_id = "urn:ngsi-ld:Service:xai-service" + # app_zones = [] # Not used in current implementation + + # result = self.manager.deploy_app(app_id, app_zones) + + # self.assertIsInstance(result, dict) + # self.assertIn("appInstanceId", result) + # self.assertIsInstance(result["appInstanceId"], str) + # self.assertTrue( + # result["appInstanceId"].startswith("urn:ngsi-ld:Service:")) + + def test_get_edge_cloud_zones_details_structure(self): + """ + Test if get_edge_cloud_zones_details returns a dictionary + Check if the dictionary contains expected keys and values. + Check if the values are of the expected types. + """ + + zone_id = "urn:ngsi-ld:Domain:NCSRD" + + # When + result: Dict[str, Any] = self.manager.get_edge_cloud_zones_details( + zone_id + ) # <-- FIX HERE! + + # Then + self.assertIsInstance(result, dict) + self.assertIn("zoneId", result) + self.assertIn("reservedComputeResources", result) + self.assertIn("computeResourceQuotaLimits", result) + self.assertIn("flavoursSupported", result) + + reserved_resources = result.get("reservedComputeResources", []) + self.assertIsInstance(reserved_resources, list) + + for resource in reserved_resources: + self.assertIsInstance(resource, dict) + self.assertIn("cpuArchType", resource) + self.assertIn("numCPU", resource) + self.assertIn("memory", resource) + + quota_limits = result.get("computeResourceQuotaLimits", []) + self.assertIsInstance(quota_limits, list) + + for limit in quota_limits: + self.assertIsInstance(limit, dict) + self.assertIn("cpuArchType", limit) + self.assertIn("numCPU", limit) + self.assertIn("memory", limit) + + flavours = result.get("flavoursSupported", []) + self.assertIsInstance(flavours, list) + + for flavour in flavours: + self.assertIsInstance(flavour, dict) + self.assertIn("flavourId", flavour) + self.assertIn("cpuArchType", flavour) + self.assertIn("supportedOSTypes", flavour) + self.assertIn("numCPU", flavour) + self.assertIn("memorySize", flavour) + self.assertIn("storageSize", flavour) + + supported_oses = flavour.get("supportedOSTypes", []) + self.assertIsInstance(supported_oses, list) + + for os_type in supported_oses: + self.assertIsInstance(os_type, dict) + self.assertIn("architecture", os_type) + self.assertIn("distribution", os_type) + self.assertIn("version", os_type) + self.assertIn("license", os_type) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index b9270e7..da584ee 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- test_cases = [ ("i2edge", "http://192.168.123.237:30769/"), - # ("aeros", "http://aeros.example.com/"), + # ("aeros", "https://ncsrd-mvp-domain.aeros-project.eu"), # ("piedge", "http://piedge.example.com/"), ] -- GitLab From e2f51a63bd5062684b24ebeac5f8d5cd685024a6 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Mon, 28 Apr 2025 19:56:47 +0300 Subject: [PATCH 082/281] Add some comments, regarding environment variables needed and unit test file --- src/edgecloud/clients/aeros/config.py | 1 + tests/edgecloud/test_aeros_edge_manager.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/edgecloud/clients/aeros/config.py b/src/edgecloud/clients/aeros/config.py index ae28e3e..e11c9c6 100644 --- a/src/edgecloud/clients/aeros/config.py +++ b/src/edgecloud/clients/aeros/config.py @@ -7,6 +7,7 @@ ## """ aerOS access configuration +Access tokens need to be provided in environment variables. """ import os diff --git a/tests/edgecloud/test_aeros_edge_manager.py b/tests/edgecloud/test_aeros_edge_manager.py index c18b5cc..4f2e041 100644 --- a/tests/edgecloud/test_aeros_edge_manager.py +++ b/tests/edgecloud/test_aeros_edge_manager.py @@ -7,7 +7,12 @@ # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## """ -aerOS continuum, exposed under CAMARA APIs unit testing +aerOS continuum, SUNRISE-6G SDK unit testing. +Please do not run in the same pass all of: + test_onboard_app_success, test_undeploy_app_completes_successfully, test_deploy_app_returns_app_instance_id +Leave uncommented just one of them each time. +Also environment variables must be sset in advance, regarding access tokens + see also config.py file in aerOS tree """ import unittest from typing import Any, Dict -- GitLab From 83d9e78d080c249ba416e5c6122f4e426bc303af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Mon, 5 May 2025 10:23:13 +0200 Subject: [PATCH 083/281] initial implementation for open5gs --- src/network/clients/open5gs/client.py | 59 +++++++++++++++++++++++++-- src/network/clients/open5gs/common.py | 9 ++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index dc5ddf6..0ba368a 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -1,4 +1,7 @@ +from itertools import product from typing import Dict + +from pydantic import ValidationError from src import logger from src.network.core.network_interface import NetworkManagementInterface from . import common @@ -6,6 +9,26 @@ from . import schemas log = logger.get_logger(__name__) +flow_id_mapping = { + "qos-e": 3, + "qos-s": 4, + "qos-m": 5, + "qos-l": 6 +} + +def flatten_port_spec(ports_spec: schemas.PortsSpec | None)-> list[str]: + has_ports = False + has_ranges = False + flat_ports = [] + if ports_spec and ports_spec.ports: + has_ports = True + flat_ports.extend([str(port) for port in ports_spec.ports]) + if ports_spec and ports_spec.ranges: + has_ranges = True + flat_ports.extend([f"{range.from_}-{range.to}" for range in ports_spec.ranges]) + if not has_ports and not has_ranges: + flat_ports.append("0-65535") + return flat_ports class NetworkManager(NetworkManagementInterface): """ @@ -40,7 +63,35 @@ class NetworkManager(NetworkManagementInterface): Creates a QoD session based on the CAMARA QoD API input. Maps the CAMARA QoD POST /sessions to Open5GS NEF POST /{scsAsId}/subscriptions. """ - pass + url = f"{self.base_url}/{self.scs_as_id}/subscriptions" + # Raises ValidationError if the object is not valid. + valid_session_info = schemas.CreateSession.model_validate(session_info) + if valid_session_info.qosProfile not in flow_id_mapping.keys(): + raise ValidationError(f"Open5Gs only supports these qos-profiles: {", ".join(flow_id_mapping.keys())}") + + flow_id = flow_id_mapping[valid_session_info.qosProfile] + device_ip = valid_session_info.device.ipv4Address or session_info.device.ipv4Address + server_ip = valid_session_info.applicationServer.ipv4Address or valid_session_info.applicationServer.ipv6Address + device_ports = flatten_port_spec(valid_session_info.devicePorts) + server_ports = flatten_port_spec(valid_session_info.applicationServerPorts) + ports_combis = list(product(device_ports, server_ports)) + + flow_descrs = [] + for device_port, server_port in ports_combis: + flow_descrs.append(f"permit in ip from {device_ip} {device_port} to {server_ip} {server_port}") + flow_descrs.append(f"permit out ip from {device_ip} {device_port} to {server_ip} {server_port}") + flows = [schemas.FlowInfo( + flowId=flow_id, + flowDescriptions=[", ".join(flow_descrs)] + )] + subscription = schemas.AsSessionWithQoSSubscription( + supportedFeatures=schemas.SupportedFeatures("003C"), + flowInfo=flows, + qosReference = valid_session_info.qosProfile, + ueIpv4Addr=valid_session_info.device.ipv4Address, + ueIpv6Addr=valid_session_info.device.ipv6Address, + ) + common.open5gs_post(url, subscription) def get_qod_session(self, session_id: str) -> Dict: """ @@ -48,7 +99,8 @@ class NetworkManager(NetworkManagementInterface): Maps CAMARA QoD GET /sessions/{sessionId} to Open5GS NEF GET / {scsAsId}/subscriptions/{subscriptionId}. """ - pass + url = f"{self.base_url}/{self.scs_as_id}/subscriptions/{session_id}" + common.open5gs_get(url) def delete_qod_session(self, session_id: str) -> None: """ @@ -56,7 +108,8 @@ class NetworkManager(NetworkManagementInterface): Maps CAMARA QoD DELETE /sessions/{sessionId} to Open5GS NEF DELETE / {scsAsId}/subscriptions/{subscriptionId}. """ - pass + url = f"{self.base_url}/{self.scs_as_id}/subscriptions/{session_id}" + common.open5gs_delete(url) # Note: # As this class is inheriting from NetworkManagementInterface, it is diff --git a/src/network/clients/open5gs/common.py b/src/network/clients/open5gs/common.py index 067c756..db311e8 100644 --- a/src/network/clients/open5gs/common.py +++ b/src/network/clients/open5gs/common.py @@ -23,18 +23,21 @@ class Open5GSErrorResponse(BaseModel): def open5gs_post(url: str, model_payload: BaseModel) -> dict: """ Placeholder for the POST request function.""" - pass + response = requests.post(url, model_payload) + return response.json() def open5gs_get(url: str, params: Optional[dict] = None) -> dict: """ Placeholder for the GET request function. """ - pass + response = requests.get(url, params=params) + return response.json() def open5gs_delete(url: str) -> None: """ Placeholder for the DELETE request function. """ - pass + requests.delete(url) + -- GitLab From f6f0b80f3db4f6bfcdb2a4c6fa0a3ade9f071034 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Wed, 7 May 2025 14:32:20 +0200 Subject: [PATCH 084/281] feat: traffic influence api. Closes #41 --- src/network/clients/oai/client.py | 74 +++++++++++++++++++++---- src/network/clients/oai/common.py | 26 ++++++++- src/network/clients/oai/schemas.py | 79 +++++++++++++++++++++++++-- src/network/clients/oai/utils.py | 24 +++++++- src/network/core/network_interface.py | 2 +- 5 files changed, 188 insertions(+), 17 deletions(-) diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py index 13fb9d4..9e0db68 100644 --- a/src/network/clients/oai/client.py +++ b/src/network/clients/oai/client.py @@ -14,16 +14,19 @@ import shortuuid import time from pydantic import ValidationError from src.network.core.network_interface import NetworkManagementInterface -from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription +from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription,CamaraTrafficInfluence, TrafficInfluSub from src.network.clients.oai.common import ( oai_as_session_with_qos_post, oai_as_session_with_qos_get, oai_as_session_with_qos_delete, + oai_traffic_influence_post, + oai_traffic_influence_delete, + oai_traffic_influence_put, OaiHttpError, OaiNetworkError ) -from src.network.clients.oai.utils import camara_qod_to_as_session_with_qos, as_session_with_qos_to_camara_qod +from src.network.clients.oai.utils import camara_qod_to_as_session_with_qos, as_session_with_qos_to_camara_qod, camara_ti_to_3gpp_ti log = logger.get_logger(__name__) @@ -121,19 +124,70 @@ class OaiNefClient(NetworkManagementInterface): #implementation of the NetworkManagementInterface Traffic Influence Methods def create_traffic_influence_resource(self, traffic_influence_info): + try: + ti_input = CamaraTrafficInfluence(**traffic_influence_info) + + #convert CAMARA TI to NEF TrafficInflSub model and do POST + nef_req = camara_ti_to_3gpp_ti(ti_input) + nef_res = oai_traffic_influence_post(self.base_url, self.scs_as_id, nef_req) + + #retrieve the NEF resource id + if "self" in nef_res.keys(): + nef_url = nef_res["self"] + nef_id = nef_url.split("subscriptions/")[1] + else: + raise OaiNetworkError("No valid ID for the created resource was returned") + + #create TI session detail and return info with resource Id + ti_input.trafficInfluenceID = nef_id + + log.info(f"Traffic Influence session activated successfully [id={nef_id}]") + + return ti_input + + except ValidationError as e: + raise OaiNetworkError("Could not validate Traffic Influence data") from e + except KeyError as e: + raise OaiNetworkError(f"Missing field in Traffic Influence data: {e}") from e + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not enable the Traffic Influence Session. It returned {e}") from e + except OaiNetworkError as e: + raise - log.error(f"create_traffic_influence_resource not implemented yet") - raise NotImplementedError() + def delete_traffic_influence_resource(self, session_id): + """ + Deletes a specific Traffic Influence (TI) session. + It maps CAMARA TI API DELETE /sessions/{sessionId} to + OAI NEF DELETE /3gpp-traffic-influence/v1/{scs_as_id}/subscriptions/{subscriptionId} + """ + try: + oai_traffic_influence_delete(self.base_url, self.scs_as_id, session_id=session_id) + + log.info(f"TI session deleted successfully [id={session_id}]") - def delete_traffic_influence_resource(self, resource_id): + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not delete the TI session. It returned {e}") from e + except OaiNetworkError as e: + raise - log.error(f"delete_traffic_influence_resource not implemented yet") + def put_traffic_influence_resource(self, resource_id, traffic_influence_info): + try: + qod_input = CamaraTrafficInfluence(**traffic_influence_info) - raise NotImplementedError() + #convert CAMARA TI to NEF TrafficInflSub model and do POST + nef_req = camara_ti_to_3gpp_ti(qod_input) + updated_res = oai_traffic_influence_put(self.base_url, self.scs_as_id, resource_id, nef_req) - def get_traffic_influence_resource(self, resource_id): + log.info(f"Traffic Influence resource updated successfully [id={resource_id}]") - log.error(f"get_traffic_influence_resource not implemented yet") + return qod_input - raise NotImplementedError() + except ValidationError as e: + raise OaiNetworkError("Could not validate Traffic Influence data") from e + except KeyError as e: + raise OaiNetworkError(f"Missing field in Traffic Influence data: {e}") from e + except OaiHttpError as e: + raise OaiNetworkError(f"The network could not update the Traffic Influence Session. It returned {e}") from e + except OaiNetworkError as e: + raise diff --git a/src/network/clients/oai/common.py b/src/network/clients/oai/common.py index 62eab05..39ea869 100644 --- a/src/network/clients/oai/common.py +++ b/src/network/clients/oai/common.py @@ -18,7 +18,7 @@ import requests def _make_request(method: str, url: str, data=None): try: headers = None - if method == 'POST': + if method == 'POST' or method == 'PUT': headers = { "Content-Type": "application/json", "accept": "application/json", @@ -60,6 +60,30 @@ def oai_as_session_with_qos_build_url(base_url: str, scs_as_id: str, session_id: else: return url + +## Traffic Influence methods +def oai_traffic_influence_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = oai_traffic_influence_build_url(base_url, scs_as_id) + return _make_request("POST", url, data=data) + +def oai_traffic_influence_delete(base_url: str, scs_as_id: str, session_id: str): + url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) + return _make_request("DELETE", url) + +def oai_traffic_influence_put(base_url: str, scs_as_id: str, session_id: str, model_payload: BaseModel) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) + return _make_request("PUT", url, data=data) + + +def oai_traffic_influence_build_url(base_url: str, scs_as_id: str, session_id: str = None): + url = f"{base_url}/3gpp-traffic-influence/v1/{scs_as_id}/subscriptions" + if session_id != None and len(session_id) > 0: + return f"{url}/{session_id}" + else: + return url + class OaiHttpError(Exception): pass diff --git a/src/network/clients/oai/schemas.py b/src/network/clients/oai/schemas.py index 5cea63b..369bc56 100644 --- a/src/network/clients/oai/schemas.py +++ b/src/network/clients/oai/schemas.py @@ -16,7 +16,8 @@ class Snssai(BaseModel): sst: int = Field(default=1) sd: str = Field(default="FFFFFF") -class FlowInfoItem(BaseModel): + +class TrafficFilter(BaseModel): flowId: int flowDescriptions: List[str] @@ -27,7 +28,7 @@ class OaiAsSessionWithQosSubscription(BaseModel): supportedFeatures: str = Field(default="12") dnn: str = Field(default="oai") snssai: Snssai - flowInfo: List[FlowInfoItem] + flowInfo: List[TrafficFilter] ueIpv4Addr: str notificationDestination: str qosReference: str @@ -36,7 +37,7 @@ class OaiAsSessionWithQosSubscription(BaseModel): def add_flow_descriptor(self, flow_desriptor: str): self.flowInfo = list() - self.flowInfo.append(FlowInfoItem( + self.flowInfo.append(TrafficFilter( flowId=len(self.flowInfo)+1, flowDescriptions=[flow_desriptor] )) @@ -117,4 +118,74 @@ class CamaraQoDSessionInfo(BaseModel): if self.device is None: self.device = Device() if self.device.ipv4Address is None: - self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) \ No newline at end of file + self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) + + +## traffic_influence schemas + +class SourceTrafficFilters(BaseModel): + sourcePort: int + +class DestinationTrafficFilters(BaseModel): + destinationPort: int + destinationProtocol: str + +class TrafficRoute(BaseModel): + dnai: str + +class NotificationSink(BaseModel): + sink: Optional[str] = None + sinkCredential: Optional[SinkCredential] = None + +class TrafficInfluSub(BaseModel): # Replace with a meaningful name + afServiceId: str + afAppId: str + dnn: str + snssai: Snssai + trafficFilters: List[TrafficFilter] + ipv4Addr: str + notificationDestination: str + trafficRoutes: List[TrafficRoute] + suppFeat: str + + def add_flow_descriptor(self, flow_desriptor: str): + self.trafficFilters = list() + self.trafficFilters.append(TrafficFilter( + flowId=len(self.trafficFilters)+1, + flowDescriptions=[flow_desriptor] + )) + + def add_traffic_route(self, dnai: str): + self.trafficRoutes = list() + self.trafficRoutes.append(TrafficRoute( + dnai=dnai + )) + + def add_snssai(self, sst: int, sd: str = None): + self.snssai = Snssai(sst=sst, sd=sd) + +class CamaraTrafficInfluence(BaseModel): + trafficInfluenceID : Optional[str] = None + apiConsumerId: str + appId: str + appInstanceId: str + edgeCloudRegion: Optional[str] = None + edgeCloudZoneId: str + sourceTrafficFilters: Optional[SourceTrafficFilters] = None + destinationTrafficFilters: Optional[DestinationTrafficFilters] = None + notificationUri: Optional[str] = None + notificationAuthToken: Optional[str] = None + device: Device + notificationSink: Optional[NotificationSink] = None + + def retrieve_ue_ipv4(self): + if self.device is not None and self.device.ipv4Address is not None: + return self.device.ipv4Address.publicAddress + else: + raise KeyError("device.ipv4Address.publicAddress") + + def add_ue_ipv4(self, ipv4: str): + if self.device is None: + self.device = Device() + if self.device.ipv4Address is None: + self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) diff --git a/src/network/clients/oai/utils.py b/src/network/clients/oai/utils.py index 96936e3..fde7952 100644 --- a/src/network/clients/oai/utils.py +++ b/src/network/clients/oai/utils.py @@ -9,7 +9,7 @@ ## -from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription +from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription, CamaraTrafficInfluence, TrafficInfluSub from pydantic import BaseModel def camara_qod_to_as_session_with_qos(qod_input: CamaraQoDSessionInfo) -> OaiAsSessionWithQosSubscription : @@ -53,3 +53,25 @@ def as_session_with_qos_to_camara_qod(nef_input: OaiAsSessionWithQosSubscription return qod_info + +def camara_ti_to_3gpp_ti(ti_input: CamaraTrafficInfluence) -> TrafficInfluSub: + + device_ip = ti_input.retrieve_ue_ipv4() + server_ip = ti_input.appInstanceId #assume that the instance id corresponds to its IPv4 address + sink_url = ti_input.notificationSink.sink + edge_zone = ti_input.edgeCloudZoneId + + #build flow descriptor in oai format using device ip and server ip + flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" + + nef_traffic_influence = TrafficInfluSub.model_construct() + nef_traffic_influence.afAppId = ti_input.appId + nef_traffic_influence.afServiceId = "aa" + nef_traffic_influence.ipv4Addr = device_ip + nef_traffic_influence.notificationDestination = sink_url + nef_traffic_influence.add_flow_descriptor(flow_desriptor=flow_descriptor) + nef_traffic_influence.add_traffic_route(dnai=edge_zone) + nef_traffic_influence.add_snssai(1, "FFFFFF") + nef_traffic_influence.dnn ="oai" + + return nef_traffic_influence \ No newline at end of file diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 3ff6491..f004bd2 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -81,7 +81,7 @@ class NetworkManagementInterface(ABC): @abstractmethod - def get_traffic_influence_resource(self, resource_id: str) -> Dict: + def put_traffic_influence_resource(self, resource_id: str, traffic_influence_info: Dict) -> Dict: """ Retrieves details of a specific Traffic Influence resource. -- GitLab From 63eda97b970d03817122a9b061b22bbe68fb0dba Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Wed, 7 May 2025 14:39:11 +0200 Subject: [PATCH 085/281] refactor using github hooks --- requirements.txt | 2 +- src/network/clients/oai/client.py | 133 +++++++++++++++++--------- src/network/clients/oai/common.py | 41 +++++--- src/network/clients/oai/schemas.py | 53 ++++++---- src/network/clients/oai/utils.py | 37 ++++--- src/network/core/network_interface.py | 5 +- 6 files changed, 177 insertions(+), 94 deletions(-) diff --git a/requirements.txt b/requirements.txt index e7c92bd..b37b141 100644 --- a/requirements.txt +++ b/requirements.txt @@ -64,8 +64,8 @@ PyYAML==6.0.2 pyzmq==26.4.0 referencing==0.36.2 requests==2.32.3 -shortuuid==1.0.13 rpds-py==0.24.0 +shortuuid==1.0.13 six==1.17.0 soupsieve==2.6 stack-data==0.6.3 diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py index 9e0db68..8bb9d4b 100644 --- a/src/network/clients/oai/client.py +++ b/src/network/clients/oai/client.py @@ -9,27 +9,36 @@ ## from typing import Dict -from src import logger + import shortuuid -import time from pydantic import ValidationError -from src.network.core.network_interface import NetworkManagementInterface -from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription,CamaraTrafficInfluence, TrafficInfluSub + +from src import logger from src.network.clients.oai.common import ( - oai_as_session_with_qos_post, - oai_as_session_with_qos_get, + OaiHttpError, + OaiNetworkError, oai_as_session_with_qos_delete, - oai_traffic_influence_post, + oai_as_session_with_qos_get, + oai_as_session_with_qos_post, oai_traffic_influence_delete, + oai_traffic_influence_post, oai_traffic_influence_put, - OaiHttpError, - OaiNetworkError ) - -from src.network.clients.oai.utils import camara_qod_to_as_session_with_qos, as_session_with_qos_to_camara_qod, camara_ti_to_3gpp_ti +from src.network.clients.oai.schemas import ( + CamaraQoDSessionInfo, + CamaraTrafficInfluence, + OaiAsSessionWithQosSubscription, +) +from src.network.clients.oai.utils import ( + as_session_with_qos_to_camara_qod, + camara_qod_to_as_session_with_qos, + camara_ti_to_3gpp_ti, +) +from src.network.core.network_interface import NetworkManagementInterface log = logger.get_logger(__name__) + class OaiNefClient(NetworkManagementInterface): def __init__(self, base_url: str, scs_as_id: str = None): """ @@ -42,12 +51,14 @@ class OaiNefClient(NetworkManagementInterface): super().__init__() self.base_url = base_url self.scs_as_id = shortuuid.uuid() - log.info(f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}") + log.info( + f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}" + ) except Exception as e: log.error(f"Failed to initialize OaiNefClient: {e}") raise e - #implementation of the NetworkManagementInterface QoD Methods + # implementation of the NetworkManagementInterface QoD Methods def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -57,18 +68,22 @@ class OaiNefClient(NetworkManagementInterface): try: qod_input = CamaraQoDSessionInfo(**session_info) - #convert CAMARA QoD to NEF AsSessionWithQos model and do POST + # convert CAMARA QoD to NEF AsSessionWithQos model and do POST nef_req = camara_qod_to_as_session_with_qos(qod_input) - nef_res = oai_as_session_with_qos_post(self.base_url, self.scs_as_id, nef_req) + nef_res = oai_as_session_with_qos_post( + self.base_url, self.scs_as_id, nef_req + ) - #retrieve the NEF resource id + # retrieve the NEF resource id if "self" in nef_res.keys(): nef_url = nef_res["self"] nef_id = nef_url.split("subscriptions/")[1] else: - raise OaiNetworkError("No valid ID for the created resource was returned") + raise OaiNetworkError( + "No valid ID for the created resource was returned" + ) - #create QoD session detail and return info with resource Id + # create QoD session detail and return info with resource Id qod_input.sessionId = nef_id log.info(f"QoD session activated successfully [id={nef_id}]") @@ -80,10 +95,11 @@ class OaiNefClient(NetworkManagementInterface): except KeyError as e: raise OaiNetworkError(f"Missing field in QoD Session Info data: {e}") from e except OaiHttpError as e: - raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not enable the QoD Session. It returned {e}" + ) from e except OaiNetworkError as e: - raise - + raise e def get_qod_session(self, session_id: str) -> Dict: """ @@ -92,7 +108,9 @@ class OaiNefClient(NetworkManagementInterface): OAI NEF GET /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} """ try: - res = oai_as_session_with_qos_get(self.base_url, self.scs_as_id, session_id=session_id) + res = oai_as_session_with_qos_get( + self.base_url, self.scs_as_id, session_id=session_id + ) nef_res = OaiAsSessionWithQosSubscription(**res) qod_info = as_session_with_qos_to_camara_qod(nef_res) @@ -102,9 +120,11 @@ class OaiNefClient(NetworkManagementInterface): except ValidationError as e: raise OaiNetworkError("Could not validate network response data") from e except OaiHttpError as e: - raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not enable the QoD Session. It returned {e}" + ) from e except OaiNetworkError as e: - raise + raise e def delete_qod_session(self, session_id: str) -> None: """ @@ -113,32 +133,38 @@ class OaiNefClient(NetworkManagementInterface): OAI NEF DELETE /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} """ try: - oai_as_session_with_qos_delete(self.base_url, self.scs_as_id, session_id=session_id) + oai_as_session_with_qos_delete( + self.base_url, self.scs_as_id, session_id=session_id + ) log.info(f"QoD session deleted successfully [id={session_id}]") except OaiHttpError as e: - raise OaiNetworkError(f"The network could not enable the QoD Session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not enable the QoD Session. It returned {e}" + ) from e except OaiNetworkError as e: - raise + raise e - #implementation of the NetworkManagementInterface Traffic Influence Methods + # implementation of the NetworkManagementInterface Traffic Influence Methods def create_traffic_influence_resource(self, traffic_influence_info): try: ti_input = CamaraTrafficInfluence(**traffic_influence_info) - #convert CAMARA TI to NEF TrafficInflSub model and do POST + # convert CAMARA TI to NEF TrafficInflSub model and do POST nef_req = camara_ti_to_3gpp_ti(ti_input) nef_res = oai_traffic_influence_post(self.base_url, self.scs_as_id, nef_req) - #retrieve the NEF resource id + # retrieve the NEF resource id if "self" in nef_res.keys(): nef_url = nef_res["self"] nef_id = nef_url.split("subscriptions/")[1] else: - raise OaiNetworkError("No valid ID for the created resource was returned") + raise OaiNetworkError( + "No valid ID for the created resource was returned" + ) - #create TI session detail and return info with resource Id + # create TI session detail and return info with resource Id ti_input.trafficInfluenceID = nef_id log.info(f"Traffic Influence session activated successfully [id={nef_id}]") @@ -148,12 +174,15 @@ class OaiNefClient(NetworkManagementInterface): except ValidationError as e: raise OaiNetworkError("Could not validate Traffic Influence data") from e except KeyError as e: - raise OaiNetworkError(f"Missing field in Traffic Influence data: {e}") from e + raise OaiNetworkError( + f"Missing field in Traffic Influence data: {e}" + ) from e except OaiHttpError as e: - raise OaiNetworkError(f"The network could not enable the Traffic Influence Session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not enable the Traffic Influence Session. It returned {e}" + ) from e except OaiNetworkError as e: - raise - + raise e def delete_traffic_influence_resource(self, session_id): """ @@ -162,32 +191,44 @@ class OaiNefClient(NetworkManagementInterface): OAI NEF DELETE /3gpp-traffic-influence/v1/{scs_as_id}/subscriptions/{subscriptionId} """ try: - oai_traffic_influence_delete(self.base_url, self.scs_as_id, session_id=session_id) + oai_traffic_influence_delete( + self.base_url, self.scs_as_id, session_id=session_id + ) log.info(f"TI session deleted successfully [id={session_id}]") except OaiHttpError as e: - raise OaiNetworkError(f"The network could not delete the TI session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not delete the TI session. It returned {e}" + ) from e except OaiNetworkError as e: - raise + raise e def put_traffic_influence_resource(self, resource_id, traffic_influence_info): try: qod_input = CamaraTrafficInfluence(**traffic_influence_info) - #convert CAMARA TI to NEF TrafficInflSub model and do POST + # convert CAMARA TI to NEF TrafficInflSub model and do POST nef_req = camara_ti_to_3gpp_ti(qod_input) - updated_res = oai_traffic_influence_put(self.base_url, self.scs_as_id, resource_id, nef_req) + updated_res = oai_traffic_influence_put( + self.base_url, self.scs_as_id, resource_id, nef_req + ) - log.info(f"Traffic Influence resource updated successfully [id={resource_id}]") + log.info( + f"Traffic Influence resource updated successfully [id={resource_id}]" + ) - return qod_input + return updated_res except ValidationError as e: raise OaiNetworkError("Could not validate Traffic Influence data") from e except KeyError as e: - raise OaiNetworkError(f"Missing field in Traffic Influence data: {e}") from e + raise OaiNetworkError( + f"Missing field in Traffic Influence data: {e}" + ) from e except OaiHttpError as e: - raise OaiNetworkError(f"The network could not update the Traffic Influence Session. It returned {e}") from e + raise OaiNetworkError( + f"The network could not update the Traffic Influence Session. It returned {e}" + ) from e except OaiNetworkError as e: - raise + raise e diff --git a/src/network/clients/oai/common.py b/src/network/clients/oai/common.py index 39ea869..6df5ccc 100644 --- a/src/network/clients/oai/common.py +++ b/src/network/clients/oai/common.py @@ -9,21 +9,21 @@ ## +import requests from pydantic import BaseModel + from src.network.clients.errors import NetworkPlatformError -import json -import requests def _make_request(method: str, url: str, data=None): try: headers = None - if method == 'POST' or method == 'PUT': + if method == "POST" or method == "PUT": headers = { "Content-Type": "application/json", "accept": "application/json", } - elif method == 'GET': + elif method == "GET": headers = { "accept": "application/json", } @@ -37,8 +37,10 @@ def _make_request(method: str, url: str, data=None): raise OaiHttpError("connection error") from e -#QoD methods -def oai_as_session_with_qos_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: +# QoD methods +def oai_as_session_with_qos_post( + base_url: str, scs_as_id: str, model_payload: BaseModel +) -> dict: data = model_payload.model_dump_json(exclude_none=True) url = oai_as_session_with_qos_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) @@ -53,39 +55,52 @@ def oai_as_session_with_qos_delete(base_url: str, scs_as_id: str, session_id: st url = oai_as_session_with_qos_build_url(base_url, scs_as_id, session_id) return _make_request("DELETE", url) -def oai_as_session_with_qos_build_url(base_url: str, scs_as_id: str, session_id: str = None): + +def oai_as_session_with_qos_build_url( + base_url: str, scs_as_id: str, session_id: str = None +): url = f"{base_url}/3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions" - if session_id != None and len(session_id) > 0: + if session_id is not None and len(session_id) > 0: return f"{url}/{session_id}" else: return url ## Traffic Influence methods -def oai_traffic_influence_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: +def oai_traffic_influence_post( + base_url: str, scs_as_id: str, model_payload: BaseModel +) -> dict: data = model_payload.model_dump_json(exclude_none=True) url = oai_traffic_influence_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) + def oai_traffic_influence_delete(base_url: str, scs_as_id: str, session_id: str): url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) return _make_request("DELETE", url) -def oai_traffic_influence_put(base_url: str, scs_as_id: str, session_id: str, model_payload: BaseModel) -> dict: + +def oai_traffic_influence_put( + base_url: str, scs_as_id: str, session_id: str, model_payload: BaseModel +) -> dict: data = model_payload.model_dump_json(exclude_none=True) url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) return _make_request("PUT", url, data=data) -def oai_traffic_influence_build_url(base_url: str, scs_as_id: str, session_id: str = None): +def oai_traffic_influence_build_url( + base_url: str, scs_as_id: str, session_id: str = None +): url = f"{base_url}/3gpp-traffic-influence/v1/{scs_as_id}/subscriptions" - if session_id != None and len(session_id) > 0: + if session_id is not None and len(session_id) > 0: return f"{url}/{session_id}" else: return url + class OaiHttpError(Exception): pass + class OaiNetworkError(NetworkPlatformError): - pass \ No newline at end of file + pass diff --git a/src/network/clients/oai/schemas.py b/src/network/clients/oai/schemas.py index 369bc56..87e35fb 100644 --- a/src/network/clients/oai/schemas.py +++ b/src/network/clients/oai/schemas.py @@ -8,9 +8,10 @@ # - Giulio Carota (giulio.carota@eurecom.fr) ## -from pydantic import BaseModel, Field, AnyHttpUrl from typing import List, Optional +from pydantic import BaseModel, Field + class Snssai(BaseModel): sst: int = Field(default=1) @@ -21,10 +22,12 @@ class TrafficFilter(BaseModel): flowId: int flowDescriptions: List[str] + class OaiAsSessionWithQosSubscription(BaseModel): """ Represents the model to create an AsSessionWithQoS resource inside the OAI NEF. """ + supportedFeatures: str = Field(default="12") dnn: str = Field(default="oai") snssai: Snssai @@ -37,14 +40,16 @@ class OaiAsSessionWithQosSubscription(BaseModel): def add_flow_descriptor(self, flow_desriptor: str): self.flowInfo = list() - self.flowInfo.append(TrafficFilter( - flowId=len(self.flowInfo)+1, - flowDescriptions=[flow_desriptor] - )) + self.flowInfo.append( + TrafficFilter( + flowId=len(self.flowInfo) + 1, flowDescriptions=[flow_desriptor] + ) + ) def add_snssai(self, sst: int, sd: str = None): self.snssai = Snssai(sst=sst, sd=sd) + class PortRange(BaseModel): from_: int = Field(alias="from") to: int @@ -52,31 +57,38 @@ class PortRange(BaseModel): class Config: populate_by_name = True + class Ports(BaseModel): ranges: Optional[List[PortRange]] = None ports: Optional[List[int]] = None + class Ipv4Address(BaseModel): publicAddress: str publicPort: Optional[int] = None + class Device(BaseModel): phoneNumber: Optional[str] = None networkAccessIdentifier: Optional[str] = None ipv4Address: Optional[Ipv4Address] = None ipv6Address: Optional[str] = None + class ApplicationServer(BaseModel): ipv4Address: Optional[str] = None ipv6Address: Optional[str] = None + class SinkCredential(BaseModel): credentialType: Optional[str] = None + class CamaraQoDSessionInfo(BaseModel): """ Represents the input data for creating a QoD session. """ + duration: int qosProfile: str applicationServer: ApplicationServer @@ -87,14 +99,13 @@ class CamaraQoDSessionInfo(BaseModel): sink: Optional[str] = None sinkCredential: Optional[SinkCredential] = None - #fields only applicable to sessionInfo in responses: + # fields only applicable to sessionInfo in responses: sessionId: Optional[str] = None startedAt: Optional[int] = None expiresAt: Optional[int] = None qosStatus: Optional[str] = None statusInfo: Optional[str] = None - class Config: populate_by_name = True @@ -111,8 +122,7 @@ class CamaraQoDSessionInfo(BaseModel): raise KeyError("applicationServer.ipv4Address") def add_server_ipv4(self, ipv4: str): - self.applicationServer = ApplicationServer(ipv4Address = ipv4) - + self.applicationServer = ApplicationServer(ipv4Address=ipv4) def add_ue_ipv4(self, ipv4: str): if self.device is None: @@ -123,20 +133,25 @@ class CamaraQoDSessionInfo(BaseModel): ## traffic_influence schemas + class SourceTrafficFilters(BaseModel): sourcePort: int + class DestinationTrafficFilters(BaseModel): destinationPort: int destinationProtocol: str + class TrafficRoute(BaseModel): dnai: str + class NotificationSink(BaseModel): sink: Optional[str] = None sinkCredential: Optional[SinkCredential] = None + class TrafficInfluSub(BaseModel): # Replace with a meaningful name afServiceId: str afAppId: str @@ -150,22 +165,22 @@ class TrafficInfluSub(BaseModel): # Replace with a meaningful name def add_flow_descriptor(self, flow_desriptor: str): self.trafficFilters = list() - self.trafficFilters.append(TrafficFilter( - flowId=len(self.trafficFilters)+1, - flowDescriptions=[flow_desriptor] - )) + self.trafficFilters.append( + TrafficFilter( + flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_desriptor] + ) + ) def add_traffic_route(self, dnai: str): self.trafficRoutes = list() - self.trafficRoutes.append(TrafficRoute( - dnai=dnai - )) + self.trafficRoutes.append(TrafficRoute(dnai=dnai)) def add_snssai(self, sst: int, sd: str = None): self.snssai = Snssai(sst=sst, sd=sd) + class CamaraTrafficInfluence(BaseModel): - trafficInfluenceID : Optional[str] = None + trafficInfluenceID: Optional[str] = None apiConsumerId: str appId: str appInstanceId: str @@ -173,8 +188,8 @@ class CamaraTrafficInfluence(BaseModel): edgeCloudZoneId: str sourceTrafficFilters: Optional[SourceTrafficFilters] = None destinationTrafficFilters: Optional[DestinationTrafficFilters] = None - notificationUri: Optional[str] = None - notificationAuthToken: Optional[str] = None + notificationUri: Optional[str] = None + notificationAuthToken: Optional[str] = None device: Device notificationSink: Optional[NotificationSink] = None diff --git a/src/network/clients/oai/utils.py b/src/network/clients/oai/utils.py index fde7952..1262d74 100644 --- a/src/network/clients/oai/utils.py +++ b/src/network/clients/oai/utils.py @@ -9,10 +9,17 @@ ## -from src.network.clients.oai.schemas import CamaraQoDSessionInfo, OaiAsSessionWithQosSubscription, CamaraTrafficInfluence, TrafficInfluSub -from pydantic import BaseModel +from src.network.clients.oai.schemas import ( + CamaraQoDSessionInfo, + CamaraTrafficInfluence, + OaiAsSessionWithQosSubscription, + TrafficInfluSub, +) -def camara_qod_to_as_session_with_qos(qod_input: CamaraQoDSessionInfo) -> OaiAsSessionWithQosSubscription : + +def camara_qod_to_as_session_with_qos( + qod_input: CamaraQoDSessionInfo, +) -> OaiAsSessionWithQosSubscription: device_ip = qod_input.retrieve_ue_ipv4() server_ip = qod_input.retrieve_app_ipv4() @@ -20,10 +27,10 @@ def camara_qod_to_as_session_with_qos(qod_input: CamaraQoDSessionInfo) -> OaiAsS sink_url = qod_input.sink qos_profile = qod_input.qosProfile - #build flow descriptor in oai format using device ip and server ip + # build flow descriptor in oai format using device ip and server ip flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" - #create the nef request model + # create the nef request model nef_req = OaiAsSessionWithQosSubscription.construct() nef_req.ueIpv4Addr = device_ip nef_req.notificationDestination = sink_url @@ -31,14 +38,16 @@ def camara_qod_to_as_session_with_qos(qod_input: CamaraQoDSessionInfo) -> OaiAsS nef_req.qosReference = qos_profile nef_req.add_snssai(1, "FFFFFF") - #the qos duration feature is not available yet in oai - #nef_req.qosDuration = qod_input.duration + # the qos duration feature is not available yet in oai + # nef_req.qosDuration = qod_input.duration return nef_req -def as_session_with_qos_to_camara_qod(nef_input: OaiAsSessionWithQosSubscription) -> CamaraQoDSessionInfo : - #create the camara qod model +def as_session_with_qos_to_camara_qod( + nef_input: OaiAsSessionWithQosSubscription, +) -> CamaraQoDSessionInfo: + # create the camara qod model qod_info = CamaraQoDSessionInfo.construct() @@ -57,11 +66,13 @@ def as_session_with_qos_to_camara_qod(nef_input: OaiAsSessionWithQosSubscription def camara_ti_to_3gpp_ti(ti_input: CamaraTrafficInfluence) -> TrafficInfluSub: device_ip = ti_input.retrieve_ue_ipv4() - server_ip = ti_input.appInstanceId #assume that the instance id corresponds to its IPv4 address + server_ip = ( + ti_input.appInstanceId + ) # assume that the instance id corresponds to its IPv4 address sink_url = ti_input.notificationSink.sink edge_zone = ti_input.edgeCloudZoneId - #build flow descriptor in oai format using device ip and server ip + # build flow descriptor in oai format using device ip and server ip flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" nef_traffic_influence = TrafficInfluSub.model_construct() @@ -72,6 +83,6 @@ def camara_ti_to_3gpp_ti(ti_input: CamaraTrafficInfluence) -> TrafficInfluSub: nef_traffic_influence.add_flow_descriptor(flow_desriptor=flow_descriptor) nef_traffic_influence.add_traffic_route(dnai=edge_zone) nef_traffic_influence.add_snssai(1, "FFFFFF") - nef_traffic_influence.dnn ="oai" + nef_traffic_influence.dnn = "oai" - return nef_traffic_influence \ No newline at end of file + return nef_traffic_influence diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index f004bd2..c3724bd 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -79,9 +79,10 @@ class NetworkManagementInterface(ABC): """ pass - @abstractmethod - def put_traffic_influence_resource(self, resource_id: str, traffic_influence_info: Dict) -> Dict: + def put_traffic_influence_resource( + self, resource_id: str, traffic_influence_info: Dict + ) -> Dict: """ Retrieves details of a specific Traffic Influence resource. -- GitLab From 8e88ab15555159f239839d56598245d639bd3936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Mon, 26 May 2025 18:03:27 +0200 Subject: [PATCH 086/281] Refactor to put the logic in the core --- src/network/clients/open5gs/client.py | 65 +++++------- src/network/clients/open5gs/common.py | 43 -------- src/network/core/common.py | 64 ++++++++++++ src/network/core/network_interface.py | 99 ++++++++++++++++++- .../{clients/open5gs => core}/schemas.py | 96 +++++++++++++----- 5 files changed, 249 insertions(+), 118 deletions(-) delete mode 100644 src/network/clients/open5gs/common.py create mode 100644 src/network/core/common.py rename src/network/{clients/open5gs => core}/schemas.py (79%) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index d9623cd..eb608c2 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -1,35 +1,16 @@ # -*- coding: utf-8 -*- -from itertools import product from typing import Dict from pydantic import ValidationError from src import logger -from src.network.core.network_interface import NetworkManagementInterface -from . import common -from . import schemas +from src.network.core.network_interface import NetworkManagementInterface, build_flows +from ...core import common +from ...core import schemas log = logger.get_logger(__name__) -flow_id_mapping = { - "qos-e": 3, - "qos-s": 4, - "qos-m": 5, - "qos-l": 6 -} +flow_id_mapping = {"qos-e": 3, "qos-s": 4, "qos-m": 5, "qos-l": 6} -def flatten_port_spec(ports_spec: schemas.PortsSpec | None)-> list[str]: - has_ports = False - has_ranges = False - flat_ports = [] - if ports_spec and ports_spec.ports: - has_ports = True - flat_ports.extend([str(port) for port in ports_spec.ports]) - if ports_spec and ports_spec.ranges: - has_ranges = True - flat_ports.extend([f"{range.from_}-{range.to}" for range in ports_spec.ranges]) - if not has_ports and not has_ranges: - flat_ports.append("0-65535") - return flat_ports class NetworkManager(NetworkManagementInterface): """ @@ -58,6 +39,19 @@ class NetworkManager(NetworkManagementInterface): log.error(f"Failed to initialize Open5GSClient: {e}") raise e + def core_specific_validation(self, session_info: schemas.CreateSession): + if session_info.qosProfile not in flow_id_mapping.keys(): + raise ValidationError( + f"Open5Gs only supports these qos-profiles: {', '.join(flow_id_mapping.keys())}" + ) + + def add_core_specific_parameters( + self, session_info: schemas.AsSessionWithQoSSubscription + ) -> None: + session_info.supportedFeatures = schemas.SupportedFeatures("003C") + flow_id = flow_id_mapping[session_info.qosProfile] + session_info.flowInfo = build_flows(flow_id, session_info) + # --- Implementation of NetworkManagementInterface methods --- def create_qod_session(self, session_info: Dict) -> Dict: """ @@ -67,31 +61,15 @@ class NetworkManager(NetworkManagementInterface): url = f"{self.base_url}/{self.scs_as_id}/subscriptions" # Raises ValidationError if the object is not valid. valid_session_info = schemas.CreateSession.model_validate(session_info) - if valid_session_info.qosProfile not in flow_id_mapping.keys(): - raise ValidationError(f"Open5Gs only supports these qos-profiles: {", ".join(flow_id_mapping.keys())}") - flow_id = flow_id_mapping[valid_session_info.qosProfile] - device_ip = valid_session_info.device.ipv4Address or session_info.device.ipv4Address - server_ip = valid_session_info.applicationServer.ipv4Address or valid_session_info.applicationServer.ipv6Address - device_ports = flatten_port_spec(valid_session_info.devicePorts) - server_ports = flatten_port_spec(valid_session_info.applicationServerPorts) - ports_combis = list(product(device_ports, server_ports)) - - flow_descrs = [] - for device_port, server_port in ports_combis: - flow_descrs.append(f"permit in ip from {device_ip} {device_port} to {server_ip} {server_port}") - flow_descrs.append(f"permit out ip from {device_ip} {device_port} to {server_ip} {server_port}") - flows = [schemas.FlowInfo( - flowId=flow_id, - flowDescriptions=[", ".join(flow_descrs)] - )] subscription = schemas.AsSessionWithQoSSubscription( - supportedFeatures=schemas.SupportedFeatures("003C"), - flowInfo=flows, - qosReference = valid_session_info.qosProfile, + notificationDestination=valid_session_info.sink, + qosReference=valid_session_info.qosProfile, ueIpv4Addr=valid_session_info.device.ipv4Address, ueIpv6Addr=valid_session_info.device.ipv6Address, + usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), ) + self.add_core_specific_parameters(subscription) common.open5gs_post(url, subscription) def get_qod_session(self, session_id: str) -> Dict: @@ -112,6 +90,7 @@ class NetworkManager(NetworkManagementInterface): url = f"{self.base_url}/{self.scs_as_id}/subscriptions/{session_id}" common.open5gs_delete(url) + # Note: # As this class is inheriting from NetworkManagementInterface, it is # expected to implement all the abstract methods defined in that interface. diff --git a/src/network/clients/open5gs/common.py b/src/network/clients/open5gs/common.py deleted file mode 100644 index 0ef9617..0000000 --- a/src/network/clients/open5gs/common.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -# Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). -from typing import Optional - -from pydantic import BaseModel - -from src import logger -from src.network.clients.errors import NetworkPlatformError - -log = logger.get_logger(__name__) - - -class Open5GSError(NetworkPlatformError): - pass - - -class Open5GSErrorResponse(BaseModel): - message: str - detail: dict - - -# --- HTTP Request Helper Functions --- -def open5gs_post(url: str, model_payload: BaseModel) -> dict: - """ - Placeholder for the POST request function.""" - response = requests.post(url, model_payload) - return response.json() - - -def open5gs_get(url: str, params: Optional[dict] = None) -> dict: - """ - Placeholder for the GET request function. - """ - response = requests.get(url, params=params) - return response.json() - - -def open5gs_delete(url: str) -> None: - """ - Placeholder for the DELETE request function. - """ - requests.delete(url) - diff --git a/src/network/core/common.py b/src/network/core/common.py new file mode 100644 index 0000000..214b774 --- /dev/null +++ b/src/network/core/common.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). + +from pydantic import BaseModel +import requests + +from src import logger + +log = logger.get_logger(__name__) + + +def _make_request(method: str, url: str, data=None): + try: + headers = None + if method == "POST" or method == "PUT": + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + elif method == "GET": + headers = { + "accept": "application/json", + } + response = requests.request(method, url, headers=headers, data=data) + response.raise_for_status() + if response.content: + return response.json() + except requests.exceptions.HTTPError as e: + raise CoreHttpError(e) from e + except requests.exceptions.ConnectionError as e: + raise CoreHttpError("connection error") from e + + +# QoD methods +def as_session_with_qos_post( + base_url: str, scs_as_id: str, model_payload: BaseModel +) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = as_session_with_qos_build_url(base_url, scs_as_id) + return _make_request("POST", url, data=data) + + +def as_session_with_qos_get(base_url: str, scs_as_id: str, session_id: str) -> dict: + url = as_session_with_qos_build_url(base_url, scs_as_id, session_id) + return _make_request("GET", url) + + +def as_session_with_qos_delete(base_url: str, scs_as_id: str, session_id: str): + url = as_session_with_qos_build_url(base_url, scs_as_id, session_id) + return _make_request("DELETE", url) + + +def as_session_with_qos_build_url( + base_url: str, scs_as_id: str, session_id: str = None +): + url = f"{base_url}/3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions" + if session_id is not None and len(session_id) > 0: + return f"{url}/{session_id}" + else: + return url + + +class CoreHttpError(Exception): + pass diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 0628ca9..fe0a3e2 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -10,8 +10,56 @@ # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) ## from abc import ABC, abstractmethod +from itertools import product from typing import Dict +from src import logger +from src.network.core import common, schemas + +log = logger.get_logger(__name__) + + +def flatten_port_spec(ports_spec: schemas.PortsSpec | None) -> list[str]: + has_ports = False + has_ranges = False + flat_ports = [] + if ports_spec and ports_spec.ports: + has_ports = True + flat_ports.extend([str(port) for port in ports_spec.ports]) + if ports_spec and ports_spec.ranges: + has_ranges = True + flat_ports.extend([f"{range.from_}-{range.to}" for range in ports_spec.ranges]) + if not has_ports and not has_ranges: + flat_ports.append("0-65535") + return flat_ports + + +def build_flows( + flow_id: int, session_info: schemas.CreateSession +) -> list[schemas.FlowInfo]: + device_ports = flatten_port_spec(session_info.devicePorts) + server_ports = flatten_port_spec(session_info.applicationServerPorts) + ports_combis = list(product(device_ports, server_ports)) + + device_ip = session_info.device.ipv4Address or session_info.device.ipv4Address + server_ip = ( + session_info.applicationServer.ipv4Address + or session_info.applicationServer.ipv6Address + ) + + flow_descrs = [] + for device_port, server_port in ports_combis: + flow_descrs.append( + f"permit in ip from {device_ip} {device_port} to {server_ip} {server_port}" + ) + flow_descrs.append( + f"permit out ip from {device_ip} {device_port} to {server_ip} {server_port}" + ) + flows = [ + schemas.FlowInfo(flowId=flow_id, flowDescriptions=[", ".join(flow_descrs)]) + ] + return flows + class NetworkManagementInterface(ABC): """ @@ -25,7 +73,32 @@ class NetworkManagementInterface(ABC): to their specific NEF capabilities. """ + base_url: str + scs_as_id: str + + @abstractmethod + def add_core_specific_parameters(x): + """ + Placeholder for adding core-specific parameters to the subscription. + This method should be overridden by subclasses to implement specific logic. + """ + pass + @abstractmethod + def core_specific_validation(self, session_info: schemas.CreateSession) -> None: + """ + Validates core-specific parameters for the session creation. + + args: + session_info: The session information to validate. + + raises: + ValidationError: If the session information does not meet core-specific requirements. + """ + # Placeholder for core-specific validation logic + # This method should be overridden by subclasses if needed + pass + def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -37,9 +110,19 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created session details, including its ID. """ - pass + valid_session_info = schemas.CreateSession.model_validate(session_info) + self.core_specific_validation(valid_session_info) + subscription = schemas.AsSessionWithQoSSubscription( + notificationDestination=valid_session_info.sink, + qosReference=valid_session_info.qosProfile, + ueIpv4Addr=valid_session_info.device.ipv4Address, + ueIpv6Addr=valid_session_info.device.ipv6Address, + usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), + ) + self.add_core_specific_parameters(subscription) + url = f"{self.base_url}/{self.scs_as_id}/subscriptions" + common.as_session_with_qos_post(self.base_url, self.scs_as_id, subscription) - @abstractmethod def get_qod_session(self, session_id: str) -> Dict: """ Retrieves details of a specific Quality on Demand (QoS) session. @@ -50,9 +133,12 @@ class NetworkManagementInterface(ABC): returns: Dictionary containing the details of the requested QoS session. """ - pass + session = common.as_session_with_qos_get( + self.base_url, self.scs_as_id, session_id=session_id + ) + log.info(f"QoD session retrived successfully [id={session_id}]") + return session - @abstractmethod def delete_qod_session(self, session_id: str) -> None: """ Deletes a specific Quality on Demand (QoS) session. @@ -63,7 +149,10 @@ class NetworkManagementInterface(ABC): returns: None """ - pass + common.as_session_with_qos_delete( + self.base_url, self.scs_as_id, session_id=session_id + ) + log.info(f"QoD session deleted successfully [id={session_id}]") # Placeholder for other CAMARA APIs (e.g., Traffic Influence, # Location-retrieval, etc.) diff --git a/src/network/clients/open5gs/schemas.py b/src/network/core/schemas.py similarity index 79% rename from src/network/clients/open5gs/schemas.py rename to src/network/core/schemas.py index ffd36e1..cb5ea9c 100644 --- a/src/network/clients/open5gs/schemas.py +++ b/src/network/core/schemas.py @@ -7,7 +7,7 @@ import ipaddress from enum import Enum from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, RootModel +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress from ipaddress import IPv4Address, IPv6Address @@ -157,38 +157,43 @@ class AsSessionWithQoSSubscription(BaseModel): ############################################################### ############################################################### -# CAMARA Models +# CAMARA Models + class PhoneNumber(RootModel[str]): root: Annotated[ str, Field( description="A public identifier addressing a telephone subscription. In mobile networks it corresponds to the MSISDN (Mobile Station International Subscriber Directory Number). In order to be globally unique it has to be formatted in international format, according to E.164 standard, prefixed with '+'.", - examples=['+123456789'], - pattern='^\\+[1-9][0-9]{4,14}$', + examples=["+123456789"], + pattern="^\\+[1-9][0-9]{4,14}$", ), ] + class NetworkAccessIdentifier(RootModel[str]): root: Annotated[ str, Field( - description='A public identifier addressing a subscription in a mobile network. In 3GPP terminology, it corresponds to the GPSI formatted with the External Identifier ({Local Identifier}@{Domain Identifier}). Unlike the telephone number, the network access identifier is not subjected to portability ruling in force, and is individually managed by each operator.', - examples=['123456789@domain.com'], + description="A public identifier addressing a subscription in a mobile network. In 3GPP terminology, it corresponds to the GPSI formatted with the External Identifier ({Local Identifier}@{Domain Identifier}). Unlike the telephone number, the network access identifier is not subjected to portability ruling in force, and is individually managed by each operator.", + examples=["123456789@domain.com"], ), ] + class SingleIpv4Addr(RootModel[IPv4Address]): root: Annotated[ IPv4Address, Field( - description='A single IPv4 address with no subnet mask', - examples=['203.0.113.0'], + description="A single IPv4 address with no subnet mask", + examples=["203.0.113.0"], ), ] + class Port(RootModel[int]): - root: Annotated[int, Field(description='TCP or UDP port number', ge=0, le=65535)] + root: Annotated[int, Field(description="TCP or UDP port number", ge=0, le=65535)] + class DeviceIpv4Addr1(BaseModel): publicAddress: SingleIpv4Addr @@ -206,96 +211,133 @@ class DeviceIpv4Addr(RootModel[DeviceIpv4Addr1 | DeviceIpv4Addr2]): root: Annotated[ DeviceIpv4Addr1 | DeviceIpv4Addr2, Field( - description='The device should be identified by either the public (observed) IP address and port as seen by the application server, or the private (local) and any public (observed) IP addresses in use by the device (this information can be obtained by various means, for example from some DNS servers).\n\nIf the allocated and observed IP addresses are the same (i.e. NAT is not in use) then the same address should be specified for both publicAddress and privateAddress.\n\nIf NAT64 is in use, the device should be identified by its publicAddress and publicPort, or separately by its allocated IPv6 address (field ipv6Address of the Device object)\n\nIn all cases, publicAddress must be specified, along with at least one of either privateAddress or publicPort, dependent upon which is known. In general, mobile devices cannot be identified by their public IPv4 address alone.\n', - examples=[{'publicAddress': '203.0.113.0', 'publicPort': 59765}], + description="The device should be identified by either the public (observed) IP address and port as seen by the application server, or the private (local) and any public (observed) IP addresses in use by the device (this information can be obtained by various means, for example from some DNS servers).\n\nIf the allocated and observed IP addresses are the same (i.e. NAT is not in use) then the same address should be specified for both publicAddress and privateAddress.\n\nIf NAT64 is in use, the device should be identified by its publicAddress and publicPort, or separately by its allocated IPv6 address (field ipv6Address of the Device object)\n\nIn all cases, publicAddress must be specified, along with at least one of either privateAddress or publicPort, dependent upon which is known. In general, mobile devices cannot be identified by their public IPv4 address alone.\n", + examples=[{"publicAddress": "203.0.113.0", "publicPort": 59765}], ), ] + class DeviceIpv6Address(RootModel[IPv6Address]): root: Annotated[ IPv6Address, Field( - description='The device should be identified by the observed IPv6 address, or by any single IPv6 address from within the subnet allocated to the device (e.g. adding ::0 to the /64 prefix).\n\nThe session shall apply to all IP flows between the device subnet and the specified application server, unless further restricted by the optional parameters devicePorts or applicationServerPorts.\n', - examples=['2001:db8:85a3:8d3:1319:8a2e:370:7344'], + description="The device should be identified by the observed IPv6 address, or by any single IPv6 address from within the subnet allocated to the device (e.g. adding ::0 to the /64 prefix).\n\nThe session shall apply to all IP flows between the device subnet and the specified application server, unless further restricted by the optional parameters devicePorts or applicationServerPorts.\n", + examples=["2001:db8:85a3:8d3:1319:8a2e:370:7344"], ), ] + class Device(BaseModel): phoneNumber: PhoneNumber | None = None networkAccessIdentifier: NetworkAccessIdentifier | None = None ipv4Address: DeviceIpv4Addr | None = None ipv6Address: DeviceIpv6Address | None = None + class ApplicationServerIpv4Address(RootModel[str]): root: Annotated[ str, Field( - description='IPv4 address may be specified in form
as:\n - address - an IPv4 number in dotted-quad form 1.2.3.4. Only this exact IP number will match the flow control rule.\n - address/mask - an IP number as above with a mask width of the form 1.2.3.4/24.\n In this case, all IP numbers from 1.2.3.0 to 1.2.3.255 will match. The bit width MUST be valid for the IP version.\n', - examples=['198.51.100.0/24'], + description="IPv4 address may be specified in form
as:\n - address - an IPv4 number in dotted-quad form 1.2.3.4. Only this exact IP number will match the flow control rule.\n - address/mask - an IP number as above with a mask width of the form 1.2.3.4/24.\n In this case, all IP numbers from 1.2.3.0 to 1.2.3.255 will match. The bit width MUST be valid for the IP version.\n", + examples=["198.51.100.0/24"], ), ] + class ApplicationServerIpv6Address(RootModel[str]): root: Annotated[ str, Field( - description='IPv6 address may be specified in form
as:\n - address - The /128 subnet is optional for single addresses:\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344/128\n - address/mask - an IP v6 number with a mask:\n - 2001:db8:85a3:8d3::0/64\n - 2001:db8:85a3:8d3::/64\n', - examples=['2001:db8:85a3:8d3:1319:8a2e:370:7344'], + description="IPv6 address may be specified in form
as:\n - address - The /128 subnet is optional for single addresses:\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344\n - 2001:db8:85a3:8d3:1319:8a2e:370:7344/128\n - address/mask - an IP v6 number with a mask:\n - 2001:db8:85a3:8d3::0/64\n - 2001:db8:85a3:8d3::/64\n", + examples=["2001:db8:85a3:8d3:1319:8a2e:370:7344"], ), ] + class ApplicationServer(BaseModel): ipv4Address: ApplicationServerIpv4Address | None = None ipv6Address: ApplicationServerIpv6Address | None = None + class Range(BaseModel): - from_: Annotated[Port, Field(alias='from')] + from_: Annotated[Port, Field(alias="from")] to: Port + class PortsSpec(BaseModel): ranges: Annotated[ - list[Range] | None, Field(description='Range of TCP or UDP ports', min_length=1) + list[Range] | None, Field(description="Range of TCP or UDP ports", min_length=1) ] = None ports: Annotated[ - list[Port] | None, Field(description='Array of TCP or UDP ports', min_length=1) + list[Port] | None, Field(description="Array of TCP or UDP ports", min_length=1) ] = None + class QosProfileName(RootModel[str]): root: Annotated[ str, Field( - description='A unique name for identifying a specific QoS profile.\nThis may follow different formats depending on the API provider implementation.\nSome options addresses:\n - A UUID style string\n - Support for predefined profiles QOS_S, QOS_M, QOS_L, and QOS_E\n - A searchable descriptive name\nThe set of QoS Profiles that an API provider is offering may be retrieved by means of the QoS Profile API (qos-profile) or agreed on onboarding time.\n', - examples=['voice'], + description="A unique name for identifying a specific QoS profile.\nThis may follow different formats depending on the API provider implementation.\nSome options addresses:\n - A UUID style string\n - Support for predefined profiles QOS_S, QOS_M, QOS_L, and QOS_E\n - A searchable descriptive name\nThe set of QoS Profiles that an API provider is offering may be retrieved by means of the QoS Profile API (qos-profile) or agreed on onboarding time.\n", + examples=["voice"], max_length=256, min_length=3, - pattern='^[a-zA-Z0-9_.-]+$', + pattern="^[a-zA-Z0-9_.-]+$", + ), + ] + + +class CredentialType(Enum): + PLAIN = "PLAIN" + ACCESSTOKEN = "ACCESSTOKEN" + REFRESHTOKEN = "REFRESHTOKEN" + + +class SinkCredential(BaseModel): + credentialType: Annotated[ + CredentialType, + Field( + description="The type of the credential.\nNote: Type of the credential - MUST be set to ACCESSTOKEN for now\n" ), ] + class BaseSessionInfo(BaseModel): device: Device | None = None applicationServer: ApplicationServer devicePorts: Annotated[ PortsSpec | None, Field( - description='The ports used locally by the device for flows to which the requested QoS profile should apply. If omitted, then the qosProfile will apply to all flows between the device and the specified application server address and ports' + description="The ports used locally by the device for flows to which the requested QoS profile should apply. If omitted, then the qosProfile will apply to all flows between the device and the specified application server address and ports" ), ] = None applicationServerPorts: Annotated[ PortsSpec | None, Field( - description='A list of single ports or port ranges on the application server' + description="A list of single ports or port ranges on the application server" ), ] = None qosProfile: QosProfileName + sink: Annotated[ + AnyUrl | None, + Field( + description="The address to which events about all status changes of the session (e.g. session termination) shall be delivered using the selected protocol.", + examples=["https://endpoint.example.com/sink"], + ), + ] = None + sinkCredential: Annotated[ + SinkCredential | None, + Field( + description="A sink credential provides authentication or authorization information necessary to enable delivery of events to a target." + ), + ] = None class CreateSession(BaseSessionInfo): duration: Annotated[ int, Field( - description='Requested session duration in seconds. Value may be explicitly limited for the QoS profile, as specified in the Qos Profile (see qos-profile API). Implementations can grant the requested session duration or set a different duration, based on network policies or conditions.\n', + description="Requested session duration in seconds. Value may be explicitly limited for the QoS profile, as specified in the Qos Profile (see qos-profile API). Implementations can grant the requested session duration or set a different duration, based on network policies or conditions.\n", examples=[3600], ge=1, ), - ] \ No newline at end of file + ] -- GitLab From a80a277619a86d4305d17f12a01d1e75bfade752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 10:13:31 +0200 Subject: [PATCH 087/281] Open5gs cleanup --- src/network/clients/open5gs/client.py | 38 --------------------------- 1 file changed, 38 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index eb608c2..a7c20e5 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -52,44 +52,6 @@ class NetworkManager(NetworkManagementInterface): flow_id = flow_id_mapping[session_info.qosProfile] session_info.flowInfo = build_flows(flow_id, session_info) - # --- Implementation of NetworkManagementInterface methods --- - def create_qod_session(self, session_info: Dict) -> Dict: - """ - Creates a QoD session based on the CAMARA QoD API input. - Maps the CAMARA QoD POST /sessions to Open5GS NEF POST /{scsAsId}/subscriptions. - """ - url = f"{self.base_url}/{self.scs_as_id}/subscriptions" - # Raises ValidationError if the object is not valid. - valid_session_info = schemas.CreateSession.model_validate(session_info) - - subscription = schemas.AsSessionWithQoSSubscription( - notificationDestination=valid_session_info.sink, - qosReference=valid_session_info.qosProfile, - ueIpv4Addr=valid_session_info.device.ipv4Address, - ueIpv6Addr=valid_session_info.device.ipv6Address, - usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), - ) - self.add_core_specific_parameters(subscription) - common.open5gs_post(url, subscription) - - def get_qod_session(self, session_id: str) -> Dict: - """ - Retrieves a specific Open5GS QoS Subscription details. - Maps CAMARA QoD GET /sessions/{sessionId} to Open5GS NEF GET / - {scsAsId}/subscriptions/{subscriptionId}. - """ - url = f"{self.base_url}/{self.scs_as_id}/subscriptions/{session_id}" - common.open5gs_get(url) - - def delete_qod_session(self, session_id: str) -> None: - """ - Deletes a specific Open5GS QoS Subscription. - Maps CAMARA QoD DELETE /sessions/{sessionId} to Open5GS NEF DELETE / - {scsAsId}/subscriptions/{subscriptionId}. - """ - url = f"{self.base_url}/{self.scs_as_id}/subscriptions/{session_id}" - common.open5gs_delete(url) - # Note: # As this class is inheriting from NetworkManagementInterface, it is -- GitLab From 7c4f81be0c4cc0972ce4b3c75dbbc58cc5dc1eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 11:15:15 +0200 Subject: [PATCH 088/281] More cleanup --- src/network/clients/open5gs/client.py | 3 --- src/network/core/network_interface.py | 5 +++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index a7c20e5..7162774 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -1,10 +1,7 @@ # -*- coding: utf-8 -*- -from typing import Dict - from pydantic import ValidationError from src import logger from src.network.core.network_interface import NetworkManagementInterface, build_flows -from ...core import common from ...core import schemas log = logger.get_logger(__name__) diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index fe0a3e2..aaf4e3a 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -120,8 +120,9 @@ class NetworkManagementInterface(ABC): usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), ) self.add_core_specific_parameters(subscription) - url = f"{self.base_url}/{self.scs_as_id}/subscriptions" - common.as_session_with_qos_post(self.base_url, self.scs_as_id, subscription) + return common.as_session_with_qos_post( + self.base_url, self.scs_as_id, subscription + ) def get_qod_session(self, session_id: str) -> Dict: """ -- GitLab From 0b45a4a0927c984e630aa273ac693041fccbff61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 27 May 2025 12:12:22 +0200 Subject: [PATCH 089/281] Add o-ran placeholders --- src/o-ran/__init__.py | 0 src/o-ran/clients/__init__.py | 0 src/o-ran/clients/juniper-ric/__init__.py | 0 src/o-ran/clients/juniper-ric/client.py | 0 src/o-ran/core/__init__.py | 0 src/o-ran/core/o-ran_interface.py | 1 + 6 files changed, 1 insertion(+) create mode 100644 src/o-ran/__init__.py create mode 100644 src/o-ran/clients/__init__.py create mode 100644 src/o-ran/clients/juniper-ric/__init__.py create mode 100644 src/o-ran/clients/juniper-ric/client.py create mode 100644 src/o-ran/core/__init__.py create mode 100644 src/o-ran/core/o-ran_interface.py diff --git a/src/o-ran/__init__.py b/src/o-ran/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/o-ran/clients/__init__.py b/src/o-ran/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/o-ran/clients/juniper-ric/__init__.py b/src/o-ran/clients/juniper-ric/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/o-ran/clients/juniper-ric/client.py b/src/o-ran/clients/juniper-ric/client.py new file mode 100644 index 0000000..e69de29 diff --git a/src/o-ran/core/__init__.py b/src/o-ran/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/o-ran/core/o-ran_interface.py b/src/o-ran/core/o-ran_interface.py new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/src/o-ran/core/o-ran_interface.py @@ -0,0 +1 @@ +# TODO -- GitLab From 6ce2fe56572aec5f79c5987b75823b6e79d02ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 27 May 2025 12:17:23 +0200 Subject: [PATCH 090/281] Update readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 7ef489a..c9c5ec6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Thank you for contributing to this project. Please follow the guidelines below t Each contribution should be made in the appropriate directory: - **EdgeCloud Adapters** → `src/edgecloud/clients/` - **Network Adapters** → `src/network/clients/` +- **O-RAN Adapters** → `src/o-ran/clients/` ### Testing (Mandatory) To merge a feature branch into `main`, the adapter **must pass the unit tests**. Instructions to do so available at [TESTING.md](docs/TESTING.md) @@ -43,6 +44,17 @@ Example: feature/add-network-open5gs ``` + +#### 📶 O-RAN Adapters +Branch Name Format: +``` +feature/add-oran- +``` +Example: +``` +feature/add-oran-juniper +``` + ## Sequence Diagram Example Refer to the sequence diagram example from `docs/workflows/edgecloud/get_av_zones.md` for guidance on workflow structure: -- GitLab From fd7c0585bd091bd194ae77b79b41898eb986cd45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 14:38:08 +0200 Subject: [PATCH 091/281] Bug fixes --- src/network/clients/open5gs/client.py | 12 +++++---- src/network/core/network_factory.py | 26 ++++++++++--------- src/network/core/network_interface.py | 37 ++++++++++++++++++--------- src/network/core/schemas.py | 20 +++++++-------- 4 files changed, 56 insertions(+), 39 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index 7162774..67b9397 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -37,17 +37,19 @@ class NetworkManager(NetworkManagementInterface): raise e def core_specific_validation(self, session_info: schemas.CreateSession): - if session_info.qosProfile not in flow_id_mapping.keys(): + if session_info.qosProfile.root not in flow_id_mapping.keys(): raise ValidationError( f"Open5Gs only supports these qos-profiles: {', '.join(flow_id_mapping.keys())}" ) def add_core_specific_parameters( - self, session_info: schemas.AsSessionWithQoSSubscription + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, ) -> None: - session_info.supportedFeatures = schemas.SupportedFeatures("003C") - flow_id = flow_id_mapping[session_info.qosProfile] - session_info.flowInfo = build_flows(flow_id, session_info) + subscription.supportedFeatures = schemas.SupportedFeatures("003C") + flow_id = flow_id_mapping[session_info.qosProfile.root] + subscription.flowInfo = build_flows(flow_id, session_info) # Note: diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py index 9669b6c..f155347 100644 --- a/src/network/core/network_factory.py +++ b/src/network/core/network_factory.py @@ -13,9 +13,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from src.network.clients.oai.client import OaiNefClient -from src.network.clients.open5gcore.client import Open5GCoreClient -from src.network.clients.open5gs.client import Open5GSClient +from src.network.clients.oai.client import NetworkManager as OaiNefClient +from src.network.clients.open5gcore.client import NetworkManager as Open5GCoreClient +from src.network.clients.open5gs.client import NetworkManager as Open5GSClient if TYPE_CHECKING: from .network_interface import NetworkManagementInterface @@ -28,20 +28,18 @@ class NetworkClientFactory: @staticmethod def create_network_client( - client_name: str, base_url: str + client_name: str, base_url: str, scs_as_id: str ) -> NetworkManagementInterface: """ Creates and returns an instance of the specified Network Client. """ try: - constructor = NetworkClientFactory.network_client_constructors[client_name] - network_client_instance = constructor(base_url) + constructor = NetworkClientTypes.network_types[client_name] + network_client_instance = constructor(base_url, scs_as_id) return network_client_instance except KeyError: # Get the list of supported client names - supported_clients = list( - NetworkClientFactory.network_client_constructors.keys() - ) + supported_clients = list(NetworkClientTypes.network_types.keys()) raise ValueError( f"Invalid network client name: '{client_name}'. " "Supported clients are: " @@ -60,7 +58,11 @@ class NetworkClientTypes: # --- Dictionary mapping type constants to constructors --- network_types = { - OPEN5GS: lambda url: Open5GSClient(base_url=url), - OAI: lambda url: OaiNefClient(base_url=url), - OPEN5GCORE: lambda url: Open5GCoreClient(base_url=url), + OPEN5GS: lambda url, scs_as_id: Open5GSClient( + base_url=url, scs_as_id=scs_as_id + ), + OAI: lambda url, scs_as_id: OaiNefClient(base_url=url, scs_as_id=scs_as_id), + OPEN5GCORE: lambda url, scs_as_id: Open5GCoreClient( + base_url=url, scs_as_id=scs_as_id + ), } diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index aaf4e3a..ffefa2b 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -35,7 +35,8 @@ def flatten_port_spec(ports_spec: schemas.PortsSpec | None) -> list[str]: def build_flows( - flow_id: int, session_info: schemas.CreateSession + flow_id: int, + session_info: schemas.CreateSession, ) -> list[schemas.FlowInfo]: device_ports = flatten_port_spec(session_info.devicePorts) server_ports = flatten_port_spec(session_info.applicationServerPorts) @@ -77,7 +78,11 @@ class NetworkManagementInterface(ABC): scs_as_id: str @abstractmethod - def add_core_specific_parameters(x): + def add_core_specific_parameters( + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, + ): """ Placeholder for adding core-specific parameters to the subscription. This method should be overridden by subclasses to implement specific logic. @@ -99,6 +104,23 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass + def _build_subscription(self, session_info: Dict) -> None: + valid_session_info = schemas.CreateSession.model_validate(session_info) + device_ipv4 = None + if valid_session_info.device.ipv4Address: + device_ipv4 = valid_session_info.device.ipv4Address.root.publicAddress.root + + self.core_specific_validation(valid_session_info) + subscription = schemas.AsSessionWithQoSSubscription( + notificationDestination=str(valid_session_info.sink), + qosReference=valid_session_info.qosProfile.root, + ueIpv4Addr=device_ipv4, + ueIpv6Addr=valid_session_info.device.ipv6Address, + usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), + ) + self.add_core_specific_parameters(valid_session_info, subscription) + return subscription + def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -110,16 +132,7 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created session details, including its ID. """ - valid_session_info = schemas.CreateSession.model_validate(session_info) - self.core_specific_validation(valid_session_info) - subscription = schemas.AsSessionWithQoSSubscription( - notificationDestination=valid_session_info.sink, - qosReference=valid_session_info.qosProfile, - ueIpv4Addr=valid_session_info.device.ipv4Address, - ueIpv6Addr=valid_session_info.device.ipv6Address, - usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), - ) - self.add_core_specific_parameters(subscription) + subscription = self._build_subscription(session_info) return common.as_session_with_qos_post( self.base_url, self.scs_as_id, subscription ) diff --git a/src/network/core/schemas.py b/src/network/core/schemas.py index cb5ea9c..91a819f 100644 --- a/src/network/core/schemas.py +++ b/src/network/core/schemas.py @@ -86,7 +86,7 @@ class EthFlowDescription(BaseModel): fDesc: FlowDescriptionModel | None = None fDir: FlowDirection | None = None sourceMacAddr: MacAddress | None = None - vlanTags: list[str] | None = Field(None, max_items=2, min_items=1) + vlanTags: list[str] | None = Field(None, max_length=2, min_length=1) srcMacAddrEnd: MacAddress | None = None destMacAddrEnd: MacAddress | None = None @@ -105,9 +105,9 @@ class SponsorInformation(BaseModel): class QosMonitoringInformationModel(BaseModel): reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field( - None, min_items=1 + None, min_length=1 ) - repFreqs: list[ReportingFrequency] | None = Field(None, min_items=1) + repFreqs: list[ReportingFrequency] | None = Field(None, min_length=1) repThreshDl: Uinteger | None = None repThreshUl: Uinteger | None = None repThreshRp: Uinteger | None = None @@ -122,8 +122,8 @@ class FlowInfo(BaseModel): description="Indicates the packet filters of the IP flow. Refer to subclause \ 5.3.8 of 3GPP TS 29.214 for encoding. It shall contain UL and/or DL IP \ flow description.", - max_items=2, - min_items=1, + max_length=2, + min_length=1, ) @@ -133,10 +133,10 @@ class AsSessionWithQoSSubscription(BaseModel): supportedFeatures: SupportedFeatures | None = None notificationDestination: Link flowInfo: list[FlowInfo] | None = Field( - None, description="Describe the data flow which requires QoS.", min_items=1 + None, description="Describe the data flow which requires QoS.", min_length=1 ) ethFlowInfo: list[EthFlowDescription] | None = Field( - None, description="Identifies Ethernet packet flows.", min_items=1 + None, description="Identifies Ethernet packet flows.", min_length=1 ) qosReference: str | None = Field( None, description="Identifies a pre-defined QoS information" @@ -145,10 +145,10 @@ class AsSessionWithQoSSubscription(BaseModel): None, description="Identifies an ordered list of pre-defined QoS information. The \ lower the index of the array for a given entry, the higher the priority.", - min_items=1, + min_length=1, ) - ueIpv4Addr: ipaddress.Ipv4Addr | None = None - ueIpv6Addr: ipaddress.Ipv6Addr | None = None + ueIpv4Addr: ipaddress.IPv4Address | None = None + ueIpv6Addr: ipaddress.IPv6Address | None = None macAddr: MacAddress | None = None usageThreshold: UsageThreshold | None = None sponsorInfo: SponsorInformation | None = None -- GitLab From 1887194f518160b3ca041cd4722c32a5924575b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 14:38:08 +0200 Subject: [PATCH 092/281] Bug fixes --- src/network/clients/open5gs/client.py | 12 ++++---- src/network/core/network_factory.py | 26 +++++++++-------- src/network/core/network_interface.py | 40 ++++++++++++++++++--------- src/network/core/schemas.py | 20 +++++++------- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index eb608c2..b87e4d0 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -40,17 +40,19 @@ class NetworkManager(NetworkManagementInterface): raise e def core_specific_validation(self, session_info: schemas.CreateSession): - if session_info.qosProfile not in flow_id_mapping.keys(): + if session_info.qosProfile.root not in flow_id_mapping.keys(): raise ValidationError( f"Open5Gs only supports these qos-profiles: {', '.join(flow_id_mapping.keys())}" ) def add_core_specific_parameters( - self, session_info: schemas.AsSessionWithQoSSubscription + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, ) -> None: - session_info.supportedFeatures = schemas.SupportedFeatures("003C") - flow_id = flow_id_mapping[session_info.qosProfile] - session_info.flowInfo = build_flows(flow_id, session_info) + subscription.supportedFeatures = schemas.SupportedFeatures("003C") + flow_id = flow_id_mapping[session_info.qosProfile.root] + subscription.flowInfo = build_flows(flow_id, session_info) # --- Implementation of NetworkManagementInterface methods --- def create_qod_session(self, session_info: Dict) -> Dict: diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py index 9669b6c..f155347 100644 --- a/src/network/core/network_factory.py +++ b/src/network/core/network_factory.py @@ -13,9 +13,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from src.network.clients.oai.client import OaiNefClient -from src.network.clients.open5gcore.client import Open5GCoreClient -from src.network.clients.open5gs.client import Open5GSClient +from src.network.clients.oai.client import NetworkManager as OaiNefClient +from src.network.clients.open5gcore.client import NetworkManager as Open5GCoreClient +from src.network.clients.open5gs.client import NetworkManager as Open5GSClient if TYPE_CHECKING: from .network_interface import NetworkManagementInterface @@ -28,20 +28,18 @@ class NetworkClientFactory: @staticmethod def create_network_client( - client_name: str, base_url: str + client_name: str, base_url: str, scs_as_id: str ) -> NetworkManagementInterface: """ Creates and returns an instance of the specified Network Client. """ try: - constructor = NetworkClientFactory.network_client_constructors[client_name] - network_client_instance = constructor(base_url) + constructor = NetworkClientTypes.network_types[client_name] + network_client_instance = constructor(base_url, scs_as_id) return network_client_instance except KeyError: # Get the list of supported client names - supported_clients = list( - NetworkClientFactory.network_client_constructors.keys() - ) + supported_clients = list(NetworkClientTypes.network_types.keys()) raise ValueError( f"Invalid network client name: '{client_name}'. " "Supported clients are: " @@ -60,7 +58,11 @@ class NetworkClientTypes: # --- Dictionary mapping type constants to constructors --- network_types = { - OPEN5GS: lambda url: Open5GSClient(base_url=url), - OAI: lambda url: OaiNefClient(base_url=url), - OPEN5GCORE: lambda url: Open5GCoreClient(base_url=url), + OPEN5GS: lambda url, scs_as_id: Open5GSClient( + base_url=url, scs_as_id=scs_as_id + ), + OAI: lambda url, scs_as_id: OaiNefClient(base_url=url, scs_as_id=scs_as_id), + OPEN5GCORE: lambda url, scs_as_id: Open5GCoreClient( + base_url=url, scs_as_id=scs_as_id + ), } diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index fe0a3e2..ffefa2b 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -35,7 +35,8 @@ def flatten_port_spec(ports_spec: schemas.PortsSpec | None) -> list[str]: def build_flows( - flow_id: int, session_info: schemas.CreateSession + flow_id: int, + session_info: schemas.CreateSession, ) -> list[schemas.FlowInfo]: device_ports = flatten_port_spec(session_info.devicePorts) server_ports = flatten_port_spec(session_info.applicationServerPorts) @@ -77,7 +78,11 @@ class NetworkManagementInterface(ABC): scs_as_id: str @abstractmethod - def add_core_specific_parameters(x): + def add_core_specific_parameters( + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, + ): """ Placeholder for adding core-specific parameters to the subscription. This method should be overridden by subclasses to implement specific logic. @@ -99,6 +104,23 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass + def _build_subscription(self, session_info: Dict) -> None: + valid_session_info = schemas.CreateSession.model_validate(session_info) + device_ipv4 = None + if valid_session_info.device.ipv4Address: + device_ipv4 = valid_session_info.device.ipv4Address.root.publicAddress.root + + self.core_specific_validation(valid_session_info) + subscription = schemas.AsSessionWithQoSSubscription( + notificationDestination=str(valid_session_info.sink), + qosReference=valid_session_info.qosProfile.root, + ueIpv4Addr=device_ipv4, + ueIpv6Addr=valid_session_info.device.ipv6Address, + usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), + ) + self.add_core_specific_parameters(valid_session_info, subscription) + return subscription + def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -110,18 +132,10 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created session details, including its ID. """ - valid_session_info = schemas.CreateSession.model_validate(session_info) - self.core_specific_validation(valid_session_info) - subscription = schemas.AsSessionWithQoSSubscription( - notificationDestination=valid_session_info.sink, - qosReference=valid_session_info.qosProfile, - ueIpv4Addr=valid_session_info.device.ipv4Address, - ueIpv6Addr=valid_session_info.device.ipv6Address, - usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), + subscription = self._build_subscription(session_info) + return common.as_session_with_qos_post( + self.base_url, self.scs_as_id, subscription ) - self.add_core_specific_parameters(subscription) - url = f"{self.base_url}/{self.scs_as_id}/subscriptions" - common.as_session_with_qos_post(self.base_url, self.scs_as_id, subscription) def get_qod_session(self, session_id: str) -> Dict: """ diff --git a/src/network/core/schemas.py b/src/network/core/schemas.py index cb5ea9c..91a819f 100644 --- a/src/network/core/schemas.py +++ b/src/network/core/schemas.py @@ -86,7 +86,7 @@ class EthFlowDescription(BaseModel): fDesc: FlowDescriptionModel | None = None fDir: FlowDirection | None = None sourceMacAddr: MacAddress | None = None - vlanTags: list[str] | None = Field(None, max_items=2, min_items=1) + vlanTags: list[str] | None = Field(None, max_length=2, min_length=1) srcMacAddrEnd: MacAddress | None = None destMacAddrEnd: MacAddress | None = None @@ -105,9 +105,9 @@ class SponsorInformation(BaseModel): class QosMonitoringInformationModel(BaseModel): reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field( - None, min_items=1 + None, min_length=1 ) - repFreqs: list[ReportingFrequency] | None = Field(None, min_items=1) + repFreqs: list[ReportingFrequency] | None = Field(None, min_length=1) repThreshDl: Uinteger | None = None repThreshUl: Uinteger | None = None repThreshRp: Uinteger | None = None @@ -122,8 +122,8 @@ class FlowInfo(BaseModel): description="Indicates the packet filters of the IP flow. Refer to subclause \ 5.3.8 of 3GPP TS 29.214 for encoding. It shall contain UL and/or DL IP \ flow description.", - max_items=2, - min_items=1, + max_length=2, + min_length=1, ) @@ -133,10 +133,10 @@ class AsSessionWithQoSSubscription(BaseModel): supportedFeatures: SupportedFeatures | None = None notificationDestination: Link flowInfo: list[FlowInfo] | None = Field( - None, description="Describe the data flow which requires QoS.", min_items=1 + None, description="Describe the data flow which requires QoS.", min_length=1 ) ethFlowInfo: list[EthFlowDescription] | None = Field( - None, description="Identifies Ethernet packet flows.", min_items=1 + None, description="Identifies Ethernet packet flows.", min_length=1 ) qosReference: str | None = Field( None, description="Identifies a pre-defined QoS information" @@ -145,10 +145,10 @@ class AsSessionWithQoSSubscription(BaseModel): None, description="Identifies an ordered list of pre-defined QoS information. The \ lower the index of the array for a given entry, the higher the priority.", - min_items=1, + min_length=1, ) - ueIpv4Addr: ipaddress.Ipv4Addr | None = None - ueIpv6Addr: ipaddress.Ipv6Addr | None = None + ueIpv4Addr: ipaddress.IPv4Address | None = None + ueIpv6Addr: ipaddress.IPv6Address | None = None macAddr: MacAddress | None = None usageThreshold: UsageThreshold | None = None sponsorInfo: SponsorInformation | None = None -- GitLab From 5d80550454354b73da62e3793b536dee962f7275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 14:51:05 +0200 Subject: [PATCH 093/281] applies pre-commit fixes --- src/network/clients/open5gs/client.py | 5 ++- src/network/core/common.py | 2 +- src/network/core/schemas.py | 2 +- tests/network/test_1_factory copy.py | 24 +++++++++++ tests/network/test_2_create_qod_session.py | 48 ++++++++++++++++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/network/test_1_factory copy.py create mode 100644 tests/network/test_2_create_qod_session.py diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index b87e4d0..35d5430 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -2,10 +2,11 @@ from typing import Dict from pydantic import ValidationError + from src import logger from src.network.core.network_interface import NetworkManagementInterface, build_flows -from ...core import common -from ...core import schemas + +from ...core import common, schemas log = logger.get_logger(__name__) diff --git a/src/network/core/common.py b/src/network/core/common.py index 214b774..777c31d 100644 --- a/src/network/core/common.py +++ b/src/network/core/common.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). -from pydantic import BaseModel import requests +from pydantic import BaseModel from src import logger diff --git a/src/network/core/schemas.py b/src/network/core/schemas.py index 91a819f..1138c40 100644 --- a/src/network/core/schemas.py +++ b/src/network/core/schemas.py @@ -5,11 +5,11 @@ import ipaddress from enum import Enum +from ipaddress import IPv4Address, IPv6Address from typing import Annotated from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress -from ipaddress import IPv4Address, IPv6Address class FlowDirection(Enum): diff --git a/tests/network/test_1_factory copy.py b/tests/network/test_1_factory copy.py new file mode 100644 index 0000000..dc1197b --- /dev/null +++ b/tests/network/test_1_factory copy.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +import pytest + +from src.network.clients.open5gs.client import NetworkManager as Open5GsClient +from src.network.core.network_factory import NetworkClientFactory + +test_cases = [ + ("open5gs", "http://192.168.124.233:30769/", "scs"), +] + + +@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) +def test_factory_network(client_name, base_url, scs_as_id): + """ + Test the factory pattern for the network client. + """ + client_class_map = { + "open5gs": Open5GsClient, + } + expected_client_class = client_class_map[client_name] + network_client = NetworkClientFactory.create_network_client( + client_name, base_url, scs_as_id + ) + assert isinstance(network_client, expected_client_class) diff --git a/tests/network/test_2_create_qod_session.py b/tests/network/test_2_create_qod_session.py new file mode 100644 index 0000000..bd1ee45 --- /dev/null +++ b/tests/network/test_2_create_qod_session.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +import pytest + +from src.network.core.network_factory import NetworkClientFactory + +test_cases = [ + ("open5gs", "http://192.168.124.233:30769/", "scs"), +] + + +@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) +def test_valid_input(client_name, base_url, scs_as_id): + network_client = NetworkClientFactory.create_network_client( + client_name, base_url, scs_as_id + ) + + camara_session = { + "duration": 3600, + "device": { + "ipv4Address": {"publicAddress": "10.45.0.3", "privateAddress": "10.45.0.3"} + }, + "applicationServer": {"ipv4Address": "10.45.0.1"}, + "devicePorts": {"ranges": [{"from": 0, "to": 65535}]}, + "applicationServerPorts": {"ranges": [{"from": 0, "to": 65535}]}, + "qosProfile": "qos-e", + "sink": "https://endpoint.example.com/sink", + } + network_client._build_subscription(camara_session) + + +@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) +def test_create_qod_session(client_name, base_url, scs_as_id): + network_client = NetworkClientFactory.create_network_client( + client_name, base_url, scs_as_id + ) + + camara_session = { + "duration": 3600, + "device": { + "ipv4Address": {"publicAddress": "10.45.0.3", "privateAddress": "10.45.0.3"} + }, + "applicationServer": {"ipv4Address": "10.45.0.1"}, + "devicePorts": {"ranges": [{"from": 0, "to": 65535}]}, + "applicationServerPorts": {"ranges": [{"from": 0, "to": 65535}]}, + "qosProfile": "qos-e", + "sink": "https://endpoint.example.com/sink", + } + network_client.create_qod_session(camara_session) -- GitLab From f3567b015538493f5536f1b02d099e21e0ee6906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 27 May 2025 15:26:08 +0200 Subject: [PATCH 094/281] renamed test_1 --- tests/network/{test_1_factory copy.py => test_1_factory.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/network/{test_1_factory copy.py => test_1_factory.py} (100%) diff --git a/tests/network/test_1_factory copy.py b/tests/network/test_1_factory.py similarity index 100% rename from tests/network/test_1_factory copy.py rename to tests/network/test_1_factory.py -- GitLab From 236d45cdcd493336a20325ce1a76797a8d6b7f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 28 May 2025 17:07:23 +0200 Subject: [PATCH 095/281] Add get edge cloud zones details function --- src/edgecloud/clients/i2edge/client.py | 44 +++++--------------------- tests/edgecloud/test_2_av_zones.py | 26 +++++++++++++++ 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 5396fd6..2d98242 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -43,45 +43,17 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # Harcoded def get_edge_cloud_zones_details( self, zone_id: str, flavour_id: Optional[str] = None ) -> Dict: - # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API - return { - "zoneId": zone_id, - "reservedComputeResources": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": "4", - "memory": 8192, - } - ], - "computeResourceQuotaLimits": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": "8", - "memory": 16384, - } - ], - "flavoursSupported": [ - { - "flavourId": "medium-x86", - "cpuArchType": "ISA_X86_64", - "supportedOSTypes": [ - { - "architecture": "x86_64", - "distribution": "UBUNTU", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - } - ], - "numCPU": 4, - "memorySize": 8192, - "storageSize": 100, - } - ], - } + url = "{}zone/{}".format(self.base_url, zone_id) + params = {} + try: + response = i2edge_get(url, params=params) + log.info("Availability zone details retrieved successfully") + return response + except I2EdgeError as e: + raise e def _create_artefact( self, diff --git a/tests/edgecloud/test_2_av_zones.py b/tests/edgecloud/test_2_av_zones.py index b06bb7d..23a2a4f 100644 --- a/tests/edgecloud/test_2_av_zones.py +++ b/tests/edgecloud/test_2_av_zones.py @@ -5,6 +5,8 @@ from src.edgecloud.clients.errors import EdgeCloudPlatformError from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases +zone_id = "Omega" + @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_edge_cloud_zones(client_name, base_url): @@ -20,3 +22,27 @@ def test_get_edge_cloud_zones(client_name, base_url): assert "geographyDetails" in zone except EdgeCloudPlatformError as e: pytest.fail(f"Failed to retrieve zones: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_edge_cloud_zones_details(client_name, base_url, zone_id="Omega"): + """ + Test that get_edge_cloud_zone_details returns valid responses for each client. + Since each client has different response formats, we only verify basic success criteria. + """ + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + zones = edgecloud_platform.get_edge_cloud_zones() + assert len(zones) > 0, "No zones available for testing" + + zone_details = edgecloud_platform.get_edge_cloud_zones_details(zone_id) + + # Basic checks that apply to all clients + assert zone_details is not None, "Zone details should not be None" + assert isinstance(zone_details, dict), "Zone details should be a dictionary" + assert len(zone_details) > 0, "Zone details should not be empty" + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to retrieve zone details: {e}") + except KeyError as e: + pytest.fail(f"Missing expected key in response: {e}") -- GitLab From 50582a2dde168029303097db51a88485e502a3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 28 May 2025 17:09:38 +0200 Subject: [PATCH 096/281] Add deploy instance CRUD operations. Update schemas. --- src/edgecloud/clients/i2edge/client.py | 85 ++++++++++++++++++++---- src/edgecloud/clients/i2edge/schemas.py | 2 +- tests/edgecloud/test_4_app_onboarding.py | 2 +- tests/edgecloud/test_5_app_deployment.py | 79 +++++++++++++++++++++- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 2d98242..77678cb 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -151,19 +151,80 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # Harcoded + # WIP - Harcoded by now + def _select_best_flavour_for_app(self, zone_id) -> str: + """ + Selects the best flavour for the specified app requirements in a given zone. + """ + # list_of_flavours = self.get_edge_cloud_zones_details(zone_id) + # TODO - Harcoded + # flavourId = "67080247e43a30ca79b50d7d" + flavourId = "6800c5199f29328e5691cd68" + return flavourId + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - return {"appInstanceId": "abcd-efgh"} + appId = app_id + # appProviderId & appVersion are specified in the previous call (onboard_app) + app = self.get_onboarded_app(appId) + profile_data = app["profile_data"] + appProviderId = profile_data["appProviderId"] + appVersion = profile_data["appMetaData"]["version"] + # TODO: Iterate in the list; deploy the app in all zones + zone_info = app_zones[0]["EdgeCloudZone"] + zone_id = zone_info["edgeCloudZoneId"] + flavourId = self._select_best_flavour_for_app(zone_id=zone_id) + app_deploy_data = schemas.AppDeployData( + appId=appId, + appProviderId=appProviderId, + appVersion=appVersion, + zoneInfo=schemas.ZoneInfo(flavourId=flavourId, zoneId=zone_id), + ) + url = "{}/app/".format(self.base_url) + payload = schemas.AppDeploy(app_deploy_data=app_deploy_data) + try: + response = i2edge_post(url, payload) + log.info("App deployed successfully") + print(response) + return response + except I2EdgeError as e: + raise e - # Harcoded - def get_all_deployed_apps( - self, - app_id: Optional[str] = None, - app_instance_id: Optional[str] = None, - region: Optional[str] = None, - ) -> List[Dict]: - return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + def get_all_deployed_apps(self) -> List[Dict]: + url = "{}/app/".format(self.base_url) + params = {} + try: + response = i2edge_get(url, params=params) + log.info("All app instances retrieved successfully") + return response + except I2EdgeError as e: + raise e + + # TODO: Partially harcoded + def get_deployed_app(self, app_id, zone_id) -> List[Dict]: + # Idea: Get all onboarded apps and filter the one where release_name == artifact name + # Step 1) Extract the app name from the app id + app = self.get_onboarded_app(app_id) + appName = app["profile_data"]["appMetaData"]["appName"] + + # Step 2) Retrieve all deployed apps and filter the ones where release_name == app_name + deployed_apps = self.get_all_deployed_apps() + # This logic should be improved + deploy_names = [app["deploy_name"] for app in deployed_apps] + appName = deploy_names[0] + + url = "{}/app/{}/{}".format(self.base_url, zone_id, appName) + params = {} + try: + response = i2edge_get(url, params=params) + log.info("App instance retrieved successfully") + return response + except I2EdgeError as e: + raise e - # Harcoded def undeploy_app(self, app_instance_id: str) -> None: - print(f"Deleting app instance: {app_instance_id}") + url = "{}/app".format(self.base_url) + try: + i2edge_delete(url, app_instance_id) + log.info("App instance deleted successfully") + except I2EdgeError as e: + raise e diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/edgecloud/clients/i2edge/schemas.py index ed1a15d..c0d522f 100644 --- a/src/edgecloud/clients/i2edge/schemas.py +++ b/src/edgecloud/clients/i2edge/schemas.py @@ -34,7 +34,7 @@ class AppDeployData(BaseModel): class AppDeploy(BaseModel): app_deploy_data: AppDeployData - app_parameters: Optional[AppParameters] = None + app_parameters: Optional[AppParameters] = Field(default=AppParameters()) # Artefact diff --git a/tests/edgecloud/test_4_app_onboarding.py b/tests/edgecloud/test_4_app_onboarding.py index 0bd14d3..1531be3 100644 --- a/tests/edgecloud/test_4_app_onboarding.py +++ b/tests/edgecloud/test_4_app_onboarding.py @@ -10,7 +10,7 @@ app_manifest = { "appId": "test_app_from_SDK", "name": "my-application", "version": "1.0.0", - "appProvider": "MyAppProvider", + "appProvider": "i2CAT", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", diff --git a/tests/edgecloud/test_5_app_deployment.py b/tests/edgecloud/test_5_app_deployment.py index 6859c78..edeb10f 100644 --- a/tests/edgecloud/test_5_app_deployment.py +++ b/tests/edgecloud/test_5_app_deployment.py @@ -1,2 +1,79 @@ # -*- coding: utf-8 -*- -# TODO +import pytest + +from src.edgecloud.clients.errors import EdgeCloudPlatformError +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from tests.edgecloud.test_cases import test_cases + +appId = "i2edgechart-id" +app_zones = [ + { + "kubernetesClusterRef": "not-used", + "EdgeCloudZone": { + # "edgeCloudZoneId": "Omega", + "edgeCloudZoneId": "Omega12345", + "edgeCloudZoneName": "not-used", + "edgeCloudZoneStatus": "not-used", + "edgeCloudProvider": "not-used", + "edgeCloudRegion": "not-used", + }, + } +] + + +@pytest.fixture(scope="module") # or "session" depending on your needs +def deployed_app(request): + client_name, base_url = request.param + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + + try: + output = edgecloud_platform.deploy_app(appId, app_zones) + return { + "client_name": client_name, + "base_url": base_url, + "appInstanceId": output["deploy_name"], + } + except EdgeCloudPlatformError as e: + pytest.fail(f"App deployment failed unexpectedly: {e}") + + +@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +def test_deploy_app_success(deployed_app): + assert "appInstanceId" in deployed_app + assert deployed_app["appInstanceId"].startswith("i2edgechart") # simple check + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_all_apps_success(client_name, base_url): + + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + edgecloud_platform.get_all_deployed_apps() + + except EdgeCloudPlatformError as e: + pytest.fail(f"App instance retrieval failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_app_success(client_name, base_url): + + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + edgecloud_platform.get_deployed_app( + appId, app_zones[0]["EdgeCloudZone"]["edgeCloudZoneId"] + ) + + except EdgeCloudPlatformError as e: + pytest.fail(f"App instance retrieval failed unexpectedly: {e}") + + +@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +def test_undeploy_app_success(deployed_app): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + deployed_app["client_name"], deployed_app["base_url"] + ) + + try: + edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App undeployment failed unexpectedly: {e}") -- GitLab From a3422485e151fc981f65bcb653493cedf6197f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 28 May 2025 17:10:53 +0200 Subject: [PATCH 097/281] Update logger so it creates the .log folder if not present --- src/logger.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/logger.py b/src/logger.py index 4fb7825..14c3f6b 100644 --- a/src/logger.py +++ b/src/logger.py @@ -11,6 +11,7 @@ ## import logging import sys +from pathlib import Path from colorlog import ColoredFormatter @@ -24,21 +25,20 @@ FILE_FORMATTER = "[%(asctime)s] {%(name)s: %(lineno)d} %(levelname)s - %(message def setup_logger(logger_name=APP_LOGGER_NAME, is_debug=True, file_name=None): - logger = logging.getLogger(logger_name) logger.setLevel(logging.DEBUG if is_debug else logging.INFO) colored_formatter = ColoredFormatter(COLORED_FORMATERR) - file_formatter = logging.Formatter(FILE_FORMATTER) - sh = logging.StreamHandler(sys.stdout) sh.setFormatter(colored_formatter) logger.handlers.clear() logger.addHandler(sh) if file_name: + log_path = Path(file_name) + log_path.parent.mkdir(parents=True, exist_ok=True) fh = logging.FileHandler(file_name) - fh.setFormatter(file_formatter) + fh.setFormatter(logging.Formatter(FILE_FORMATTER)) logger.addHandler(fh) return logger -- GitLab From 44bf58f750a57eef7c43f40710b3916d8dfa570b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 29 May 2025 13:37:24 +0200 Subject: [PATCH 098/281] Update get_deployed_apps. Not harcoded anymore --- src/edgecloud/clients/i2edge/client.py | 37 +++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index 77678cb..c163cd3 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -164,7 +164,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: appId = app_id - # appProviderId & appVersion are specified in the previous call (onboard_app) app = self.get_onboarded_app(appId) profile_data = app["profile_data"] appProviderId = profile_data["appProviderId"] @@ -199,20 +198,34 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # TODO: Partially harcoded def get_deployed_app(self, app_id, zone_id) -> List[Dict]: - # Idea: Get all onboarded apps and filter the one where release_name == artifact name - # Step 1) Extract the app name from the app id - app = self.get_onboarded_app(app_id) - appName = app["profile_data"]["appMetaData"]["appName"] + # Logic: Get all onboarded apps and filter the one where release_name == artifact name - # Step 2) Retrieve all deployed apps and filter the ones where release_name == app_name - deployed_apps = self.get_all_deployed_apps() - # This logic should be improved - deploy_names = [app["deploy_name"] for app in deployed_apps] - appName = deploy_names[0] + # Step 1) Extract "app_name" from the onboarded app using the "app_id" + onboarded_app = self.get_onboarded_app(app_id) + if not onboarded_app: + raise ValueError(f"No onboarded app found with ID: {app_id}") - url = "{}/app/{}/{}".format(self.base_url, zone_id, appName) + try: + app_name = onboarded_app["profile_data"]["appMetaData"]["appName"] + except KeyError as e: + raise ValueError(f"Onboarded app missing required field: {e}") + + # Step 2) Retrieve all deployed apps and filter the one(s) where release_name == app_name + deployed_apps = self.get_all_deployed_apps() + if not deployed_apps: + return [] + + # Filter apps where release_name matches our app_name and zone matches + for app_instance_name in deployed_apps: + if ( + app_instance_name.get("release_name") == app_name + and app_instance_name.get("zone_id") == zone_id + ): + return app_instance_name + return None + + url = "{}/app/{}/{}".format(self.base_url, zone_id, app_instance_name) params = {} try: response = i2edge_get(url, params=params) -- GitLab From 418419058683a6db1b46f74be7e6d401f9fdf913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 29 May 2025 13:40:11 +0200 Subject: [PATCH 099/281] Add e2e test. Add test configuration dedicated file. Also, move individual tests to /old --- tests/edgecloud/test_config.py | 87 ++++++++++++++++++ tests/edgecloud/test_e2e.py | 155 +++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 tests/edgecloud/test_config.py create mode 100644 tests/edgecloud/test_e2e.py diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py new file mode 100644 index 0000000..e2015d5 --- /dev/null +++ b/tests/edgecloud/test_config.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +""" +EdgeCloud Platform Test Configuration + +This module contains shared configuration constants and manifests for testing +the EdgeCloud Platform integration across different clients. +""" + +###################### +# i2Edge variables +###################### +# EdgeCloud Zone +ZONE_ID = "Omega" + +# Artefact +ARTEFACT_ID = "i2edgechart-id-2" +ARTEFACT_NAME = "i2edgechart" +REPO_NAME = "github-cesar" +REPO_TYPE = "PUBLICREPO" +REPO_URL = "https://cesarcajas.github.io/helm-charts-examples/" + +# Onboarding: CAMARA /app payload (only mandatory fields) +APP_ONBOARD_MANIFEST = { + "appId": ARTEFACT_ID, + "name": "i2edge-app-SDK", + "version": "1.0.0", + "appProvider": "i2CAT", + "packageType": "CONTAINER", + "appRepo": { + "type": "PUBLICREPO", + "imagePath": "https://example.com/my-app-image:1.0.0", + }, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": { + "cpuPool": { + "numCPU": 2, + "memory": 2048, + "topology": { + "minNumberOfNodes": 2, + "minNodeCpu": 1, + "minNodeMemory": 1024, + }, + } + }, + "isStandalone": False, + "version": "1.29", + }, + "componentSpec": [ + { + "componentName": "my-component", + "networkInterfaces": [ + { + "interfaceId": "eth0", + "protocol": "TCP", + "port": 8080, + "visibilityType": "VISIBILITY_EXTERNAL", + } + ], + } + ], +} + +# App deployment config +APP_ID = ARTEFACT_ID +APP_ZONES = [ + { + "kubernetesClusterRef": "not-used", + "EdgeCloudZone": { + "edgeCloudZoneId": ZONE_ID, + "edgeCloudZoneName": "not-used", + "edgeCloudZoneStatus": "not-used", + "edgeCloudProvider": "not-used", + "edgeCloudRegion": "not-used", + }, + } +] + +###################### +# PiEdge variables +###################### +# TODO + +###################### +# aerOS variables +###################### +# TODO diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py new file mode 100644 index 0000000..e6a0c6f --- /dev/null +++ b/tests/edgecloud/test_e2e.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +""" +EdgeCloud Platform Integration Tests + +Validates the complete application lifecycle across multiple clients: +1. Infrastructure (zone discovery) +2. Artefact management (create/delete) +3. Application lifecycle (onboard/deploy/undeploy/delete) + +Key features: +- Tests all client implementations (parametrized via test_cases) +- Ensures proper resource cleanup +- Uses shared test constants and CAMARA-compliant manifests +- Includes i2edge-specific tests where needed +""" +import pytest + +from src.edgecloud.clients.errors import EdgeCloudPlatformError +from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from tests.edgecloud.test_cases import test_cases +from tests.edgecloud.test_config import ( + APP_ID, + APP_ONBOARD_MANIFEST, + APP_ZONES, + ARTEFACT_ID, + ARTEFACT_NAME, + REPO_NAME, + REPO_TYPE, + REPO_URL, + ZONE_ID, +) + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_edge_cloud_zones(client_name, base_url): + """ + Test the format of the response from get_edge_cloud_zones for each client. + """ + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + zones = edgecloud_platform.get_edge_cloud_zones() + assert isinstance(zones, list) + for zone in zones: + assert "zoneId" in zone + assert "geographyDetails" in zone + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to retrieve zones: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_get_edge_cloud_zones_details(client_name, base_url, zone_id=ZONE_ID): + """ + Test that get_edge_cloud_zone_details returns valid responses for each client. + Since each client has different response formats, we only verify basic success criteria. + """ + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + zones = edgecloud_platform.get_edge_cloud_zones() + assert len(zones) > 0, "No zones available for testing" + + zone_details = edgecloud_platform.get_edge_cloud_zones_details(zone_id) + + # Basic checks that apply to all clients + assert zone_details is not None, "Zone details should not be None" + assert isinstance(zone_details, dict), "Zone details should be a dictionary" + assert len(zone_details) > 0, "Zone details should not be empty" + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to retrieve zone details: {e}") + except KeyError as e: + pytest.fail(f"Missing expected key in response: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_create_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + try: + edgecloud_platform._create_artefact( + artefact_id=ARTEFACT_ID, + artefact_name=ARTEFACT_NAME, + repo_name=REPO_NAME, + repo_type=REPO_TYPE, + repo_url=REPO_URL, + password=None, + token=None, + user_name=None, + ) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact creation failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_onboard_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + edgecloud_platform.onboard_app(APP_ONBOARD_MANIFEST) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding failed unexpectedly: {e}") + + +@pytest.fixture(scope="module") +def deployed_app(request): + client_name, base_url = request.param + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + output = edgecloud_platform.deploy_app(APP_ID, APP_ZONES) + return { + "client_name": client_name, + "base_url": base_url, + "appInstanceId": output["deploy_name"], + } + except EdgeCloudPlatformError as e: + pytest.fail(f"App deployment failed unexpectedly: {e}") + + +@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +def test_deploy_app_success(deployed_app): + assert "appInstanceId" in deployed_app + if "client_name" in deployed_app == "i2edge": + assert deployed_app["appInstanceId"].startswith(ARTEFACT_NAME) + + +@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +def test_undeploy_app_success(deployed_app): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + deployed_app["client_name"], deployed_app["base_url"] + ) + try: + edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App undeployment failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_onboarded_app_success(client_name, base_url): + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) + try: + edgecloud_platform.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") + + +@pytest.mark.parametrize("client_name, base_url", test_cases) +def test_delete_artefact_success(client_name, base_url): + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + try: + edgecloud_platform._delete_artefact(artefact_id=ARTEFACT_ID) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact deletion failed unexpectedly: {e}") -- GitLab From ac694d7436278b04fe35749018eb7658b8b58a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 29 May 2025 13:40:48 +0200 Subject: [PATCH 100/281] Move individual unit tests to /old. --- tests/edgecloud/{ => old}/test_1_factory.py | 5 +- tests/edgecloud/{ => old}/test_2_av_zones.py | 4 +- tests/edgecloud/{ => old}/test_3_artefact.py | 74 +++++++++++-------- .../{ => old}/test_4_app_onboarding.py | 4 +- .../{ => old}/test_5_app_deployment.py | 58 +++++++-------- 5 files changed, 79 insertions(+), 66 deletions(-) rename tests/edgecloud/{ => old}/test_1_factory.py (86%) rename tests/edgecloud/{ => old}/test_2_av_zones.py (98%) rename tests/edgecloud/{ => old}/test_3_artefact.py (53%) rename tests/edgecloud/{ => old}/test_4_app_onboarding.py (98%) rename tests/edgecloud/{ => old}/test_5_app_deployment.py (50%) diff --git a/tests/edgecloud/test_1_factory.py b/tests/edgecloud/old/test_1_factory.py similarity index 86% rename from tests/edgecloud/test_1_factory.py rename to tests/edgecloud/old/test_1_factory.py index 5433f0e..5c8d483 100644 --- a/tests/edgecloud/test_1_factory.py +++ b/tests/edgecloud/old/test_1_factory.py @@ -3,7 +3,8 @@ import pytest from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient + +# from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases @@ -16,7 +17,7 @@ def test_factory_edgecloud(client_name, base_url): client_class_map = { "i2edge": I2EdgeClient, "aeros": AerosClient, - "piedge": PiEdgeClient, + # "piedge": PiEdgeClient, } expected_client_class = client_class_map[client_name] edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) diff --git a/tests/edgecloud/test_2_av_zones.py b/tests/edgecloud/old/test_2_av_zones.py similarity index 98% rename from tests/edgecloud/test_2_av_zones.py rename to tests/edgecloud/old/test_2_av_zones.py index 23a2a4f..000a38b 100644 --- a/tests/edgecloud/test_2_av_zones.py +++ b/tests/edgecloud/old/test_2_av_zones.py @@ -5,7 +5,7 @@ from src.edgecloud.clients.errors import EdgeCloudPlatformError from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases -zone_id = "Omega" +zone_id = "Omega12345" @pytest.mark.parametrize("client_name, base_url", test_cases) @@ -25,7 +25,7 @@ def test_get_edge_cloud_zones(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones_details(client_name, base_url, zone_id="Omega"): +def test_get_edge_cloud_zones_details(client_name, base_url, zone_id=zone_id): """ Test that get_edge_cloud_zone_details returns valid responses for each client. Since each client has different response formats, we only verify basic success criteria. diff --git a/tests/edgecloud/test_3_artefact.py b/tests/edgecloud/old/test_3_artefact.py similarity index 53% rename from tests/edgecloud/test_3_artefact.py rename to tests/edgecloud/old/test_3_artefact.py index 77e345b..550e218 100644 --- a/tests/edgecloud/test_3_artefact.py +++ b/tests/edgecloud/old/test_3_artefact.py @@ -3,16 +3,15 @@ import pytest from src.edgecloud.clients.errors import EdgeCloudPlatformError from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from tests.edgecloud.test_cases import test_cases # Note: artifact mgmt is only supported by i2Edge -test_cases = [ - ("i2edge", "http://192.168.123.237:30769/"), -] - -artefact_id = "hello-world-from-sdk-2" -artefact_name = "hello-word-2" -repo_name = "dummy-repo-2" +artefact_id = "i2edgechart-id" +artefact_name = "i2edgechart" +repo_name = "github-cesar" +repo_type = "PUBLICREPO" +repo_url = "https://cesarcajas.github.io/helm-charts-examples/" @pytest.mark.parametrize("client_name, base_url", test_cases) @@ -26,8 +25,8 @@ def test_create_artefact_success(client_name, base_url): artefact_id=artefact_id, artefact_name=artefact_name, repo_name=repo_name, - repo_type="PUBLICREPO", - repo_url="https://helm.github.io/examples", + repo_type=repo_type, + repo_url=repo_url, password=None, token=None, user_name=None, @@ -57,40 +56,55 @@ def test_create_artefact_failure(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform._get_artefact(artefact_id=artefact_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + try: + edgecloud_platform._get_artefact(artefact_id=artefact_id) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_all_artefacts_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform._get_all_artefacts() - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + try: + edgecloud_platform._get_all_artefacts() + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform._delete_artefact(artefact_id=artefact_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact deletion failed unexpectedly: {e}") + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + try: + edgecloud_platform._delete_artefact(artefact_id=artefact_id) + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact deletion failed unexpectedly: {e}") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_delete_artefact_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") + if client_name == "i2edge": + edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( + client_name, base_url + ) + with pytest.raises(EdgeCloudPlatformError): + edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") diff --git a/tests/edgecloud/test_4_app_onboarding.py b/tests/edgecloud/old/test_4_app_onboarding.py similarity index 98% rename from tests/edgecloud/test_4_app_onboarding.py rename to tests/edgecloud/old/test_4_app_onboarding.py index 1531be3..bf10635 100644 --- a/tests/edgecloud/test_4_app_onboarding.py +++ b/tests/edgecloud/old/test_4_app_onboarding.py @@ -7,8 +7,8 @@ from tests.edgecloud.test_cases import test_cases # CAMARA app payload (only mandatory fields) app_manifest = { - "appId": "test_app_from_SDK", - "name": "my-application", + "appId": "i2edgechart-id", + "name": "i2edge-app-SDK", "version": "1.0.0", "appProvider": "i2CAT", "packageType": "CONTAINER", diff --git a/tests/edgecloud/test_5_app_deployment.py b/tests/edgecloud/old/test_5_app_deployment.py similarity index 50% rename from tests/edgecloud/test_5_app_deployment.py rename to tests/edgecloud/old/test_5_app_deployment.py index edeb10f..24573e4 100644 --- a/tests/edgecloud/test_5_app_deployment.py +++ b/tests/edgecloud/old/test_5_app_deployment.py @@ -5,6 +5,7 @@ from src.edgecloud.clients.errors import EdgeCloudPlatformError from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory from tests.edgecloud.test_cases import test_cases +# As a pre-requirement for this test, the app should be already onboarded appId = "i2edgechart-id" app_zones = [ { @@ -20,32 +21,31 @@ app_zones = [ } ] +# TODO: Revise this test, something is wrong. It doesn't fail even though I specify a non-existent av zone -@pytest.fixture(scope="module") # or "session" depending on your needs -def deployed_app(request): - client_name, base_url = request.param - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +# @pytest.fixture(scope="module") +# def deployed_app(request): +# client_name, base_url = request.param +# edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +# try: +# output = edgecloud_platform.deploy_app(appId, app_zones) +# return { +# "client_name": client_name, +# "base_url": base_url, +# "appInstanceId": output["deploy_name"], +# } +# except EdgeCloudPlatformError as e: +# pytest.fail(f"App deployment failed unexpectedly: {e}") - try: - output = edgecloud_platform.deploy_app(appId, app_zones) - return { - "client_name": client_name, - "base_url": base_url, - "appInstanceId": output["deploy_name"], - } - except EdgeCloudPlatformError as e: - pytest.fail(f"App deployment failed unexpectedly: {e}") - -@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -def test_deploy_app_success(deployed_app): - assert "appInstanceId" in deployed_app - assert deployed_app["appInstanceId"].startswith("i2edgechart") # simple check +# @pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +# def test_deploy_app_success(deployed_app): +# assert "appInstanceId" in deployed_app +# assert deployed_app["appInstanceId"].startswith("i2edgechart") @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_all_apps_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.get_all_deployed_apps() @@ -56,7 +56,6 @@ def test_get_all_apps_success(client_name, base_url): @pytest.mark.parametrize("client_name, base_url", test_cases) def test_get_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: edgecloud_platform.get_deployed_app( @@ -67,13 +66,12 @@ def test_get_app_success(client_name, base_url): pytest.fail(f"App instance retrieval failed unexpectedly: {e}") -@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -def test_undeploy_app_success(deployed_app): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - deployed_app["client_name"], deployed_app["base_url"] - ) - - try: - edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) - except EdgeCloudPlatformError as e: - pytest.fail(f"App undeployment failed unexpectedly: {e}") +# @pytest.mark.parametrize("deployed_app", test_cases, indirect=True) +# def test_undeploy_app_success(deployed_app): +# edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( +# deployed_app["client_name"], deployed_app["base_url"] +# ) +# try: +# edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"App undeployment failed unexpectedly: {e}") -- GitLab From 651c4b1abcfe01c99187b5704b89da69dbf297a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 29 May 2025 13:41:06 +0200 Subject: [PATCH 101/281] Add missing init.py file --- tests/edgecloud/old/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/edgecloud/old/__init__.py diff --git a/tests/edgecloud/old/__init__.py b/tests/edgecloud/old/__init__.py new file mode 100644 index 0000000..e69de29 -- GitLab From 81cb9760365aea1da0f44887848884d5f420665c Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Fri, 30 May 2025 18:43:26 +0200 Subject: [PATCH 102/281] move traffic influence to core and update oai client --- src/network/clients/oai/client.py | 267 +++++++++----------------- src/network/clients/oai/common.py | 106 ---------- src/network/clients/oai/schemas.py | 206 -------------------- src/network/clients/oai/utils.py | 88 --------- src/network/core/common.py | 32 ++- src/network/core/network_interface.py | 98 ++++++++-- src/network/core/schemas.py | 89 +++++++++ 7 files changed, 292 insertions(+), 594 deletions(-) delete mode 100644 src/network/clients/oai/common.py delete mode 100644 src/network/clients/oai/schemas.py delete mode 100644 src/network/clients/oai/utils.py diff --git a/src/network/clients/oai/client.py b/src/network/clients/oai/client.py index 8bb9d4b..a1d4766 100644 --- a/src/network/clients/oai/client.py +++ b/src/network/clients/oai/client.py @@ -8,38 +8,23 @@ # - Giulio Carota (giulio.carota@eurecom.fr) ## -from typing import Dict - -import shortuuid -from pydantic import ValidationError from src import logger -from src.network.clients.oai.common import ( - OaiHttpError, - OaiNetworkError, - oai_as_session_with_qos_delete, - oai_as_session_with_qos_get, - oai_as_session_with_qos_post, - oai_traffic_influence_delete, - oai_traffic_influence_post, - oai_traffic_influence_put, -) -from src.network.clients.oai.schemas import ( - CamaraQoDSessionInfo, - CamaraTrafficInfluence, - OaiAsSessionWithQosSubscription, -) -from src.network.clients.oai.utils import ( - as_session_with_qos_to_camara_qod, - camara_qod_to_as_session_with_qos, - camara_ti_to_3gpp_ti, -) from src.network.core.network_interface import NetworkManagementInterface +from src.network.core.schemas import ( + AsSessionWithQoSSubscription, + CreateSession, + CreateTrafficInfluence, + FlowInfo, + Snssai, + TrafficInfluSub, +) log = logger.get_logger(__name__) +supportedQos = ["qos-e", "qos-s", "qos-m", "qos-l"] -class OaiNefClient(NetworkManagementInterface): +class NetworkManager(NetworkManagementInterface): def __init__(self, base_url: str, scs_as_id: str = None): """ Initialize Network Client for OAI Core Network @@ -50,185 +35,105 @@ class OaiNefClient(NetworkManagementInterface): try: super().__init__() self.base_url = base_url - self.scs_as_id = shortuuid.uuid() + self.scs_as_id = scs_as_id log.info( f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}" ) + except Exception as e: log.error(f"Failed to initialize OaiNefClient: {e}") raise e - # implementation of the NetworkManagementInterface QoD Methods - def create_qod_session(self, session_info: Dict) -> Dict: + def core_specific_qod_validation(self, session_info: CreateSession): """ - Creates a QoS session based on CAMARA QoD API input. - It maps CAMARA QoD API POST /sessions to - OAI NEF POST /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions - """ - try: - qod_input = CamaraQoDSessionInfo(**session_info) + Validates core-specific parameters for the session creation. - # convert CAMARA QoD to NEF AsSessionWithQos model and do POST - nef_req = camara_qod_to_as_session_with_qos(qod_input) - nef_res = oai_as_session_with_qos_post( - self.base_url, self.scs_as_id, nef_req - ) - - # retrieve the NEF resource id - if "self" in nef_res.keys(): - nef_url = nef_res["self"] - nef_id = nef_url.split("subscriptions/")[1] - else: - raise OaiNetworkError( - "No valid ID for the created resource was returned" - ) - - # create QoD session detail and return info with resource Id - qod_input.sessionId = nef_id - - log.info(f"QoD session activated successfully [id={nef_id}]") - - return qod_input - - except ValidationError as e: - raise OaiNetworkError("Could not validate QoD Session Info data") from e - except KeyError as e: - raise OaiNetworkError(f"Missing field in QoD Session Info data: {e}") from e - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not enable the QoD Session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e + args: + session_info: The session information to validate. - def get_qod_session(self, session_id: str) -> Dict: - """ - Retrieves details of a specific Quality on Demand (QoS) session. - It maps CAMARA QoD API GET /sessions/{sessionId} to - OAI NEF GET /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} + raises: + ValidationError: If the session information does not meet core-specific requirements. """ - try: - res = oai_as_session_with_qos_get( - self.base_url, self.scs_as_id, session_id=session_id + if session_info.qosProfile.root not in supportedQos: + raise OaiValidationError( + f"QoS profile {session_info.qosProfile} not supported by OAI, supported profiles are {supportedQos}" ) - nef_res = OaiAsSessionWithQosSubscription(**res) - qod_info = as_session_with_qos_to_camara_qod(nef_res) - - log.info(f"QoD session retrived successfully [id={session_id}]") - - return qod_info - except ValidationError as e: - raise OaiNetworkError("Could not validate network response data") from e - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not enable the QoD Session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e - def delete_qod_session(self, session_id: str) -> None: + if session_info.device is None or session_info.device.ipv4Address is None: + raise OaiValidationError("OAI requires UE IPv4 Address to activate QoS") + + if session_info.applicationServer.ipv4Address is None: + raise OaiValidationError("OAI requires App IPv4 Address to activate QoS") + return + + def add_core_specific_qod_parameters( + self, + session_info: CreateSession, + subscription: AsSessionWithQoSSubscription, + ) -> None: + device_ip = _retrieve_ue_ipv4(session_info) + server_ip = _retrieve_app_ipv4(session_info) + + # build flow descriptor in oai format using device ip and server ip + flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" + _add_qod_flow_descriptor(subscription, flow_descriptor) + _add_qod_snssai(subscription, 1, "FFFFFF") + subscription.dnn = "oai" + + def add_core_specific_ti_parameters( + self, + traffic_influence_info: CreateTrafficInfluence, + subscription: TrafficInfluSub, + ): + # todo oai add dnn, ssnai, afServiceId + subscription.dnn = "oai" + subscription.add_snssai(1, "FFFFFF") + subscription.afServiceId = self.scs_as_id + + def core_specific_traffic_influence_validation( + self, traffic_influence_info: CreateTrafficInfluence + ) -> None: """ - Deletes a specific Quality on Demand (QoS) session. - It maps CAMARA QoD API DELETE /sessions/{sessionId} to - OAI NEF DELETE /3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions/{subscriptionId} + Validates core-specific parameters for the session creation. + + args: + session_info: The session information to validate. + + raises: + ValidationError: If the session information does not meet core-specific requirements. """ - try: - oai_as_session_with_qos_delete( - self.base_url, self.scs_as_id, session_id=session_id + # Placeholder for core-specific validation logic + # This method should be overridden by subclasses if needed + + if ( + traffic_influence_info.device is None + or traffic_influence_info.device.ipv4Address is None + ): + raise OaiValidationError( + "OAI requires UE IPv4 Address to activate Traffic Influence" ) - log.info(f"QoD session deleted successfully [id={session_id}]") - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not enable the QoD Session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e +def _retrieve_ue_ipv4(session_info: CreateSession): + return session_info.device.ipv4Address.root.privateAddress - # implementation of the NetworkManagementInterface Traffic Influence Methods - def create_traffic_influence_resource(self, traffic_influence_info): - try: - ti_input = CamaraTrafficInfluence(**traffic_influence_info) - - # convert CAMARA TI to NEF TrafficInflSub model and do POST - nef_req = camara_ti_to_3gpp_ti(ti_input) - nef_res = oai_traffic_influence_post(self.base_url, self.scs_as_id, nef_req) - - # retrieve the NEF resource id - if "self" in nef_res.keys(): - nef_url = nef_res["self"] - nef_id = nef_url.split("subscriptions/")[1] - else: - raise OaiNetworkError( - "No valid ID for the created resource was returned" - ) - - # create TI session detail and return info with resource Id - ti_input.trafficInfluenceID = nef_id - - log.info(f"Traffic Influence session activated successfully [id={nef_id}]") - - return ti_input - - except ValidationError as e: - raise OaiNetworkError("Could not validate Traffic Influence data") from e - except KeyError as e: - raise OaiNetworkError( - f"Missing field in Traffic Influence data: {e}" - ) from e - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not enable the Traffic Influence Session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e - def delete_traffic_influence_resource(self, session_id): - """ - Deletes a specific Traffic Influence (TI) session. - It maps CAMARA TI API DELETE /sessions/{sessionId} to - OAI NEF DELETE /3gpp-traffic-influence/v1/{scs_as_id}/subscriptions/{subscriptionId} - """ - try: - oai_traffic_influence_delete( - self.base_url, self.scs_as_id, session_id=session_id - ) +def _retrieve_app_ipv4(session_info: CreateSession): + return session_info.applicationServer.ipv4Address - log.info(f"TI session deleted successfully [id={session_id}]") - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not delete the TI session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e +def _add_qod_flow_descriptor( + qos_sub: AsSessionWithQoSSubscription, flow_desriptor: str +): + qos_sub.flowInfo = list() + qos_sub.flowInfo.append( + FlowInfo(flowId=len(qos_sub.flowInfo) + 1, flowDescriptions=[flow_desriptor]) + ) - def put_traffic_influence_resource(self, resource_id, traffic_influence_info): - try: - qod_input = CamaraTrafficInfluence(**traffic_influence_info) - # convert CAMARA TI to NEF TrafficInflSub model and do POST - nef_req = camara_ti_to_3gpp_ti(qod_input) - updated_res = oai_traffic_influence_put( - self.base_url, self.scs_as_id, resource_id, nef_req - ) +def _add_qod_snssai(qos_sub: AsSessionWithQoSSubscription, sst: int, sd: str = None): + qos_sub.snssai = Snssai(sst=sst, sd=sd) - log.info( - f"Traffic Influence resource updated successfully [id={resource_id}]" - ) - return updated_res - - except ValidationError as e: - raise OaiNetworkError("Could not validate Traffic Influence data") from e - except KeyError as e: - raise OaiNetworkError( - f"Missing field in Traffic Influence data: {e}" - ) from e - except OaiHttpError as e: - raise OaiNetworkError( - f"The network could not update the Traffic Influence Session. It returned {e}" - ) from e - except OaiNetworkError as e: - raise e +class OaiValidationError(Exception): + pass diff --git a/src/network/clients/oai/common.py b/src/network/clients/oai/common.py deleted file mode 100644 index 6df5ccc..0000000 --- a/src/network/clients/oai/common.py +++ /dev/null @@ -1,106 +0,0 @@ -## -# Copyright (c) 2025 Netsoft Group, EURECOM. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Giulio Carota (giulio.carota@eurecom.fr) -## - - -import requests -from pydantic import BaseModel - -from src.network.clients.errors import NetworkPlatformError - - -def _make_request(method: str, url: str, data=None): - try: - headers = None - if method == "POST" or method == "PUT": - headers = { - "Content-Type": "application/json", - "accept": "application/json", - } - elif method == "GET": - headers = { - "accept": "application/json", - } - response = requests.request(method, url, headers=headers, data=data) - response.raise_for_status() - if response.content: - return response.json() - except requests.exceptions.HTTPError as e: - raise OaiHttpError(e) from e - except requests.exceptions.ConnectionError as e: - raise OaiHttpError("connection error") from e - - -# QoD methods -def oai_as_session_with_qos_post( - base_url: str, scs_as_id: str, model_payload: BaseModel -) -> dict: - data = model_payload.model_dump_json(exclude_none=True) - url = oai_as_session_with_qos_build_url(base_url, scs_as_id) - return _make_request("POST", url, data=data) - - -def oai_as_session_with_qos_get(base_url: str, scs_as_id: str, session_id: str) -> dict: - url = oai_as_session_with_qos_build_url(base_url, scs_as_id, session_id) - return _make_request("GET", url) - - -def oai_as_session_with_qos_delete(base_url: str, scs_as_id: str, session_id: str): - url = oai_as_session_with_qos_build_url(base_url, scs_as_id, session_id) - return _make_request("DELETE", url) - - -def oai_as_session_with_qos_build_url( - base_url: str, scs_as_id: str, session_id: str = None -): - url = f"{base_url}/3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions" - if session_id is not None and len(session_id) > 0: - return f"{url}/{session_id}" - else: - return url - - -## Traffic Influence methods -def oai_traffic_influence_post( - base_url: str, scs_as_id: str, model_payload: BaseModel -) -> dict: - data = model_payload.model_dump_json(exclude_none=True) - url = oai_traffic_influence_build_url(base_url, scs_as_id) - return _make_request("POST", url, data=data) - - -def oai_traffic_influence_delete(base_url: str, scs_as_id: str, session_id: str): - url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) - return _make_request("DELETE", url) - - -def oai_traffic_influence_put( - base_url: str, scs_as_id: str, session_id: str, model_payload: BaseModel -) -> dict: - data = model_payload.model_dump_json(exclude_none=True) - url = oai_traffic_influence_build_url(base_url, scs_as_id, session_id) - return _make_request("PUT", url, data=data) - - -def oai_traffic_influence_build_url( - base_url: str, scs_as_id: str, session_id: str = None -): - url = f"{base_url}/3gpp-traffic-influence/v1/{scs_as_id}/subscriptions" - if session_id is not None and len(session_id) > 0: - return f"{url}/{session_id}" - else: - return url - - -class OaiHttpError(Exception): - pass - - -class OaiNetworkError(NetworkPlatformError): - pass diff --git a/src/network/clients/oai/schemas.py b/src/network/clients/oai/schemas.py deleted file mode 100644 index 87e35fb..0000000 --- a/src/network/clients/oai/schemas.py +++ /dev/null @@ -1,206 +0,0 @@ -## -# Copyright (c) 2025 Netsoft Group, EURECOM. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Giulio Carota (giulio.carota@eurecom.fr) -## - -from typing import List, Optional - -from pydantic import BaseModel, Field - - -class Snssai(BaseModel): - sst: int = Field(default=1) - sd: str = Field(default="FFFFFF") - - -class TrafficFilter(BaseModel): - flowId: int - flowDescriptions: List[str] - - -class OaiAsSessionWithQosSubscription(BaseModel): - """ - Represents the model to create an AsSessionWithQoS resource inside the OAI NEF. - """ - - supportedFeatures: str = Field(default="12") - dnn: str = Field(default="oai") - snssai: Snssai - flowInfo: List[TrafficFilter] - ueIpv4Addr: str - notificationDestination: str - qosReference: str - self: Optional[str] = None - qosDuration: Optional[int] = None - - def add_flow_descriptor(self, flow_desriptor: str): - self.flowInfo = list() - self.flowInfo.append( - TrafficFilter( - flowId=len(self.flowInfo) + 1, flowDescriptions=[flow_desriptor] - ) - ) - - def add_snssai(self, sst: int, sd: str = None): - self.snssai = Snssai(sst=sst, sd=sd) - - -class PortRange(BaseModel): - from_: int = Field(alias="from") - to: int - - class Config: - populate_by_name = True - - -class Ports(BaseModel): - ranges: Optional[List[PortRange]] = None - ports: Optional[List[int]] = None - - -class Ipv4Address(BaseModel): - publicAddress: str - publicPort: Optional[int] = None - - -class Device(BaseModel): - phoneNumber: Optional[str] = None - networkAccessIdentifier: Optional[str] = None - ipv4Address: Optional[Ipv4Address] = None - ipv6Address: Optional[str] = None - - -class ApplicationServer(BaseModel): - ipv4Address: Optional[str] = None - ipv6Address: Optional[str] = None - - -class SinkCredential(BaseModel): - credentialType: Optional[str] = None - - -class CamaraQoDSessionInfo(BaseModel): - """ - Represents the input data for creating a QoD session. - """ - - duration: int - qosProfile: str - applicationServer: ApplicationServer - - device: Optional[Device] = None - devicePorts: Optional[Ports] = None - applicationServerPorts: Optional[Ports] = None - sink: Optional[str] = None - sinkCredential: Optional[SinkCredential] = None - - # fields only applicable to sessionInfo in responses: - sessionId: Optional[str] = None - startedAt: Optional[int] = None - expiresAt: Optional[int] = None - qosStatus: Optional[str] = None - statusInfo: Optional[str] = None - - class Config: - populate_by_name = True - - def retrieve_ue_ipv4(self): - if self.device is not None and self.device.ipv4Address is not None: - return self.device.ipv4Address.publicAddress - else: - raise KeyError("device.ipv4Address.publicAddress") - - def retrieve_app_ipv4(self): - if self.applicationServer.ipv4Address is not None: - return self.applicationServer.ipv4Address - else: - raise KeyError("applicationServer.ipv4Address") - - def add_server_ipv4(self, ipv4: str): - self.applicationServer = ApplicationServer(ipv4Address=ipv4) - - def add_ue_ipv4(self, ipv4: str): - if self.device is None: - self.device = Device() - if self.device.ipv4Address is None: - self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) - - -## traffic_influence schemas - - -class SourceTrafficFilters(BaseModel): - sourcePort: int - - -class DestinationTrafficFilters(BaseModel): - destinationPort: int - destinationProtocol: str - - -class TrafficRoute(BaseModel): - dnai: str - - -class NotificationSink(BaseModel): - sink: Optional[str] = None - sinkCredential: Optional[SinkCredential] = None - - -class TrafficInfluSub(BaseModel): # Replace with a meaningful name - afServiceId: str - afAppId: str - dnn: str - snssai: Snssai - trafficFilters: List[TrafficFilter] - ipv4Addr: str - notificationDestination: str - trafficRoutes: List[TrafficRoute] - suppFeat: str - - def add_flow_descriptor(self, flow_desriptor: str): - self.trafficFilters = list() - self.trafficFilters.append( - TrafficFilter( - flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_desriptor] - ) - ) - - def add_traffic_route(self, dnai: str): - self.trafficRoutes = list() - self.trafficRoutes.append(TrafficRoute(dnai=dnai)) - - def add_snssai(self, sst: int, sd: str = None): - self.snssai = Snssai(sst=sst, sd=sd) - - -class CamaraTrafficInfluence(BaseModel): - trafficInfluenceID: Optional[str] = None - apiConsumerId: str - appId: str - appInstanceId: str - edgeCloudRegion: Optional[str] = None - edgeCloudZoneId: str - sourceTrafficFilters: Optional[SourceTrafficFilters] = None - destinationTrafficFilters: Optional[DestinationTrafficFilters] = None - notificationUri: Optional[str] = None - notificationAuthToken: Optional[str] = None - device: Device - notificationSink: Optional[NotificationSink] = None - - def retrieve_ue_ipv4(self): - if self.device is not None and self.device.ipv4Address is not None: - return self.device.ipv4Address.publicAddress - else: - raise KeyError("device.ipv4Address.publicAddress") - - def add_ue_ipv4(self, ipv4: str): - if self.device is None: - self.device = Device() - if self.device.ipv4Address is None: - self.device.ipv4Address = Ipv4Address(publicAddress=ipv4) diff --git a/src/network/clients/oai/utils.py b/src/network/clients/oai/utils.py deleted file mode 100644 index 1262d74..0000000 --- a/src/network/clients/oai/utils.py +++ /dev/null @@ -1,88 +0,0 @@ -## -# Copyright (c) 2025 Netsoft Group, EURECOM. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Giulio Carota (giulio.carota@eurecom.fr) -## - - -from src.network.clients.oai.schemas import ( - CamaraQoDSessionInfo, - CamaraTrafficInfluence, - OaiAsSessionWithQosSubscription, - TrafficInfluSub, -) - - -def camara_qod_to_as_session_with_qos( - qod_input: CamaraQoDSessionInfo, -) -> OaiAsSessionWithQosSubscription: - device_ip = qod_input.retrieve_ue_ipv4() - server_ip = qod_input.retrieve_app_ipv4() - - # Extract callback sink and QoS profile - sink_url = qod_input.sink - qos_profile = qod_input.qosProfile - - # build flow descriptor in oai format using device ip and server ip - flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" - - # create the nef request model - nef_req = OaiAsSessionWithQosSubscription.construct() - nef_req.ueIpv4Addr = device_ip - nef_req.notificationDestination = sink_url - nef_req.add_flow_descriptor(flow_desriptor=flow_descriptor) - nef_req.qosReference = qos_profile - nef_req.add_snssai(1, "FFFFFF") - - # the qos duration feature is not available yet in oai - # nef_req.qosDuration = qod_input.duration - - return nef_req - - -def as_session_with_qos_to_camara_qod( - nef_input: OaiAsSessionWithQosSubscription, -) -> CamaraQoDSessionInfo: - # create the camara qod model - - qod_info = CamaraQoDSessionInfo.construct() - - flowDesc = nef_input.flowInfo[0].flowDescriptions[0] - serverIp = flowDesc.split("to ")[1].split("/32")[0] - - qod_info.add_server_ipv4(serverIp) - qod_info.qosProfile = nef_input.qosReference - qod_info.add_ue_ipv4(nef_input.ueIpv4Addr) - qod_info.sink = nef_input.notificationDestination - qod_info.duration = nef_input.qosDuration - - return qod_info - - -def camara_ti_to_3gpp_ti(ti_input: CamaraTrafficInfluence) -> TrafficInfluSub: - - device_ip = ti_input.retrieve_ue_ipv4() - server_ip = ( - ti_input.appInstanceId - ) # assume that the instance id corresponds to its IPv4 address - sink_url = ti_input.notificationSink.sink - edge_zone = ti_input.edgeCloudZoneId - - # build flow descriptor in oai format using device ip and server ip - flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" - - nef_traffic_influence = TrafficInfluSub.model_construct() - nef_traffic_influence.afAppId = ti_input.appId - nef_traffic_influence.afServiceId = "aa" - nef_traffic_influence.ipv4Addr = device_ip - nef_traffic_influence.notificationDestination = sink_url - nef_traffic_influence.add_flow_descriptor(flow_desriptor=flow_descriptor) - nef_traffic_influence.add_traffic_route(dnai=edge_zone) - nef_traffic_influence.add_snssai(1, "FFFFFF") - nef_traffic_influence.dnn = "oai" - - return nef_traffic_influence diff --git a/src/network/core/common.py b/src/network/core/common.py index 777c31d..54c2b90 100644 --- a/src/network/core/common.py +++ b/src/network/core/common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). +# Common utilities (errors, HTTP helpers) used by the core network interface (network_interface.py). import requests from pydantic import BaseModel @@ -60,5 +60,35 @@ def as_session_with_qos_build_url( return url +# Traffic Influence Methods +def traffic_influence_post( + base_url: str, scs_as_id: str, model_payload: BaseModel +) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = traffic_influence_build_url(base_url, scs_as_id) + return _make_request("POST", url, data=data) + + +def traffic_influence_delete(base_url: str, scs_as_id: str, session_id: str): + url = traffic_influence_build_url(base_url, scs_as_id, session_id) + return _make_request("DELETE", url) + + +def traffic_influence_put( + base_url: str, scs_as_id: str, session_id: str, model_payload: BaseModel +) -> dict: + data = model_payload.model_dump_json(exclude_none=True) + url = traffic_influence_build_url(base_url, scs_as_id, session_id) + return _make_request("PUT", url, data=data) + + +def traffic_influence_build_url(base_url: str, scs_as_id: str, session_id: str = None): + url = f"{base_url}/3gpp-traffic-influence/v1/{scs_as_id}/subscriptions" + if session_id is not None and len(session_id) > 0: + return f"{url}/{session_id}" + else: + return url + + class CoreHttpError(Exception): pass diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 186ba4f..bb1c4e6 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -78,7 +78,7 @@ class NetworkManagementInterface(ABC): scs_as_id: str @abstractmethod - def add_core_specific_parameters( + def add_core_specific_qod_parameters( self, session_info: schemas.CreateSession, subscription: schemas.AsSessionWithQoSSubscription, @@ -90,7 +90,36 @@ class NetworkManagementInterface(ABC): pass @abstractmethod - def core_specific_validation(self, session_info: schemas.CreateSession) -> None: + def add_core_specific_ti_parameters( + self, + traffic_influence_info: schemas.CreateTrafficInfluence, + subscription: schemas.TrafficInfluSub, + ): + """ + Placeholder for adding core-specific parameters to the subscription. + This method should be overridden by subclasses to implement specific logic. + """ + pass + + @abstractmethod + def core_specific_qod_validation(self, session_info: schemas.CreateSession) -> None: + """ + Validates core-specific parameters for the session creation. + + args: + session_info: The session information to validate. + + raises: + ValidationError: If the session information does not meet core-specific requirements. + """ + # Placeholder for core-specific validation logic + # This method should be overridden by subclasses if needed + pass + + @abstractmethod + def core_specific_traffic_influence_validation( + self, traffic_influence_info: schemas.CreateTrafficInfluence + ) -> None: """ Validates core-specific parameters for the session creation. @@ -104,13 +133,13 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass - def _build_subscription(self, session_info: Dict) -> None: + def _build_qod_subscription(self, session_info: Dict) -> None: valid_session_info = schemas.CreateSession.model_validate(session_info) device_ipv4 = None if valid_session_info.device.ipv4Address: device_ipv4 = valid_session_info.device.ipv4Address.root.publicAddress.root - self.core_specific_validation(valid_session_info) + self.core_specific_qod_validation(valid_session_info) subscription = schemas.AsSessionWithQoSSubscription( notificationDestination=str(valid_session_info.sink), qosReference=valid_session_info.qosProfile.root, @@ -118,7 +147,35 @@ class NetworkManagementInterface(ABC): ueIpv6Addr=valid_session_info.device.ipv6Address, usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), ) - self.add_core_specific_parameters(valid_session_info, subscription) + self.add_core_specific_qod_parameters(valid_session_info, subscription) + return subscription + + def _build_ti_subscription(self, traffic_influence_info: Dict): + + traffic_influence_data = schemas.CreateTrafficInfluence.model_validate( + traffic_influence_info + ) + self.core_specific_traffic_influence_validation(traffic_influence_data) + + device_ip = traffic_influence_data.retrieve_ue_ipv4() + server_ip = ( + traffic_influence_data.appInstanceId + ) # assume that the instance id corresponds to its IPv4 address + sink_url = traffic_influence_data.notificationUri + edge_zone = traffic_influence_data.edgeCloudZoneId + + # build flow descriptor in oai format using device ip and server ip + flow_descriptor = f"permit out ip from {device_ip}/32 to {server_ip}/32" + + subscription = schemas.TrafficInfluSub( + afAppId=traffic_influence_data.appId, + ipv4Addr=str(device_ip), + notificationDestination=sink_url, + ) + subscription.add_flow_descriptor(flow_desriptor=flow_descriptor) + subscription.add_traffic_route(dnai=edge_zone) + + self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription def create_qod_session(self, session_info: Dict) -> Dict: @@ -132,7 +189,7 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created session details, including its ID. """ - subscription = self._build_subscription(session_info) + subscription = self._build_qod_subscription(session_info) return common.as_session_with_qos_post( self.base_url, self.scs_as_id, subscription ) @@ -168,7 +225,6 @@ class NetworkManagementInterface(ABC): ) log.info(f"QoD session deleted successfully [id={session_id}]") - @abstractmethod def create_traffic_influence_resource(self, traffic_influence_info: Dict) -> Dict: """ Creates a Traffic Influence resource based on CAMARA TI API input. @@ -180,9 +236,21 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created traffic influence resource details, including its ID. """ - pass - @abstractmethod + subscription = self._build_ti_subscription(traffic_influence_info) + response = common.traffic_influence_post( + self.base_url, self.scs_as_id, subscription + ) + + # retrieve the NEF resource id + if "self" in response.keys(): + subscription_id = response["self"] + else: + subscription_id = None + + traffic_influence_info["trafficInfluenceID"] = subscription_id + return traffic_influence_info + def put_traffic_influence_resource( self, resource_id: str, traffic_influence_info: Dict ) -> Dict: @@ -195,9 +263,14 @@ class NetworkManagementInterface(ABC): returns: Dictionary containing the details of the requested Traffic Influence resource. """ - pass + subscription = self._build_ti_subscription(traffic_influence_info) + common.traffic_influence_put( + self.base_url, self.scs_as_id, resource_id, subscription + ) + + traffic_influence_info.trafficInfluenceID = resource_id + return traffic_influence_info - @abstractmethod def delete_traffic_influence_resource(self, resource_id: str) -> None: """ Deletes a specific Traffic Influence resource. @@ -208,7 +281,8 @@ class NetworkManagementInterface(ABC): returns: None """ - pass + common.traffic_influence_delete(self.base_url, self.scs_as_id, resource_id) + return # Placeholder for other CAMARA APIs (e.g., Traffic Influence, # Location-retrieval, etc.) diff --git a/src/network/core/schemas.py b/src/network/core/schemas.py index 1138c40..74d57c9 100644 --- a/src/network/core/schemas.py +++ b/src/network/core/schemas.py @@ -127,6 +127,11 @@ class FlowInfo(BaseModel): ) +class Snssai(BaseModel): + sst: int = Field(default=1) + sd: str = Field(default="FFFFFF") + + class AsSessionWithQoSSubscription(BaseModel): model_config = ConfigDict(serialize_by_alias=True) self_: Link | None = Field(None, alias="self") @@ -147,6 +152,8 @@ class AsSessionWithQoSSubscription(BaseModel): lower the index of the array for a given entry, the higher the priority.", min_length=1, ) + snssai: Snssai | None = None + dnn: str | None = None ueIpv4Addr: ipaddress.IPv4Address | None = None ueIpv6Addr: ipaddress.IPv6Address | None = None macAddr: MacAddress | None = None @@ -155,6 +162,56 @@ class AsSessionWithQoSSubscription(BaseModel): qosMonInfo: QosMonitoringInformationModel | None = None +class SourceTrafficFilters(BaseModel): + sourcePort: int + + +class DestinationTrafficFilters(BaseModel): + destinationPort: int + destinationProtocol: str + + +class TrafficRoute(BaseModel): + dnai: str + + +class TrafficInfluSub(BaseModel): # Replace with a meaningful name + afServiceId: str | None = None + afAppId: str + dnn: str | None = None + snssai: Snssai | None = None + trafficFilters: list[FlowInfo] | None = Field( + None, + description="Describe the data flow which requires Traffic Influence.", + min_length=1, + ) + ipv4Addr: str | None = None + ipv6Addr: str | None = None + + notificationDestination: str + trafficRoutes: list[TrafficRoute] | None = Field( + None, + description="Describe the list of DNAIs to reach the destination", + min_length=1, + ) + suppFeat: str | None = None + + def add_flow_descriptor(self, flow_desriptor: str): + self.trafficFilters = list() + self.trafficFilters.append( + FlowInfo( + flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_desriptor] + ) + ) + + def add_traffic_route(self, dnai: str): + self.trafficRoutes = list() + self.trafficRoutes.append(TrafficRoute(dnai=dnai)) + + def add_snssai(self, sst: int, sd: str = None): + self.snssai = Snssai(sst=sst, sd=sd) + + ############################################################### ############################################################### # CAMARA Models @@ -301,6 +358,11 @@ class SinkCredential(BaseModel): ] +class NotificationSink(BaseModel): + sink: str | None + sinkCredential: SinkCredential | None + + class BaseSessionInfo(BaseModel): device: Device | None = None applicationServer: ApplicationServer @@ -341,3 +403,30 @@ class CreateSession(BaseSessionInfo): ge=1, ), ] + + +class CreateTrafficInfluence(BaseModel): + trafficInfluenceID: str | None = None + apiConsumerId: str | None = None + appId: str + appInstanceId: str + edgeCloudRegion: str | None = None + edgeCloudZoneId: str | None = None + sourceTrafficFilters: SourceTrafficFilters | None = None + destinationTrafficFilters: DestinationTrafficFilters | None = None + notificationUri: str | None = None + notificationAuthToken: str | None = None + device: Device + notificationSink: NotificationSink | None = None + + def retrieve_ue_ipv4(self): + if self.device is not None and self.device.ipv4Address is not None: + return self.device.ipv4Address.root.privateAddress.root + else: + raise KeyError("device.ipv4Address.publicAddress") + + def add_ue_ipv4(self, ipv4: str): + if self.device is None: + self.device = Device() + if self.device.ipv4Address is None: + self.device.ipv4Address = DeviceIpv4Addr(publicAddress=ipv4) -- GitLab From 208cf57f4fac49e4b50901876836cc3deb624b05 Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Fri, 30 May 2025 18:43:41 +0200 Subject: [PATCH 103/281] test cases for oai --- ...st_1_factory copy.py => test_1_factory.py} | 3 ++ tests/network/test_2_create_qod_session.py | 3 +- .../test_3_create_traffic_influence.py | 42 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) rename tests/network/{test_1_factory copy.py => test_1_factory.py} (84%) create mode 100644 tests/network/test_3_create_traffic_influence.py diff --git a/tests/network/test_1_factory copy.py b/tests/network/test_1_factory.py similarity index 84% rename from tests/network/test_1_factory copy.py rename to tests/network/test_1_factory.py index dc1197b..9cc7b60 100644 --- a/tests/network/test_1_factory copy.py +++ b/tests/network/test_1_factory.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- import pytest +from src.network.clients.oai.client import NetworkManager as OaiClient from src.network.clients.open5gs.client import NetworkManager as Open5GsClient from src.network.core.network_factory import NetworkClientFactory test_cases = [ ("open5gs", "http://192.168.124.233:30769/", "scs"), + ("oai", "http://127.0.0.1", "scs-oai"), ] @@ -16,6 +18,7 @@ def test_factory_network(client_name, base_url, scs_as_id): """ client_class_map = { "open5gs": Open5GsClient, + "oai": OaiClient, } expected_client_class = client_class_map[client_name] network_client = NetworkClientFactory.create_network_client( diff --git a/tests/network/test_2_create_qod_session.py b/tests/network/test_2_create_qod_session.py index bd1ee45..88df7c9 100644 --- a/tests/network/test_2_create_qod_session.py +++ b/tests/network/test_2_create_qod_session.py @@ -5,6 +5,7 @@ from src.network.core.network_factory import NetworkClientFactory test_cases = [ ("open5gs", "http://192.168.124.233:30769/", "scs"), + ("oai", "http://127.0.0.1:8080/", "scs-oai"), ] @@ -25,7 +26,7 @@ def test_valid_input(client_name, base_url, scs_as_id): "qosProfile": "qos-e", "sink": "https://endpoint.example.com/sink", } - network_client._build_subscription(camara_session) + network_client._build_qod_subscription(camara_session) @pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) diff --git a/tests/network/test_3_create_traffic_influence.py b/tests/network/test_3_create_traffic_influence.py new file mode 100644 index 0000000..ae3a776 --- /dev/null +++ b/tests/network/test_3_create_traffic_influence.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +import pytest + +from src.network.core.network_factory import NetworkClientFactory + +test_cases = [("oai", "http://127.0.0.1/", "scs-oai")] + + +@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) +def test_valid_input(client_name, base_url, scs_as_id): + network_client = NetworkClientFactory.create_network_client( + client_name, base_url, scs_as_id + ) + + ti_session = { + "device": { + "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} + }, + "edgeCloudZoneId": "edge", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.3", + "notificationUri": "https://endpoint.example.com/sink", + } + network_client._build_ti_subscription(ti_session) + + +@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) +def test_create_traffic_influence(client_name, base_url, scs_as_id): + network_client = NetworkClientFactory.create_network_client( + client_name, base_url, scs_as_id + ) + + ti_session = { + "device": { + "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} + }, + "edgeCloudZoneId": "edge", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.3", + "notificationUri": "https://endpoint.example.com/sink", + } + network_client.create_traffic_influence_resource(ti_session) -- GitLab From 93ce2df6ca97ae1a639d98a04d405cd2d4b11b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:21:29 +0200 Subject: [PATCH 104/281] Add common client invocation. Delete old dedicated factory files --- src/common/universal_catalog.py | 55 +++++++++++++++++++++ src/common/universal_client_catalog.py | 55 +++++++++++++++++++++ src/edgecloud/core/edgecloud_factory.py | 56 --------------------- src/main.py | 49 ------------------ src/network/core/network_factory.py | 66 ------------------------- 5 files changed, 110 insertions(+), 171 deletions(-) create mode 100644 src/common/universal_catalog.py create mode 100644 src/common/universal_client_catalog.py delete mode 100644 src/edgecloud/core/edgecloud_factory.py delete mode 100644 src/main.py delete mode 100644 src/network/core/network_factory.py diff --git a/src/common/universal_catalog.py b/src/common/universal_catalog.py new file mode 100644 index 0000000..9c5353a --- /dev/null +++ b/src/common/universal_catalog.py @@ -0,0 +1,55 @@ +from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from src.network.clients.oai.client import NetworkManager as OaiCoreClient +from src.network.clients.open5gcore.client import NetworkManager as Open5GCoreClient +from src.network.clients.open5gs.client import NetworkManager as Open5GSClient + +# from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient + + +def _edgecloud_catalog(client_name: str, base_url: str): + edge_cloud_factory = { + "aeros": lambda url: AerosClient(base_url=url), + "i2edge": lambda url: I2EdgeClient(base_url=url), + # "piedge": lambda url: PiEdgeClient(base_url=url), + } + try: + return edge_cloud_factory[client_name](base_url) + except KeyError: + raise ValueError( + f"Invalid edgecloud client '{client_name}'. Available: {list(edge_cloud_factory)}" + ) + + +def _network_catalog(client_name: str, base_url: str): + network_factory = { + "open5gs": lambda url: Open5GSClient(base_url=url), + "oai": lambda url: OaiCoreClient(base_url=url), + "open5gcore": lambda url: Open5GCoreClient(base_url=url), + } + try: + return network_factory[client_name](base_url) + except KeyError: + raise ValueError( + f"Invalid network client '{client_name}'. Available: {list(network_factory)}" + ) + + +class UniversalClientCatalog: + _domain_factories = { + "edgecloud": _edgecloud_catalog, + "network": _network_catalog, + } + + @classmethod + def get_client(cls, domain: str, client_name: str, base_url: str): + try: + catalog = cls._domain_factories[domain] + except KeyError: + raise ValueError( + f"Unsupported domain '{domain}'. Supported: {list(cls._domain_factories)}" + ) + return catalog(client_name, base_url) + + +universal_client_catalog = UniversalClientCatalog() diff --git a/src/common/universal_client_catalog.py b/src/common/universal_client_catalog.py new file mode 100644 index 0000000..8cbdb0f --- /dev/null +++ b/src/common/universal_client_catalog.py @@ -0,0 +1,55 @@ +from typing import Dict + +from src.common.universal_catalog import universal_client_catalog + + +class UniversalClientCatalog: + @staticmethod + def create_clients(specs: Dict[str, Dict[str, str]]) -> Dict[str, object]: + """ + Create and return a dictionary of instantiated edgecloud/network/o-ran clients + based on the provided specifications. + + Args: + client_specs (list[dict]): A list of dictionaries, where each dictionary + specifies a client to be created. + Each client dictionary must contain: + - 'domain' (str): The client's domain (e.g., 'edgecloud', 'network'). + - 'client_name' (str): The specific name of the client + (e.g., 'i2edge', 'open5gs'). + - 'base_url' (str): The base URL for the client's API. + Optional parameters (like 'scs_as_id') can also be included + if required by the specific client's constructor. + + Returns: + dict: A dictionary where keys are the 'client_name' (str) and values are + the instantiated client objects. + + Example: + >>> from src.common.universal_client_catalog import UniversalClientCatalog + >>> + >>> client_specs_example = [ + >>> { + >>> 'domain': 'edgecloud', + >>> 'client_name': 'i2edge', + >>> 'base_url': 'http://localhost:8081', + >>> 'description': 'i2edge client example.' + >>> }, + >>> { + >>> 'domain': 'network', + >>> 'client_name': 'open5gs', + >>> 'base_url': 'http://localhost:8084', + >>> 'scs_as_id': 'my_unique_scs_id_example' # Example of optional parameter + >>> } + >>> ] + >>> + >>> clients = invoke_clients(client_specs_example) + >>> # Access a client: i2edge_client = clients['i2edge'] + """ + clients = {} + for domain, config in specs.items(): + client_name = config["client_name"] + base_url = config["base_url"] + client = universal_client_catalog.get_client(domain, client_name, base_url) + clients[domain] = client + return clients diff --git a/src/edgecloud/core/edgecloud_factory.py b/src/edgecloud/core/edgecloud_factory.py deleted file mode 100644 index e2c3ca1..0000000 --- a/src/edgecloud/core/edgecloud_factory.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Adrián Pino Martínez (adrian.pino@i2cat.net) -# - Sergio Giménez (sergio.gimenez@i2cat.net) -## -from __future__ import annotations - -from typing import TYPE_CHECKING - -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient - -if TYPE_CHECKING: - from .edgecloud_interface import EdgeCloudInterface - - -class EdgeCloudFactory: - """ - Factory class for creating EdgeCloud Clients - """ - - @staticmethod - def create_edgecloud_client(client_name: str, base_url: str) -> EdgeCloudInterface: - try: - return EdgeCloudTypes.edgecloud_types[client_name](base_url) - except KeyError: - # Get the list of supported client names - supported_clients = list(EdgeCloudTypes.edgecloud_types.keys()) - raise ValueError( - f"Invalid edgecloud client name: '{client_name}'. " - f"Supported clients are: {', '.join(supported_clients)}" - ) - - -class EdgeCloudTypes: - """ - Class dedicated for the different types of edgecloud clients. - """ - - I2EDGE = "i2edge" - AEROS = "aeros" - PIEDGE = "piedge" - - edgecloud_types = { - I2EDGE: lambda url: I2EdgeClient(base_url=url), - AEROS: lambda url: AerosClient(base_url=url), - PIEDGE: lambda url: PiEdgeClient(base_url=url), - } diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 6fede35..0000000 --- a/src/main.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -from src import logger -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory - -logger.setup_logger(is_debug=True, file_name="sdk.log") - - -def create_edgecloud_client(client_name: str, base_url: str): - """ - Create and return an edgecloud client. - - Args: - client_name (str): Name of the edge cloud platform. Must be one of: - 'i2edge', 'aeros', 'piedge' - base_url (str): The base URL for the client. - - Returns: - The created edgecloud client. - - Example: - >>> client = create_edgecloud_client('i2edge', 'http://localhost:8080') - """ - return EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - - -# ########################################### -# # Temporal code - Testing purposes -# ########################################### -# if __name__ == "__main__": -# # Define the client name and base URL -# client_name = "i2edge" -# base_url = "http://192.168.123.237:30769/" - -# # Create the edgecloud client -# sbi = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - -# # Print the edgecloud client being used and its URL -# print(f"Using edgecloud client: {sbi}") -# print(f"Edge Cloud Platform: {client_name}") -# print(f"URL: {sbi.base_url}") -# print("") - -# # Get all availability zones -# print("Running test endpoint: get_edge_cloud_zones:") -# zones = sbi.get_edge_cloud_zones() -# print(zones) -# ########################################### -# # End of temporal code -# ########################################### diff --git a/src/network/core/network_factory.py b/src/network/core/network_factory.py deleted file mode 100644 index 9669b6c..0000000 --- a/src/network/core/network_factory.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Reza Mosahebfard (reza.mosahebfard@i2cat.net) -## -from __future__ import annotations - -from typing import TYPE_CHECKING - -from src.network.clients.oai.client import OaiNefClient -from src.network.clients.open5gcore.client import Open5GCoreClient -from src.network.clients.open5gs.client import Open5GSClient - -if TYPE_CHECKING: - from .network_interface import NetworkManagementInterface - - -class NetworkClientFactory: - """ - Factory class for creating Network Management Clients. - """ - - @staticmethod - def create_network_client( - client_name: str, base_url: str - ) -> NetworkManagementInterface: - """ - Creates and returns an instance of the specified Network Client. - """ - try: - constructor = NetworkClientFactory.network_client_constructors[client_name] - network_client_instance = constructor(base_url) - return network_client_instance - except KeyError: - # Get the list of supported client names - supported_clients = list( - NetworkClientFactory.network_client_constructors.keys() - ) - raise ValueError( - f"Invalid network client name: '{client_name}'. " - "Supported clients are: " - f"{', '.join(supported_clients)}" - ) - - -class NetworkClientTypes: - """ - Class for creating Network Clients. - """ - - OPEN5GS = "open5gs" - OAI = "oai" - OPEN5GCORE = "open5gcore" - - # --- Dictionary mapping type constants to constructors --- - network_types = { - OPEN5GS: lambda url: Open5GSClient(base_url=url), - OAI: lambda url: OaiNefClient(base_url=url), - OPEN5GCORE: lambda url: Open5GCoreClient(base_url=url), - } -- GitLab From 6298e05cc25e757400f91bd6aa6baf28ee6f18f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:22:07 +0200 Subject: [PATCH 105/281] Add missing init file --- src/common/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/common/__init__.py diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..e69de29 -- GitLab From c8545cdfa1757a5fcd661242a7691d94cc56b175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:22:39 +0200 Subject: [PATCH 106/281] Update aeros init file. Add TODO to fix unnecessary logging. --- src/edgecloud/clients/aeros/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/edgecloud/clients/aeros/__init__.py b/src/edgecloud/clients/aeros/__init__.py index 191fcb7..0ea3493 100644 --- a/src/edgecloud/clients/aeros/__init__.py +++ b/src/edgecloud/clients/aeros/__init__.py @@ -14,8 +14,10 @@ from src.logger import setup_logger logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) -logger.info("aerOS client initialized") -logger.debug("aerOS API URL: %s", config.aerOS_API_URL) -logger.debug("aerOS access token: %s", config.aerOS_ACCESS_TOKEN) -logger.debug("aerOS debug mode: %s", config.DEBUG) -logger.debug("aerOS log file: %s", config.LOG_FILE) +# TODO: The following should only appear in case aerOS client is used +# Currently even if another client is used, the logs appear +# logger.info("aerOS client initialized") +# logger.debug("aerOS API URL: %s", config.aerOS_API_URL) +# logger.debug("aerOS access token: %s", config.aerOS_ACCESS_TOKEN) +# logger.debug("aerOS debug mode: %s", config.DEBUG) +# logger.debug("aerOS log file: %s", config.LOG_FILE) -- GitLab From ba91fc7ebb162d7781bdb3a4b7b88220ef413cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:24:10 +0200 Subject: [PATCH 107/281] Add default value to scs_as_id to avoid invocation error. Satisfy linter --- src/network/clients/open5gs/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index eb608c2..9c6e1db 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -2,10 +2,11 @@ from typing import Dict from pydantic import ValidationError + from src import logger from src.network.core.network_interface import NetworkManagementInterface, build_flows -from ...core import common -from ...core import schemas + +from ...core import common, schemas log = logger.get_logger(__name__) @@ -24,7 +25,8 @@ class NetworkManager(NetworkManagementInterface): 3GPP Monitoring Event API exposed Open5GS NEF. """ - def __init__(self, base_url: str, scs_as_id: str): + # TODO: Warning! "scs_as_is" is defaulted to None. + def __init__(self, base_url: str, scs_as_id: str = None): """ Initializes the Open5GS Client. """ -- GitLab From 9ce1864e9b27a7d0fe009cc8c87b680039b97ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:24:40 +0200 Subject: [PATCH 108/281] Satisfy linters --- src/network/core/common.py | 2 +- src/network/core/network_interface.py | 3 ++- src/network/core/schemas.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/network/core/common.py b/src/network/core/common.py index 214b774..777c31d 100644 --- a/src/network/core/common.py +++ b/src/network/core/common.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Common utilities (errors, HTTP helpers) used by the Open5GS client implementation (client.py). -from pydantic import BaseModel import requests +from pydantic import BaseModel from src import logger diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index fe0a3e2..9744417 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -8,6 +8,7 @@ # # Contributors: # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) +# - Ferran Cañellas (ferran.canellas@i2cat.net) ## from abc import ABC, abstractmethod from itertools import product @@ -121,7 +122,7 @@ class NetworkManagementInterface(ABC): ) self.add_core_specific_parameters(subscription) url = f"{self.base_url}/{self.scs_as_id}/subscriptions" - common.as_session_with_qos_post(self.base_url, self.scs_as_id, subscription) + common.as_session_with_qos_post(url, self.scs_as_id, subscription) def get_qod_session(self, session_id: str) -> Dict: """ diff --git a/src/network/core/schemas.py b/src/network/core/schemas.py index cb5ea9c..7c08b02 100644 --- a/src/network/core/schemas.py +++ b/src/network/core/schemas.py @@ -5,11 +5,11 @@ import ipaddress from enum import Enum +from ipaddress import IPv4Address, IPv6Address from typing import Annotated from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress -from ipaddress import IPv4Address, IPv6Address class FlowDirection(Enum): @@ -147,8 +147,8 @@ class AsSessionWithQoSSubscription(BaseModel): lower the index of the array for a given entry, the higher the priority.", min_items=1, ) - ueIpv4Addr: ipaddress.Ipv4Addr | None = None - ueIpv6Addr: ipaddress.Ipv6Addr | None = None + ueIpv4Addr: ipaddress.IPv4Address | None = None + ueIpv6Addr: ipaddress.IPv6Address | None = None macAddr: MacAddress | None = None usageThreshold: UsageThreshold | None = None sponsorInfo: SponsorInformation | None = None -- GitLab From ce43961154bafdfd1271a5b1c4670630f7473390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 2 Jun 2025 18:25:05 +0200 Subject: [PATCH 109/281] Add example on how to import the sdk under /examples --- examples/__init__.py | 0 examples/basic_usage.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 examples/__init__.py create mode 100644 examples/basic_usage.py diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..b900df6 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,30 @@ +from src.common.universal_client_catalog import UniversalClientCatalog + + +def main(): + # Hardcoded client configuration; here we would expect to load the config + client_specs = { + "edgecloud": { + "client_name": "i2edge", + "base_url": "http://192.168.123.237:30769/", + } + # , + # "network": { + # "client_name": "open5gs", + # "base_url": "http://IP:PORT" + # } + } + + clients = UniversalClientCatalog.create_clients(client_specs) + + edgecloud_client = clients.get("edgecloud") + # network_client = clients.get("network") + + # Example usage + print("Running test endpoint: get_edge_cloud_zones:") + zones = edgecloud_client.get_edge_cloud_zones() + print(zones) + + +if __name__ == "__main__": + main() -- GitLab From 73d65fea78e27ebdf1d1dd173ad0bbdad401ff8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 12:04:26 +0200 Subject: [PATCH 110/281] Rename sdk invocation from universal catalog to sdk catalog --- examples/basic_usage.py | 19 ++++-- .../{universal_catalog.py => sdk_catalog.py} | 31 ++++++--- src/common/sdk_catalog_client.py | 64 +++++++++++++++++++ src/common/universal_client_catalog.py | 55 ---------------- 4 files changed, 98 insertions(+), 71 deletions(-) rename src/common/{universal_catalog.py => sdk_catalog.py} (63%) create mode 100644 src/common/sdk_catalog_client.py delete mode 100644 src/common/universal_client_catalog.py diff --git a/examples/basic_usage.py b/examples/basic_usage.py index b900df6..579f7c9 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,8 +1,10 @@ -from src.common.universal_client_catalog import UniversalClientCatalog +from src.common.sdk_catalog_client import SdkCatalogClient + +# "src.common.universal_catalog_client" would be equivalent to "sunrise6g_opensdk" def main(): - # Hardcoded client configuration; here we would expect to load the config + # The module importing the SDK, loads the config client_specs = { "edgecloud": { "client_name": "i2edge", @@ -11,20 +13,25 @@ def main(): # , # "network": { # "client_name": "open5gs", - # "base_url": "http://IP:PORT" + # "base_url": "http://IP:PORT", + # "scs_as_id": "id_example" # } } - clients = UniversalClientCatalog.create_clients(client_specs) + clients = SdkCatalogClient.create_clients(client_specs) edgecloud_client = clients.get("edgecloud") # network_client = clients.get("network") - # Example usage - print("Running test endpoint: get_edge_cloud_zones:") + # Example of edgecloud client in action + print("Testing edgecloud client function: get_edge_cloud_zones:") zones = edgecloud_client.get_edge_cloud_zones() print(zones) + # # Example of network client in action + # print("Testing network client function: EXAMPLE_FUNCTION:") + # network_client.get_qod_session(session_id="example_session_id") + if __name__ == "__main__": main() diff --git a/src/common/universal_catalog.py b/src/common/sdk_catalog.py similarity index 63% rename from src/common/universal_catalog.py rename to src/common/sdk_catalog.py index 9c5353a..a4e4f8b 100644 --- a/src/common/universal_catalog.py +++ b/src/common/sdk_catalog.py @@ -11,6 +11,7 @@ def _edgecloud_catalog(client_name: str, base_url: str): edge_cloud_factory = { "aeros": lambda url: AerosClient(base_url=url), "i2edge": lambda url: I2EdgeClient(base_url=url), + # TODO: uncomment when missing PiEdge's imports are added # "piedge": lambda url: PiEdgeClient(base_url=url), } try: @@ -21,35 +22,45 @@ def _edgecloud_catalog(client_name: str, base_url: str): ) -def _network_catalog(client_name: str, base_url: str): +def _network_catalog(client_name: str, base_url: str, scs_as_id: str): network_factory = { - "open5gs": lambda url: Open5GSClient(base_url=url), - "oai": lambda url: OaiCoreClient(base_url=url), - "open5gcore": lambda url: Open5GCoreClient(base_url=url), + "open5gs": lambda url, scs_id: Open5GSClient(base_url=url, scs_as_id=scs_id), + "oai": lambda url, scs_id: OaiCoreClient(base_url=url, scs_as_id=scs_id), + "open5gcore": lambda url, scs_id: Open5GCoreClient( + base_url=url, scs_as_id=scs_id + ), } try: - return network_factory[client_name](base_url) + return network_factory[client_name](base_url, scs_as_id) except KeyError: raise ValueError( f"Invalid network client '{client_name}'. Available: {list(network_factory)}" ) -class UniversalClientCatalog: +# def _oran_catalog(client_name: str, base_url: str): +# # TODO + + +class SdkClientCatalog: _domain_factories = { "edgecloud": _edgecloud_catalog, "network": _network_catalog, + # "oran": _oran_catalog, } @classmethod - def get_client(cls, domain: str, client_name: str, base_url: str): + def get_client(cls, domain: str, client_name: str, base_url: str, **kwargs): try: catalog = cls._domain_factories[domain] except KeyError: raise ValueError( f"Unsupported domain '{domain}'. Supported: {list(cls._domain_factories)}" ) - return catalog(client_name, base_url) - -universal_client_catalog = UniversalClientCatalog() + if domain == "network": + if "scs_as_id" not in kwargs: + raise ValueError("Missing required 'scs_as_id' for network clients.") + return catalog(client_name, base_url, kwargs["scs_as_id"]) + else: + return catalog(client_name, base_url) diff --git a/src/common/sdk_catalog_client.py b/src/common/sdk_catalog_client.py new file mode 100644 index 0000000..2a3f137 --- /dev/null +++ b/src/common/sdk_catalog_client.py @@ -0,0 +1,64 @@ +from typing import Dict + +from src.common.sdk_catalog import SdkClientCatalog + + +class SdkCatalogClient: + @staticmethod + def create_clients(client_specs: Dict[str, Dict[str, str]]) -> Dict[str, object]: + """ + Create and return a dictionary of instantiated edgecloud/network/o-ran clients + based on the provided specifications. + + Args: + client_specs (dict): A dictionary where each key is the client's domain (e.g., 'edgecloud', 'network'), + and each value is a dictionary containing: + - 'client_name' (str): The specific name of the client (e.g., 'i2edge', 'open5gs'). + - 'base_url' (str): The base URL for the client's API. + Additional parameters like 'scs_as_id' may also be included. + + Returns: + dict: A dictionary where keys are the 'client_name' (str) and values are + the instantiated client objects. + + Example: + >>> from src.common.universal_client_catalog import UniversalCatalogClient + >>> + >>> client_specs_example = { + >>> 'edgecloud': { + >>> 'client_name': 'i2edge', + >>> 'base_url': 'http://ip_edge_cloud:port', + >>> 'additionalEdgeCloudParamater1': 'example' + >>> }, + >>> 'network': { + >>> 'client_name': 'open5gs', + >>> 'base_url': 'http://ip_network:port', + >>> 'additionalNetworkParamater1': 'example' + >>> } + >>> } + >>> + >>> clients = UniversalCatalogClient.create_clients(client_specs_example) + >>> edgecloud_client = clients.get("edgecloud") + >>> network_client = clients.get("network") + >>> + >>> edgecloud_client.get_edge_cloud_zones() + >>> network_client.get_qod_session(session_id="example_session_id") + """ + universal_client_catalog = SdkClientCatalog() + clients = {} + + for domain, config in client_specs.items(): + client_name = config["client_name"] + base_url = config["base_url"] + + # Support of additional paramaters for specific clients + kwargs = { + k: v for k, v in config.items() if k not in ("client_name", "base_url") + } + + client = universal_client_catalog.get_client( + domain, client_name, base_url, **kwargs + ) + clients[domain] = client + + return clients diff --git a/src/common/universal_client_catalog.py b/src/common/universal_client_catalog.py deleted file mode 100644 index 8cbdb0f..0000000 --- a/src/common/universal_client_catalog.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Dict - -from src.common.universal_catalog import universal_client_catalog - - -class UniversalClientCatalog: - @staticmethod - def create_clients(specs: Dict[str, Dict[str, str]]) -> Dict[str, object]: - """ - Create and return a dictionary of instantiated edgecloud/network/o-ran clients - based on the provided specifications. - - Args: - client_specs (list[dict]): A list of dictionaries, where each dictionary - specifies a client to be created. - Each client dictionary must contain: - - 'domain' (str): The client's domain (e.g., 'edgecloud', 'network'). - - 'client_name' (str): The specific name of the client - (e.g., 'i2edge', 'open5gs'). - - 'base_url' (str): The base URL for the client's API. - Optional parameters (like 'scs_as_id') can also be included - if required by the specific client's constructor. - - Returns: - dict: A dictionary where keys are the 'client_name' (str) and values are - the instantiated client objects. - - Example: - >>> from src.common.universal_client_catalog import UniversalClientCatalog - >>> - >>> client_specs_example = [ - >>> { - >>> 'domain': 'edgecloud', - >>> 'client_name': 'i2edge', - >>> 'base_url': 'http://localhost:8081', - >>> 'description': 'i2edge client example.' - >>> }, - >>> { - >>> 'domain': 'network', - >>> 'client_name': 'open5gs', - >>> 'base_url': 'http://localhost:8084', - >>> 'scs_as_id': 'my_unique_scs_id_example' # Example of optional parameter - >>> } - >>> ] - >>> - >>> clients = invoke_clients(client_specs_example) - >>> # Access a client: i2edge_client = clients['i2edge'] - """ - clients = {} - for domain, config in specs.items(): - client_name = config["client_name"] - base_url = config["base_url"] - client = universal_client_catalog.get_client(domain, client_name, base_url) - clients[domain] = client - return clients -- GitLab From b48620d4aab770fe395d709728194823099e3100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 12:05:04 +0200 Subject: [PATCH 111/281] Avoid open5gs client to default scs_as_id to None --- src/network/clients/open5gs/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index 3384294..44c70a3 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -25,8 +25,7 @@ class NetworkManager(NetworkManagementInterface): 3GPP Monitoring Event API exposed Open5GS NEF. """ - # TODO: Warning! "scs_as_is" is defaulted to None. - def __init__(self, base_url: str, scs_as_id: str = None): + def __init__(self, base_url: str, scs_as_id): """ Initializes the Open5GS Client. """ -- GitLab From 8397f22806954c12734d36847d1121a66d50e9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 17:58:06 +0200 Subject: [PATCH 112/281] Improve aerOS variables mgmt. Add support for additional parameters --- src/common/sdk_catalog.py | 11 +++++------ src/edgecloud/clients/aeros/client.py | 17 ++++++++++++++++- src/edgecloud/clients/aeros/config.py | 13 ++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/common/sdk_catalog.py b/src/common/sdk_catalog.py index a4e4f8b..1718c26 100644 --- a/src/common/sdk_catalog.py +++ b/src/common/sdk_catalog.py @@ -7,15 +7,14 @@ from src.network.clients.open5gs.client import NetworkManager as Open5GSClient # from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient -def _edgecloud_catalog(client_name: str, base_url: str): +def _edgecloud_catalog(client_name: str, base_url: str, **kwargs): edge_cloud_factory = { - "aeros": lambda url: AerosClient(base_url=url), + "aeros": lambda url, **kw: AerosClient(base_url=url, **kw), "i2edge": lambda url: I2EdgeClient(base_url=url), - # TODO: uncomment when missing PiEdge's imports are added - # "piedge": lambda url: PiEdgeClient(base_url=url), + # "piedge": lambda url: PiEdgeClient(base_url=url), Uncomment when import issues are solved } try: - return edge_cloud_factory[client_name](base_url) + return edge_cloud_factory[client_name](base_url, **kwargs) except KeyError: raise ValueError( f"Invalid edgecloud client '{client_name}'. Available: {list(edge_cloud_factory)}" @@ -57,7 +56,7 @@ class SdkClientCatalog: raise ValueError( f"Unsupported domain '{domain}'. Supported: {list(cls._domain_factories)}" ) - + return catalog(client_name, base_url, **kwargs) if domain == "network": if "scs_as_id" not in kwargs: raise ValueError("Missing required 'scs_as_id' for network clients.") diff --git a/src/edgecloud/clients/aeros/client.py b/src/edgecloud/clients/aeros/client.py index ead33eb..1e8742c 100644 --- a/src/edgecloud/clients/aeros/client.py +++ b/src/edgecloud/clients/aeros/client.py @@ -19,10 +19,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): FIXME: Handle None responses from continuum client """ - def __init__(self, base_url: str): + def __init__(self, base_url: str, **kwargs): self.base_url = base_url self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + # Overwrite config values if provided via kwargs + if "aerOS_API_URL" in kwargs: + config.aerOS_API_URL = kwargs["aerOS_API_URL"] + if "aerOS_ACCESS_TOKEN" in kwargs: + config.aerOS_ACCESS_TOKEN = kwargs["aerOS_ACCESS_TOKEN"] + if "aerOS_HLO_TOKEN" in kwargs: + config.aerOS_HLO_TOKEN = kwargs["aerOS_HLO_TOKEN"] + + if not config.aerOS_API_URL: + raise ValueError("Missing 'aerOS_API_URL'") + if not config.aerOS_ACCESS_TOKEN: + raise ValueError("Missing 'aerOS_ACCESS_TOKEN'") + if not config.aerOS_HLO_TOKEN: + raise ValueError("Missing 'aerOS_HLO_TOKEN'") + def onboard_app(self, app_manifest: Dict) -> Dict: # HLO-FE POST with TOSCA and app_id (service_id) service_id = app_manifest.get("serviceId") diff --git a/src/edgecloud/clients/aeros/config.py b/src/edgecloud/clients/aeros/config.py index e11c9c6..794cba5 100644 --- a/src/edgecloud/clients/aeros/config.py +++ b/src/edgecloud/clients/aeros/config.py @@ -9,16 +9,19 @@ aerOS access configuration Access tokens need to be provided in environment variables. """ -import os +# import os -aerOS_API_URL = os.environ.get("aerOS_API_URL") +# aerOS_API_URL = os.environ.get("aerOS_API_URL") +aerOS_API_URL = "harcoded_api" if not aerOS_API_URL: raise ValueError("Environment variable 'aerOS_API_URL' is not set.") -aerOS_ACCESS_TOKEN = os.environ.get("aerOS_ACCESS_TOKEN") +# aerOS_ACCESS_TOKEN = os.environ.get("aerOS_ACCESS_TOKEN") +aerOS_ACCESS_TOKEN = "harcoded_access_token" if not aerOS_ACCESS_TOKEN: raise ValueError("Environment variable 'aerOS_ACCESS_TOKEN' is not set.") -aerOS_HLO_TOKEN = os.environ.get("aerOS_HLO_TOKEN") +# aerOS_HLO_TOKEN = os.environ.get("aerOS_HLO_TOKEN") +aerOS_HLO_TOKEN = "harcoded_hlo_token" if not aerOS_HLO_TOKEN: raise ValueError("Environment variable 'aerOS_HLO_TOKEN' is not set.") -DEBUG = True +DEBUG = False LOG_FILE = ".log/aeros_client.log" -- GitLab From 4afcf7f21c746657d14fa5d577ab8960930a566e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 17:59:58 +0200 Subject: [PATCH 113/281] Rename functions in sdk_catalog and sdk_catalog_client for clarity sake --- src/common/sdk_catalog.py | 10 +++------- src/common/sdk_catalog_client.py | 8 +++++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/common/sdk_catalog.py b/src/common/sdk_catalog.py index 1718c26..028cc44 100644 --- a/src/common/sdk_catalog.py +++ b/src/common/sdk_catalog.py @@ -49,7 +49,9 @@ class SdkClientCatalog: } @classmethod - def get_client(cls, domain: str, client_name: str, base_url: str, **kwargs): + def instantiate_and_retrieve_clients( + cls, domain: str, client_name: str, base_url: str, **kwargs + ): try: catalog = cls._domain_factories[domain] except KeyError: @@ -57,9 +59,3 @@ class SdkClientCatalog: f"Unsupported domain '{domain}'. Supported: {list(cls._domain_factories)}" ) return catalog(client_name, base_url, **kwargs) - if domain == "network": - if "scs_as_id" not in kwargs: - raise ValueError("Missing required 'scs_as_id' for network clients.") - return catalog(client_name, base_url, kwargs["scs_as_id"]) - else: - return catalog(client_name, base_url) diff --git a/src/common/sdk_catalog_client.py b/src/common/sdk_catalog_client.py index 2a3f137..ae57803 100644 --- a/src/common/sdk_catalog_client.py +++ b/src/common/sdk_catalog_client.py @@ -5,7 +5,9 @@ from src.common.sdk_catalog import SdkClientCatalog class SdkCatalogClient: @staticmethod - def create_clients(client_specs: Dict[str, Dict[str, str]]) -> Dict[str, object]: + def create_clients_from( + client_specs: Dict[str, Dict[str, str]], + ) -> Dict[str, object]: """ Create and return a dictionary of instantiated edgecloud/network/o-ran clients based on the provided specifications. @@ -44,7 +46,7 @@ class SdkCatalogClient: >>> edgecloud_client.get_edge_cloud_zones() >>> network_client.get_qod_session(session_id="example_session_id") """ - universal_client_catalog = SdkClientCatalog() + sdk_client_catalog = SdkClientCatalog() clients = {} for domain, config in client_specs.items(): @@ -56,7 +58,7 @@ class SdkCatalogClient: k: v for k, v in config.items() if k not in ("client_name", "base_url") } - client = universal_client_catalog.get_client( + client = sdk_client_catalog.instantiate_and_retrieve_clients( domain, client_name, base_url, **kwargs ) clients[domain] = client -- GitLab From 816c0d96dc99c33f726c4a8319fc792b266205a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 18:01:28 +0200 Subject: [PATCH 114/281] Add kwargs variables in _network_catalog. Add scs_as_id check --- src/common/sdk_catalog.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/common/sdk_catalog.py b/src/common/sdk_catalog.py index 028cc44..b2e5047 100644 --- a/src/common/sdk_catalog.py +++ b/src/common/sdk_catalog.py @@ -21,16 +21,24 @@ def _edgecloud_catalog(client_name: str, base_url: str, **kwargs): ) -def _network_catalog(client_name: str, base_url: str, scs_as_id: str): +def _network_catalog(client_name: str, base_url: str, **kwargs): + if "scs_as_id" not in kwargs: + raise ValueError("Missing required 'scs_as_id' for network clients.") + scs_as_id = kwargs.pop("scs_as_id") + network_factory = { - "open5gs": lambda url, scs_id: Open5GSClient(base_url=url, scs_as_id=scs_id), - "oai": lambda url, scs_id: OaiCoreClient(base_url=url, scs_as_id=scs_id), - "open5gcore": lambda url, scs_id: Open5GCoreClient( - base_url=url, scs_as_id=scs_id + "open5gs": lambda url, scs_id, **kw: Open5GSClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "oai": lambda url, scs_id, **kw: OaiCoreClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( + base_url=url, scs_as_id=scs_id, **kw ), } try: - return network_factory[client_name](base_url, scs_as_id) + return network_factory[client_name](base_url, scs_as_id, **kwargs) except KeyError: raise ValueError( f"Invalid network client '{client_name}'. Available: {list(network_factory)}" -- GitLab From 6f66f3acd76a0ee3db93dad9ba7761c649a8ed15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 18:02:06 +0200 Subject: [PATCH 115/281] Update example script, as network clients are not ready yet --- examples/basic_usage.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 579f7c9..e7e9f94 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,7 +1,5 @@ from src.common.sdk_catalog_client import SdkCatalogClient -# "src.common.universal_catalog_client" would be equivalent to "sunrise6g_opensdk" - def main(): # The module importing the SDK, loads the config @@ -9,8 +7,7 @@ def main(): "edgecloud": { "client_name": "i2edge", "base_url": "http://192.168.123.237:30769/", - } - # , + }, # "network": { # "client_name": "open5gs", # "base_url": "http://IP:PORT", @@ -18,17 +15,16 @@ def main(): # } } - clients = SdkCatalogClient.create_clients(client_specs) + clients = SdkCatalogClient.create_clients_from(client_specs) + # EdgeCloud edgecloud_client = clients.get("edgecloud") - # network_client = clients.get("network") - - # Example of edgecloud client in action print("Testing edgecloud client function: get_edge_cloud_zones:") zones = edgecloud_client.get_edge_cloud_zones() print(zones) - # # Example of network client in action + # # Network + # network_client = clients.get("network") # print("Testing network client function: EXAMPLE_FUNCTION:") # network_client.get_qod_session(session_id="example_session_id") -- GitLab From 2f3ac3a6e73aa79e4a84c7f37698b3f993c55471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 18:03:08 +0200 Subject: [PATCH 116/281] Update tests to satisfy new invocation method --- tests/common/__init__.py | 0 tests/common/test_invoke_edgecloud_clients.py | 43 +++++++++ tests/common/test_invoke_network_clients.py | 41 ++++++++ tests/edgecloud/test_cases.py | 26 ++++- tests/edgecloud/test_config.py | 2 +- tests/edgecloud/test_e2e.py | 96 ++++++++----------- 6 files changed, 149 insertions(+), 59 deletions(-) create mode 100644 tests/common/__init__.py create mode 100644 tests/common/test_invoke_edgecloud_clients.py create mode 100644 tests/common/test_invoke_network_clients.py diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py new file mode 100644 index 0000000..5faf2c7 --- /dev/null +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import pytest + +from src.common.sdk_catalog_client import SdkCatalogClient + +EDGE_CLOUD_TEST_CASES = [ + {"edgecloud": {"client_name": "i2edge", "base_url": "http://test-i2edge.url"}}, + { + "edgecloud": { + "client_name": "aeros", + "base_url": "http://test-aeros.url", + "aerOS_API_URL": "http://fake.api.url", + "aerOS_ACCESS_TOKEN": "fake-access", + "aerOS_HLO_TOKEN": "fake-hlo", + } + }, + # Uncomment once piedge import issues are fixed + # { + # "edgecloud": { + # "client_name": "piedge", + # "base_url": "http://test-piedge.url" + # } + # } +] + + +@pytest.mark.parametrize( + "client_specs", + EDGE_CLOUD_TEST_CASES, + ids=[ + "i2edge", + "aeros", + # "piedge" + ], +) +def test_edgecloud_platform_instantiation(client_specs): + """Test instantiation of all edgecloud platform clients""" + clients = SdkCatalogClient.create_clients_from(client_specs) + + assert "edgecloud" in clients + edge_client = clients["edgecloud"] + assert edge_client is not None + assert "EdgeApplicationManager" in str(type(edge_client)) diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py new file mode 100644 index 0000000..4dd282f --- /dev/null +++ b/tests/common/test_invoke_network_clients.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +import pytest + +from src.common.sdk_catalog_client import SdkCatalogClient + +NETWORK_TEST_CASES = [ + { + "network": { + "client_name": "open5gs", + "base_url": "http://test-open5gs.url", + "scs_as_id": "scs1", + } + }, + { + "network": { + "client_name": "oai", + "base_url": "http://test-oai.url", + "scs_as_id": "scs2", + } + }, + { + "network": { + "client_name": "open5gcore", + "base_url": "http://test-open5gcore.url", + "scs_as_id": "scs3", + } + }, +] + + +@pytest.mark.parametrize( + "client_specs", NETWORK_TEST_CASES, ids=["open5gs", "oai", "open5gcore"] +) +def test_network_platform_instantiation(client_specs): + """Test instantiation of all network platform clients""" + clients = SdkCatalogClient.create_clients_from(client_specs) + + assert "network" in clients + network_client = clients["network"] + assert network_client is not None + assert "NetworkManager" in str(type(network_client)) diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index da584ee..a7ffc77 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -1,6 +1,26 @@ # -*- coding: utf-8 -*- test_cases = [ - ("i2edge", "http://192.168.123.237:30769/"), - # ("aeros", "https://ncsrd-mvp-domain.aeros-project.eu"), - # ("piedge", "http://piedge.example.com/"), + { + "edgecloud": { + "client_name": "i2edge", + # "base_url": "http://192.168.123.89:30769/", + "base_url": "http://192.168.123.89:30769/", + # "base_url": "http://192.168.123.237:30760/", + } + }, + # { + # "edgecloud": { + # "client_name": "aeros", + # "base_url": "http://test-aeros.url", + # "aerOS_API_URL": "http://fake.api.url", + # "aerOS_ACCESS_TOKEN": "fake-access", + # "aerOS_HLO_TOKEN": "fake-hlo" + # } + # }, + # { + # "edgecloud": { + # "client_name": "piedge", + # "base_url": "http://test-piedge.url" + # } + # } ] diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index e2015d5..2dccbd1 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -10,7 +10,7 @@ the EdgeCloud Platform integration across different clients. # i2Edge variables ###################### # EdgeCloud Zone -ZONE_ID = "Omega" +ZONE_ID = "Omega12345" # Artefact ARTEFACT_ID = "i2edgechart-id-2" diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index e6a0c6f..489c471 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -15,8 +15,9 @@ Key features: """ import pytest +from src.common.sdk_catalog_client import SdkCatalogClient from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory +from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config import ( APP_ID, @@ -31,14 +32,18 @@ from tests.edgecloud.test_config import ( ) -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones(client_name, base_url): - """ - Test the format of the response from get_edge_cloud_zones for each client. - """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +@pytest.fixture(scope="function") +def edgecloud_client(request): + """Fixture to create and share an edgecloud client across tests""" + client_specs = request.param + clients = SdkCatalogClient.create_clients_from(client_specs) + return clients.get("edgecloud") + + +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_get_edge_cloud_zones(edgecloud_client): try: - zones = edgecloud_platform.get_edge_cloud_zones() + zones = edgecloud_client.get_edge_cloud_zones() assert isinstance(zones, list) for zone in zones: assert "zoneId" in zone @@ -47,18 +52,17 @@ def test_get_edge_cloud_zones(client_name, base_url): pytest.fail(f"Failed to retrieve zones: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones_details(client_name, base_url, zone_id=ZONE_ID): +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): """ Test that get_edge_cloud_zone_details returns valid responses for each client. Since each client has different response formats, we only verify basic success criteria. """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) try: - zones = edgecloud_platform.get_edge_cloud_zones() + zones = edgecloud_client.get_edge_cloud_zones() assert len(zones) > 0, "No zones available for testing" - zone_details = edgecloud_platform.get_edge_cloud_zones_details(zone_id) + zone_details = edgecloud_client.get_edge_cloud_zones_details(zone_id) # Basic checks that apply to all clients assert zone_details is not None, "Zone details should not be None" @@ -71,14 +75,11 @@ def test_get_edge_cloud_zones_details(client_name, base_url, zone_id=ZONE_ID): pytest.fail(f"Missing expected key in response: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_create_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_create_artefact_success(edgecloud_client): + if isinstance(edgecloud_client, I2EdgeClient): try: - edgecloud_platform._create_artefact( + edgecloud_client._create_artefact( artefact_id=ARTEFACT_ID, artefact_name=ARTEFACT_NAME, repo_name=REPO_NAME, @@ -92,64 +93,49 @@ def test_create_artefact_success(client_name, base_url): pytest.fail(f"Artefact creation failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_onboard_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_onboard_app_success(edgecloud_client): try: - edgecloud_platform.onboard_app(APP_ONBOARD_MANIFEST) + edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.fixture(scope="module") -def deployed_app(request): - client_name, base_url = request.param - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +@pytest.fixture(scope="function") +def deployed_app(edgecloud_client): try: - output = edgecloud_platform.deploy_app(APP_ID, APP_ZONES) - return { - "client_name": client_name, - "base_url": base_url, - "appInstanceId": output["deploy_name"], - } + output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) + return {"appInstanceId": output["deploy_name"]} except EdgeCloudPlatformError as e: pytest.fail(f"App deployment failed unexpectedly: {e}") -@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -def test_deploy_app_success(deployed_app): +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_deploy_app_success(edgecloud_client, deployed_app): assert "appInstanceId" in deployed_app - if "client_name" in deployed_app == "i2edge": - assert deployed_app["appInstanceId"].startswith(ARTEFACT_NAME) + assert deployed_app["appInstanceId"] is not None -@pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -def test_undeploy_app_success(deployed_app): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - deployed_app["client_name"], deployed_app["base_url"] - ) +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_undeploy_app_success(edgecloud_client, deployed_app): try: - edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) + edgecloud_client.undeploy_app(deployed_app["appInstanceId"]) except EdgeCloudPlatformError as e: pytest.fail(f"App undeployment failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_delete_onboarded_app_success(edgecloud_client): try: - edgecloud_platform.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) + edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) +@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +def test_delete_artefact_success(edgecloud_client): + if isinstance(edgecloud_client, I2EdgeClient): try: - edgecloud_platform._delete_artefact(artefact_id=ARTEFACT_ID) + edgecloud_client._delete_artefact(artefact_id=ARTEFACT_ID) except EdgeCloudPlatformError as e: pytest.fail(f"Artefact deletion failed unexpectedly: {e}") -- GitLab From 0feb3ab97a0824ffb8d79695dec876ba67a3cb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 18:07:53 +0200 Subject: [PATCH 117/281] Update pipeline to run unit test that validates client instantiation --- .github/workflows/ci.yaml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8520624..ba1b925 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,21 +7,24 @@ on: branches: [ main ] jobs: - # tests: - # name: Run tests - # runs-on: ubuntu-latest - # container: - # image: python:3.12-slim + tests: + name: Run tests + runs-on: ubuntu-latest + container: + image: python:3.12-slim - # steps: - # - name: Checkout code - # uses: actions/checkout@v4 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: pip install -r requirements.txt - # - name: Install dependencies - # run: pip install -r requirements.txt + - name: "Run test: validate edge cloud clients instantiation" + run: pytest -v tests/common/test_invoke_edgecloud_clients.py - # - name: Run pytest - # run: pytest tests/edgecloud + # - name: "Run test: validate network clients instantiation" + # run: pytest -v tests/common/test_invoke_network_clients.py lint: name: Run linters -- GitLab From 6f8cccf5f5a782d03a1a779d146e86d39187e4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 3 Jun 2025 18:40:02 +0200 Subject: [PATCH 118/281] Update placeholder for open5gcore so the invocation test succed --- .github/workflows/ci.yaml | 4 ++-- src/network/clients/open5gcore/client.py | 24 ++++----------------- tests/common/test_invoke_network_clients.py | 18 +++++++++------- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba1b925..7368169 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,8 +23,8 @@ jobs: - name: "Run test: validate edge cloud clients instantiation" run: pytest -v tests/common/test_invoke_edgecloud_clients.py - # - name: "Run test: validate network clients instantiation" - # run: pytest -v tests/common/test_invoke_network_clients.py + - name: "Run test: validate network clients instantiation" + run: pytest -v tests/common/test_invoke_network_clients.py lint: name: Run linters diff --git a/src/network/clients/open5gcore/client.py b/src/network/clients/open5gcore/client.py index 195e676..2d9d397 100644 --- a/src/network/clients/open5gcore/client.py +++ b/src/network/clients/open5gcore/client.py @@ -1,30 +1,14 @@ # -*- coding: utf-8 -*- -from typing import Dict - from src import logger from src.network.core.network_interface import NetworkManagementInterface log = logger.get_logger(__name__) -# Placeholder for the Open5gcore Network Management Client -class NetworkManager(NetworkManagementInterface): - def __init__(self, base_url: str, scs_as_id: str): - pass +# TODO: Define any specific parameters or methods needed for Open5GCore +# In case any functionality is not implemented, raise NotImplementedError - def create_qod_session(self, session_info: Dict) -> Dict: - pass - def get_qod_session(self, session_id: str) -> Dict: - pass - - def delete_qod_session(self, session_id: str) -> None: +class NetworkManager(NetworkManagementInterface): + def __init__(self, base_url: str, scs_as_id: str): pass - - -# Note: -# As this class is inheriting from NetworkManagementInterface, it is -# expected to implement all the abstract methods defined in that interface. -# -# In case this network adapter doesn't support a specific method, it should -# be marked as NotImplementedError. diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py index 4dd282f..251353c 100644 --- a/tests/common/test_invoke_network_clients.py +++ b/tests/common/test_invoke_network_clients.py @@ -18,18 +18,20 @@ NETWORK_TEST_CASES = [ "scs_as_id": "scs2", } }, - { - "network": { - "client_name": "open5gcore", - "base_url": "http://test-open5gcore.url", - "scs_as_id": "scs3", - } - }, + # TODO: Once the functionality from QoD, Location-retrieval and + # traffic influnce is validated, tests can be carried out for Open5GCore + # { + # "network": { + # "client_name": "open5gcore", + # "base_url": "http://test-open5gcore.url", + # "scs_as_id": "scs3", + # } + # }, ] @pytest.mark.parametrize( - "client_specs", NETWORK_TEST_CASES, ids=["open5gs", "oai", "open5gcore"] + "client_specs", NETWORK_TEST_CASES, ids=["open5gs", "oai"] # TODO add Open5gcore ) def test_network_platform_instantiation(client_specs): """Test instantiation of all network platform clients""" -- GitLab From 541796f20e6c2ebc7a28430786e05ab1bebbc83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Wed, 4 Jun 2025 11:01:01 +0200 Subject: [PATCH 119/281] fixes broken open5gs tests Removes factory test (now centralized) Renames other test files --- src/network/core/network_interface.py | 19 +-------- tests/network/test_1_factory.py | 27 ------------- ..._session.py => test_create_qod_session.py} | 39 ++++++++++++------- ...ce.py => test_create_traffic_influence.py} | 2 +- 4 files changed, 26 insertions(+), 61 deletions(-) delete mode 100644 tests/network/test_1_factory.py rename tests/network/{test_2_create_qod_session.py => test_create_qod_session.py} (60%) rename tests/network/{test_3_create_traffic_influence.py => test_create_traffic_influence.py} (98%) diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 0a94031..1323957 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -10,7 +10,7 @@ # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) # - Ferran Cañellas (ferran.canellas@i2cat.net) ## -from abc import ABC, abstractmethod +from abc import ABC from itertools import product from typing import Dict @@ -78,7 +78,6 @@ class NetworkManagementInterface(ABC): base_url: str scs_as_id: str - @abstractmethod def add_core_specific_qod_parameters( self, session_info: schemas.CreateSession, @@ -90,7 +89,6 @@ class NetworkManagementInterface(ABC): """ pass - @abstractmethod def add_core_specific_ti_parameters( self, traffic_influence_info: schemas.CreateTrafficInfluence, @@ -102,7 +100,6 @@ class NetworkManagementInterface(ABC): """ pass - @abstractmethod def core_specific_qod_validation(self, session_info: schemas.CreateSession) -> None: """ Validates core-specific parameters for the session creation. @@ -117,7 +114,6 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass - @abstractmethod def core_specific_traffic_influence_validation( self, traffic_influence_info: schemas.CreateTrafficInfluence ) -> None: @@ -152,7 +148,6 @@ class NetworkManagementInterface(ABC): return subscription def _build_ti_subscription(self, traffic_influence_info: Dict): - traffic_influence_data = schemas.CreateTrafficInfluence.model_validate( traffic_influence_info ) @@ -194,18 +189,6 @@ class NetworkManagementInterface(ABC): return common.as_session_with_qos_post( self.base_url, self.scs_as_id, subscription ) - valid_session_info = schemas.CreateSession.model_validate(session_info) - self.core_specific_validation(valid_session_info) - subscription = schemas.AsSessionWithQoSSubscription( - notificationDestination=valid_session_info.sink, - qosReference=valid_session_info.qosProfile, - ueIpv4Addr=valid_session_info.device.ipv4Address, - ueIpv6Addr=valid_session_info.device.ipv6Address, - usageThreshold=schemas.UsageThreshold(duration=valid_session_info.duration), - ) - self.add_core_specific_parameters(subscription) - url = f"{self.base_url}/{self.scs_as_id}/subscriptions" - common.as_session_with_qos_post(url, self.scs_as_id, subscription) def get_qod_session(self, session_id: str) -> Dict: """ diff --git a/tests/network/test_1_factory.py b/tests/network/test_1_factory.py deleted file mode 100644 index 9cc7b60..0000000 --- a/tests/network/test_1_factory.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.network.clients.oai.client import NetworkManager as OaiClient -from src.network.clients.open5gs.client import NetworkManager as Open5GsClient -from src.network.core.network_factory import NetworkClientFactory - -test_cases = [ - ("open5gs", "http://192.168.124.233:30769/", "scs"), - ("oai", "http://127.0.0.1", "scs-oai"), -] - - -@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) -def test_factory_network(client_name, base_url, scs_as_id): - """ - Test the factory pattern for the network client. - """ - client_class_map = { - "open5gs": Open5GsClient, - "oai": OaiClient, - } - expected_client_class = client_class_map[client_name] - network_client = NetworkClientFactory.create_network_client( - client_name, base_url, scs_as_id - ) - assert isinstance(network_client, expected_client_class) diff --git a/tests/network/test_2_create_qod_session.py b/tests/network/test_create_qod_session.py similarity index 60% rename from tests/network/test_2_create_qod_session.py rename to tests/network/test_create_qod_session.py index 88df7c9..9853190 100644 --- a/tests/network/test_2_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -1,19 +1,26 @@ # -*- coding: utf-8 -*- import pytest -from src.network.core.network_factory import NetworkClientFactory - -test_cases = [ - ("open5gs", "http://192.168.124.233:30769/", "scs"), - ("oai", "http://127.0.0.1:8080/", "scs-oai"), +from src.common.sdk_catalog_client import SdkCatalogClient + +OPEN5GS_TEST_CASES = [ + { + "network": { + "client_name": "open5gs", + "base_url": "http://192.168.124.233:30769/", + "scs_as_id": "scs1", + } + } ] -@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) -def test_valid_input(client_name, base_url, scs_as_id): - network_client = NetworkClientFactory.create_network_client( - client_name, base_url, scs_as_id - ) +@pytest.mark.parametrize( + "client_specs", + OPEN5GS_TEST_CASES, + ids=["open5gs"], +) +def test_valid_input_open5gs(client_specs): + network_client = SdkCatalogClient.create_clients_from(client_specs)["network"] camara_session = { "duration": 3600, @@ -29,11 +36,13 @@ def test_valid_input(client_name, base_url, scs_as_id): network_client._build_qod_subscription(camara_session) -@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) -def test_create_qod_session(client_name, base_url, scs_as_id): - network_client = NetworkClientFactory.create_network_client( - client_name, base_url, scs_as_id - ) +@pytest.mark.parametrize( + "client_specs", + OPEN5GS_TEST_CASES, + ids=["open5gs"], +) +def test_create_qod_session_open5gs(client_specs): + network_client = SdkCatalogClient.create_clients_from(client_specs)["network"] camara_session = { "duration": 3600, diff --git a/tests/network/test_3_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py similarity index 98% rename from tests/network/test_3_create_traffic_influence.py rename to tests/network/test_create_traffic_influence.py index ae3a776..5bf9763 100644 --- a/tests/network/test_3_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# # -*- coding: utf-8 -*- import pytest from src.network.core.network_factory import NetworkClientFactory -- GitLab From 0b61ebe5180ca66e925874467413ebff43e875b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 4 Jun 2025 13:44:17 +0200 Subject: [PATCH 120/281] Update common invocation file names --- examples/basic_usage.py | 36 +++++++----- src/common/{sdk_catalog_client.py => sdk.py} | 18 ++++-- src/common/{sdk_catalog.py => sdk_factory.py} | 24 +++++--- tests/common/test_invoke_edgecloud_clients.py | 26 +++++---- tests/common/test_invoke_network_clients.py | 12 ++-- tests/edgecloud/test_e2e.py | 58 +++++++++++-------- 6 files changed, 108 insertions(+), 66 deletions(-) rename src/common/{sdk_catalog_client.py => sdk.py} (86%) rename src/common/{sdk_catalog.py => sdk_factory.py} (80%) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index e7e9f94..cf8046b 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,30 +1,36 @@ -from src.common.sdk_catalog_client import SdkCatalogClient +from src.common.sdk import Sdk as sdkclient + +# Note: that is equivalent to "from sunrise6g-sdk import Sdk as sdkclient" def main(): - # The module importing the SDK, loads the config + # The module that imports the SDK library, must specify "client_specs" client_specs = { "edgecloud": { "client_name": "i2edge", - "base_url": "http://192.168.123.237:30769/", + "base_url": "http://IP:PORT", + }, + "network": { + "client_name": "open5gs", + "base_url": "http://IP:PORT", + "scs_as_id": "id_example", }, - # "network": { - # "client_name": "open5gs", - # "base_url": "http://IP:PORT", - # "scs_as_id": "id_example" - # } } - clients = SdkCatalogClient.create_clients_from(client_specs) + clients = sdkclient.create_clients_from(client_specs) + edgecloud_client = clients.get("edgecloud") + network_client = clients.get("network") + print("EdgeCloud client ready to be used:", edgecloud_client) + print("EdgeCloud client ready to be used:", network_client) + + # Examples: # EdgeCloud - edgecloud_client = clients.get("edgecloud") - print("Testing edgecloud client function: get_edge_cloud_zones:") - zones = edgecloud_client.get_edge_cloud_zones() - print(zones) + # print("Testing edgecloud client function: get_edge_cloud_zones:") + # zones = edgecloud_client.get_edge_cloud_zones() + # print(zones) - # # Network - # network_client = clients.get("network") + # Network # print("Testing network client function: EXAMPLE_FUNCTION:") # network_client.get_qod_session(session_id="example_session_id") diff --git a/src/common/sdk_catalog_client.py b/src/common/sdk.py similarity index 86% rename from src/common/sdk_catalog_client.py rename to src/common/sdk.py index ae57803..2070946 100644 --- a/src/common/sdk_catalog_client.py +++ b/src/common/sdk.py @@ -1,9 +1,19 @@ +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +## from typing import Dict -from src.common.sdk_catalog import SdkClientCatalog +from src.common.sdk_factory import SdkFactory -class SdkCatalogClient: +class Sdk: @staticmethod def create_clients_from( client_specs: Dict[str, Dict[str, str]], @@ -46,7 +56,7 @@ class SdkCatalogClient: >>> edgecloud_client.get_edge_cloud_zones() >>> network_client.get_qod_session(session_id="example_session_id") """ - sdk_client_catalog = SdkClientCatalog() + sdk_client = SdkFactory() clients = {} for domain, config in client_specs.items(): @@ -58,7 +68,7 @@ class SdkCatalogClient: k: v for k, v in config.items() if k not in ("client_name", "base_url") } - client = sdk_client_catalog.instantiate_and_retrieve_clients( + client = sdk_client.instantiate_and_retrieve_clients( domain, client_name, base_url, **kwargs ) clients[domain] = client diff --git a/src/common/sdk_catalog.py b/src/common/sdk_factory.py similarity index 80% rename from src/common/sdk_catalog.py rename to src/common/sdk_factory.py index b2e5047..976875c 100644 --- a/src/common/sdk_catalog.py +++ b/src/common/sdk_factory.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +## from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient from src.network.clients.oai.client import NetworkManager as OaiCoreClient @@ -7,7 +17,7 @@ from src.network.clients.open5gs.client import NetworkManager as Open5GSClient # from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient -def _edgecloud_catalog(client_name: str, base_url: str, **kwargs): +def _edgecloud_factory(client_name: str, base_url: str, **kwargs): edge_cloud_factory = { "aeros": lambda url, **kw: AerosClient(base_url=url, **kw), "i2edge": lambda url: I2EdgeClient(base_url=url), @@ -21,7 +31,7 @@ def _edgecloud_catalog(client_name: str, base_url: str, **kwargs): ) -def _network_catalog(client_name: str, base_url: str, **kwargs): +def _network_factory(client_name: str, base_url: str, **kwargs): if "scs_as_id" not in kwargs: raise ValueError("Missing required 'scs_as_id' for network clients.") scs_as_id = kwargs.pop("scs_as_id") @@ -45,15 +55,15 @@ def _network_catalog(client_name: str, base_url: str, **kwargs): ) -# def _oran_catalog(client_name: str, base_url: str): +# def _oran_factory(client_name: str, base_url: str): # # TODO -class SdkClientCatalog: +class SdkFactory: _domain_factories = { - "edgecloud": _edgecloud_catalog, - "network": _network_catalog, - # "oran": _oran_catalog, + "edgecloud": _edgecloud_factory, + "network": _network_factory, + # "oran": _oran_factory, } @classmethod diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index 5faf2c7..b0bec3b 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -1,14 +1,20 @@ # -*- coding: utf-8 -*- import pytest -from src.common.sdk_catalog_client import SdkCatalogClient +from src.common.sdk import Sdk as sdkclient EDGE_CLOUD_TEST_CASES = [ - {"edgecloud": {"client_name": "i2edge", "base_url": "http://test-i2edge.url"}}, + { + "edgecloud": { + "client_name": "i2edge", + "base_url": "http://test-nbi-i2edge.sunrise6g", + } + }, { "edgecloud": { "client_name": "aeros", "base_url": "http://test-aeros.url", + # Additional parameters for aerOS client: "aerOS_API_URL": "http://fake.api.url", "aerOS_ACCESS_TOKEN": "fake-access", "aerOS_HLO_TOKEN": "fake-hlo", @@ -24,18 +30,14 @@ EDGE_CLOUD_TEST_CASES = [ ] -@pytest.mark.parametrize( - "client_specs", - EDGE_CLOUD_TEST_CASES, - ids=[ - "i2edge", - "aeros", - # "piedge" - ], -) +def id_func(val): + return val["edgecloud"]["client_name"] + + +@pytest.mark.parametrize("client_specs", EDGE_CLOUD_TEST_CASES, ids=id_func) def test_edgecloud_platform_instantiation(client_specs): """Test instantiation of all edgecloud platform clients""" - clients = SdkCatalogClient.create_clients_from(client_specs) + clients = sdkclient.create_clients_from(client_specs) assert "edgecloud" in clients edge_client = clients["edgecloud"] diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py index 251353c..923b9c9 100644 --- a/tests/common/test_invoke_network_clients.py +++ b/tests/common/test_invoke_network_clients.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import pytest -from src.common.sdk_catalog_client import SdkCatalogClient +from src.common.sdk import Sdk as sdkclient NETWORK_TEST_CASES = [ { @@ -30,12 +30,14 @@ NETWORK_TEST_CASES = [ ] -@pytest.mark.parametrize( - "client_specs", NETWORK_TEST_CASES, ids=["open5gs", "oai"] # TODO add Open5gcore -) +def id_func(val): + return val["network"]["client_name"] + + +@pytest.mark.parametrize("client_specs", NETWORK_TEST_CASES, ids=id_func) def test_network_platform_instantiation(client_specs): """Test instantiation of all network platform clients""" - clients = SdkCatalogClient.create_clients_from(client_specs) + clients = sdkclient.create_clients_from(client_specs) assert "network" in clients network_client = clients["network"] diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 489c471..cebfd45 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -1,21 +1,32 @@ # -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## """ EdgeCloud Platform Integration Tests Validates the complete application lifecycle across multiple clients: 1. Infrastructure (zone discovery) 2. Artefact management (create/delete) -3. Application lifecycle (onboard/deploy/undeploy/delete) +3. Application lifecycle (onboard/deploy/undeploy/delete app onboarded) Key features: - Tests all client implementations (parametrized via test_cases) +- Tests configuration available in test_config.py - Ensures proper resource cleanup - Uses shared test constants and CAMARA-compliant manifests - Includes i2edge-specific tests where needed """ import pytest -from src.common.sdk_catalog_client import SdkCatalogClient +from src.common.sdk import Sdk as sdkclient from src.edgecloud.clients.errors import EdgeCloudPlatformError from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient from tests.edgecloud.test_cases import test_cases @@ -32,15 +43,19 @@ from tests.edgecloud.test_config import ( ) -@pytest.fixture(scope="function") -def edgecloud_client(request): +def id_func(val): + return val["edgecloud"]["client_name"] + + +@pytest.fixture(scope="module", name="edgecloud_client") +def instantiate_edgecloud_client(request): """Fixture to create and share an edgecloud client across tests""" client_specs = request.param - clients = SdkCatalogClient.create_clients_from(client_specs) + clients = sdkclient.create_clients_from(client_specs) return clients.get("edgecloud") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones(edgecloud_client): try: zones = edgecloud_client.get_edge_cloud_zones() @@ -52,7 +67,7 @@ def test_get_edge_cloud_zones(edgecloud_client): pytest.fail(f"Failed to retrieve zones: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): """ Test that get_edge_cloud_zone_details returns valid responses for each client. @@ -75,7 +90,7 @@ def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): pytest.fail(f"Missing expected key in response: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_create_artefact_success(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: @@ -93,7 +108,7 @@ def test_create_artefact_success(edgecloud_client): pytest.fail(f"Artefact creation failed unexpectedly: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_onboard_app_success(edgecloud_client): try: edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) @@ -101,30 +116,27 @@ def test_onboard_app_success(edgecloud_client): pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.fixture(scope="function") -def deployed_app(edgecloud_client): +@pytest.fixture(scope="module", name="app_instance_id") +def test_deploy_app(edgecloud_client): try: output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) - return {"appInstanceId": output["deploy_name"]} + deployed_app = {"appInstanceId": output["deploy_name"]} + assert "appInstanceId" in deployed_app + assert deployed_app["appInstanceId"] is not None + return deployed_app["appInstanceId"] except EdgeCloudPlatformError as e: pytest.fail(f"App deployment failed unexpectedly: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) -def test_deploy_app_success(edgecloud_client, deployed_app): - assert "appInstanceId" in deployed_app - assert deployed_app["appInstanceId"] is not None - - -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) -def test_undeploy_app_success(edgecloud_client, deployed_app): +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_undeploy_app_success(edgecloud_client, app_instance_id): try: - edgecloud_client.undeploy_app(deployed_app["appInstanceId"]) + edgecloud_client.undeploy_app(app_instance_id) except EdgeCloudPlatformError as e: pytest.fail(f"App undeployment failed unexpectedly: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_delete_onboarded_app_success(edgecloud_client): try: edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) @@ -132,7 +144,7 @@ def test_delete_onboarded_app_success(edgecloud_client): pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, indirect=True) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_delete_artefact_success(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: -- GitLab From 4a41ba7e97747700a4df46ed456265c52fe70724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 4 Jun 2025 13:46:41 +0200 Subject: [PATCH 121/281] Polish changes --- src/edgecloud/clients/i2edge/client.py | 6 ++---- tests/edgecloud/test_cases.py | 4 +--- tests/edgecloud/test_config.py | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/edgecloud/clients/i2edge/client.py b/src/edgecloud/clients/i2edge/client.py index c163cd3..ff597ad 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/edgecloud/clients/i2edge/client.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- ## # Copyright 2025-present by Software Networks Area, i2CAT. @@ -151,15 +150,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # WIP - Harcoded by now def _select_best_flavour_for_app(self, zone_id) -> str: """ Selects the best flavour for the specified app requirements in a given zone. """ # list_of_flavours = self.get_edge_cloud_zones_details(zone_id) + # # TODO - Harcoded - # flavourId = "67080247e43a30ca79b50d7d" - flavourId = "6800c5199f29328e5691cd68" + flavourId = "67f3a0b0e3184a85952e174d" return flavourId def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index a7ffc77..62640a7 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -3,9 +3,7 @@ test_cases = [ { "edgecloud": { "client_name": "i2edge", - # "base_url": "http://192.168.123.89:30769/", - "base_url": "http://192.168.123.89:30769/", - # "base_url": "http://192.168.123.237:30760/", + "base_url": "http://192.168.123.48:30769/", } }, # { diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 2dccbd1..76be859 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -1,8 +1,18 @@ # -*- coding: utf-8 -*- +## +# Copyright 2025-present by Software Networks Area, i2CAT. +# All rights reserved. +# +# This file is part of the Open SDK +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - Sergio Giménez (sergio.gimenez@i2cat.net) +## """ EdgeCloud Platform Test Configuration -This module contains shared configuration constants and manifests for testing +This file contains the configuration constants and manifests for testing the EdgeCloud Platform integration across different clients. """ @@ -10,7 +20,7 @@ the EdgeCloud Platform integration across different clients. # i2Edge variables ###################### # EdgeCloud Zone -ZONE_ID = "Omega12345" +ZONE_ID = "Omega" # Artefact ARTEFACT_ID = "i2edgechart-id-2" -- GitLab From bfccdb3b9df758c27d6de8b48d077117c493e3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Wed, 4 Jun 2025 16:34:38 +0200 Subject: [PATCH 122/281] More open5gs fixes Create qod session validated --- src/network/clients/open5gs/client.py | 4 ++-- src/network/core/common.py | 2 +- src/network/core/network_interface.py | 21 ++++++++++++++++----- tests/network/test_create_qod_session.py | 16 +++++++++++----- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/network/clients/open5gs/client.py b/src/network/clients/open5gs/client.py index 85a9699..fbc7f6b 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/network/clients/open5gs/client.py @@ -38,13 +38,13 @@ class NetworkManager(NetworkManagementInterface): log.error(f"Failed to initialize Open5GSClient: {e}") raise e - def core_specific_validation(self, session_info: schemas.CreateSession): + def core_specific_qod_validation(self, session_info: schemas.CreateSession): if session_info.qosProfile.root not in flow_id_mapping.keys(): raise ValidationError( f"Open5Gs only supports these qos-profiles: {', '.join(flow_id_mapping.keys())}" ) - def add_core_specific_parameters( + def add_core_specific_qod_parameters( self, session_info: schemas.CreateSession, subscription: schemas.AsSessionWithQoSSubscription, diff --git a/src/network/core/common.py b/src/network/core/common.py index 54c2b90..ace91da 100644 --- a/src/network/core/common.py +++ b/src/network/core/common.py @@ -35,7 +35,7 @@ def _make_request(method: str, url: str, data=None): def as_session_with_qos_post( base_url: str, scs_as_id: str, model_payload: BaseModel ) -> dict: - data = model_payload.model_dump_json(exclude_none=True) + data = model_payload.model_dump_json(exclude_none=True, by_alias=True) url = as_session_with_qos_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) diff --git a/src/network/core/network_interface.py b/src/network/core/network_interface.py index 1323957..2fedbcf 100644 --- a/src/network/core/network_interface.py +++ b/src/network/core/network_interface.py @@ -29,7 +29,9 @@ def flatten_port_spec(ports_spec: schemas.PortsSpec | None) -> list[str]: flat_ports.extend([str(port) for port in ports_spec.ports]) if ports_spec and ports_spec.ranges: has_ranges = True - flat_ports.extend([f"{range.from_}-{range.to}" for range in ports_spec.ranges]) + flat_ports.extend( + [f"{range.from_.root}-{range.to.root}" for range in ports_spec.ranges] + ) if not has_ports and not has_ranges: flat_ports.append("0-65535") return flat_ports @@ -43,19 +45,26 @@ def build_flows( server_ports = flatten_port_spec(session_info.applicationServerPorts) ports_combis = list(product(device_ports, server_ports)) - device_ip = session_info.device.ipv4Address or session_info.device.ipv4Address + device_ip = session_info.device.ipv4Address or session_info.device.ipv6Address + if isinstance(device_ip, schemas.DeviceIpv6Address): + device_ip = device_ip.root + else: # IPv4 + device_ip = ( + device_ip.root.publicAddress.root or device_ip.root.privateAddress.root + ) + device_ip = str(device_ip) server_ip = ( session_info.applicationServer.ipv4Address or session_info.applicationServer.ipv6Address ) - + server_ip = server_ip.root flow_descrs = [] for device_port, server_port in ports_combis: flow_descrs.append( f"permit in ip from {device_ip} {device_port} to {server_ip} {server_port}" ) flow_descrs.append( - f"permit out ip from {device_ip} {device_port} to {server_ip} {server_port}" + f"permit out ip from {server_ip} {server_port} to {device_ip} {device_port}" ) flows = [ schemas.FlowInfo(flowId=flow_id, flowDescriptions=[", ".join(flow_descrs)]) @@ -130,7 +139,9 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass - def _build_qod_subscription(self, session_info: Dict) -> None: + def _build_qod_subscription( + self, session_info: Dict + ) -> schemas.AsSessionWithQoSSubscription: valid_session_info = schemas.CreateSession.model_validate(session_info) device_ipv4 = None if valid_session_info.device.ipv4Address: diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 9853190..1c0ea55 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -2,13 +2,14 @@ import pytest from src.common.sdk_catalog_client import SdkCatalogClient +from src.network.clients.open5gs.client import NetworkManager OPEN5GS_TEST_CASES = [ { "network": { "client_name": "open5gs", - "base_url": "http://192.168.124.233:30769/", - "scs_as_id": "scs1", + "base_url": "http://192.168.124.233:8082/", + "scs_as_id": "scs", } } ] @@ -20,7 +21,9 @@ OPEN5GS_TEST_CASES = [ ids=["open5gs"], ) def test_valid_input_open5gs(client_specs): - network_client = SdkCatalogClient.create_clients_from(client_specs)["network"] + network_client: NetworkManager = SdkCatalogClient.create_clients_from(client_specs)[ + "network" + ] camara_session = { "duration": 3600, @@ -33,7 +36,8 @@ def test_valid_input_open5gs(client_specs): "qosProfile": "qos-e", "sink": "https://endpoint.example.com/sink", } - network_client._build_qod_subscription(camara_session) + subscription = network_client._build_qod_subscription(camara_session) + print(subscription.model_dump_json(exclude_none=True, by_alias=True)) @pytest.mark.parametrize( @@ -42,7 +46,9 @@ def test_valid_input_open5gs(client_specs): ids=["open5gs"], ) def test_create_qod_session_open5gs(client_specs): - network_client = SdkCatalogClient.create_clients_from(client_specs)["network"] + network_client: NetworkManager = SdkCatalogClient.create_clients_from(client_specs)[ + "network" + ] camara_session = { "duration": 3600, -- GitLab From 4f2b0ba9587011a3a7cbc240eac337625bf022eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 13:16:18 +0200 Subject: [PATCH 123/281] Fix issue in e2e test. Add timer test before undeploy. --- tests/edgecloud/test_e2e.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index cebfd45..99763e3 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -24,6 +24,8 @@ Key features: - Uses shared test constants and CAMARA-compliant manifests - Includes i2edge-specific tests where needed """ +import time + import pytest from src.common.sdk import Sdk as sdkclient @@ -91,7 +93,7 @@ def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_create_artefact_success(edgecloud_client): +def test_create_artefact(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: edgecloud_client._create_artefact( @@ -109,27 +111,37 @@ def test_create_artefact_success(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_onboard_app_success(edgecloud_client): +def test_onboard_app(edgecloud_client): try: edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.fixture(scope="module", name="app_instance_id") -def test_deploy_app(edgecloud_client): +@pytest.fixture(scope="module") +def app_instance_id(edgecloud_client): try: output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) deployed_app = {"appInstanceId": output["deploy_name"]} assert "appInstanceId" in deployed_app assert deployed_app["appInstanceId"] is not None - return deployed_app["appInstanceId"] - except EdgeCloudPlatformError as e: - pytest.fail(f"App deployment failed unexpectedly: {e}") + yield deployed_app["appInstanceId"] + finally: + pass + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_deploy_app(app_instance_id): + assert app_instance_id is not None + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_30_seconds(edgecloud_client): + time.sleep(30) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_undeploy_app_success(edgecloud_client, app_instance_id): +def test_undeploy_app(edgecloud_client, app_instance_id): try: edgecloud_client.undeploy_app(app_instance_id) except EdgeCloudPlatformError as e: @@ -137,7 +149,7 @@ def test_undeploy_app_success(edgecloud_client, app_instance_id): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_onboarded_app_success(edgecloud_client): +def test_delete_onboarded_app(edgecloud_client): try: edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) except EdgeCloudPlatformError as e: @@ -145,7 +157,7 @@ def test_delete_onboarded_app_success(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_artefact_success(edgecloud_client): +def test_delete_artefact(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: edgecloud_client._delete_artefact(artefact_id=ARTEFACT_ID) -- GitLab From b3376ab2656c2be054501bebda1b07b125d1f90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 13:33:04 +0200 Subject: [PATCH 124/281] Refactor repository for pypi package built --- .gitignore | 4 +- README.md | 6 +- examples/basic_usage.py | 4 +- pyproject.toml | 63 ++++++++++ src/o-ran/core/__init__.py | 0 src/sunrise6g_opensdk/__init__.py | 2 + .../common}/__init__.py | 0 src/{ => sunrise6g_opensdk}/common/sdk.py | 4 +- .../common/sdk_factory.py | 20 +++- src/{ => sunrise6g_opensdk}/edgecloud/.env | 0 .../edgecloud}/__init__.py | 0 .../edgecloud/clients}/__init__.py | 0 .../edgecloud/clients/aeros/__init__.py | 4 +- .../edgecloud/clients/aeros/client.py | 10 +- .../edgecloud/clients/aeros/config.py | 0 .../clients/aeros/continuum_client.py | 6 +- .../edgecloud/clients/aeros/utils.py | 4 +- .../edgecloud/clients/errors.py | 0 .../edgecloud/clients/i2edge}/__init__.py | 0 .../edgecloud/clients/i2edge/client.py | 6 +- .../edgecloud/clients/i2edge/common.py | 4 +- .../edgecloud/clients/i2edge/schemas.py | 0 .../edgecloud/clients/i2edge/utils.py | 5 +- .../edgecloud/clients/piedge}/__init__.py | 0 .../edgecloud/clients/piedge/client.py | 3 +- .../edgecloud/core}/__init__.py | 0 .../edgecloud/core/edgecloud_interface.py | 0 src/{ => sunrise6g_opensdk}/logger.py | 0 .../network}/__init__.py | 0 .../network/clients}/__init__.py | 0 .../network/clients/errors.py | 0 .../network/clients/oai}/__init__.py | 0 .../network/clients/oai/client.py | 6 +- .../network/clients/open5gcore}/__init__.py | 0 .../network/clients/open5gcore/client.py | 4 +- .../network/clients/open5gs}/__init__.py | 0 .../network/clients/open5gs/client.py | 7 +- .../network/core}/__init__.py | 0 .../network/core/common.py | 2 +- .../network/core/network_interface.py | 4 +- .../network/core/schemas.py | 0 .../oran}/__init__.py | 0 .../oran/clients}/__init__.py | 0 .../oran/clients/juniper_ric}/__init__.py | 0 .../oran/clients/juniper_ric}/client.py | 0 .../oran/core}/__init__.py | 0 .../oran/core/oran_interface.py} | 0 tests/common/test_invoke_edgecloud_clients.py | 2 +- tests/common/test_invoke_network_clients.py | 2 +- tests/edgecloud/old/__init__.py | 0 tests/edgecloud/old/test_1_factory.py | 24 ---- tests/edgecloud/old/test_2_av_zones.py | 48 -------- tests/edgecloud/old/test_3_artefact.py | 110 ------------------ tests/edgecloud/old/test_4_app_onboarding.py | 105 ----------------- tests/edgecloud/old/test_5_app_deployment.py | 77 ------------ tests/edgecloud/test_aeros_edge_manager.py | 2 +- tests/edgecloud/test_e2e.py | 48 +++++--- tests/network/test_create_qod_session.py | 4 +- .../network/test_create_traffic_influence.py | 2 +- 59 files changed, 161 insertions(+), 431 deletions(-) create mode 100644 pyproject.toml delete mode 100644 src/o-ran/core/__init__.py create mode 100644 src/sunrise6g_opensdk/__init__.py rename src/{ => sunrise6g_opensdk/common}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/common/sdk.py (97%) rename src/{ => sunrise6g_opensdk}/common/sdk_factory.py (78%) rename src/{ => sunrise6g_opensdk}/edgecloud/.env (100%) rename src/{common => sunrise6g_opensdk/edgecloud}/__init__.py (100%) rename src/{edgecloud => sunrise6g_opensdk/edgecloud/clients}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/aeros/__init__.py (88%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/aeros/client.py (97%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/aeros/config.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/aeros/continuum_client.py (96%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/aeros/utils.py (91%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/errors.py (100%) rename src/{edgecloud/clients => sunrise6g_opensdk/edgecloud/clients/i2edge}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/i2edge/client.py (98%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/i2edge/common.py (96%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/i2edge/schemas.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/i2edge/utils.py (96%) rename src/{edgecloud/clients/i2edge => sunrise6g_opensdk/edgecloud/clients/piedge}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/clients/piedge/client.py (99%) rename src/{edgecloud/clients/piedge => sunrise6g_opensdk/edgecloud/core}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/edgecloud/core/edgecloud_interface.py (100%) rename src/{ => sunrise6g_opensdk}/logger.py (100%) rename src/{edgecloud/core => sunrise6g_opensdk/network}/__init__.py (100%) rename src/{network => sunrise6g_opensdk/network/clients}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/network/clients/errors.py (100%) rename src/{network/clients => sunrise6g_opensdk/network/clients/oai}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/network/clients/oai/client.py (96%) rename src/{network/clients/oai => sunrise6g_opensdk/network/clients/open5gcore}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/network/clients/open5gcore/client.py (72%) rename src/{network/clients/open5gcore => sunrise6g_opensdk/network/clients/open5gs}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/network/clients/open5gs/client.py (93%) rename src/{network/clients/open5gs => sunrise6g_opensdk/network/core}/__init__.py (100%) rename src/{ => sunrise6g_opensdk}/network/core/common.py (98%) rename src/{ => sunrise6g_opensdk}/network/core/network_interface.py (99%) rename src/{ => sunrise6g_opensdk}/network/core/schemas.py (100%) rename src/{network/core => sunrise6g_opensdk/oran}/__init__.py (100%) rename src/{o-ran => sunrise6g_opensdk/oran/clients}/__init__.py (100%) rename src/{o-ran/clients => sunrise6g_opensdk/oran/clients/juniper_ric}/__init__.py (100%) rename src/{o-ran/clients/juniper-ric => sunrise6g_opensdk/oran/clients/juniper_ric}/client.py (100%) rename src/{o-ran/clients/juniper-ric => sunrise6g_opensdk/oran/core}/__init__.py (100%) rename src/{o-ran/core/o-ran_interface.py => sunrise6g_opensdk/oran/core/oran_interface.py} (100%) delete mode 100644 tests/edgecloud/old/__init__.py delete mode 100644 tests/edgecloud/old/test_1_factory.py delete mode 100644 tests/edgecloud/old/test_2_av_zones.py delete mode 100644 tests/edgecloud/old/test_3_artefact.py delete mode 100644 tests/edgecloud/old/test_4_app_onboarding.py delete mode 100644 tests/edgecloud/old/test_5_app_deployment.py diff --git a/.gitignore b/.gitignore index 3c71d16..0d8a1e4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .pytest_cache .pyc __pycache__/ -tmp/ .vscode/ .log +.tar.gz +/dist +src/sunrise6g_opensdk.egg-info/ diff --git a/README.md b/README.md index c9c5ec6..d9a192b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Thank you for contributing to this project. Please follow the guidelines below t ### Directory Structure Each contribution should be made in the appropriate directory: -- **EdgeCloud Adapters** → `src/edgecloud/clients/` -- **Network Adapters** → `src/network/clients/` -- **O-RAN Adapters** → `src/o-ran/clients/` +- **EdgeCloud Adapters** → `src/sunrise6g-opendk/edgecloud/clients/` +- **Network Adapters** → `src/sunrise6g-opendk/network/clients/` +- **O-RAN Adapters** → `src/sunrise6g-opendk/oran/clients/` ### Testing (Mandatory) To merge a feature branch into `main`, the adapter **must pass the unit tests**. Instructions to do so available at [TESTING.md](docs/TESTING.md) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index cf8046b..0830f7e 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -1,6 +1,4 @@ -from src.common.sdk import Sdk as sdkclient - -# Note: that is equivalent to "from sunrise6g-sdk import Sdk as sdkclient" +from sunrise6g_opensdk import Sdk as sdkclient def main(): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18ea848 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "sunrise6g-opensdk" +version = "0.9.2" +description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN." +keywords = [ + "Federation", + "Transformation Functions", + "TFs", + "CAMARA", + "GSMA OPG", + "Open source", + "OpenSDK", + "SDK", + "SUNRISE-6G", + "6G", + "ETSI", + "Edge Cloud Platform", + "5G Core", + "Open RAN", + "O-RAN", +] +authors = [ + { name="Adrian Pino", email="adrian.pino@i2cat.net" } +] +readme = "README.md" +requires-python = ">=3.7" +license = { text = "Apache-2.0" } +classifiers = [ + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "auto_mix_prep==0.2.0", + "colorlog==6.8.2", + "pydantic==2.11.3", + "requests==2.32.3", +] + +[project.urls] +Homepage = "https://sunrise6g.eu/" +Repository = "https://github.com/OpenOperatorPlatform/OpenSDK" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["sunrise6g_opensdk*"] + +[tool.setuptools.package-data] +sunrise6g_opensdk = ["py.typed"] + +[bdist_wheel] +universal = 1 diff --git a/src/o-ran/core/__init__.py b/src/o-ran/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/sunrise6g_opensdk/__init__.py b/src/sunrise6g_opensdk/__init__.py new file mode 100644 index 0000000..c0e4bd9 --- /dev/null +++ b/src/sunrise6g_opensdk/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from .common.sdk import Sdk diff --git a/src/__init__.py b/src/sunrise6g_opensdk/common/__init__.py similarity index 100% rename from src/__init__.py rename to src/sunrise6g_opensdk/common/__init__.py diff --git a/src/common/sdk.py b/src/sunrise6g_opensdk/common/sdk.py similarity index 97% rename from src/common/sdk.py rename to src/sunrise6g_opensdk/common/sdk.py index 2070946..5fa3144 100644 --- a/src/common/sdk.py +++ b/src/sunrise6g_opensdk/common/sdk.py @@ -10,7 +10,7 @@ ## from typing import Dict -from src.common.sdk_factory import SdkFactory +from sunrise6g_opensdk.common.sdk_factory import SdkFactory class Sdk: @@ -19,7 +19,7 @@ class Sdk: client_specs: Dict[str, Dict[str, str]], ) -> Dict[str, object]: """ - Create and return a dictionary of instantiated edgecloud/network/o-ran clients + Create and return a dictionary of instantiated edgecloud/network/oran clients based on the provided specifications. Args: diff --git a/src/common/sdk_factory.py b/src/sunrise6g_opensdk/common/sdk_factory.py similarity index 78% rename from src/common/sdk_factory.py rename to src/sunrise6g_opensdk/common/sdk_factory.py index 976875c..ffddb10 100644 --- a/src/common/sdk_factory.py +++ b/src/sunrise6g_opensdk/common/sdk_factory.py @@ -8,13 +8,21 @@ # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) ## -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient -from src.network.clients.oai.client import NetworkManager as OaiCoreClient -from src.network.clients.open5gcore.client import NetworkManager as Open5GCoreClient -from src.network.clients.open5gs.client import NetworkManager as Open5GSClient +from sunrise6g_opensdk.edgecloud.clients.aeros.client import ( + EdgeApplicationManager as AerosClient, +) +from sunrise6g_opensdk.edgecloud.clients.i2edge.client import ( + EdgeApplicationManager as I2EdgeClient, +) +from sunrise6g_opensdk.network.clients.oai.client import NetworkManager as OaiCoreClient +from sunrise6g_opensdk.network.clients.open5gcore.client import ( + NetworkManager as Open5GCoreClient, +) +from sunrise6g_opensdk.network.clients.open5gs.client import ( + NetworkManager as Open5GSClient, +) -# from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +# from sunrise6g_opensdk.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient def _edgecloud_factory(client_name: str, base_url: str, **kwargs): diff --git a/src/edgecloud/.env b/src/sunrise6g_opensdk/edgecloud/.env similarity index 100% rename from src/edgecloud/.env rename to src/sunrise6g_opensdk/edgecloud/.env diff --git a/src/common/__init__.py b/src/sunrise6g_opensdk/edgecloud/__init__.py similarity index 100% rename from src/common/__init__.py rename to src/sunrise6g_opensdk/edgecloud/__init__.py diff --git a/src/edgecloud/__init__.py b/src/sunrise6g_opensdk/edgecloud/clients/__init__.py similarity index 100% rename from src/edgecloud/__init__.py rename to src/sunrise6g_opensdk/edgecloud/clients/__init__.py diff --git a/src/edgecloud/clients/aeros/__init__.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py similarity index 88% rename from src/edgecloud/clients/aeros/__init__.py rename to src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py index 0ea3493..a3d0a73 100644 --- a/src/edgecloud/clients/aeros/__init__.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py @@ -9,8 +9,8 @@ aerOS client and an access token for authentication. """ -from src.edgecloud.clients.aeros import config -from src.logger import setup_logger +from sunrise6g_opensdk.edgecloud.clients.aeros import config +from sunrise6g_opensdk.logger import setup_logger logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) diff --git a/src/edgecloud/clients/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py similarity index 97% rename from src/edgecloud/clients/aeros/client.py rename to src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py index 1e8742c..a37fec6 100644 --- a/src/edgecloud/clients/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py @@ -7,10 +7,12 @@ ## from typing import Any, Dict, List, Optional -from src.edgecloud.clients.aeros import config -from src.edgecloud.clients.aeros.continuum_client import ContinuumClient -from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -from src.logger import setup_logger +from sunrise6g_opensdk.edgecloud.clients.aeros import config +from sunrise6g_opensdk.edgecloud.clients.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( + EdgeCloudManagementInterface, +) +from sunrise6g_opensdk.logger import setup_logger class EdgeApplicationManager(EdgeCloudManagementInterface): diff --git a/src/edgecloud/clients/aeros/config.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/config.py similarity index 100% rename from src/edgecloud/clients/aeros/config.py rename to src/sunrise6g_opensdk/edgecloud/clients/aeros/config.py diff --git a/src/edgecloud/clients/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py similarity index 96% rename from src/edgecloud/clients/aeros/continuum_client.py rename to src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py index 064ad6e..eb8668f 100644 --- a/src/edgecloud/clients/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py @@ -12,9 +12,9 @@ aerOS REST API Client import requests -from src.edgecloud.clients.aeros import config -from src.edgecloud.clients.aeros.utils import catch_requests_exceptions -from src.logger import setup_logger +from sunrise6g_opensdk.edgecloud.clients.aeros import config +from sunrise6g_opensdk.edgecloud.clients.aeros.utils import catch_requests_exceptions +from sunrise6g_opensdk.logger import setup_logger class ContinuumClient: diff --git a/src/edgecloud/clients/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py similarity index 91% rename from src/edgecloud/clients/aeros/utils.py rename to src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py index d4f5cf5..3061c96 100644 --- a/src/edgecloud/clients/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py @@ -10,8 +10,8 @@ Docstring """ from requests.exceptions import HTTPError, RequestException, Timeout -import src.edgecloud.clients.aeros.config as config -from src.logger import setup_logger +import sunrise6g_opensdk.edgecloud.clients.aeros.config as config +from sunrise6g_opensdk.logger import setup_logger def catch_requests_exceptions(func): diff --git a/src/edgecloud/clients/errors.py b/src/sunrise6g_opensdk/edgecloud/clients/errors.py similarity index 100% rename from src/edgecloud/clients/errors.py rename to src/sunrise6g_opensdk/edgecloud/clients/errors.py diff --git a/src/edgecloud/clients/__init__.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/__init__.py similarity index 100% rename from src/edgecloud/clients/__init__.py rename to src/sunrise6g_opensdk/edgecloud/clients/i2edge/__init__.py diff --git a/src/edgecloud/clients/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py similarity index 98% rename from src/edgecloud/clients/i2edge/client.py rename to src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py index ff597ad..d4d5ff4 100644 --- a/src/edgecloud/clients/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py @@ -11,8 +11,10 @@ ## from typing import Dict, List, Optional -from src import logger -from src.edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( + EdgeCloudManagementInterface, +) from . import schemas from .common import ( diff --git a/src/edgecloud/clients/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py similarity index 96% rename from src/edgecloud/clients/i2edge/common.py rename to src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py index d4cda49..c91ada5 100644 --- a/src/edgecloud/clients/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py @@ -15,8 +15,8 @@ from typing import Optional import requests from pydantic import BaseModel -from src import logger -from src.edgecloud.clients.errors import EdgeCloudPlatformError +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError log = logger.get_logger(__name__) diff --git a/src/edgecloud/clients/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/schemas.py similarity index 100% rename from src/edgecloud/clients/i2edge/schemas.py rename to src/sunrise6g_opensdk/edgecloud/clients/i2edge/schemas.py diff --git a/src/edgecloud/clients/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py similarity index 96% rename from src/edgecloud/clients/i2edge/utils.py rename to src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py index bee5e94..4fcbe68 100644 --- a/src/edgecloud/clients/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py @@ -15,8 +15,9 @@ from typing import Optional, Union from uuid import UUID from src.edgecloud import logger -from src.edgecloud.api.routers.lcm.schemas import RequiredResources -from src.edgecloud.core import utils as core_utils + +from sunrise6g_opensdk.edgecloud.api.routers.lcm.schemas import RequiredResources +from sunrise6g_opensdk.edgecloud.core import utils as core_utils from .client import I2EdgeClient from .common import I2EdgeError diff --git a/src/edgecloud/clients/i2edge/__init__.py b/src/sunrise6g_opensdk/edgecloud/clients/piedge/__init__.py similarity index 100% rename from src/edgecloud/clients/i2edge/__init__.py rename to src/sunrise6g_opensdk/edgecloud/clients/piedge/__init__.py diff --git a/src/edgecloud/clients/piedge/client.py b/src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py similarity index 99% rename from src/edgecloud/clients/piedge/client.py rename to src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py index e6c767f..203acaf 100644 --- a/src/edgecloud/clients/piedge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py @@ -4,6 +4,7 @@ import logging import os from typing import Dict, List, Optional +from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface from swagger_server.core.piedge_encoder import deploy_service_function from swagger_server.models.deploy_service_function import DeployServiceFunction from swagger_server.models.service_function_registration_request import ( @@ -11,8 +12,6 @@ from swagger_server.models.service_function_registration_request import ( ) from swagger_server.utils import connector_db, kubernetes_connector -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface - piedge_ip = os.environ["EDGE_CLOUD_ADAPTER"] edge_cloud_provider = os.environ["PLATFORM_PROVIDER"] diff --git a/src/edgecloud/clients/piedge/__init__.py b/src/sunrise6g_opensdk/edgecloud/core/__init__.py similarity index 100% rename from src/edgecloud/clients/piedge/__init__.py rename to src/sunrise6g_opensdk/edgecloud/core/__init__.py diff --git a/src/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py similarity index 100% rename from src/edgecloud/core/edgecloud_interface.py rename to src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py diff --git a/src/logger.py b/src/sunrise6g_opensdk/logger.py similarity index 100% rename from src/logger.py rename to src/sunrise6g_opensdk/logger.py diff --git a/src/edgecloud/core/__init__.py b/src/sunrise6g_opensdk/network/__init__.py similarity index 100% rename from src/edgecloud/core/__init__.py rename to src/sunrise6g_opensdk/network/__init__.py diff --git a/src/network/__init__.py b/src/sunrise6g_opensdk/network/clients/__init__.py similarity index 100% rename from src/network/__init__.py rename to src/sunrise6g_opensdk/network/clients/__init__.py diff --git a/src/network/clients/errors.py b/src/sunrise6g_opensdk/network/clients/errors.py similarity index 100% rename from src/network/clients/errors.py rename to src/sunrise6g_opensdk/network/clients/errors.py diff --git a/src/network/clients/__init__.py b/src/sunrise6g_opensdk/network/clients/oai/__init__.py similarity index 100% rename from src/network/clients/__init__.py rename to src/sunrise6g_opensdk/network/clients/oai/__init__.py diff --git a/src/network/clients/oai/client.py b/src/sunrise6g_opensdk/network/clients/oai/client.py similarity index 96% rename from src/network/clients/oai/client.py rename to src/sunrise6g_opensdk/network/clients/oai/client.py index a1d4766..801c99f 100644 --- a/src/network/clients/oai/client.py +++ b/src/sunrise6g_opensdk/network/clients/oai/client.py @@ -9,9 +9,9 @@ ## -from src import logger -from src.network.core.network_interface import NetworkManagementInterface -from src.network.core.schemas import ( +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from sunrise6g_opensdk.network.core.schemas import ( AsSessionWithQoSSubscription, CreateSession, CreateTrafficInfluence, diff --git a/src/network/clients/oai/__init__.py b/src/sunrise6g_opensdk/network/clients/open5gcore/__init__.py similarity index 100% rename from src/network/clients/oai/__init__.py rename to src/sunrise6g_opensdk/network/clients/open5gcore/__init__.py diff --git a/src/network/clients/open5gcore/client.py b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py similarity index 72% rename from src/network/clients/open5gcore/client.py rename to src/sunrise6g_opensdk/network/clients/open5gcore/client.py index 2d9d397..922bee5 100644 --- a/src/network/clients/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from src import logger -from src.network.core.network_interface import NetworkManagementInterface +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface log = logger.get_logger(__name__) diff --git a/src/network/clients/open5gcore/__init__.py b/src/sunrise6g_opensdk/network/clients/open5gs/__init__.py similarity index 100% rename from src/network/clients/open5gcore/__init__.py rename to src/sunrise6g_opensdk/network/clients/open5gs/__init__.py diff --git a/src/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py similarity index 93% rename from src/network/clients/open5gs/client.py rename to src/sunrise6g_opensdk/network/clients/open5gs/client.py index fbc7f6b..c2e0f76 100644 --- a/src/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- from pydantic import ValidationError -from src import logger -from src.network.core.network_interface import NetworkManagementInterface, build_flows +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.core.network_interface import ( + NetworkManagementInterface, + build_flows, +) from ...core import schemas diff --git a/src/network/clients/open5gs/__init__.py b/src/sunrise6g_opensdk/network/core/__init__.py similarity index 100% rename from src/network/clients/open5gs/__init__.py rename to src/sunrise6g_opensdk/network/core/__init__.py diff --git a/src/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py similarity index 98% rename from src/network/core/common.py rename to src/sunrise6g_opensdk/network/core/common.py index ace91da..b9002db 100644 --- a/src/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -4,7 +4,7 @@ import requests from pydantic import BaseModel -from src import logger +from sunrise6g_opensdk import logger log = logger.get_logger(__name__) diff --git a/src/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py similarity index 99% rename from src/network/core/network_interface.py rename to src/sunrise6g_opensdk/network/core/network_interface.py index 2fedbcf..fe396b4 100644 --- a/src/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -14,8 +14,8 @@ from abc import ABC from itertools import product from typing import Dict -from src import logger -from src.network.core import common, schemas +from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.core import common, schemas log = logger.get_logger(__name__) diff --git a/src/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py similarity index 100% rename from src/network/core/schemas.py rename to src/sunrise6g_opensdk/network/core/schemas.py diff --git a/src/network/core/__init__.py b/src/sunrise6g_opensdk/oran/__init__.py similarity index 100% rename from src/network/core/__init__.py rename to src/sunrise6g_opensdk/oran/__init__.py diff --git a/src/o-ran/__init__.py b/src/sunrise6g_opensdk/oran/clients/__init__.py similarity index 100% rename from src/o-ran/__init__.py rename to src/sunrise6g_opensdk/oran/clients/__init__.py diff --git a/src/o-ran/clients/__init__.py b/src/sunrise6g_opensdk/oran/clients/juniper_ric/__init__.py similarity index 100% rename from src/o-ran/clients/__init__.py rename to src/sunrise6g_opensdk/oran/clients/juniper_ric/__init__.py diff --git a/src/o-ran/clients/juniper-ric/client.py b/src/sunrise6g_opensdk/oran/clients/juniper_ric/client.py similarity index 100% rename from src/o-ran/clients/juniper-ric/client.py rename to src/sunrise6g_opensdk/oran/clients/juniper_ric/client.py diff --git a/src/o-ran/clients/juniper-ric/__init__.py b/src/sunrise6g_opensdk/oran/core/__init__.py similarity index 100% rename from src/o-ran/clients/juniper-ric/__init__.py rename to src/sunrise6g_opensdk/oran/core/__init__.py diff --git a/src/o-ran/core/o-ran_interface.py b/src/sunrise6g_opensdk/oran/core/oran_interface.py similarity index 100% rename from src/o-ran/core/o-ran_interface.py rename to src/sunrise6g_opensdk/oran/core/oran_interface.py diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index b0bec3b..c745200 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import pytest -from src.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient EDGE_CLOUD_TEST_CASES = [ { diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py index 923b9c9..9c82a56 100644 --- a/tests/common/test_invoke_network_clients.py +++ b/tests/common/test_invoke_network_clients.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import pytest -from src.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient NETWORK_TEST_CASES = [ { diff --git a/tests/edgecloud/old/__init__.py b/tests/edgecloud/old/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/edgecloud/old/test_1_factory.py b/tests/edgecloud/old/test_1_factory.py deleted file mode 100644 index 5c8d483..0000000 --- a/tests/edgecloud/old/test_1_factory.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.edgecloud.clients.aeros.client import EdgeApplicationManager as AerosClient -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient - -# from src.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from tests.edgecloud.test_cases import test_cases - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_factory_edgecloud(client_name, base_url): - """ - Test the factory pattern for the edgecloud client. - """ - client_class_map = { - "i2edge": I2EdgeClient, - "aeros": AerosClient, - # "piedge": PiEdgeClient, - } - expected_client_class = client_class_map[client_name] - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - assert isinstance(edgecloud_platform, expected_client_class) diff --git a/tests/edgecloud/old/test_2_av_zones.py b/tests/edgecloud/old/test_2_av_zones.py deleted file mode 100644 index 000a38b..0000000 --- a/tests/edgecloud/old/test_2_av_zones.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from tests.edgecloud.test_cases import test_cases - -zone_id = "Omega12345" - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones(client_name, base_url): - """ - Test the format of the response from get_edge_cloud_zones for each client. - """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - zones = edgecloud_platform.get_edge_cloud_zones() - assert isinstance(zones, list) - for zone in zones: - assert "zoneId" in zone - assert "geographyDetails" in zone - except EdgeCloudPlatformError as e: - pytest.fail(f"Failed to retrieve zones: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_edge_cloud_zones_details(client_name, base_url, zone_id=zone_id): - """ - Test that get_edge_cloud_zone_details returns valid responses for each client. - Since each client has different response formats, we only verify basic success criteria. - """ - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - zones = edgecloud_platform.get_edge_cloud_zones() - assert len(zones) > 0, "No zones available for testing" - - zone_details = edgecloud_platform.get_edge_cloud_zones_details(zone_id) - - # Basic checks that apply to all clients - assert zone_details is not None, "Zone details should not be None" - assert isinstance(zone_details, dict), "Zone details should be a dictionary" - assert len(zone_details) > 0, "Zone details should not be empty" - - except EdgeCloudPlatformError as e: - pytest.fail(f"Failed to retrieve zone details: {e}") - except KeyError as e: - pytest.fail(f"Missing expected key in response: {e}") diff --git a/tests/edgecloud/old/test_3_artefact.py b/tests/edgecloud/old/test_3_artefact.py deleted file mode 100644 index 550e218..0000000 --- a/tests/edgecloud/old/test_3_artefact.py +++ /dev/null @@ -1,110 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from tests.edgecloud.test_cases import test_cases - -# Note: artifact mgmt is only supported by i2Edge - -artefact_id = "i2edgechart-id" -artefact_name = "i2edgechart" -repo_name = "github-cesar" -repo_type = "PUBLICREPO" -repo_url = "https://cesarcajas.github.io/helm-charts-examples/" - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_create_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - try: - edgecloud_platform._create_artefact( - artefact_id=artefact_id, - artefact_name=artefact_name, - repo_name=repo_name, - repo_type=repo_type, - repo_url=repo_url, - password=None, - token=None, - user_name=None, - ) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact creation failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_create_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform._create_artefact( - artefact_id=artefact_id, - artefact_name=artefact_name, - repo_name=repo_name, - repo_type="PUBLICREPO", - repo_url="http://invalid.url", - password=None, - token=None, - user_name=None, - ) - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - try: - edgecloud_platform._get_artefact(artefact_id=artefact_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform._get_artefact(artefact_id="non-existent-artefact") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_all_artefacts_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - try: - edgecloud_platform._get_all_artefacts() - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact retrieval failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_artefact_success(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - try: - edgecloud_platform._delete_artefact(artefact_id=artefact_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact deletion failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_artefact_failure(client_name, base_url): - if client_name == "i2edge": - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( - client_name, base_url - ) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform._delete_artefact(artefact_id="non-existent-artefact") diff --git a/tests/edgecloud/old/test_4_app_onboarding.py b/tests/edgecloud/old/test_4_app_onboarding.py deleted file mode 100644 index bf10635..0000000 --- a/tests/edgecloud/old/test_4_app_onboarding.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from tests.edgecloud.test_cases import test_cases - -# CAMARA app payload (only mandatory fields) -app_manifest = { - "appId": "i2edgechart-id", - "name": "i2edge-app-SDK", - "version": "1.0.0", - "appProvider": "i2CAT", - "packageType": "CONTAINER", - "appRepo": { - "type": "PUBLICREPO", - "imagePath": "https://example.com/my-app-image:1.0.0", - }, - "requiredResources": { - "infraKind": "kubernetes", - "applicationResources": { - "cpuPool": { - "numCPU": 2, - "memory": 2048, - "topology": { - "minNumberOfNodes": 2, - "minNodeCpu": 1, - "minNodeMemory": 1024, - }, - } - }, - "isStandalone": False, - "version": "1.29", - }, - "componentSpec": [ - { - "componentName": "my-component", - "networkInterfaces": [ - { - "interfaceId": "eth0", - "protocol": "TCP", - "port": 8080, - "visibilityType": "VISIBILITY_EXTERNAL", - } - ], - } - ], -} - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_onboard_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.onboard_app(app_manifest) - except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_onboard_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform.onboard_app({}) - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.get_onboarded_app(app_id=app_manifest["appId"]) - except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform.get_onboarded_app(app_id="non-existent-app") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_all_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.get_all_onboarded_apps() - except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_onboarded_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.delete_onboarded_app(app_id=app_manifest["appId"]) - except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_delete_onboarded_app_failure(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - with pytest.raises(EdgeCloudPlatformError): - edgecloud_platform.delete_onboarded_app(app_id="non-existent-app") diff --git a/tests/edgecloud/old/test_5_app_deployment.py b/tests/edgecloud/old/test_5_app_deployment.py deleted file mode 100644 index 24573e4..0000000 --- a/tests/edgecloud/old/test_5_app_deployment.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.core.edgecloud_factory import EdgeCloudFactory -from tests.edgecloud.test_cases import test_cases - -# As a pre-requirement for this test, the app should be already onboarded -appId = "i2edgechart-id" -app_zones = [ - { - "kubernetesClusterRef": "not-used", - "EdgeCloudZone": { - # "edgeCloudZoneId": "Omega", - "edgeCloudZoneId": "Omega12345", - "edgeCloudZoneName": "not-used", - "edgeCloudZoneStatus": "not-used", - "edgeCloudProvider": "not-used", - "edgeCloudRegion": "not-used", - }, - } -] - -# TODO: Revise this test, something is wrong. It doesn't fail even though I specify a non-existent av zone - -# @pytest.fixture(scope="module") -# def deployed_app(request): -# client_name, base_url = request.param -# edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) -# try: -# output = edgecloud_platform.deploy_app(appId, app_zones) -# return { -# "client_name": client_name, -# "base_url": base_url, -# "appInstanceId": output["deploy_name"], -# } -# except EdgeCloudPlatformError as e: -# pytest.fail(f"App deployment failed unexpectedly: {e}") - - -# @pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -# def test_deploy_app_success(deployed_app): -# assert "appInstanceId" in deployed_app -# assert deployed_app["appInstanceId"].startswith("i2edgechart") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_all_apps_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.get_all_deployed_apps() - - except EdgeCloudPlatformError as e: - pytest.fail(f"App instance retrieval failed unexpectedly: {e}") - - -@pytest.mark.parametrize("client_name, base_url", test_cases) -def test_get_app_success(client_name, base_url): - edgecloud_platform = EdgeCloudFactory.create_edgecloud_client(client_name, base_url) - try: - edgecloud_platform.get_deployed_app( - appId, app_zones[0]["EdgeCloudZone"]["edgeCloudZoneId"] - ) - - except EdgeCloudPlatformError as e: - pytest.fail(f"App instance retrieval failed unexpectedly: {e}") - - -# @pytest.mark.parametrize("deployed_app", test_cases, indirect=True) -# def test_undeploy_app_success(deployed_app): -# edgecloud_platform = EdgeCloudFactory.create_edgecloud_client( -# deployed_app["client_name"], deployed_app["base_url"] -# ) -# try: -# edgecloud_platform.undeploy_app(deployed_app["appInstanceId"]) -# except EdgeCloudPlatformError as e: -# pytest.fail(f"App undeployment failed unexpectedly: {e}") diff --git a/tests/edgecloud/test_aeros_edge_manager.py b/tests/edgecloud/test_aeros_edge_manager.py index 4f2e041..1359fda 100644 --- a/tests/edgecloud/test_aeros_edge_manager.py +++ b/tests/edgecloud/test_aeros_edge_manager.py @@ -17,7 +17,7 @@ Also environment variables must be sset in advance, regarding access tokens import unittest from typing import Any, Dict -from src.edgecloud.clients.aeros.client import EdgeApplicationManager +from sunrise6g_opensdk.edgecloud.clients.aeros.client import EdgeApplicationManager TOSCA_YAML_EXAMPLE: str = """ tosca_definitions_version: tosca_simple_yaml_1_3 diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index cebfd45..beb5177 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -24,11 +24,15 @@ Key features: - Uses shared test constants and CAMARA-compliant manifests - Includes i2edge-specific tests where needed """ +import time + import pytest -from src.common.sdk import Sdk as sdkclient -from src.edgecloud.clients.errors import EdgeCloudPlatformError -from src.edgecloud.clients.i2edge.client import EdgeApplicationManager as I2EdgeClient +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.clients.i2edge.client import ( + EdgeApplicationManager as I2EdgeClient, +) from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config import ( APP_ID, @@ -43,10 +47,6 @@ from tests.edgecloud.test_config import ( ) -def id_func(val): - return val["edgecloud"]["client_name"] - - @pytest.fixture(scope="module", name="edgecloud_client") def instantiate_edgecloud_client(request): """Fixture to create and share an edgecloud client across tests""" @@ -55,6 +55,10 @@ def instantiate_edgecloud_client(request): return clients.get("edgecloud") +def id_func(val): + return val["edgecloud"]["client_name"] + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones(edgecloud_client): try: @@ -91,7 +95,7 @@ def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_create_artefact_success(edgecloud_client): +def test_create_artefact(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: edgecloud_client._create_artefact( @@ -109,27 +113,37 @@ def test_create_artefact_success(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_onboard_app_success(edgecloud_client): +def test_onboard_app(edgecloud_client): try: edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed unexpectedly: {e}") -@pytest.fixture(scope="module", name="app_instance_id") -def test_deploy_app(edgecloud_client): +@pytest.fixture(scope="module") +def app_instance_id(edgecloud_client): try: output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) deployed_app = {"appInstanceId": output["deploy_name"]} assert "appInstanceId" in deployed_app assert deployed_app["appInstanceId"] is not None - return deployed_app["appInstanceId"] - except EdgeCloudPlatformError as e: - pytest.fail(f"App deployment failed unexpectedly: {e}") + yield deployed_app["appInstanceId"] + finally: + pass + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_deploy_app(app_instance_id): + assert app_instance_id is not None + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_30_seconds(edgecloud_client): + time.sleep(30) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_undeploy_app_success(edgecloud_client, app_instance_id): +def test_undeploy_app(edgecloud_client, app_instance_id): try: edgecloud_client.undeploy_app(app_instance_id) except EdgeCloudPlatformError as e: @@ -137,7 +151,7 @@ def test_undeploy_app_success(edgecloud_client, app_instance_id): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_onboarded_app_success(edgecloud_client): +def test_delete_onboarded_app(edgecloud_client): try: edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) except EdgeCloudPlatformError as e: @@ -145,7 +159,7 @@ def test_delete_onboarded_app_success(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_artefact_success(edgecloud_client): +def test_delete_artefact(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: edgecloud_client._delete_artefact(artefact_id=ARTEFACT_ID) diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 1c0ea55..5e4751e 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- import pytest -from src.common.sdk_catalog_client import SdkCatalogClient -from src.network.clients.open5gs.client import NetworkManager +from sunrise6g_opensdk.common.sdk_catalog_client import SdkCatalogClient +from sunrise6g_opensdk.network.clients.open5gs.client import NetworkManager OPEN5GS_TEST_CASES = [ { diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index 5bf9763..ddba5e0 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -1,7 +1,7 @@ # # -*- coding: utf-8 -*- import pytest -from src.network.core.network_factory import NetworkClientFactory +from sunrise6g_opensdk.network.core.network_factory import NetworkClientFactory test_cases = [("oai", "http://127.0.0.1/", "scs-oai")] -- GitLab From 85caf177f43258c0534429d04858b5ad55874ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 13:40:41 +0200 Subject: [PATCH 125/281] Add pythonpath in CI pipeline to fix ModuleNotFoundError --- .github/workflows/ci.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7368169..6d73c30 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,10 +21,14 @@ jobs: run: pip install -r requirements.txt - name: "Run test: validate edge cloud clients instantiation" - run: pytest -v tests/common/test_invoke_edgecloud_clients.py + run: | + export PYTHONPATH=$PYTHONPATH:$(pwd)/src + pytest -v tests/common/test_invoke_edgecloud_clients.py - name: "Run test: validate network clients instantiation" - run: pytest -v tests/common/test_invoke_network_clients.py + run: | + export PYTHONPATH=$PYTHONPATH:$(pwd)/src + pytest -v tests/common/test_invoke_network_clients.py lint: name: Run linters -- GitLab From ee3ebdb70f616a16cc00d6a27ae7b831485ccd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 13:49:00 +0200 Subject: [PATCH 126/281] Add pip install -e in the CI --- .github/workflows/ci.yaml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d73c30..692c332 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,17 +18,15 @@ jobs: uses: actions/checkout@v4 - name: Install dependencies - run: pip install -r requirements.txt + run: | + pip install -r requirements.txt + pip install -e . - name: "Run test: validate edge cloud clients instantiation" - run: | - export PYTHONPATH=$PYTHONPATH:$(pwd)/src - pytest -v tests/common/test_invoke_edgecloud_clients.py + run: pytest -v tests/common/test_invoke_edgecloud_clients.py - name: "Run test: validate network clients instantiation" - run: | - export PYTHONPATH=$PYTHONPATH:$(pwd)/src - pytest -v tests/common/test_invoke_network_clients.py + run: pytest -v tests/common/test_invoke_network_clients.py lint: name: Run linters -- GitLab From 1e63d1134b58fb77a8ea9e1db65bc0ff9ae0730c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 14:33:43 +0200 Subject: [PATCH 127/281] Update requirements to avoid vulnerabilities detected by dependabot --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2609ed5..4341f2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ Jinja2==3.1.6 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 jupyter_client==8.6.3 -jupyter_core==5.7.2 +jupyter_core==5.8.1 jupyterlab_pygments==0.3.0 MarkupSafe==3.0.2 matplotlib-inline==0.1.7 @@ -72,7 +72,7 @@ soupsieve==2.6 stack-data==0.6.3 tinycss2==1.4.0 tomli==2.2.1 -tornado==6.4.2 +tornado==6.5 traitlets==5.14.3 typing_extensions==4.12.2 urllib3==2.3.0 -- GitLab From 65bddf78fd5587cf368ff3164750184f31a76dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 18:09:07 +0200 Subject: [PATCH 128/281] Improve readme --- README.md | 196 ++++++++++++++++-------- docs/CONTRIBUTING.md | 95 ++++++++++-- docs/TESTING.md | 6 + examples/__init__.py | 0 examples/{basic_usage.py => example.py} | 4 +- 5 files changed, 225 insertions(+), 76 deletions(-) delete mode 100644 examples/__init__.py rename examples/{basic_usage.py => example.py} (86%) diff --git a/README.md b/README.md index d9a192b..734a632 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,152 @@ # OpenSDK -Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms & 5G network cores +Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and O-RAN solutions. -## Contributing Guidelines -Thank you for contributing to this project. Please follow the guidelines below to ensure a smooth collaboration. +## Features -### Directory Structure -Each contribution should be made in the appropriate directory: -- **EdgeCloud Adapters** → `src/sunrise6g-opendk/edgecloud/clients/` -- **Network Adapters** → `src/sunrise6g-opendk/network/clients/` -- **O-RAN Adapters** → `src/sunrise6g-opendk/oran/clients/` +- Unified SDK for interacting with Edge Cloud platforms, 5G Core solutions, and O-RAN solutions. +- Modular and extensible adapter structure +- Conforms to CAMARA/GSMA API standards. -### Testing (Mandatory) -To merge a feature branch into `main`, the adapter **must pass the unit tests**. Instructions to do so available at [TESTING.md](docs/TESTING.md) +--- -### Contributing -1. **Check Guidelines at [CONTRIBUTING.md](docs/CONTRIBUTING.md).** -2. **Create a New Branch** following the naming convention. -3. **Develop Your Feature** inside the correct directory. -4. **Ensure All Tests Pass** before the merge. -5. **Submit a Merge Request (MR)** to the `main` branch. +## API & Platform Support Matrix -### Branch Naming Convention -Each partner should create a feature branch following the naming convention based on the type of adapter they are contributing: +### CAMARA APIs -#### ☁️ EdgeCloud Adapters -Branch Name Format: -``` -feature/add-edgecloud- -``` -Example: -``` -feature/add-edgecloud-i2edge -``` +| API Name | Version | +|----------------------|--------------| +| Edge Application Management | v0.9.3-wip | +| Quality-on-Demand | v1.0.0 | +| Location Retrieval | v1.0.0 | +| Traffic Influence | v0.8.1 | -#### 🌐 Network Adapters -Branch Name Format: -``` -feature/add-network-<5G_CORE_NAME> -``` -Example: -``` -feature/add-network-open5gs -``` +### EdgeCloud Platforms +| Platform | Status | +|------------|------------| +| Kubernetes | To be supported soon | +| i2Edge | Supported | +| aerOS | Supported | -#### 📶 O-RAN Adapters -Branch Name Format: -``` -feature/add-oran- +### Network Adapters + +| Platform | NEF Version | QoD | Location Retrieval | Traffic Influence | +|--------------|-------------|-----|---------------------|--------------------| +| Open5GS | v1.2.3 | ✅ | ✅ | ❌ | +| Open5GCore | v1.2.3 | ✅ | ❌ | ❌ | +| OAI | v1.2.3 | ✅ | ❌ | ✅ | + +--- + +## How to Use + +### Option 1: Install via PyPI + +For end users: + +```bash +pip install sunrise6g-opensdk ``` -Example: + +### Option 2: Development Mode + +If you plan to modify the SDK: + +```bash +git clone https://github.com//sunrise6g-opensdk.git +cd sunrise6g-opensdk +pip install -r requirements.txt ``` -feature/add-oran-juniper + +### Basic Usage + +You can use the SDK by simply specifying the adapters to be used. E.g. i2Edge and Open5gs + +```python +from sunrise6g_opensdk import Sdk as sdkclient + +def main(): + client_specs = { + "edgecloud": { + "client_name": "i2edge", + "base_url": "http://IP:PORT", + }, + "network": { + "client_name": "open5gs", + "base_url": "http://IP:PORT", + "scs_as_id": "id_example", + }, + } + + clients = sdkclient.create_clients_from(client_specs) + edgecloud_client = clients.get("edgecloud") + network_client = clients.get("network") + + print(edgecloud_client.get_edge_cloud_zones()) + print(network_client.get_qod_session(session_id="example_session_id")) + + +if __name__ == "__main__": + main() ``` -## Sequence Diagram Example -Refer to the sequence diagram example from `docs/workflows/edgecloud/get_av_zones.md` for guidance on workflow structure: +Example available in [`/examples/example.py`](examples/example.py) + +--- + +## How to Contribute + +We welcome contributions to OpenSDK! + +To get started: + +1. Fork the repository and create a branch from `main`. +2. Add your changes in the appropriate adapter directory. +3. Write or update tests for your changes. +4. Ensure all tests and pre-commit checks pass. +5. Submit a pull request with a clear description. + +Please follow our full [Contributing Guidelines](docs/CONTRIBUTING.md) for details on: +- Directory structure +- Branch naming conventions +- Coding standards (PEP8, docstrings) +- Pre-commit setup +- Reporting issues + +--- + +## Example Workflow (Mermaid) ```mermaid sequenceDiagram -title Retrieve Edge Cloud Zones -actor AP as App Vertical Provider -participant CE as Capabilities Exposure -box Service Resource Manager - participant API - participant SDK as EdgeCloudSDK +title Application Deployment using the Open SDK + +actor AP as Application Vertical Provider +box Module implementing CAMARA APIs + participant API as CAMARA Edge Application Management API + participant SDK as Open SDK end -participant i2Edge -participant PiEdge - -note over AP,CE: CAMARA EdgeCloud API -AP ->> CE: GET /edge-cloud-zones -CE ->> API: GET /av. zones -API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(i2Edge) -API ->> SDK: sbi.get_edge_cloud_zones() -SDK ->> i2Edge: GET /zones/list -API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) -API ->> SDK: sbi.get_edge_cloud_zones() -SDK ->> PiEdge: GET /nodes -``` +participant K8s as Kubernetes + +note over SDK: [configuration] Edge Cloud platform: Kubernetes +API ->> SDK: edgecloud_client = clients.get("edgecloud") +API ->> SDK: sdkclient.create_clients_from(configuration) +AP ->> API: POST /app (APP_ONBOARD_MANIFEST) +API ->> SDK: edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) +SDK ->> K8s: POST /onboard +AP ->> API: POST /appinstances (APP_ID, APP_ZONES) +API ->> SDK: edgecloud_client.deploy_app(APP_ID, APP_ZONES) +SDK ->> K8s: POST /deploy +--- + +## Roadmap + +- [ ] Expand GSMA TFs coverage +- [ ] Include JUNIPER O-RAN adapter + +--- + +## License + +Apache 2.0 License – see [`LICENSE`](LICENSE) file for details. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 922fbe2..5256a1e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,21 +1,94 @@ -# Contributing +# Contributing to OpenSDK -To contribute with code, follow the instructions below to enforce automatic syntax formatting and linting. -Configuration is taken from https://www.pre-commit.com/#2-add-a-pre-commit-configuration and from git-hooks. +Thank you for considering contributing to OpenSDK! This guide outlines how to contribute code, report issues, and ensure consistency across submissions. -*Note*: apply commands from the root of the repository. +--- + +## Getting Started + +To contribute: + +1. Fork the repository and create a feature branch from `main`. +2. Develop your changes in the appropriate adapter directory: + - `src/sunrise6g_opensdk/edgecloud/clients/` + - `src/sunrise6g_opensdk/network/clients/` + - `src/sunrise6g_opensdk/oran/clients/` +3. Follow the coding guidelines below. +4. Write or update unit tests for your changes. +5. Ensure all tests pass. +6. Set up and run `pre-commit` hooks before pushing changes. +7. Submit a pull request with a clear and concise description. + +--- + +## Branch Naming Convention + +Choose a branch name that reflects the adapter type and your feature: + +- `feature/add-edgecloud-` +- `feature/add-network-<5gcore>` +- `feature/add-oran-` + +Examples: +```bash +feature/add-edgecloud-i2edge +feature/add-network-open5gs +feature/add-oran-juniper +``` + +--- + +## Coding Guidelines + +- Write meaningful commit messages. +- Keep pull requests focused and concise. +- Document public methods and classes using docstrings. + +--- + +## Pre-commit Hook Setup + +We use `pre-commit` to enforce formatting and static analysis. Apply these commands from the root of the repository: + +### Initial Setup ```bash -# Required: install the pre-commit hooks pip3 install pre-commit pre-commit install +``` + +### Optional (Run hooks manually or before commit) -# Optional: manual trigger (to know how validation will be applied or to force it manually on files before/after commit) +```bash pre-commit run --all-files +``` -# Optional: to keep the pre-commit versions of validation up-to-date -pre-commit autoupdate +--- -# Optional: to remove the pre-commit git-hook binding -pre-commit uninstall -``` +## Testing + +Before submitting your contribution, ensure all unit tests pass. + +See [TESTING.md](TESTING.md) for instructions. + +--- + +## Reporting Issues + +Please use the [Issue Tracker](https://github.com/SunriseOpenOperatorPlatform/sunrise6g-opensdk/issues) for bug reports or feature requests. + +When reporting a bug, include: + +- A clear description of the problem +- Steps to reproduce it +- Relevant logs or error messages (if any) + +--- + +## Code of Conduct + +We are committed to maintaining a welcoming and respectful environment for all contributors. + +--- + +Thank you for helping improve OpenSDK! diff --git a/docs/TESTING.md b/docs/TESTING.md index cc0b857..7264f55 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,6 +2,12 @@ *Note*: apply commands from the root of the repository. +To test that the adapters can be instantiated: + +```bash +pytest tests/common/ +``` + To run tests for the Edge Cloud adapters: ```bash diff --git a/examples/__init__.py b/examples/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/basic_usage.py b/examples/example.py similarity index 86% rename from examples/basic_usage.py rename to examples/example.py index 0830f7e..cc6483e 100644 --- a/examples/basic_usage.py +++ b/examples/example.py @@ -2,7 +2,7 @@ from sunrise6g_opensdk import Sdk as sdkclient def main(): - # The module that imports the SDK library, must specify "client_specs" + # The module that imports the SDK package, must specify which adapters will be used: client_specs = { "edgecloud": { "client_name": "i2edge", @@ -20,7 +20,7 @@ def main(): network_client = clients.get("network") print("EdgeCloud client ready to be used:", edgecloud_client) - print("EdgeCloud client ready to be used:", network_client) + print("Network client ready to be used:", network_client) # Examples: # EdgeCloud -- GitLab From 2fce1f94cd3275fbe66e4c79723fc27c39627f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 19:01:44 +0200 Subject: [PATCH 129/281] Fix typo in readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 734a632..04348a7 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ SDK ->> K8s: POST /onboard AP ->> API: POST /appinstances (APP_ID, APP_ZONES) API ->> SDK: edgecloud_client.deploy_app(APP_ID, APP_ZONES) SDK ->> K8s: POST /deploy +``` --- ## Roadmap -- GitLab From ff1389b0ed26bc7f6474595d172b5da94e3b1645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 5 Jun 2025 19:04:11 +0200 Subject: [PATCH 130/281] Add roadmap/next steps in readme --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 04348a7..bca6ec6 100644 --- a/README.md +++ b/README.md @@ -139,12 +139,13 @@ AP ->> API: POST /appinstances (APP_ID, APP_ZONES) API ->> SDK: edgecloud_client.deploy_app(APP_ID, APP_ZONES) SDK ->> K8s: POST /deploy ``` + --- ## Roadmap -- [ ] Expand GSMA TFs coverage -- [ ] Include JUNIPER O-RAN adapter +- [ ] Add support to GSMA OPG.02 TFs (WIP) +- [ ] Include JUNIPER O-RAN adapter (WIP) --- -- GitLab From ad25ee3d900c4c103712147b92788167e1dba199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 11 Jun 2025 17:02:45 +0100 Subject: [PATCH 131/281] Fix import issue --- tests/network/test_create_qod_session.py | 6 +++--- tests/network/test_create_traffic_influence.py | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 5e4751e..741eb6d 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import pytest -from sunrise6g_opensdk.common.sdk_catalog_client import SdkCatalogClient +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.network.clients.open5gs.client import NetworkManager OPEN5GS_TEST_CASES = [ @@ -21,7 +21,7 @@ OPEN5GS_TEST_CASES = [ ids=["open5gs"], ) def test_valid_input_open5gs(client_specs): - network_client: NetworkManager = SdkCatalogClient.create_clients_from(client_specs)[ + network_client: NetworkManager = sdkclient.create_clients_from(client_specs)[ "network" ] @@ -46,7 +46,7 @@ def test_valid_input_open5gs(client_specs): ids=["open5gs"], ) def test_create_qod_session_open5gs(client_specs): - network_client: NetworkManager = SdkCatalogClient.create_clients_from(client_specs)[ + network_client: NetworkManager = sdkclient.create_clients_from(client_specs)[ "network" ] diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index ddba5e0..c39e4df 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -1,16 +1,14 @@ # # -*- coding: utf-8 -*- import pytest -from sunrise6g_opensdk.network.core.network_factory import NetworkClientFactory +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient test_cases = [("oai", "http://127.0.0.1/", "scs-oai")] @pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) def test_valid_input(client_name, base_url, scs_as_id): - network_client = NetworkClientFactory.create_network_client( - client_name, base_url, scs_as_id - ) + network_client = sdkclient.create_network_client(client_name, base_url, scs_as_id) ti_session = { "device": { @@ -26,9 +24,7 @@ def test_valid_input(client_name, base_url, scs_as_id): @pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) def test_create_traffic_influence(client_name, base_url, scs_as_id): - network_client = NetworkClientFactory.create_network_client( - client_name, base_url, scs_as_id - ) + network_client = sdkclient.create_network_client(client_name, base_url, scs_as_id) ti_session = { "device": { -- GitLab From 756894fafa26e0cc7ecbecde91b3dfe390db22d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 11 Jun 2025 17:03:00 +0100 Subject: [PATCH 132/281] Improve example --- examples/example.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/example.py b/examples/example.py index cc6483e..1ae2b17 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,4 +1,5 @@ -from sunrise6g_opensdk import Sdk as sdkclient +# from sunrise6g_opensdk import Sdk as sdkclient # For PyPI users +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient # For developers def main(): -- GitLab From 38c08425bc88474c9dcbe53202d89eb75e299c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 11 Jun 2025 17:03:34 +0100 Subject: [PATCH 133/281] Polish readme --- README.md | 48 +++++++++++------------------------------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index bca6ec6..2816137 100644 --- a/README.md +++ b/README.md @@ -54,45 +54,24 @@ pip install sunrise6g-opensdk If you plan to modify the SDK: ```bash -git clone https://github.com//sunrise6g-opensdk.git -cd sunrise6g-opensdk +git clone https://github.com/SunriseOpenOperatorPlatform/open-sdk.git +cd open-sdk +python3 -m venv .venv +source .venv/bin/activate pip install -r requirements.txt +pip intall -e . ``` ### Basic Usage -You can use the SDK by simply specifying the adapters to be used. E.g. i2Edge and Open5gs +You can use the SDK by simply specifying the adapters to be used. E.g. Edge Cloud Platform: i2Edge, 5G core: Open5Gs + +Example available in [`/examples/example.py`](examples/example.py) ```python -from sunrise6g_opensdk import Sdk as sdkclient - -def main(): - client_specs = { - "edgecloud": { - "client_name": "i2edge", - "base_url": "http://IP:PORT", - }, - "network": { - "client_name": "open5gs", - "base_url": "http://IP:PORT", - "scs_as_id": "id_example", - }, - } - - clients = sdkclient.create_clients_from(client_specs) - edgecloud_client = clients.get("edgecloud") - network_client = clients.get("network") - - print(edgecloud_client.get_edge_cloud_zones()) - print(network_client.get_qod_session(session_id="example_session_id")) - - -if __name__ == "__main__": - main() +python3 -m examples.example ``` -Example available in [`/examples/example.py`](examples/example.py) - --- ## How to Contribute @@ -107,16 +86,11 @@ To get started: 4. Ensure all tests and pre-commit checks pass. 5. Submit a pull request with a clear description. -Please follow our full [Contributing Guidelines](docs/CONTRIBUTING.md) for details on: -- Directory structure -- Branch naming conventions -- Coding standards (PEP8, docstrings) -- Pre-commit setup -- Reporting issues +Please follow our full [Contributing Guidelines](docs/CONTRIBUTING.md) for further details. --- -## Example Workflow (Mermaid) +## Example Workflow ```mermaid sequenceDiagram -- GitLab From 0c92473fc1f29cee4a2111448f42bf65156c2033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 11 Jun 2025 17:04:14 +0100 Subject: [PATCH 134/281] Update pyproject to use version 0.0.1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18ea848..80cba7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "0.9.2" -description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN." +version = "0.0.1" +description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", "Transformation Functions", -- GitLab From b420b9982def2308323de3dfa38e13ea2a38ed54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 11 Jun 2025 17:37:13 +0100 Subject: [PATCH 135/281] Improve seq diagram under readme. Add network example --- README.md | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2816137..e22deeb 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Please follow our full [Contributing Guidelines](docs/CONTRIBUTING.md) for furth --- -## Example Workflow +## Example Workflow #1: App deployment over Kubernetes ```mermaid sequenceDiagram @@ -103,17 +103,45 @@ box Module implementing CAMARA APIs end participant K8s as Kubernetes -note over SDK: [configuration] Edge Cloud platform: Kubernetes -API ->> SDK: edgecloud_client = clients.get("edgecloud") +note over SDK: [Config] Edge Cloud platform: Kubernetes, IP, Port +API ->> SDK: from sunrise6g_opensdk import Sdk as sdkclient API ->> SDK: sdkclient.create_clients_from(configuration) +API ->> SDK: edgecloud_client = clients.get("edgecloud") +SDK ->> SDK: SDK initialized and ready to be used +note over AP,API: Platform ready to receive CAMARA calls AP ->> API: POST /app (APP_ONBOARD_MANIFEST) API ->> SDK: edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) -SDK ->> K8s: POST /onboard +SDK ->> K8s: Equivalent dedicated endpoint AP ->> API: POST /appinstances (APP_ID, APP_ZONES) API ->> SDK: edgecloud_client.deploy_app(APP_ID, APP_ZONES) -SDK ->> K8s: POST /deploy +SDK ->> K8s: Equivalent dedicated endpoint ``` +## Example Workflow #2: QoS Session Creation over Open5Gs + +```mermaid +sequenceDiagram +title QoS Session Creation over Open5GS + +actor AP as Application Vertical Provider +box Module implementing CAMARA APIs + participant API as CAMARA QoS Management API + participant SDK as Open SDK +end +participant NEF as NEF +participant 5GS as Open5GS + +note over SDK: [Config] Network core: Open5Gs, IP, Port +API ->> SDK: from sunrise6g_opensdk import Sdk as sdkclient +API ->> SDK: sdkclient.create_clients_from(configuration) +API ->> SDK: network_client = clients.get("network") +SDK ->> SDK: SDK initialized and ready to be used +note over AP,API: Platform ready to receive CAMARA calls +AP ->> API: POST /sessions (QOS_SESSION_REQUEST) +API ->> SDK: network_client.create_qos_session(QOS_SESSION_REQUEST) +SDK ->> NEF: Equivalent endpoint +NEF ->> 5GS: QoS session creation +``` --- ## Roadmap -- GitLab From d2b74e08260c57d23b1dd9b38589cbe740b56746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Fri, 13 Jun 2025 12:43:50 +0200 Subject: [PATCH 136/281] implements get/delete tests for open5gs Adds missing part that returns the session id. --- .../network/core/network_interface.py | 18 +++- src/sunrise6g_opensdk/network/core/schemas.py | 46 ++++++++ tests/network/test_cases.py | 10 ++ tests/network/test_create_qod_session.py | 101 ++++++++++++------ 4 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 tests/network/test_cases.py diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index fe396b4..1406a1c 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -10,11 +10,13 @@ # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) # - Ferran Cañellas (ferran.canellas@i2cat.net) ## +import uuid from abc import ABC from itertools import product from typing import Dict from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.clients.errors import NetworkPlatformError from sunrise6g_opensdk.network.core import common, schemas log = logger.get_logger(__name__) @@ -197,9 +199,23 @@ class NetworkManagementInterface(ABC): dictionary containing the created session details, including its ID. """ subscription = self._build_qod_subscription(session_info) - return common.as_session_with_qos_post( + response = common.as_session_with_qos_post( self.base_url, self.scs_as_id, subscription ) + subscription_info: schemas.AsSessionWithQoSSubscription = ( + schemas.AsSessionWithQoSSubscription(**response) + ) + subscription_url = subscription_info.self_.root + subscription_id = subscription_url.split("/")[-1] if subscription_url else None + if not subscription_id: + log.error("Failed to retrieve QoS session ID from response") + raise NetworkPlatformError("QoS session ID not found in response") + session_info = schemas.SessionInfo( + sessionId=schemas.SessionId(uuid.UUID(subscription_id)), + qosStatus=schemas.QosStatus.REQUESTED, + **session_info, + ) + return session_info.model_dump() def get_qod_session(self, session_id: str) -> Dict: """ diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index ac8dc11..6a6a1b0 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -4,9 +4,11 @@ # specifically focusing on the APIs needed to support CAMARA QoD. import ipaddress +from datetime import datetime from enum import Enum from ipaddress import IPv4Address, IPv6Address from typing import Annotated +from uuid import UUID from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress @@ -403,6 +405,50 @@ class CreateSession(BaseSessionInfo): ] +class SessionId(RootModel[UUID]): + root: Annotated[UUID, Field(description="Session ID in UUID format")] + + +class QosStatus(Enum): + REQUESTED = "REQUESTED" + AVAILABLE = "AVAILABLE" + UNAVAILABLE = "UNAVAILABLE" + + +class StatusInfo(Enum): + DURATION_EXPIRED = "DURATION_EXPIRED" + NETWORK_TERMINATED = "NETWORK_TERMINATED" + DELETE_REQUESTED = "DELETE_REQUESTED" + + +class SessionInfo(BaseSessionInfo): + sessionId: SessionId + duration: Annotated[ + int, + Field( + description='Session duration in seconds. Implementations can grant the requested session duration or set a different duration, based on network policies or conditions.\n- When `qosStatus` is "REQUESTED", the value is the duration to be scheduled, granted by the implementation.\n- When `qosStatus` is AVAILABLE", the value is the overall duration since `startedAt. When the session is extended, the value is the new overall duration of the session.\n- When `qosStatus` is "UNAVAILABLE", the value is the overall effective duration since `startedAt` until the session was terminated.\n', + examples=[3600], + ge=1, + ), + ] + startedAt: Annotated[ + datetime | None, + Field( + description='Date and time when the QoS status became "AVAILABLE". Not to be returned when `qosStatus` is "REQUESTED". Format must follow RFC 3339 and must indicate time zone (UTC or local).', + examples=["2024-06-01T12:00:00Z"], + ), + ] = None + expiresAt: Annotated[ + datetime | None, + Field( + description='Date and time of the QoS session expiration. Format must follow RFC 3339 and must indicate time zone (UTC or local).\n- When `qosStatus` is "AVAILABLE", it is the limit time when the session is scheduled to finnish, if not terminated by other means.\n- When `qosStatus` is "UNAVAILABLE", it is the time when the session was terminated.\n- Not to be returned when `qosStatus` is "REQUESTED".\nWhen the session is extended, the value is the new expiration time of the session.\n', + examples=["2024-06-01T13:00:00Z"], + ), + ] = None + qosStatus: QosStatus + statusInfo: StatusInfo | None = None + + class CreateTrafficInfluence(BaseModel): trafficInfluenceID: str | None = None apiConsumerId: str | None = None diff --git a/tests/network/test_cases.py b/tests/network/test_cases.py new file mode 100644 index 0000000..796c7aa --- /dev/null +++ b/tests/network/test_cases.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +test_cases = [ + { + "network": { + "client_name": "open5gs", + "base_url": "http://192.168.124.233:8082/", + "scs_as_id": "scs", + } + } +] diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 741eb6d..4230fe1 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -1,30 +1,33 @@ # -*- coding: utf-8 -*- +import time + import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient -from sunrise6g_opensdk.network.clients.open5gs.client import NetworkManager - -OPEN5GS_TEST_CASES = [ - { - "network": { - "client_name": "open5gs", - "base_url": "http://192.168.124.233:8082/", - "scs_as_id": "scs", - } - } -] +from sunrise6g_opensdk.network.core.common import CoreHttpError +from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from tests.network.test_cases import test_cases + + +@pytest.fixture(scope="module", name="network_client") +def instantiate_network_client(request): + """Fixture to create and share a network client across tests""" + client_specs = request.param + clients = sdkclient.create_clients_from(client_specs) + return clients.get("network") + + +def id_func(val): + return val["network"]["client_name"] @pytest.mark.parametrize( - "client_specs", - OPEN5GS_TEST_CASES, - ids=["open5gs"], + "network_client", + test_cases, + ids=id_func, + indirect=True, ) -def test_valid_input_open5gs(client_specs): - network_client: NetworkManager = sdkclient.create_clients_from(client_specs)[ - "network" - ] - +def test_valid_input_open5gs(network_client: NetworkManagementInterface): camara_session = { "duration": 3600, "device": { @@ -36,24 +39,18 @@ def test_valid_input_open5gs(client_specs): "qosProfile": "qos-e", "sink": "https://endpoint.example.com/sink", } - subscription = network_client._build_qod_subscription(camara_session) - print(subscription.model_dump_json(exclude_none=True, by_alias=True)) + network_client._build_qod_subscription(camara_session) -@pytest.mark.parametrize( - "client_specs", - OPEN5GS_TEST_CASES, - ids=["open5gs"], -) -def test_create_qod_session_open5gs(client_specs): - network_client: NetworkManager = sdkclient.create_clients_from(client_specs)[ - "network" - ] - +@pytest.fixture(scope="module") +def qod_session_id(network_client: NetworkManagementInterface): camara_session = { "duration": 3600, "device": { - "ipv4Address": {"publicAddress": "10.45.0.3", "privateAddress": "10.45.0.3"} + "ipv4Address": { + "publicAddress": "10.45.0.3", + "privateAddress": "10.45.0.3", + } }, "applicationServer": {"ipv4Address": "10.45.0.1"}, "devicePorts": {"ranges": [{"from": 0, "to": 65535}]}, @@ -61,4 +58,42 @@ def test_create_qod_session_open5gs(client_specs): "qosProfile": "qos-e", "sink": "https://endpoint.example.com/sink", } - network_client.create_qod_session(camara_session) + try: + response = network_client.create_qod_session(camara_session) + assert response is not None, "Response should not be None" + assert isinstance(response, dict), "Response should be a dictionary" + assert "sessionId" in response, "Response should contain 'sessionId'" + yield str(response["sessionId"]) + finally: + pass + + +@pytest.mark.parametrize( + "network_client", + test_cases, + ids=id_func, + indirect=True, +) +def test_create_qod_session(qod_session_id): + assert qod_session_id is not None + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_5_seconds(network_client): + time.sleep(5) + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_get_qod_session(network_client: NetworkManagementInterface, qod_session_id): + try: + network_client.get_qod_session(qod_session_id) + except CoreHttpError as e: + pytest.fail(f"Failed to retrieve qod session: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_delete_qod_session(network_client: NetworkManagementInterface, qod_session_id): + try: + network_client.delete_qod_session(qod_session_id) + except CoreHttpError as e: + pytest.fail(f"Failed to retrieve qod session: {e}") -- GitLab From f355e714bfb1b63e183ce25b08bbcbfa493b30bd Mon Sep 17 00:00:00 2001 From: Giulio Carota Date: Mon, 16 Jun 2025 12:02:04 +0200 Subject: [PATCH 137/281] add missing parameters --- src/sunrise6g_opensdk/network/core/network_interface.py | 2 +- src/sunrise6g_opensdk/network/core/schemas.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 1406a1c..b71ab5d 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -291,7 +291,7 @@ class NetworkManagementInterface(ABC): self.base_url, self.scs_as_id, resource_id, subscription ) - traffic_influence_info.trafficInfluenceID = resource_id + traffic_influence_info["trafficInfluenceID"] = resource_id return traffic_influence_info def delete_traffic_influence_resource(self, resource_id: str) -> None: diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 6a6a1b0..183e3a9 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -157,6 +157,8 @@ class AsSessionWithQoSSubscription(BaseModel): ueIpv4Addr: ipaddress.IPv4Address | None = None ueIpv6Addr: ipaddress.IPv6Address | None = None macAddr: MacAddress | None = None + snssai: Snssai | None = None + dnn: str | None = None usageThreshold: UsageThreshold | None = None sponsorInfo: SponsorInformation | None = None qosMonInfo: QosMonitoringInformationModel | None = None -- GitLab From 0c1233bd3518cd2bb033ced3407780a88c569c98 Mon Sep 17 00:00:00 2001 From: giuliocarot0 Date: Mon, 16 Jun 2025 14:57:20 +0200 Subject: [PATCH 138/281] add oai to test cases --- tests/network/test_cases.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/network/test_cases.py b/tests/network/test_cases.py index 796c7aa..5c71706 100644 --- a/tests/network/test_cases.py +++ b/tests/network/test_cases.py @@ -1,10 +1,17 @@ # -*- coding: utf-8 -*- test_cases = [ + { + "network": { + "client_name": "oai", + "base_url": "http://localhost/", + "scs_as_id": "scs", + } + }, { "network": { "client_name": "open5gs", "base_url": "http://192.168.124.233:8082/", "scs_as_id": "scs", } - } + }, ] -- GitLab From d5c352a16addd556d8cb9e797e8c7b004c0926fe Mon Sep 17 00:00:00 2001 From: giuliocarot0 Date: Mon, 16 Jun 2025 14:57:49 +0200 Subject: [PATCH 139/281] align traffic influence test cases with qod ones --- .../network/test_create_traffic_influence.py | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index c39e4df..6d07e1d 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -1,14 +1,33 @@ # # -*- coding: utf-8 -*- +import time + import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.common import CoreHttpError +from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from tests.network.test_cases import test_cases + + +@pytest.fixture(scope="module", name="network_client") +def instantiate_network_client(request): + """Fixture to create and share a network client across tests""" + client_specs = request.param + clients = sdkclient.create_clients_from(client_specs) + return clients.get("network") -test_cases = [("oai", "http://127.0.0.1/", "scs-oai")] +def id_func(val): + return val["network"]["client_name"] -@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) -def test_valid_input(client_name, base_url, scs_as_id): - network_client = sdkclient.create_network_client(client_name, base_url, scs_as_id) + +@pytest.mark.parametrize( + "network_client", + test_cases, + ids=id_func, + indirect=True, +) +def test_valid_input(network_client: NetworkManagementInterface): ti_session = { "device": { @@ -22,9 +41,8 @@ def test_valid_input(client_name, base_url, scs_as_id): network_client._build_ti_subscription(ti_session) -@pytest.mark.parametrize("client_name, base_url, scs_as_id", test_cases) -def test_create_traffic_influence(client_name, base_url, scs_as_id): - network_client = sdkclient.create_network_client(client_name, base_url, scs_as_id) +@pytest.fixture(scope="module") +def traffic_influence_id(network_client: NetworkManagementInterface): ti_session = { "device": { @@ -35,4 +53,60 @@ def test_create_traffic_influence(client_name, base_url, scs_as_id): "appInstanceId": "172.21.18.3", "notificationUri": "https://endpoint.example.com/sink", } - network_client.create_traffic_influence_resource(ti_session) + try: + response = network_client.create_traffic_influence_resource(ti_session) + assert response is not None, "Response should not be None" + assert isinstance(response, dict), "Response should be a dictionary" + assert ( + "trafficInfluenceID" in response + ), "Response should contain 'trafficInfluenceID'" + yield str(response["trafficInfluenceID"]) + finally: + pass + + +@pytest.mark.parametrize( + "network_client", + test_cases, + ids=id_func, + indirect=True, +) +def test_create_traffic_influence(traffic_influence_id): + assert traffic_influence_id is not None + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_5_seconds(network_client): + time.sleep(5) + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_put_traffic_influence_session( + network_client: NetworkManagementInterface, traffic_influence_id +): + try: + ti_session = { + "device": { + "ipv4Address": { + "publicAddress": "12.1.2.31", + "privateAddress": "12.1.2.31", + } + }, + "edgeCloudZoneId": "edge", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.5", + "notificationUri": "https://endpoint.example.com/sink", + } + network_client.put_traffic_influence_resource(traffic_influence_id, ti_session) + except CoreHttpError as e: + pytest.fail(f"Failed to update traffic influence session: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_delete_traffic_influence_session( + network_client: NetworkManagementInterface, traffic_influence_id +): + try: + network_client.delete_traffic_influence_resource(traffic_influence_id) + except CoreHttpError as e: + pytest.fail(f"Failed to delete traffic influence: {e}") -- GitLab From b881721dd0d7c65969f9df24f0487130b29d8e0a Mon Sep 17 00:00:00 2001 From: Manar Zaboub Date: Tue, 17 Jun 2025 17:13:07 +0200 Subject: [PATCH 140/281] Tests: Add Open5GCore test constants --- tests/common/test_invoke_network_clients.py | 16 +++++++--------- tests/network/test_cases.py | 7 +++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py index 9c82a56..459a7be 100644 --- a/tests/common/test_invoke_network_clients.py +++ b/tests/common/test_invoke_network_clients.py @@ -18,15 +18,13 @@ NETWORK_TEST_CASES = [ "scs_as_id": "scs2", } }, - # TODO: Once the functionality from QoD, Location-retrieval and - # traffic influnce is validated, tests can be carried out for Open5GCore - # { - # "network": { - # "client_name": "open5gcore", - # "base_url": "http://test-open5gcore.url", - # "scs_as_id": "scs3", - # } - # }, + { + "network": { + "client_name": "open5gcore", + "base_url": "http://test-open5gcore.url", + "scs_as_id": "scs3", + } + }, ] diff --git a/tests/network/test_cases.py b/tests/network/test_cases.py index 5c71706..ae1f9f4 100644 --- a/tests/network/test_cases.py +++ b/tests/network/test_cases.py @@ -14,4 +14,11 @@ test_cases = [ "scs_as_id": "scs", } }, + { + "network": { + "client_name": "open5gcore", + "base_url": "http://192.168.11.80:8080", + "scs_as_id": "0001", + } + } ] -- GitLab From d3029f68bf1498244de39cf230ea32b9a2597dee Mon Sep 17 00:00:00 2001 From: Manar Zaboub Date: Tue, 17 Jun 2025 17:26:45 +0200 Subject: [PATCH 141/281] Network: Add implementation of Open5gCore client --- .../network/clients/open5gcore/client.py | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py index 922bee5..516a90b 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py @@ -1,14 +1,45 @@ # -*- coding: utf-8 -*- +from pydantic import ValidationError from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from sunrise6g_opensdk.network.core.network_interface import ( + NetworkManagementInterface, + build_flows +) +from ...core import schemas -log = logger.get_logger(__name__) - - -# TODO: Define any specific parameters or methods needed for Open5GCore -# In case any functionality is not implemented, raise NotImplementedError +log = logger.get_logger('Open5GCore') # Usage of brand name +qos_support_map = { + "qos-e": 1, # ToDo + "qos-s": 5, + "qos-m": 9, + "qos-l": 9, #ToDo not yet available in Nokia RAN +} class NetworkManager(NetworkManagementInterface): def __init__(self, base_url: str, scs_as_id: str): - pass + if not base_url: + raise ValueError("base_url is required and cannot be empty.") + if not scs_as_id: + raise ValueError("scs_as_id is required and cannot be empty.") + + self.base_url = base_url + self.scs_as_id = scs_as_id + def core_specific_qod_validation(self, session_info: schemas.CreateSession): + qos_key = session_info.qosProfile.root.strip().lower() + + if qos_key not in qos_support_map: + supported = ', '.join(qos_support_map.keys()) + raise ValidationError( + f"Unsupported QoS profile '{session_info.qosProfile.root}'. " + f"Supported profiles for Open5GCore are: {supported}" + ) + + def add_core_specific_qod_parameters( + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, + ) -> None: + flow_id = qos_support_map[session_info.qosProfile.root] + subscription.flowInfo = build_flows(flow_id, session_info) + subscription.ueIpv4Addr = '192.168.6.1' #ToDo \ No newline at end of file -- GitLab From a1933f53872dfa8aa84d6b00df7b0a20b8e4e4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 17 Jun 2025 17:46:18 +0200 Subject: [PATCH 142/281] Satisfy linters --- .../network/clients/open5gcore/client.py | 22 +++++++++++-------- tests/network/test_cases.py | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py index 516a90b..e52cea2 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gcore/client.py @@ -1,21 +1,24 @@ # -*- coding: utf-8 -*- from pydantic import ValidationError + from sunrise6g_opensdk import logger from sunrise6g_opensdk.network.core.network_interface import ( NetworkManagementInterface, - build_flows + build_flows, ) + from ...core import schemas -log = logger.get_logger('Open5GCore') # Usage of brand name +log = logger.get_logger("Open5GCore") # Usage of brand name qos_support_map = { - "qos-e": 1, # ToDo + "qos-e": 1, # ToDo "qos-s": 5, "qos-m": 9, - "qos-l": 9, #ToDo not yet available in Nokia RAN + "qos-l": 9, # ToDo not yet available in Nokia RAN } + class NetworkManager(NetworkManagementInterface): def __init__(self, base_url: str, scs_as_id: str): if not base_url: @@ -25,21 +28,22 @@ class NetworkManager(NetworkManagementInterface): self.base_url = base_url self.scs_as_id = scs_as_id + def core_specific_qod_validation(self, session_info: schemas.CreateSession): qos_key = session_info.qosProfile.root.strip().lower() if qos_key not in qos_support_map: - supported = ', '.join(qos_support_map.keys()) + supported = ", ".join(qos_support_map.keys()) raise ValidationError( f"Unsupported QoS profile '{session_info.qosProfile.root}'. " f"Supported profiles for Open5GCore are: {supported}" ) def add_core_specific_qod_parameters( - self, - session_info: schemas.CreateSession, - subscription: schemas.AsSessionWithQoSSubscription, + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, ) -> None: flow_id = qos_support_map[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) - subscription.ueIpv4Addr = '192.168.6.1' #ToDo \ No newline at end of file + subscription.ueIpv4Addr = "192.168.6.1" # ToDo diff --git a/tests/network/test_cases.py b/tests/network/test_cases.py index ae1f9f4..e971e21 100644 --- a/tests/network/test_cases.py +++ b/tests/network/test_cases.py @@ -20,5 +20,5 @@ test_cases = [ "base_url": "http://192.168.11.80:8080", "scs_as_id": "0001", } - } + }, ] -- GitLab From 9104664f4c52cd1b72144b9e1aad0760b39bbd71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 17 Jun 2025 17:57:53 +0200 Subject: [PATCH 143/281] Fix CAMARA location retrieval version and add hyperlinks --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e22deeb..e66639a 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge ### CAMARA APIs -| API Name | Version | -|----------------------|--------------| -| Edge Application Management | v0.9.3-wip | -| Quality-on-Demand | v1.0.0 | -| Location Retrieval | v1.0.0 | -| Traffic Influence | v0.8.1 | +| API Name | Version | +|---------------------------|---------| +| Edge Application Management | [v0.9.3-wip](https://raw.githubusercontent.com/camaraproject/EdgeCloud/main/code/API_definitions/Edge-Application-Management.yaml) | +| Quality-on-Demand | [v1.0.0](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/refs/tags/r2.2/code/API_definitions/quality-on-demand.yaml) | +| Location Retrieval | [v0.4.0](https://raw.githubusercontent.com/camaraproject/DeviceLocation/refs/tags/r2.2/code/API_definitions/location-retrieval.yaml) | +| Traffic Influence | [v0.8.1](https://raw.githubusercontent.com/camaraproject/EdgeCloud/v0.8.1/code/API_definitions/Traffic_Influence.yaml) | ### EdgeCloud Platforms -- GitLab From acfdeb02cd8eb32bb4b922a85d1215dfec87fa7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 17 Jun 2025 17:59:27 +0200 Subject: [PATCH 144/281] Update readme; Kubernetes supported --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e66639a..5b4a2df 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge | Platform | Status | |------------|------------| -| Kubernetes | To be supported soon | +| Kubernetes (old PiEdge) | Supported | | i2Edge | Supported | | aerOS | Supported | @@ -144,10 +144,10 @@ NEF ->> 5GS: QoS session creation ``` --- -## Roadmap +## Roadmap for Open SDK 2nd release -- [ ] Add support to GSMA OPG.02 TFs (WIP) -- [ ] Include JUNIPER O-RAN adapter (WIP) +- [ ] Add support to GSMA OPG.02 TFs +- [ ] Include JUNIPER O-RAN adapter --- -- GitLab From aa37c76d35e5e7a6f97f0dea42a088d159cd1f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 17 Jun 2025 17:59:41 +0200 Subject: [PATCH 145/281] Add img shields in the readme --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 5b4a2df..b27e6a9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + # OpenSDK Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and O-RAN solutions. -- GitLab From d8ed47385d7215dd7baa6910f27db7a7cc64cea2 Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Wed, 18 Jun 2025 14:50:35 +0300 Subject: [PATCH 146/281] Implement NEF endpoints, data model and abstract methods for location retrieval TF --- .../network/clients/open5gs/client.py | 8 ++ src/sunrise6g_opensdk/network/core/common.py | 16 +++ .../network/core/network_interface.py | 41 ++++++ src/sunrise6g_opensdk/network/core/schemas.py | 123 +++++++++++++++++- 4 files changed, 187 insertions(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py index c2e0f76..f629c6f 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -56,6 +56,14 @@ class NetworkManager(NetworkManagementInterface): flow_id = flow_id_mapping[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) + def core_specific_monitoring_event_validation(self, retrieve_location_request : schemas.RetrievalLocationRequest) -> None: + if retrieve_location_request.device is None: + raise ValidationError( + "Open5GS requires a device to be specified for location retrieval." + ) + def add_core_specific_location_parameters(self, retrieve_location_request: schemas.RetrievalLocationRequest, subscription: schemas.MonitoringEventSubscriptionRequest) -> None: + subscription.msisdn = retrieve_location_request.device.phoneNumber + # Note: # As this class is inheriting from NetworkManagementInterface, it is diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index b9002db..feda74f 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -29,6 +29,22 @@ def _make_request(method: str, url: str, data=None): raise CoreHttpError(e) from e except requests.exceptions.ConnectionError as e: raise CoreHttpError("connection error") from e + + + +# Monitoring Event Methods +def monitoring_event_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: + data = model_payload.model_dump_json(exclude_none=True, by_alias=True) + url = monitoring_event_build_url(base_url, scs_as_id) + return _make_request("POST", url, data=data) + + +def monitoring_event_build_url(base_url: str, scs_as_id: str, session_id: str = None): + url = f"{base_url}/3gpp-monitoring-event/v1/{scs_as_id}/subscriptions" + if session_id is not None and len(session_id) > 0: + return f"{url}/{session_id}" + else: + return url # QoD methods diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 1406a1c..3bfb50a 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -111,6 +111,17 @@ class NetworkManagementInterface(ABC): """ pass + def add_core_specific_location_parameters( + self, + retrieve_location_request: schemas.RetrievalLocationRequest, + subscription: schemas.MonitoringEventSubscriptionRequest, + ): + """ + Placeholder for adding core-specific parameters to the location subscription. + This method should be overridden by subclasses to implement specific logic. + """ + pass + def core_specific_qod_validation(self, session_info: schemas.CreateSession) -> None: """ Validates core-specific parameters for the session creation. @@ -141,6 +152,20 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass + def core_specific_monitoring_event_validation(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> None: + """ + Validates core-specific parameters for the monitoring event subscription. + + args: + retrieve_location_request: The request information to validate. + + raises: + ValidationError: If the request information does not meet core-specific requirements. + """ + # Placeholder for core-specific validation logic + # This method should be overridden by subclasses if needed + pass + def _build_qod_subscription( self, session_info: Dict ) -> schemas.AsSessionWithQoSSubscription: @@ -186,6 +211,22 @@ class NetworkManagementInterface(ABC): self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription + + def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) ->schemas.MonitoringEventSubscriptionRequest: + pass + + def create_monitoring_event_subscription(self, retrieve_location_request: Dict) -> Dict: + """ + Creates a Monitoring Event subscription based on CAMARA Location API input. + + args: + retrieve_location_request: Dictionary containing location retrieval details conforming to + the CAMARA Location API parameters. + + returns: + dictionary containing the created subscription details, including its ID. + """ + pass def create_qod_session(self, session_info: Dict) -> Dict: """ diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 6a6a1b0..6f22e65 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -10,7 +10,7 @@ from ipaddress import IPv4Address, IPv6Address from typing import Annotated from uuid import UUID -from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel, AnyHttpUrl from pydantic_extra_types.mac_address import MacAddress @@ -212,6 +212,79 @@ class TrafficInfluSub(BaseModel): # Replace with a meaningful name self.snssai = Snssai(sst=sst, sd=sd) +##Monitoring Event API + +class DurationMin(BaseModel): + duration: int = Field(0,description="Unsigned integer identifying a period of time in units of minutes",ge=0) + +class PlmnId(BaseModel): + mcc: str = Field(...,description="String encoding a Mobile Country Code, comprising of 3 digits.") + mnc: str = Field(...,description="String encoding a Mobile Network Code, comprising of 2 or 3 digits.") + +#The enumeration Accuracy represents a desired granularity of accuracy of the requested location information. +class Accuracy(str,Enum): + cgi_ecgi = "CGI_ECGI" # The AF requests to be notified using cell level location accuracy. + ta_ra = "TA_RA" # The AF requests to be notified using TA/RA level location accuracy. + geo_area = "GEO_AREA" # The AF requests to be notified using the geographical area accuracy. + civic_addr = "CIVIC_ADDR" # The AF requests to be notified using the civic address accuracy. #EDGEAPP + +#If locationType set to "LAST_KNOWN_LOCATION", the monitoring event request from AF shall be only for one-time monitoring request +class LocationType(str,Enum): + CURRENT_LOCATION = "CURRENT_LOCATION" # The AF requests to be notified for current location. + LAST_KNOWN = "LAST_KNOWN_LOCATION" # The AF requests to be notified for last known location. + +#This data type represents a monitoring event type. +class MonitoringType(str, Enum): + LOCATION_REPORTING = "LOCATION_REPORTING" + +class LocationFailureCause(str,Enum): + position_denied = "POSITIONING_DENIED" # Positioning is denied. + unsupported_by_ue = "UNSUPPORTED_BY_UE" # Positioning is not supported by UE. + not_registered_ue = "NOT_REGISTERED_UE" # UE is not registered. + unspecified = "UNSPECIFIED" # Unspecified cause. + +#This data type represents the user location information which is sent from the NEF to the AF. +class LocationInfo(BaseModel): + ageOfLocationInfo: DurationMin | None = Field(None,description="Indicates the elapsed time since the last network contact of the UE.") + cellId: str | None = Field(None, description="Cell ID where the UE is located.") + trackingAreaId: str | None = Field(None, description="TrackingArea ID where the UE is located.") + enodeBId: str | None = Field(None, description="eNodeB ID where the UE is located.") + routingAreaId: str | None = Field(None, description="Routing Area ID where the UE is located") + plmnId: PlmnId | None = Field(None, description="PLMN ID where the UE is located.") + twanId: str | None = Field(None, description="TWAN ID where the UE is located.") + #geographicArea: GeographicArea | None = Field(None,description="Identifies a geographic area of the user where the UE is located.") + +class MonitoringEventSubscriptionRequest(BaseModel): + accuracy: Accuracy | None = Field(None,description="Accuracy represents a desired granularity of accuracy of the requested location information.") + externalId: str | None = Field(None, description="Identifies a user clause 4.6.2 TS 23.682 (optional)") + msisdn: str | None = Field(None,description="Identifies the MS internal PSTN/ISDN number allocated for a UE.") + ipv4Addr: IPv4Address | None = Field(None,description="Identifies the Ipv4 address.") + ipv6Addr: IPv6Address | None = Field(None,description="Identifies the Ipv6 address.") + notificationDestination: AnyHttpUrl = Field(..., description="URI of a notification destination that the T8 message shall be delivered to.") + monitoringType: MonitoringType = Field(..., description="Enumeration of monitoring type. Refer to clause 5.3.2.4.3.") + maximumNumberOfReports: int | None = Field(None, description="Identifies the maximum number of event reports to be generated by the AMF to the NEF and then the AF.") + monitorExpireTime: datetime | None = Field(None, description="Identifies the absolute time at which the related monitoring event request is considered to expire.") + locationType: LocationType | None = Field(None, description="Indicates whether the request is for Current Location, Initial Location, or Last Known Location.") + repPeriod: DurationSec | None = Field(None,description="Identifies the periodic time for the event reports.") + minimumReportInterval: DurationSec | None = Field(None,description="identifies a minimum time interval between Location Reporting notifications") + + +# This data type represents a monitoring event notification which is sent from the NEF to the AF. +class MonitoringEventReport(BaseModel): + externalId: str | None = Field(None,description="Identifies a user, clause 4.6.2 TS 23.682") + msisdn: str | None = Field(None,description="Identifies the MS internal PSTN/ISDN number allocated for a UE.") + locationInfo: LocationInfo | None = Field(None, description="Indicates the user location related information.") + locFailureCause: LocationFailureCause | None = Field(None, description="Indicates the location positioning failure cause.") + monitoringType: MonitoringType = Field(..., description="Identifies the type of monitoring as defined in clause 5.3.2.4.3.") + eventTime: datetime | None = Field(None, description="Identifies when the event is detected or received. Shall be included for each group of UEs.") + +# This data type represents a monitoring notification which is sent from the NEF to the AF. +class MonitoringNotification(BaseModel): + subscription: AnyHttpUrl = Field(..., description="Link to the subscription resource to which this notification is related.") + monitoringEventReports: list[MonitoringEventReport] | None = Field(None, description="Each element identifies a monitoring event report (optional).") + cancelInd: bool | None = Field(False,description="Indicates whether to request to cancel the corresponding monitoring subscription. Set to false or omitted otherwise.") + + ############################################################### ############################################################### # CAMARA Models @@ -291,6 +364,54 @@ class Device(BaseModel): ipv6Address: DeviceIpv6Address | None = None +class RetrievalLocationRequest(BaseModel): + """ + Request to retrieve the location of a device. Device is not required when using a 3-legged access token. + """ + device: Annotated[Device | None, Field(None,description="End-user device able to connect to a mobile network.")] + maxAge: Annotated[int | None, Field(None, description="Maximum age of the location information which is accepted for the location retrieval (in seconds).")] + maxSurface: Annotated[int | None, Field(None,description="Maximum surface in square meters which is accepted by the client for the location retrieval.",ge=1,examples=[1000000])] + + +class AreaType(str,Enum): + circle = "CIRCLE" # The area is defined as a circle. + polygon = "POLYGON" # The area is defined as a polygon. + + +class Area(RootModel[Annotated[ + AreaType, + Field(description=""" + Type of this area. + CIRCLE - The area is defined as a circle. + POLYGON - The area is defined as a polygon. + """)]]): + pass + +class Point(BaseModel): + latitude: Annotated[float,Field(description="Latitude component of a location.",examples=["50.735851"],ge=-90,le=90)] + longitude: Annotated[float,Field(..., description="Longitude component of location.",examples=["7.10066"],ge=-180,le=180)] + +class PointList(RootModel[Annotated[ + list[Point], + Field(min_length=3,max_length=15, description="List of points defining the area.")]]): + pass + +class Circle(Area): + center: Annotated[Point, Field(description="Center point of the circle.")] + radius: Annotated[float,Field(description="Radius of the circle.",ge=1)] + +class Polygon(Area): + boundary: Annotated[PointList, Field(description="List of points defining the polygon.")] + +class LastLocationTime(RootModel[Annotated[ + datetime, + Field( description="Last date and time when the device was localized.",examples="2023-09-07T10:40:52Z")]]): + pass + +class Location(BaseModel): + lastLocationTime: Annotated[LastLocationTime, Field(description="Last known location time.")] + area: Annotated[Area,Field(description="Geographical area of the location.")] + class ApplicationServerIpv4Address(RootModel[str]): root: Annotated[ str, -- GitLab From 6a4e3aebe5a123347450c415c8f2ccd1aa9e59d1 Mon Sep 17 00:00:00 2001 From: giuliocarot0 Date: Wed, 18 Jun 2025 16:48:20 +0200 Subject: [PATCH 147/281] implement missing TI endpoints --- src/sunrise6g_opensdk/network/core/common.py | 12 ++ .../network/core/network_interface.py | 32 ++++ .../network/test_create_traffic_influence.py | 146 +++++++++++++----- 3 files changed, 154 insertions(+), 36 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index b9002db..26142c2 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -82,6 +82,18 @@ def traffic_influence_put( return _make_request("PUT", url, data=data) +def traffic_influence_get(base_url: str, scs_as_id: str, sessionId: str = None) -> dict: + url = traffic_influence_build_url(base_url, scs_as_id, sessionId) + return _make_request("GET", url) + + +def traffic_influence_get_all( + base_url: str, scs_as_id: str, sessionId: str = None +) -> list[dict]: + url = traffic_influence_build_url(base_url, scs_as_id) + return _make_request("GET", url) + + def traffic_influence_build_url(base_url: str, scs_as_id: str, session_id: str = None): url = f"{base_url}/3gpp-traffic-influence/v1/{scs_as_id}/subscriptions" if session_id is not None and len(session_id) > 0: diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index b71ab5d..3845cca 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -187,6 +187,27 @@ class NetworkManagementInterface(ABC): self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription + def _build_camara_ti(self, trafficInflSub: Dict): + traffic_influence_data = schemas.TrafficInfluSub.model_validate(trafficInflSub) + + flowDesc = traffic_influence_data.trafficFilters[0].flowDescriptions[0] + serverIp = flowDesc.split("to ")[1].split("/32")[0] + edgeId = traffic_influence_data.trafficRoutes[0].dnai + + camara_ti = schemas.CreateTrafficInfluence( + appId=traffic_influence_data.afAppId, + appInstanceId=serverIp, + edgeCloudZoneId=edgeId, + notificationUri=traffic_influence_data.notificationDestination, + device=schemas.Device( + ipv4Address=schemas.DeviceIpv4Addr1( + publicAddress=traffic_influence_data.ipv4Addr, + privateAddress=traffic_influence_data.ipv4Addr, + ) + ), + ) + return camara_ti + def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -307,5 +328,16 @@ class NetworkManagementInterface(ABC): common.traffic_influence_delete(self.base_url, self.scs_as_id, resource_id) return + def get_individual_traffic_influence_resource(self, resource_id: str) -> Dict: + nef_response = common.traffic_influence_get( + self.base_url, self.scs_as_id, resource_id + ) + camara_ti = self._build_camara_ti(nef_response) + return camara_ti + + def get_all_traffic_influence_resource(self) -> list[Dict]: + r = common.traffic_influence_get(self.base_url, self.scs_as_id) + return [self._build_camara_ti(item) for item in r] + # Placeholder for other CAMARA APIs (e.g., Traffic Influence, # Location-retrieval, etc.) diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index 6d07e1d..8008395 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -8,6 +8,36 @@ from sunrise6g_opensdk.network.core.common import CoreHttpError from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface from tests.network.test_cases import test_cases +ti_session1 = { + "device": { + "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} + }, + "edgeCloudZoneId": "edge", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.3", + "notificationUri": "https://endpoint.example.com/sink", +} + +ti_session1_put = { + "device": { + "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} + }, + "edgeCloudZoneId": "edge2", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.3", + "notificationUri": "https://endpoint.example.com/sink", +} + +ti_session2 = { + "device": { + "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} + }, + "edgeCloudZoneId": "edge", + "appId": "testSdk-ffff-aaaa-c0ffe", + "appInstanceId": "172.21.18.65", + "notificationUri": "https://endpoint.example.com/sink", +} + @pytest.fixture(scope="module", name="network_client") def instantiate_network_client(request): @@ -29,32 +59,30 @@ def id_func(val): ) def test_valid_input(network_client: NetworkManagementInterface): - ti_session = { - "device": { - "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} - }, - "edgeCloudZoneId": "edge", - "appId": "testSdk-ffff-aaaa-c0ffe", - "appInstanceId": "172.21.18.3", - "notificationUri": "https://endpoint.example.com/sink", - } - network_client._build_ti_subscription(ti_session) + network_client._build_ti_subscription(ti_session1) + network_client._build_ti_subscription(ti_session1_put) + + network_client._build_ti_subscription(ti_session2) @pytest.fixture(scope="module") def traffic_influence_id(network_client: NetworkManagementInterface): + try: + response = network_client.create_traffic_influence_resource(ti_session1) + assert response is not None, "Response should not be None" + assert isinstance(response, dict), "Response should be a dictionary" + assert ( + "trafficInfluenceID" in response + ), "Response should contain 'trafficInfluenceID'" + yield str(response["trafficInfluenceID"]) + finally: + pass + - ti_session = { - "device": { - "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} - }, - "edgeCloudZoneId": "edge", - "appId": "testSdk-ffff-aaaa-c0ffe", - "appInstanceId": "172.21.18.3", - "notificationUri": "https://endpoint.example.com/sink", - } +@pytest.fixture(scope="module") +def traffic_influence_id2(network_client: NetworkManagementInterface): try: - response = network_client.create_traffic_influence_resource(ti_session) + response = network_client.create_traffic_influence_resource(ti_session2) assert response is not None, "Response should not be None" assert isinstance(response, dict), "Response should be a dictionary" assert ( @@ -71,42 +99,88 @@ def traffic_influence_id(network_client: NetworkManagementInterface): ids=id_func, indirect=True, ) -def test_create_traffic_influence(traffic_influence_id): +def test_create_traffic_influence_1(traffic_influence_id): assert traffic_influence_id is not None +@pytest.mark.parametrize( + "network_client", + test_cases, + ids=id_func, + indirect=True, +) +def test_create_traffic_influence_2(traffic_influence_id2): + assert traffic_influence_id2 is not None + + @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_timer_wait_5_seconds(network_client): time.sleep(5) @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_put_traffic_influence_session( +def test_get_traffic_influence_session_1( network_client: NetworkManagementInterface, traffic_influence_id ): try: - ti_session = { - "device": { - "ipv4Address": { - "publicAddress": "12.1.2.31", - "privateAddress": "12.1.2.31", - } - }, - "edgeCloudZoneId": "edge", - "appId": "testSdk-ffff-aaaa-c0ffe", - "appInstanceId": "172.21.18.5", - "notificationUri": "https://endpoint.example.com/sink", - } - network_client.put_traffic_influence_resource(traffic_influence_id, ti_session) + response = network_client.get_individual_traffic_influence_resource( + traffic_influence_id + ) + assert response is not None, "response should not be None" + except CoreHttpError as e: + pytest.fail(f"Failed to get traffic influence: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_put_traffic_influence_session_1( + network_client: NetworkManagementInterface, traffic_influence_id +): + try: + network_client.put_traffic_influence_resource( + traffic_influence_id, ti_session1_put + ) except CoreHttpError as e: pytest.fail(f"Failed to update traffic influence session: {e}") @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_delete_traffic_influence_session( +def test_get_traffic_influence_session_after_put_1( + network_client: NetworkManagementInterface, traffic_influence_id +): + try: + response = network_client.get_individual_traffic_influence_resource( + traffic_influence_id + ) + assert response is not None, "response should not be None" + except CoreHttpError as e: + pytest.fail(f"Failed to get traffic influence: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_get_all_traffic_influence_sessions(network_client: NetworkManagementInterface): + try: + response = network_client.get_all_traffic_influence_resource() + assert response is not None, "response should not be None" + assert len(response) == 2, "response must containt 2 elements" + except CoreHttpError as e: + pytest.fail(f"Failed to get traffic influence: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_delete_traffic_influence_session_1( network_client: NetworkManagementInterface, traffic_influence_id ): try: network_client.delete_traffic_influence_resource(traffic_influence_id) except CoreHttpError as e: pytest.fail(f"Failed to delete traffic influence: {e}") + + +@pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) +def test_delete_traffic_influence_session_2( + network_client: NetworkManagementInterface, traffic_influence_id2 +): + try: + network_client.delete_traffic_influence_resource(traffic_influence_id2) + except CoreHttpError as e: + pytest.fail(f"Failed to delete traffic influence: {e}") -- GitLab From 9e3687143e2820fffe0cf43f781405dfb6661c7e Mon Sep 17 00:00:00 2001 From: Panagiotis Pavlidis Date: Wed, 18 Jun 2025 21:46:42 +0300 Subject: [PATCH 148/281] Implement TF business logic --- .../network/clients/open5gs/client.py | 10 +++++++++- .../network/core/network_interface.py | 13 +++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py index f629c6f..f06ec55 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -59,10 +59,18 @@ class NetworkManager(NetworkManagementInterface): def core_specific_monitoring_event_validation(self, retrieve_location_request : schemas.RetrievalLocationRequest) -> None: if retrieve_location_request.device is None: raise ValidationError( - "Open5GS requires a device to be specified for location retrieval." + "Open5GS requires a device to be specified for location retrieval in NEF." ) def add_core_specific_location_parameters(self, retrieve_location_request: schemas.RetrievalLocationRequest, subscription: schemas.MonitoringEventSubscriptionRequest) -> None: subscription.msisdn = retrieve_location_request.device.phoneNumber + subscription.monitoringType = schemas.MonitoringType.LOCATION_REPORTING + subscription.locationType = schemas.LocationType.CURRENT_LOCATION + # subscription.locationType = schemas.LocationType.LAST_KNOWN + # subscription.maximumNumberOfReports = 1 + # subscription.repPeriod = schemas.DurationSec(root=20) + + return subscription + # Note: diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 3bfb50a..01267a2 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -115,7 +115,7 @@ class NetworkManagementInterface(ABC): self, retrieve_location_request: schemas.RetrievalLocationRequest, subscription: schemas.MonitoringEventSubscriptionRequest, - ): + )-> schemas.MonitoringEventSubscriptionRequest: """ Placeholder for adding core-specific parameters to the location subscription. This method should be overridden by subclasses to implement specific logic. @@ -213,7 +213,16 @@ class NetworkManagementInterface(ABC): return subscription def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) ->schemas.MonitoringEventSubscriptionRequest: - pass + self.core_specific_monitoring_event_validation(retrieve_location_request) + device = retrieve_location_request.device + subscription = schemas.MonitoringEventSubscriptionRequest( + externalId=device.networkAccessIdentifier, + ipv4Address=device.ipv4Address, + ipv6Addr=device.ipv6Address, + msisdn=device.phoneNumber, + notificationDestination= "http://test_server:8001") + mapped_3gpp_subscription = self.add_core_specific_location_parameters(retrieve_location_request,subscription) + return mapped_3gpp_subscription def create_monitoring_event_subscription(self, retrieve_location_request: Dict) -> Dict: """ -- GitLab From 8368993a8410811b2e6ba79533cc8d3a60a15e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 12:39:44 +0200 Subject: [PATCH 149/281] Update clients folder to adapters. Rename NetworkManagementInterface Rename NetworkManagementInterface to BaseNetworkClient --- examples/example.py | 10 +- .../{sdk_factory.py => adapters_factory.py} | 35 +-- src/sunrise6g_opensdk/common/sdk.py | 35 +-- .../{clients => adapters}/__init__.py | 0 .../{clients => adapters}/aeros/__init__.py | 2 +- .../{clients => adapters}/aeros/client.py | 4 +- .../{clients => adapters}/aeros/config.py | 0 .../aeros/continuum_client.py | 4 +- .../{clients => adapters}/aeros/utils.py | 2 +- .../edgecloud/{clients => adapters}/errors.py | 0 .../{clients => adapters}/i2edge/__init__.py | 0 .../{clients => adapters}/i2edge/client.py | 6 +- .../{clients => adapters}/i2edge/common.py | 2 +- .../{clients => adapters}/i2edge/schemas.py | 0 .../{clients => adapters}/i2edge/utils.py | 0 .../kubernetes}/__init__.py | 0 .../piedge => adapters/kubernetes}/client.py | 4 +- .../network/{clients => adapters}/__init__.py | 0 .../network/{clients => adapters}/errors.py | 0 .../{clients => adapters}/oai/__init__.py | 0 .../{clients => adapters}/oai/client.py | 8 +- .../open5gcore/__init__.py | 0 .../open5gcore/client.py | 6 +- .../{clients => adapters}/open5gs/__init__.py | 0 .../{clients => adapters}/open5gs/client.py | 10 +- ...rk_interface.py => base_network_client.py} | 23 +- src/sunrise6g_opensdk/network/core/common.py | 1 - tests/common/test_invoke_edgecloud_clients.py | 18 +- tests/common/test_invoke_network_clients.py | 12 +- tests/edgecloud/test_aeros_edge_manager.py | 270 ------------------ tests/edgecloud/test_cases.py | 4 +- tests/edgecloud/test_config.py | 4 +- tests/edgecloud/test_e2e.py | 18 +- tests/network/test_create_qod_session.py | 16 +- .../network/test_create_traffic_influence.py | 26 +- tests/network/test_location_retrieval.py | 1 + 36 files changed, 121 insertions(+), 400 deletions(-) rename src/sunrise6g_opensdk/common/{sdk_factory.py => adapters_factory.py} (66%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/__init__.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/aeros/__init__.py (93%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/aeros/client.py (98%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/aeros/config.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/aeros/continuum_client.py (97%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/aeros/utils.py (95%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/errors.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/i2edge/__init__.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/i2edge/client.py (99%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/i2edge/common.py (97%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/i2edge/schemas.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients => adapters}/i2edge/utils.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients/piedge => adapters/kubernetes}/__init__.py (100%) rename src/sunrise6g_opensdk/edgecloud/{clients/piedge => adapters/kubernetes}/client.py (98%) rename src/sunrise6g_opensdk/network/{clients => adapters}/__init__.py (100%) rename src/sunrise6g_opensdk/network/{clients => adapters}/errors.py (100%) rename src/sunrise6g_opensdk/network/{clients => adapters}/oai/__init__.py (100%) rename src/sunrise6g_opensdk/network/{clients => adapters}/oai/client.py (96%) rename src/sunrise6g_opensdk/network/{clients => adapters}/open5gcore/__init__.py (100%) rename src/sunrise6g_opensdk/network/{clients => adapters}/open5gcore/client.py (91%) rename src/sunrise6g_opensdk/network/{clients => adapters}/open5gs/__init__.py (100%) rename src/sunrise6g_opensdk/network/{clients => adapters}/open5gs/client.py (87%) rename src/sunrise6g_opensdk/network/core/{network_interface.py => base_network_client.py} (94%) delete mode 100644 tests/edgecloud/test_aeros_edge_manager.py create mode 100644 tests/network/test_location_retrieval.py diff --git a/examples/example.py b/examples/example.py index 1ae2b17..18cc32c 100644 --- a/examples/example.py +++ b/examples/example.py @@ -4,9 +4,9 @@ from sunrise6g_opensdk.common.sdk import Sdk as sdkclient # For developers def main(): # The module that imports the SDK package, must specify which adapters will be used: - client_specs = { + adapter_specs = { "edgecloud": { - "client_name": "i2edge", + "client_name": "kubernetes", "base_url": "http://IP:PORT", }, "network": { @@ -16,9 +16,9 @@ def main(): }, } - clients = sdkclient.create_clients_from(client_specs) - edgecloud_client = clients.get("edgecloud") - network_client = clients.get("network") + adapters = sdkclient.create_adapters_from(adapter_specs) + edgecloud_client = adapters.get("edgecloud") + network_client = adapters.get("network") print("EdgeCloud client ready to be used:", edgecloud_client) print("Network client ready to be used:", network_client) diff --git a/src/sunrise6g_opensdk/common/sdk_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py similarity index 66% rename from src/sunrise6g_opensdk/common/sdk_factory.py rename to src/sunrise6g_opensdk/common/adapters_factory.py index ffddb10..81d0e96 100644 --- a/src/sunrise6g_opensdk/common/sdk_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -8,28 +8,31 @@ # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) ## -from sunrise6g_opensdk.edgecloud.clients.aeros.client import ( + +from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( EdgeApplicationManager as AerosClient, ) -from sunrise6g_opensdk.edgecloud.clients.i2edge.client import ( +from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) -from sunrise6g_opensdk.network.clients.oai.client import NetworkManager as OaiCoreClient -from sunrise6g_opensdk.network.clients.open5gcore.client import ( +from sunrise6g_opensdk.network.adapters.oai.client import ( + NetworkManager as OaiCoreClient, +) +from sunrise6g_opensdk.network.adapters.open5gcore.client import ( NetworkManager as Open5GCoreClient, ) -from sunrise6g_opensdk.network.clients.open5gs.client import ( +from sunrise6g_opensdk.network.adapters.open5gs.client import ( NetworkManager as Open5GSClient, ) -# from sunrise6g_opensdk.edgecloud.clients.piedge.client import EdgeApplicationManager as PiEdgeClient +# from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import EdgeApplicationManager as kubernetesClient -def _edgecloud_factory(client_name: str, base_url: str, **kwargs): +def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): edge_cloud_factory = { "aeros": lambda url, **kw: AerosClient(base_url=url, **kw), "i2edge": lambda url: I2EdgeClient(base_url=url), - # "piedge": lambda url: PiEdgeClient(base_url=url), Uncomment when import issues are solved + # "kubernetes": lambda url: kubernetesClient(base_url=url), Uncomment when import issues are solved } try: return edge_cloud_factory[client_name](base_url, **kwargs) @@ -39,9 +42,9 @@ def _edgecloud_factory(client_name: str, base_url: str, **kwargs): ) -def _network_factory(client_name: str, base_url: str, **kwargs): +def _network_adapters_factory(client_name: str, base_url: str, **kwargs): if "scs_as_id" not in kwargs: - raise ValueError("Missing required 'scs_as_id' for network clients.") + raise ValueError("Missing required 'scs_as_id' for network adapters.") scs_as_id = kwargs.pop("scs_as_id") network_factory = { @@ -63,19 +66,19 @@ def _network_factory(client_name: str, base_url: str, **kwargs): ) -# def _oran_factory(client_name: str, base_url: str): +# def _oran_adapters_factory(client_name: str, base_url: str): # # TODO -class SdkFactory: +class AdaptersFactory: _domain_factories = { - "edgecloud": _edgecloud_factory, - "network": _network_factory, - # "oran": _oran_factory, + "edgecloud": _edgecloud_adapters_factory, + "network": _network_adapters_factory, + # "oran": _oran_adapters_factory, } @classmethod - def instantiate_and_retrieve_clients( + def instantiate_and_retrieve_adapters( cls, domain: str, client_name: str, base_url: str, **kwargs ): try: diff --git a/src/sunrise6g_opensdk/common/sdk.py b/src/sunrise6g_opensdk/common/sdk.py index 5fa3144..0709050 100644 --- a/src/sunrise6g_opensdk/common/sdk.py +++ b/src/sunrise6g_opensdk/common/sdk.py @@ -10,20 +10,20 @@ ## from typing import Dict -from sunrise6g_opensdk.common.sdk_factory import SdkFactory +from sunrise6g_opensdk.common.adapters_factory import AdaptersFactory class Sdk: @staticmethod - def create_clients_from( - client_specs: Dict[str, Dict[str, str]], + def create_adapters_from( + adapter_specs: Dict[str, Dict[str, str]], ) -> Dict[str, object]: """ - Create and return a dictionary of instantiated edgecloud/network/oran clients + Create and return a dictionary of instantiated edgecloud/network/oran adapters based on the provided specifications. Args: - client_specs (dict): A dictionary where each key is the client's domain (e.g., 'edgecloud', 'network'), + adapter_specs (dict): A dictionary where each key is the client's domain (e.g., 'edgecloud', 'network'), and each value is a dictionary containing: - 'client_name' (str): The specific name of the client (e.g., 'i2edge', 'open5gs'). - 'base_url' (str): The base URL for the client's API. @@ -33,10 +33,11 @@ class Sdk: dict: A dictionary where keys are the 'client_name' (str) and values are the instantiated client objects. - Example: + # TODO: Update it + # Example: >>> from src.common.universal_client_catalog import UniversalCatalogClient >>> - >>> client_specs_example = { + >>> adapter_specs_example = { >>> 'edgecloud': { >>> 'client_name': 'i2edge', >>> 'base_url': 'http://ip_edge_cloud:port', @@ -49,28 +50,22 @@ class Sdk: >>> } >>> } >>> - >>> clients = UniversalCatalogClient.create_clients(client_specs_example) - >>> edgecloud_client = clients.get("edgecloud") - >>> network_client = clients.get("network") - >>> - >>> edgecloud_client.get_edge_cloud_zones() - >>> network_client.get_qod_session(session_id="example_session_id") """ - sdk_client = SdkFactory() - clients = {} + sdk_client = AdaptersFactory() + adapters = {} - for domain, config in client_specs.items(): + for domain, config in adapter_specs.items(): client_name = config["client_name"] base_url = config["base_url"] - # Support of additional paramaters for specific clients + # Support of additional paramaters for specific adapters kwargs = { k: v for k, v in config.items() if k not in ("client_name", "base_url") } - client = sdk_client.instantiate_and_retrieve_clients( + client = sdk_client.instantiate_and_retrieve_adapters( domain, client_name, base_url, **kwargs ) - clients[domain] = client + adapters[domain] = client - return clients + return adapters diff --git a/src/sunrise6g_opensdk/edgecloud/clients/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/__init__.py rename to src/sunrise6g_opensdk/edgecloud/adapters/__init__.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/__init__.py similarity index 93% rename from src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/__init__.py index a3d0a73..3abc8cd 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/__init__.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/__init__.py @@ -9,7 +9,7 @@ aerOS client and an access token for authentication. """ -from sunrise6g_opensdk.edgecloud.clients.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.logger import setup_logger logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py similarity index 98% rename from src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index a37fec6..4bf5700 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -7,8 +7,8 @@ ## from typing import Any, Dict, List, Optional -from sunrise6g_opensdk.edgecloud.clients.aeros import config -from sunrise6g_opensdk.edgecloud.clients.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/config.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/aeros/config.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py similarity index 97% rename from src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index eb8668f..5a0a97f 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -12,8 +12,8 @@ aerOS REST API Client import requests -from sunrise6g_opensdk.edgecloud.clients.aeros import config -from sunrise6g_opensdk.edgecloud.clients.aeros.utils import catch_requests_exceptions +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import catch_requests_exceptions from sunrise6g_opensdk.logger import setup_logger diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py similarity index 95% rename from src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 3061c96..67cc543 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -10,7 +10,7 @@ Docstring """ from requests.exceptions import HTTPError, RequestException, Timeout -import sunrise6g_opensdk.edgecloud.clients.aeros.config as config +import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config from sunrise6g_opensdk.logger import setup_logger diff --git a/src/sunrise6g_opensdk/edgecloud/clients/errors.py b/src/sunrise6g_opensdk/edgecloud/adapters/errors.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/errors.py rename to src/sunrise6g_opensdk/edgecloud/adapters/errors.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/i2edge/__init__.py rename to src/sunrise6g_opensdk/edgecloud/adapters/i2edge/__init__.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py similarity index 99% rename from src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py rename to src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index d4d5ff4..ad09952 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -16,7 +16,7 @@ from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) -from . import schemas +from ...adapters.i2edge import schemas from .common import ( I2EdgeError, i2edge_delete, @@ -29,6 +29,10 @@ log = logger.get_logger(__name__) class EdgeApplicationManager(EdgeCloudManagementInterface): + """ + i2Edge Client + """ + def __init__(self, base_url: str): self.base_url = base_url diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py similarity index 97% rename from src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py rename to src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index c91ada5..f22eca0 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -16,7 +16,7 @@ import requests from pydantic import BaseModel from sunrise6g_opensdk import logger -from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError log = logger.get_logger(__name__) diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/i2edge/schemas.py rename to src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py rename to src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/piedge/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/edgecloud/clients/piedge/__init__.py rename to src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/__init__.py diff --git a/src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py similarity index 98% rename from src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py rename to src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 203acaf..35407a9 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/piedge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -5,14 +5,14 @@ import os from typing import Dict, List, Optional from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -from swagger_server.core.piedge_encoder import deploy_service_function +from swagger_server.core.kubernetes_encoder import deploy_service_function from swagger_server.models.deploy_service_function import DeployServiceFunction from swagger_server.models.service_function_registration_request import ( ServiceFunctionRegistrationRequest, ) from swagger_server.utils import connector_db, kubernetes_connector -piedge_ip = os.environ["EDGE_CLOUD_ADAPTER"] +kubernetes_ip = os.environ["EDGE_CLOUD_ADAPTER"] edge_cloud_provider = os.environ["PLATFORM_PROVIDER"] diff --git a/src/sunrise6g_opensdk/network/clients/__init__.py b/src/sunrise6g_opensdk/network/adapters/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/network/clients/__init__.py rename to src/sunrise6g_opensdk/network/adapters/__init__.py diff --git a/src/sunrise6g_opensdk/network/clients/errors.py b/src/sunrise6g_opensdk/network/adapters/errors.py similarity index 100% rename from src/sunrise6g_opensdk/network/clients/errors.py rename to src/sunrise6g_opensdk/network/adapters/errors.py diff --git a/src/sunrise6g_opensdk/network/clients/oai/__init__.py b/src/sunrise6g_opensdk/network/adapters/oai/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/network/clients/oai/__init__.py rename to src/sunrise6g_opensdk/network/adapters/oai/__init__.py diff --git a/src/sunrise6g_opensdk/network/clients/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py similarity index 96% rename from src/sunrise6g_opensdk/network/clients/oai/client.py rename to src/sunrise6g_opensdk/network/adapters/oai/client.py index 801c99f..43eac31 100644 --- a/src/sunrise6g_opensdk/network/clients/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- ## # Copyright (c) 2025 Netsoft Group, EURECOM. # All rights reserved. @@ -7,10 +9,8 @@ # Contributors: # - Giulio Carota (giulio.carota@eurecom.fr) ## - - from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from sunrise6g_opensdk.network.core.base_network_client import BaseNetworkClient from sunrise6g_opensdk.network.core.schemas import ( AsSessionWithQoSSubscription, CreateSession, @@ -24,7 +24,7 @@ log = logger.get_logger(__name__) supportedQos = ["qos-e", "qos-s", "qos-m", "qos-l"] -class NetworkManager(NetworkManagementInterface): +class NetworkManager(BaseNetworkClient): def __init__(self, base_url: str, scs_as_id: str = None): """ Initialize Network Client for OAI Core Network diff --git a/src/sunrise6g_opensdk/network/clients/open5gcore/__init__.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/network/clients/open5gcore/__init__.py rename to src/sunrise6g_opensdk/network/adapters/open5gcore/__init__.py diff --git a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py similarity index 91% rename from src/sunrise6g_opensdk/network/clients/open5gcore/client.py rename to src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index e52cea2..1b1b785 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -2,8 +2,8 @@ from pydantic import ValidationError from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.core.network_interface import ( - NetworkManagementInterface, +from sunrise6g_opensdk.network.core.base_network_client import ( + BaseNetworkClient, build_flows, ) @@ -19,7 +19,7 @@ qos_support_map = { } -class NetworkManager(NetworkManagementInterface): +class NetworkManager(BaseNetworkClient): def __init__(self, base_url: str, scs_as_id: str): if not base_url: raise ValueError("base_url is required and cannot be empty.") diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/__init__.py b/src/sunrise6g_opensdk/network/adapters/open5gs/__init__.py similarity index 100% rename from src/sunrise6g_opensdk/network/clients/open5gs/__init__.py rename to src/sunrise6g_opensdk/network/adapters/open5gs/__init__.py diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py similarity index 87% rename from src/sunrise6g_opensdk/network/clients/open5gs/client.py rename to src/sunrise6g_opensdk/network/adapters/open5gs/client.py index c2e0f76..94fddc0 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py @@ -2,8 +2,8 @@ from pydantic import ValidationError from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.core.network_interface import ( - NetworkManagementInterface, +from sunrise6g_opensdk.network.core.base_network_client import ( + BaseNetworkClient, build_flows, ) @@ -14,9 +14,9 @@ log = logger.get_logger(__name__) flow_id_mapping = {"qos-e": 3, "qos-s": 4, "qos-m": 5, "qos-l": 6} -class NetworkManager(NetworkManagementInterface): +class NetworkManager(BaseNetworkClient): """ - This client implements the NetworkManagementInterface and translates the + This client implements the BaseNetworkClient and translates the CAMARA APIs into specific HTTP requests understandable by the Open5GS NEF API. Invloved partners and their roles in this implementation: @@ -58,7 +58,7 @@ class NetworkManager(NetworkManagementInterface): # Note: -# As this class is inheriting from NetworkManagementInterface, it is +# As this class is inheriting from BaseNetworkClient, it is # expected to implement all the abstract methods defined in that interface. # # In case this network adapter doesn't support a specific method, it should diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/base_network_client.py similarity index 94% rename from src/sunrise6g_opensdk/network/core/network_interface.py rename to src/sunrise6g_opensdk/network/core/base_network_client.py index 3845cca..eff63d4 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -1,22 +1,19 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) # - Ferran Cañellas (ferran.canellas@i2cat.net) +# - Giulio Carota (giulio.carota@eurecom.fr) ## import uuid -from abc import ABC from itertools import product from typing import Dict from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.clients.errors import NetworkPlatformError +from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError from sunrise6g_opensdk.network.core import common, schemas log = logger.get_logger(__name__) @@ -74,16 +71,13 @@ def build_flows( return flows -class NetworkManagementInterface(ABC): +class BaseNetworkClient: """ - Abstract Base Class for Network Resource Management. - - This interface defines the standard methods that all - Network Clients (Open5GS, OAI, Open5GCore) must implement. + Class for Network Resource Management. - Partners implementing a new network client should inherit from this class - and provide concrete implementations for all abstract methods relevant - to their specific NEF capabilities. + This class provides shared logic and extension points for different + Network 5G Cores (e.g., Open5GS, OAI, Open5GCopre-commit run --all-filesre) interacting with + NEF-like platforms using CAMARA APIs. """ base_url: str @@ -339,5 +333,4 @@ class NetworkManagementInterface(ABC): r = common.traffic_influence_get(self.base_url, self.scs_as_id) return [self._build_camara_ti(item) for item in r] - # Placeholder for other CAMARA APIs (e.g., Traffic Influence, - # Location-retrieval, etc.) + # Placeholder for other CAMARA APIs (e.g: Location-retrieval, etc.) diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index 26142c2..ff8ae92 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -# Common utilities (errors, HTTP helpers) used by the core network interface (network_interface.py). import requests from pydantic import BaseModel diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index c745200..1839c27 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -20,11 +20,11 @@ EDGE_CLOUD_TEST_CASES = [ "aerOS_HLO_TOKEN": "fake-hlo", } }, - # Uncomment once piedge import issues are fixed + # Uncomment once kubernetes import issues are fixed # { # "edgecloud": { - # "client_name": "piedge", - # "base_url": "http://test-piedge.url" + # "client_name": "kubernetes", + # "base_url": "http://test-kubernetes.url" # } # } ] @@ -34,12 +34,12 @@ def id_func(val): return val["edgecloud"]["client_name"] -@pytest.mark.parametrize("client_specs", EDGE_CLOUD_TEST_CASES, ids=id_func) -def test_edgecloud_platform_instantiation(client_specs): - """Test instantiation of all edgecloud platform clients""" - clients = sdkclient.create_clients_from(client_specs) +@pytest.mark.parametrize("adapter_specs", EDGE_CLOUD_TEST_CASES, ids=id_func) +def test_edgecloud_platform_instantiation(adapter_specs): + """Test instantiation of all edgecloud platform adapters""" + adapters = sdkclient.create_adapters_from(adapter_specs) - assert "edgecloud" in clients - edge_client = clients["edgecloud"] + assert "edgecloud" in adapters + edge_client = adapters["edgecloud"] assert edge_client is not None assert "EdgeApplicationManager" in str(type(edge_client)) diff --git a/tests/common/test_invoke_network_clients.py b/tests/common/test_invoke_network_clients.py index 459a7be..9e5cd4e 100644 --- a/tests/common/test_invoke_network_clients.py +++ b/tests/common/test_invoke_network_clients.py @@ -32,12 +32,12 @@ def id_func(val): return val["network"]["client_name"] -@pytest.mark.parametrize("client_specs", NETWORK_TEST_CASES, ids=id_func) -def test_network_platform_instantiation(client_specs): - """Test instantiation of all network platform clients""" - clients = sdkclient.create_clients_from(client_specs) +@pytest.mark.parametrize("adapter_specs", NETWORK_TEST_CASES, ids=id_func) +def test_network_platform_instantiation(adapter_specs): + """Test instantiation of all network platform adapters""" + adapters = sdkclient.create_adapters_from(adapter_specs) - assert "network" in clients - network_client = clients["network"] + assert "network" in adapters + network_client = adapters["network"] assert network_client is not None assert "NetworkManager" in str(type(network_client)) diff --git a/tests/edgecloud/test_aeros_edge_manager.py b/tests/edgecloud/test_aeros_edge_manager.py deleted file mode 100644 index 1359fda..0000000 --- a/tests/edgecloud/test_aeros_edge_manager.py +++ /dev/null @@ -1,270 +0,0 @@ -## -# This file is part of the Open SDK -# Temporary file for testing aerOS EdgeApplicationManager class -# -# Contributors: -# - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) -# - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) -## -""" -aerOS continuum, SUNRISE-6G SDK unit testing. -Please do not run in the same pass all of: - test_onboard_app_success, test_undeploy_app_completes_successfully, test_deploy_app_returns_app_instance_id -Leave uncommented just one of them each time. -Also environment variables must be sset in advance, regarding access tokens - see also config.py file in aerOS tree -""" -import unittest -from typing import Any, Dict - -from sunrise6g_opensdk.edgecloud.clients.aeros.client import EdgeApplicationManager - -TOSCA_YAML_EXAMPLE: str = """ -tosca_definitions_version: tosca_simple_yaml_1_3 - -description: TOSCA for network performance - -node_templates: - influxdb: - type: tosca.nodes.Container.Application - requirements: - - network: - properties: - ports: - fastapi: - properties: - protocol: [tcp] - source: 8086 - exposePorts: true - - host: - node_filter: - properties: - id: "urn:ngsi-ld:InfrastructureElement:CloudFerro:fa163e5e25ef" - artifacts: - influxdb-image: - file: p4lik4ri/influxdb - type: tosca.artifacts.Deployment.Image.Container.Docker - repository: docker_hub - interfaces: - Standard: - create: - implementation: influxdb-image - inputs: - envVars: - - ENV1: void - - -""" - - -class TestAerOSEdgeApplicationManager(unittest.TestCase): - """ - Test aerOS EdgeApplicationManager class - Test aerOS ContinuumClient class - """ - - def setUp(self): - self.manager = EdgeApplicationManager( - base_url="https://ncsrd-mvp-domain.aeros-project.eu" - ) - - # def test_get_all_onboarded_apps_returns_list_of_dicts(self): - # ''' - # Test if get_all_onboarded_apps returns a list of dictionaries - # Check if the list contains at least one known item. - # ''' - # result = self.manager.get_all_onboarded_apps() - - # # Check it's a list - # self.assertIsInstance(result, list) - # self.assertTrue(all(isinstance(entry, dict) for entry in result)) - - # # Check if at least one known item is in the list - # expected_entry = { - # "appId": "urn:ngsi-ld:Service:xai-service", - # "name": "aeros_service_urn:ngsi-ld:Service:xai-service" - # } - # self.assertIn(expected_entry, result) - - # def test_get_onboarded_app_returns_expected_keys(self): - # ''' - # Test if get_onboarded_app returns a dictionary with expected keys - # Check against an existing "onboarded/deployed" service. - # ''' - # # Use an existing app ID known to be "onboarded" in aerOS - # app_id = "urn:ngsi-ld:Service:xai-service" - - # result = self.manager.get_onboarded_app(app_id) - - # self.assertIsInstance(result, dict) - # self.assertIn("appId", result) - # self.assertIn("name", result) - - # # Check specific known values - # self.assertEqual(result["appId"], app_id) - # self.assertEqual(result["name"], - # "aeros_service_urn:ngsi-ld:Service:xai-service") - - # def test_get_all_deployed_apps_returns_list(self): - # ''' - # Test if get_all_deployed_apps returns a list of dictionaries - # Check if list items (dicts) contain CAMARA expected keys. - # ''' - # result = self.manager.get_all_deployed_apps() - - # self.assertIsInstance(result, list) - # self.assertGreater(len(result), - # 0) # Expecting at least one app instance - - # for item in result: - # self.assertIn("appInstanceId", item) - # self.assertIn("status", item) - - # def test_get_all_deployed_apps_filter_by_app_id(self): - # ''' - # Test if get_all_deployed_apps returns a list of dictionaries - # when providing an app_id. - # Check if list items (dicts) contain CAMARA expected keys - # and service component name (appId) is one of the two - # components of the provided service. - # ''' - # app_id = "urn:ngsi-ld:Service:xai-service" - - # result = self.manager.get_all_deployed_apps(app_id=app_id) - - # self.assertIsInstance(result, list) - - # for item in result: - # self.assertIn("appInstanceId", item) - # self.assertIn("status", item) - # self.assertIsInstance(item["status"], str) - # self.assertIn(item["appInstanceId"], [ - # "urn:ngsi-ld:Service:xai-service:Component:server-side", - # "urn:ngsi-ld:Service:xai-service:Component:broker-side" - # ]) - - # def test_get_edge_cloud_zones_returns_valid_list(self): - # ''' - # Test if get_edge_cloud_zones returns a list of dictionaries - # Check if item NCSRD aerOS domain is contained in return object. - # ''' - # result = self.manager.get_edge_cloud_zones() - - # self.assertIsInstance(result, list) - # self.assertTrue( - # all("edgeCloudZoneId" in zone and "status" in zone - # for zone in result)) - - # # Optional: check for known zone - # known_zone = { - # "edgeCloudZoneId": "urn:ngsi-ld:Domain:NCSRD", - # "status": "functional" - # } - # self.assertIn(known_zone, result) - - # def test_onboard_app_success(self): - # ''' - # Test if onboard_app returns a dictionary with appId - # Check if the appId is correct - # ''' - # tosca_str = TOSCA_YAML_EXAMPLE - - # app_manifest = { - # "serviceId": "urn:ngsi-ld:Service:cloud-edge-app", - # "tosca": tosca_str - # } - - # result = self.manager.onboard_app(app_manifest) - - # self.assertIsInstance(result, dict) - # self.assertIn("appId", result) - # self.assertEqual(result["appId"], "urn:ngsi-ld:Service:cloud-edge-app") - - # def test_undeploy_app_completes_successfully(self): - # ''' - # Test if undeploy_app completes successfully - # Check if the appInstanceId is a string and starts with "urn:ngsi-ld:Service:" - # ''' - # app_instance_id = "urn:ngsi-ld:Service:cloud-edge-app" - # self.manager.undeploy_app(app_instance_id) - - # def test_deploy_app_returns_app_instance_id(self): - # ''' - # Test if deploy_app returns a dictionary with appInstanceId - # Check if the appInstanceId is a string and starts with "urn:ngsi-ld:Service:" - # ''' - # app_id = "urn:ngsi-ld:Service:xai-service" - # app_zones = [] # Not used in current implementation - - # result = self.manager.deploy_app(app_id, app_zones) - - # self.assertIsInstance(result, dict) - # self.assertIn("appInstanceId", result) - # self.assertIsInstance(result["appInstanceId"], str) - # self.assertTrue( - # result["appInstanceId"].startswith("urn:ngsi-ld:Service:")) - - def test_get_edge_cloud_zones_details_structure(self): - """ - Test if get_edge_cloud_zones_details returns a dictionary - Check if the dictionary contains expected keys and values. - Check if the values are of the expected types. - """ - - zone_id = "urn:ngsi-ld:Domain:NCSRD" - - # When - result: Dict[str, Any] = self.manager.get_edge_cloud_zones_details( - zone_id - ) # <-- FIX HERE! - - # Then - self.assertIsInstance(result, dict) - self.assertIn("zoneId", result) - self.assertIn("reservedComputeResources", result) - self.assertIn("computeResourceQuotaLimits", result) - self.assertIn("flavoursSupported", result) - - reserved_resources = result.get("reservedComputeResources", []) - self.assertIsInstance(reserved_resources, list) - - for resource in reserved_resources: - self.assertIsInstance(resource, dict) - self.assertIn("cpuArchType", resource) - self.assertIn("numCPU", resource) - self.assertIn("memory", resource) - - quota_limits = result.get("computeResourceQuotaLimits", []) - self.assertIsInstance(quota_limits, list) - - for limit in quota_limits: - self.assertIsInstance(limit, dict) - self.assertIn("cpuArchType", limit) - self.assertIn("numCPU", limit) - self.assertIn("memory", limit) - - flavours = result.get("flavoursSupported", []) - self.assertIsInstance(flavours, list) - - for flavour in flavours: - self.assertIsInstance(flavour, dict) - self.assertIn("flavourId", flavour) - self.assertIn("cpuArchType", flavour) - self.assertIn("supportedOSTypes", flavour) - self.assertIn("numCPU", flavour) - self.assertIn("memorySize", flavour) - self.assertIn("storageSize", flavour) - - supported_oses = flavour.get("supportedOSTypes", []) - self.assertIsInstance(supported_oses, list) - - for os_type in supported_oses: - self.assertIsInstance(os_type, dict) - self.assertIn("architecture", os_type) - self.assertIn("distribution", os_type) - self.assertIn("version", os_type) - self.assertIn("license", os_type) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index 62640a7..c634f7b 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -17,8 +17,8 @@ test_cases = [ # }, # { # "edgecloud": { - # "client_name": "piedge", - # "base_url": "http://test-piedge.url" + # "client_name": "kubernetes", + # "base_url": "http://test-kubernetes.url" # } # } ] diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 76be859..727da1c 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -13,7 +13,7 @@ EdgeCloud Platform Test Configuration This file contains the configuration constants and manifests for testing -the EdgeCloud Platform integration across different clients. +the EdgeCloud Platform integration across different adapters. """ ###################### @@ -87,7 +87,7 @@ APP_ZONES = [ ] ###################### -# PiEdge variables +# kubernetes variables ###################### # TODO diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index beb5177..4365b9e 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -12,7 +12,7 @@ """ EdgeCloud Platform Integration Tests -Validates the complete application lifecycle across multiple clients: +Validates the complete application lifecycle across multiple adapters: 1. Infrastructure (zone discovery) 2. Artefact management (create/delete) 3. Application lifecycle (onboard/deploy/undeploy/delete app onboarded) @@ -29,8 +29,8 @@ import time import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient -from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError -from sunrise6g_opensdk.edgecloud.clients.i2edge.client import ( +from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) from tests.edgecloud.test_cases import test_cases @@ -50,9 +50,9 @@ from tests.edgecloud.test_config import ( @pytest.fixture(scope="module", name="edgecloud_client") def instantiate_edgecloud_client(request): """Fixture to create and share an edgecloud client across tests""" - client_specs = request.param - clients = sdkclient.create_clients_from(client_specs) - return clients.get("edgecloud") + adapter_specs = request.param + adapters = sdkclient.create_adapters_from(adapter_specs) + return adapters.get("edgecloud") def id_func(val): @@ -73,17 +73,13 @@ def test_get_edge_cloud_zones(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): - """ - Test that get_edge_cloud_zone_details returns valid responses for each client. - Since each client has different response formats, we only verify basic success criteria. - """ try: zones = edgecloud_client.get_edge_cloud_zones() assert len(zones) > 0, "No zones available for testing" zone_details = edgecloud_client.get_edge_cloud_zones_details(zone_id) - # Basic checks that apply to all clients + # Basic checks that apply to all adapters assert zone_details is not None, "Zone details should not be None" assert isinstance(zone_details, dict), "Zone details should be a dictionary" assert len(zone_details) > 0, "Zone details should not be empty" diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 4230fe1..58c0e0b 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -4,17 +4,17 @@ import time import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.base_network_client import BaseNetworkClient from sunrise6g_opensdk.network.core.common import CoreHttpError -from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface from tests.network.test_cases import test_cases @pytest.fixture(scope="module", name="network_client") def instantiate_network_client(request): """Fixture to create and share a network client across tests""" - client_specs = request.param - clients = sdkclient.create_clients_from(client_specs) - return clients.get("network") + adapter_specs = request.param + adapters = sdkclient.create_adapters_from(adapter_specs) + return adapters.get("network") def id_func(val): @@ -27,7 +27,7 @@ def id_func(val): ids=id_func, indirect=True, ) -def test_valid_input_open5gs(network_client: NetworkManagementInterface): +def test_valid_input_open5gs(network_client: BaseNetworkClient): camara_session = { "duration": 3600, "device": { @@ -43,7 +43,7 @@ def test_valid_input_open5gs(network_client: NetworkManagementInterface): @pytest.fixture(scope="module") -def qod_session_id(network_client: NetworkManagementInterface): +def qod_session_id(network_client: BaseNetworkClient): camara_session = { "duration": 3600, "device": { @@ -84,7 +84,7 @@ def test_timer_wait_5_seconds(network_client): @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_get_qod_session(network_client: NetworkManagementInterface, qod_session_id): +def test_get_qod_session(network_client: BaseNetworkClient, qod_session_id): try: network_client.get_qod_session(qod_session_id) except CoreHttpError as e: @@ -92,7 +92,7 @@ def test_get_qod_session(network_client: NetworkManagementInterface, qod_session @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_delete_qod_session(network_client: NetworkManagementInterface, qod_session_id): +def test_delete_qod_session(network_client: BaseNetworkClient, qod_session_id): try: network_client.delete_qod_session(qod_session_id) except CoreHttpError as e: diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index 8008395..31620f3 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -4,8 +4,8 @@ import time import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.base_network_client import BaseNetworkClient from sunrise6g_opensdk.network.core.common import CoreHttpError -from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface from tests.network.test_cases import test_cases ti_session1 = { @@ -42,9 +42,9 @@ ti_session2 = { @pytest.fixture(scope="module", name="network_client") def instantiate_network_client(request): """Fixture to create and share a network client across tests""" - client_specs = request.param - clients = sdkclient.create_clients_from(client_specs) - return clients.get("network") + adapter_specs = request.param + adapters = sdkclient.create_clients_from(adapter_specs) + return adapters.get("network") def id_func(val): @@ -57,7 +57,7 @@ def id_func(val): ids=id_func, indirect=True, ) -def test_valid_input(network_client: NetworkManagementInterface): +def test_valid_input(network_client: BaseNetworkClient): network_client._build_ti_subscription(ti_session1) network_client._build_ti_subscription(ti_session1_put) @@ -66,7 +66,7 @@ def test_valid_input(network_client: NetworkManagementInterface): @pytest.fixture(scope="module") -def traffic_influence_id(network_client: NetworkManagementInterface): +def traffic_influence_id(network_client: BaseNetworkClient): try: response = network_client.create_traffic_influence_resource(ti_session1) assert response is not None, "Response should not be None" @@ -80,7 +80,7 @@ def traffic_influence_id(network_client: NetworkManagementInterface): @pytest.fixture(scope="module") -def traffic_influence_id2(network_client: NetworkManagementInterface): +def traffic_influence_id2(network_client: BaseNetworkClient): try: response = network_client.create_traffic_influence_resource(ti_session2) assert response is not None, "Response should not be None" @@ -120,7 +120,7 @@ def test_timer_wait_5_seconds(network_client): @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_get_traffic_influence_session_1( - network_client: NetworkManagementInterface, traffic_influence_id + network_client: BaseNetworkClient, traffic_influence_id ): try: response = network_client.get_individual_traffic_influence_resource( @@ -133,7 +133,7 @@ def test_get_traffic_influence_session_1( @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_put_traffic_influence_session_1( - network_client: NetworkManagementInterface, traffic_influence_id + network_client: BaseNetworkClient, traffic_influence_id ): try: network_client.put_traffic_influence_resource( @@ -145,7 +145,7 @@ def test_put_traffic_influence_session_1( @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_get_traffic_influence_session_after_put_1( - network_client: NetworkManagementInterface, traffic_influence_id + network_client: BaseNetworkClient, traffic_influence_id ): try: response = network_client.get_individual_traffic_influence_resource( @@ -157,7 +157,7 @@ def test_get_traffic_influence_session_after_put_1( @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_get_all_traffic_influence_sessions(network_client: NetworkManagementInterface): +def test_get_all_traffic_influence_sessions(network_client: BaseNetworkClient): try: response = network_client.get_all_traffic_influence_resource() assert response is not None, "response should not be None" @@ -168,7 +168,7 @@ def test_get_all_traffic_influence_sessions(network_client: NetworkManagementInt @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_delete_traffic_influence_session_1( - network_client: NetworkManagementInterface, traffic_influence_id + network_client: BaseNetworkClient, traffic_influence_id ): try: network_client.delete_traffic_influence_resource(traffic_influence_id) @@ -178,7 +178,7 @@ def test_delete_traffic_influence_session_1( @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) def test_delete_traffic_influence_session_2( - network_client: NetworkManagementInterface, traffic_influence_id2 + network_client: BaseNetworkClient, traffic_influence_id2 ): try: network_client.delete_traffic_influence_resource(traffic_influence_id2) diff --git a/tests/network/test_location_retrieval.py b/tests/network/test_location_retrieval.py new file mode 100644 index 0000000..390bdff --- /dev/null +++ b/tests/network/test_location_retrieval.py @@ -0,0 +1 @@ +# PLACEHOLDER -- GitLab From 152ef15307643998408c84b61986c33e60b72bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 12:40:54 +0200 Subject: [PATCH 150/281] Update README. Minor changeS to CONTRIBUTING & TESTING --- README.md | 35 ++++++++++++++++++----------------- docs/CONTRIBUTING.md | 6 +++--- docs/TESTING.md | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b27e6a9..28ac8ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ + + + @@ -10,10 +13,7 @@ - - - - + @@ -27,9 +27,10 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge ## Features -- Unified SDK for interacting with Edge Cloud platforms, 5G Core solutions, and O-RAN solutions. +- Abstract CAMARA Transformation Functions (TFs) +- Unified Python SDK for interacting with Edge Cloud platforms, 5G Core solutions, and O-RAN solutions. - Modular and extensible adapter structure -- Conforms to CAMARA/GSMA API standards. + --- @@ -48,17 +49,17 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge | Platform | Status | |------------|------------| -| Kubernetes (old PiEdge) | Supported | -| i2Edge | Supported | -| aerOS | Supported | +| Kubernetes | ✅ | +| i2Edge | ✅ | +| aerOS | ✅ | ### Network Adapters | Platform | NEF Version | QoD | Location Retrieval | Traffic Influence | |--------------|-------------|-----|---------------------|--------------------| -| Open5GS | v1.2.3 | ✅ | ✅ | ❌ | -| Open5GCore | v1.2.3 | ✅ | ❌ | ❌ | -| OAI | v1.2.3 | ✅ | ❌ | ✅ | +| Open5GS | [v1.2.3](https://www.3gpp.org/ftp/Specs/archive/29_series/29.122/29122-hc0.zip) TS 29.122 (v17.12.0) | ✅ | ✅ | ❌ | +| Open5GCore | [v1.2.3](https://www.3gpp.org/ftp/Specs/archive/29_series/29.122/29122-hc0.zip) TS 29.122 (v17.12.0) | ✅ | ❌ | ❌ | +| OAI | [v1.2.3](https://www.3gpp.org/ftp/Specs/archive/29_series/29.122/29122-hc0.zip) TS 29.122 (v17.12.0) | ✅ | ❌ | ✅ | --- @@ -82,7 +83,7 @@ cd open-sdk python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -pip intall -e . +pip install -e . ``` ### Basic Usage @@ -128,8 +129,8 @@ participant K8s as Kubernetes note over SDK: [Config] Edge Cloud platform: Kubernetes, IP, Port API ->> SDK: from sunrise6g_opensdk import Sdk as sdkclient -API ->> SDK: sdkclient.create_clients_from(configuration) -API ->> SDK: edgecloud_client = clients.get("edgecloud") +API ->> SDK: sdkclient.create_adapters_from(configuration) +API ->> SDK: edgecloud_client = adapters.get("edgecloud") SDK ->> SDK: SDK initialized and ready to be used note over AP,API: Platform ready to receive CAMARA calls AP ->> API: POST /app (APP_ONBOARD_MANIFEST) @@ -156,8 +157,8 @@ participant 5GS as Open5GS note over SDK: [Config] Network core: Open5Gs, IP, Port API ->> SDK: from sunrise6g_opensdk import Sdk as sdkclient -API ->> SDK: sdkclient.create_clients_from(configuration) -API ->> SDK: network_client = clients.get("network") +API ->> SDK: sdkclient.create_adapters_from(configuration) +API ->> SDK: network_client = adapters.get("network") SDK ->> SDK: SDK initialized and ready to be used note over AP,API: Platform ready to receive CAMARA calls AP ->> API: POST /sessions (QOS_SESSION_REQUEST) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5256a1e..33abf52 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -10,9 +10,9 @@ To contribute: 1. Fork the repository and create a feature branch from `main`. 2. Develop your changes in the appropriate adapter directory: - - `src/sunrise6g_opensdk/edgecloud/clients/` - - `src/sunrise6g_opensdk/network/clients/` - - `src/sunrise6g_opensdk/oran/clients/` + - `src/sunrise6g_opensdk/edgecloud/adapters/` + - `src/sunrise6g_opensdk/network/adapters/` + - `src/sunrise6g_opensdk/oran/adapters/` 3. Follow the coding guidelines below. 4. Write or update unit tests for your changes. 5. Ensure all tests pass. diff --git a/docs/TESTING.md b/docs/TESTING.md index 7264f55..a719168 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,7 +14,7 @@ To run tests for the Edge Cloud adapters: pytest tests/edgecloud/ ``` -To run tests for the Network adapters (WIP): +To run tests for the Network adapters: ```bash pytest tests/network/ ``` -- GitLab From 41581d5ee054138a3f699fb5ccbcd22f567e0389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 12:41:07 +0200 Subject: [PATCH 151/281] Delete old seq diagram --- docs/workflows/edgecloud/get_av_zones.md | 26 ------------------------ 1 file changed, 26 deletions(-) delete mode 100644 docs/workflows/edgecloud/get_av_zones.md diff --git a/docs/workflows/edgecloud/get_av_zones.md b/docs/workflows/edgecloud/get_av_zones.md deleted file mode 100644 index efa4d28..0000000 --- a/docs/workflows/edgecloud/get_av_zones.md +++ /dev/null @@ -1,26 +0,0 @@ -```mermaid -sequenceDiagram -title Retrieve Edge Cloud Zones -actor AP as App Vertical Provider -participant CE as Capabilities Exposure -box Service Resource Manager - participant API - participant SDK as EdgeCloudSDK -end -participant i2Edge -participant PiEdge -participant aerOS - -note over AP,CE: CAMARA EdgeCloud API -AP ->> CE: GET /edge-cloud-zones -CE ->> API: GET /av. zones -API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(i2Edge) -API ->> SDK: sbi.get_edge_cloud_zones() -SDK ->> i2Edge: GET /zones/list -API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(PiEdge) -API ->> SDK: sbi.get_edge_cloud_zones() -SDK ->> PiEdge: GET /nodes -API ->> SDK: sbi = EdgeCloudFactory.create_edgecloud_client(aerOS) -API ->> SDK: sbi.get_edge_cloud_zones() -SDK ->> aerOS: GET /entities?type=Domain -``` -- GitLab From f03b9a652f9d3bbce860984c2850e1742970910d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 12:41:20 +0200 Subject: [PATCH 152/281] Update requirements --- requirements.txt | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4341f2c..eab686f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,48 +1,67 @@ annotated-types==0.7.0 asttokens==3.0.0 attrs==25.3.0 +auto-mix-prep==0.2.0 backcall==0.2.0 +backports.tarfile==1.2.0 beautifulsoup4==4.13.3 black==24.8.0 bleach==6.2.0 build==1.2.2.post1 certifi==2025.1.31 +cffi==1.17.1 cfgv==3.4.0 charset-normalizer==3.4.1 click==8.1.8 colorlog==6.8.2 coverage==7.7.1 +cryptography==45.0.4 decorator==5.2.1 defusedxml==0.7.1 distlib==0.3.9 docopt==0.6.2 +docutils==0.21.2 exceptiongroup==1.2.2 executing==2.2.0 fastjsonschema==2.21.1 filelock==3.18.0 flake8==7.1.1 +id==1.5.0 identify==2.6.10 idna==3.10 +importlib_metadata==8.7.0 iniconfig==2.0.0 ipython==8.12.3 isort==5.13.2 +jaraco.classes==3.4.0 +jaraco.context==6.0.1 +jaraco.functools==4.1.0 jedi==0.19.2 +jeepney==0.9.0 Jinja2==3.1.6 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 jupyter_client==8.6.3 jupyter_core==5.8.1 jupyterlab_pygments==0.3.0 +keyring==25.6.0 +markdown-it-py==3.0.0 MarkupSafe==3.0.2 matplotlib-inline==0.1.7 +mccabe==0.7.0 +mdurl==0.1.2 mistune==3.1.3 +more-itertools==10.7.0 +mypy_extensions==1.1.0 nbclient==0.10.2 nbconvert==7.16.6 nbformat==5.10.4 +nh3==0.2.21 nodeenv==1.9.1 packaging==24.2 pandocfilters==1.5.1 parso==0.8.4 +pathspec==0.12.1 pexpect==4.9.0 pickleshare==0.7.5 pip-tools==7.4.1 @@ -53,9 +72,13 @@ pre_commit==4.2.0 prompt_toolkit==3.0.50 ptyprocess==0.7.0 pure_eval==0.2.3 -pydantic==2.10.6 +pycodestyle==2.12.1 +pycparser==2.22 +pydantic==2.11.3 pydantic-extra-types==2.10.3 -pydantic_core==2.27.2 +pydantic_core==2.33.1 +pydub==0.25.1 +pyflakes==3.2.0 Pygments==2.19.1 pyproject_hooks==1.2.0 pytest==8.3.2 @@ -63,20 +86,29 @@ pytest-cov==6.0.0 python-dateutil==2.9.0.post0 PyYAML==6.0.2 pyzmq==26.4.0 +readme_renderer==44.0 referencing==0.36.2 requests==2.32.3 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==14.0.0 rpds-py==0.24.0 +SecretStorage==3.3.3 shortuuid==1.0.13 six==1.17.0 soupsieve==2.6 stack-data==0.6.3 +-e git+ssh://git@github.com/SunriseOpenOperatorPlatform/open-sdk.git@df5a0ec92f9f26892b38bb72d2486430055faf7c#egg=sunrise6g_opensdk tinycss2==1.4.0 tomli==2.2.1 tornado==6.5 traitlets==5.14.3 +twine==6.1.0 +typing-inspection==0.4.1 typing_extensions==4.12.2 urllib3==2.3.0 virtualenv==20.30.0 wcwidth==0.2.13 webencodings==0.5.1 yarg==0.1.9 +zipp==3.23.0 -- GitLab From 07939bcd06c5c7c2abb2037df35d8d4d33707c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 12:41:51 +0200 Subject: [PATCH 153/281] Update pyproject.toml; set version 0.9.9 and delete unused config --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80cba7e..6c2b53c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "0.0.1" +version = "0.9.9" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", @@ -52,12 +52,12 @@ dependencies = [ Homepage = "https://sunrise6g.eu/" Repository = "https://github.com/OpenOperatorPlatform/OpenSDK" +[tool.setuptools] +package-dir = {"" = "src"} + [tool.setuptools.packages.find] where = ["src"] include = ["sunrise6g_opensdk*"] [tool.setuptools.package-data] sunrise6g_opensdk = ["py.typed"] - -[bdist_wheel] -universal = 1 -- GitLab From 26b1f468b4ff237782a03d8972c8107705c2c71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 13:11:13 +0200 Subject: [PATCH 154/281] Delete pip requirement which is not needed --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eab686f..6f737f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -98,7 +98,6 @@ shortuuid==1.0.13 six==1.17.0 soupsieve==2.6 stack-data==0.6.3 --e git+ssh://git@github.com/SunriseOpenOperatorPlatform/open-sdk.git@df5a0ec92f9f26892b38bb72d2486430055faf7c#egg=sunrise6g_opensdk tinycss2==1.4.0 tomli==2.2.1 tornado==6.5 -- GitLab From 11d1ebf66710c89b4ada3d4103c48235b08394a0 Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Thu, 19 Jun 2025 14:37:42 +0300 Subject: [PATCH 155/281] Implement TF logic and improve schema model --- .../network/core/network_interface.py | 17 +++++++-- src/sunrise6g_opensdk/network/core/schemas.py | 35 +++++++++++-------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 01267a2..d10ea1c 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -212,7 +212,7 @@ class NetworkManagementInterface(ABC): self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription - def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) ->schemas.MonitoringEventSubscriptionRequest: + def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.MonitoringEventSubscriptionRequest: self.core_specific_monitoring_event_validation(retrieve_location_request) device = retrieve_location_request.device subscription = schemas.MonitoringEventSubscriptionRequest( @@ -224,7 +224,7 @@ class NetworkManagementInterface(ABC): mapped_3gpp_subscription = self.add_core_specific_location_parameters(retrieve_location_request,subscription) return mapped_3gpp_subscription - def create_monitoring_event_subscription(self, retrieve_location_request: Dict) -> Dict: + def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> Dict: """ Creates a Monitoring Event subscription based on CAMARA Location API input. @@ -235,7 +235,18 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created subscription details, including its ID. """ - pass + subscription = self._build_monitoring_event_subscription(retrieve_location_request) + response = common.monitoring_event_post( + self.base_url, self.scs_as_id, subscription + ) + + monitoring_event_report = schemas.MonitoringEventReport(**response) + if monitoring_event_report.locationInfo is None: + log.error("Failed to retrieve location information from monitoring event report") + raise NetworkPlatformError("Location information not found in monitoring event report") + geo_area = monitoring_event_report.locationInfo.geographicArea + area = geo_area.polygon + camara_location = schemas.Location(area=area,lastLocationTime=) def create_qod_session(self, session_info: Dict) -> Dict: """ diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 6f22e65..64fc9c2 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -7,7 +7,7 @@ import ipaddress from datetime import datetime from enum import Enum from ipaddress import IPv4Address, IPv6Address -from typing import Annotated +from typing import Annotated, Literal from uuid import UUID from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel, AnyHttpUrl @@ -243,6 +243,19 @@ class LocationFailureCause(str,Enum): not_registered_ue = "NOT_REGISTERED_UE" # UE is not registered. unspecified = "UNSPECIFIED" # Unspecified cause. +class GeographicalCoordinates(BaseModel): + lon: float = Field(..., description="Longitude coordinate.") + lat: float = Field(..., description="Latitude coordinate.") + +class PointList(BaseModel): + geographical_coords: list[GeographicalCoordinates] = Field(..., description="List of geographical coordinates defining the points.",min_length=3,max_length=15) + +class Polygon(BaseModel): + point_list: PointList = Field(..., description="List of points defining the polygon.") + +class GeographicArea(BaseModel): + polygon: Polygon | None = Field(None, description="Identifies a polygonal geographic area.") + #This data type represents the user location information which is sent from the NEF to the AF. class LocationInfo(BaseModel): ageOfLocationInfo: DurationMin | None = Field(None,description="Indicates the elapsed time since the last network contact of the UE.") @@ -252,7 +265,7 @@ class LocationInfo(BaseModel): routingAreaId: str | None = Field(None, description="Routing Area ID where the UE is located") plmnId: PlmnId | None = Field(None, description="PLMN ID where the UE is located.") twanId: str | None = Field(None, description="TWAN ID where the UE is located.") - #geographicArea: GeographicArea | None = Field(None,description="Identifies a geographic area of the user where the UE is located.") + geographicArea: GeographicArea | None = Field(None,description="Identifies a geographic area of the user where the UE is located.") class MonitoringEventSubscriptionRequest(BaseModel): accuracy: Accuracy | None = Field(None,description="Accuracy represents a desired granularity of accuracy of the requested location information.") @@ -377,16 +390,6 @@ class AreaType(str,Enum): circle = "CIRCLE" # The area is defined as a circle. polygon = "POLYGON" # The area is defined as a polygon. - -class Area(RootModel[Annotated[ - AreaType, - Field(description=""" - Type of this area. - CIRCLE - The area is defined as a circle. - POLYGON - The area is defined as a polygon. - """)]]): - pass - class Point(BaseModel): latitude: Annotated[float,Field(description="Latitude component of a location.",examples=["50.735851"],ge=-90,le=90)] longitude: Annotated[float,Field(..., description="Longitude component of location.",examples=["7.10066"],ge=-180,le=180)] @@ -396,13 +399,17 @@ class PointList(RootModel[Annotated[ Field(min_length=3,max_length=15, description="List of points defining the area.")]]): pass -class Circle(Area): +class Circle(BaseModel): + areaType: Literal["CIRCLE"] center: Annotated[Point, Field(description="Center point of the circle.")] radius: Annotated[float,Field(description="Radius of the circle.",ge=1)] -class Polygon(Area): +class Polygon(BaseModel): + areaType: Literal["POLYGON"] boundary: Annotated[PointList, Field(description="List of points defining the polygon.")] +Area = Annotated[Circle | Polygon, Field(discriminator="areaType")] + class LastLocationTime(RootModel[Annotated[ datetime, Field( description="Last date and time when the device was localized.",examples="2023-09-07T10:40:52Z")]]): -- GitLab From ad2efde193b1bf48d693858d3109e73918d37162 Mon Sep 17 00:00:00 2001 From: giuliocarot0 Date: Thu, 19 Jun 2025 14:33:10 +0200 Subject: [PATCH 156/281] align traffic influence tests with new naming --- tests/network/test_create_traffic_influence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index 31620f3..c892b13 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -43,7 +43,7 @@ ti_session2 = { def instantiate_network_client(request): """Fixture to create and share a network client across tests""" adapter_specs = request.param - adapters = sdkclient.create_clients_from(adapter_specs) + adapters = sdkclient.create_adapters_from(adapter_specs) return adapters.get("network") -- GitLab From 929d40030dce4c42f0f9ee0bf27cc8b90314d3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 19 Jun 2025 15:03:50 +0200 Subject: [PATCH 157/281] Update requirements to fix dependabot alerts --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6f737f9..82e4568 100644 --- a/requirements.txt +++ b/requirements.txt @@ -88,7 +88,7 @@ PyYAML==6.0.2 pyzmq==26.4.0 readme_renderer==44.0 referencing==0.36.2 -requests==2.32.3 +requests==2.32.4 requests-toolbelt==1.0.0 rfc3986==2.0.0 rich==14.0.0 @@ -105,7 +105,7 @@ traitlets==5.14.3 twine==6.1.0 typing-inspection==0.4.1 typing_extensions==4.12.2 -urllib3==2.3.0 +urllib3==2.5.0 virtualenv==20.30.0 wcwidth==0.2.13 webencodings==0.5.1 -- GitLab From 67d698882a4cdb45b2fa57cf19bb9036186afa9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 13:08:14 +0000 Subject: [PATCH 158/281] Bump requests from 2.32.3 to 2.32.4 Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4) --- updated-dependencies: - dependency-name: requests dependency-version: 2.32.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6c2b53c..f1b339e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "auto_mix_prep==0.2.0", "colorlog==6.8.2", "pydantic==2.11.3", - "requests==2.32.3", + "requests==2.32.4", ] [project.urls] -- GitLab From b4d15d13d73853c10b87a68962038a056f02879e Mon Sep 17 00:00:00 2001 From: Panagiotis Pavlidis Date: Thu, 19 Jun 2025 22:42:25 +0300 Subject: [PATCH 159/281] Implement mapping from 3GPP NEF to CAMARA API --- .../network/core/network_interface.py | 29 +++++++++++++++++-- src/sunrise6g_opensdk/network/core/schemas.py | 8 ++--- tests/network/test_create_monitoring_event.py | 0 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 tests/network/test_create_monitoring_event.py diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index d10ea1c..3a7b867 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -14,6 +14,7 @@ import uuid from abc import ABC from itertools import product from typing import Dict +from datetime import datetime, timedelta from sunrise6g_opensdk import logger from sunrise6g_opensdk.network.clients.errors import NetworkPlatformError @@ -224,7 +225,19 @@ class NetworkManagementInterface(ABC): mapped_3gpp_subscription = self.add_core_specific_location_parameters(retrieve_location_request,subscription) return mapped_3gpp_subscription - def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> Dict: + def _compute_camara_last_location_time(self, event_time_str: datetime, age_of_location_info_min : int = None) -> : + """ + event_time_str: ISO 8601 string, e.g. "2025-06-18T12:30:00Z" + age_of_location_info_min: unsigned int, age of location info in minutes + """ + event_time = datetime.fromisoformat(event_time_str.replace("Z", "+00:00")) + if age_of_location_info_min is not None: + last_location_time = event_time - timedelta(minutes=age_of_location_info_min) + return last_location_time + else: + return event_time_str + + def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> dict: """ Creates a Monitoring Event subscription based on CAMARA Location API input. @@ -245,8 +258,18 @@ class NetworkManagementInterface(ABC): log.error("Failed to retrieve location information from monitoring event report") raise NetworkPlatformError("Location information not found in monitoring event report") geo_area = monitoring_event_report.locationInfo.geographicArea - area = geo_area.polygon - camara_location = schemas.Location(area=area,lastLocationTime=) + report_event_time = monitoring_event_report.eventTime + ageOfLocationInfo = None + if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: + ageOfLocationInfo = monitoring_event_report.locationInfo.ageOfLocationInfo.duration + last_location_time = self._compute_camara_last_location_time(report_event_time,ageOfLocationInfo) + + camara_polygon = schemas.Polygon(areaType=schemas.AreaType.polygon,boundary=geo_area.polygon.point_list) + camara_loc_area = schemas.Area.model_validate(camara_polygon) + + camara_location = schemas.Location(area=camara_loc_area,lastLocationTime=last_location_time) + + return camara_location.model_dump(mode="json") def create_qod_session(self, session_info: Dict) -> Dict: """ diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 64fc9c2..4c932b2 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -250,11 +250,11 @@ class GeographicalCoordinates(BaseModel): class PointList(BaseModel): geographical_coords: list[GeographicalCoordinates] = Field(..., description="List of geographical coordinates defining the points.",min_length=3,max_length=15) -class Polygon(BaseModel): +class NefPolygon(BaseModel): point_list: PointList = Field(..., description="List of points defining the polygon.") class GeographicArea(BaseModel): - polygon: Polygon | None = Field(None, description="Identifies a polygonal geographic area.") + polygon: NefPolygon | None = Field(None, description="Identifies a polygonal geographic area.") #This data type represents the user location information which is sent from the NEF to the AF. class LocationInfo(BaseModel): @@ -400,12 +400,12 @@ class PointList(RootModel[Annotated[ pass class Circle(BaseModel): - areaType: Literal["CIRCLE"] + areaType: Literal[AreaType.circle] center: Annotated[Point, Field(description="Center point of the circle.")] radius: Annotated[float,Field(description="Radius of the circle.",ge=1)] class Polygon(BaseModel): - areaType: Literal["POLYGON"] + areaType: Literal[AreaType.polygon] boundary: Annotated[PointList, Field(description="List of points defining the polygon.")] Area = Annotated[Circle | Polygon, Field(discriminator="areaType")] diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py new file mode 100644 index 0000000..e69de29 -- GitLab From a43072945802cefd852b498900df97862aa1c0ac Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Fri, 20 Jun 2025 12:58:53 +0300 Subject: [PATCH 160/281] Implement TF, test framework setup and unit test --- .../network/clients/open5gs/client.py | 23 ++++--- .../network/core/network_interface.py | 23 ++++--- tests/network/conftest.py | 62 +++++++++++++++++++ tests/network/test_create_monitoring_event.py | 19 ++++++ 4 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 tests/network/conftest.py diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py index f06ec55..6e8db63 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -61,16 +61,19 @@ class NetworkManager(NetworkManagementInterface): raise ValidationError( "Open5GS requires a device to be specified for location retrieval in NEF." ) - def add_core_specific_location_parameters(self, retrieve_location_request: schemas.RetrievalLocationRequest, subscription: schemas.MonitoringEventSubscriptionRequest) -> None: - subscription.msisdn = retrieve_location_request.device.phoneNumber - subscription.monitoringType = schemas.MonitoringType.LOCATION_REPORTING - subscription.locationType = schemas.LocationType.CURRENT_LOCATION - # subscription.locationType = schemas.LocationType.LAST_KNOWN - # subscription.maximumNumberOfReports = 1 - # subscription.repPeriod = schemas.DurationSec(root=20) - - return subscription - + def add_core_specific_location_parameters(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.MonitoringEventSubscriptionRequest: + return schemas.MonitoringEventSubscriptionRequest( + msisdn=retrieve_location_request.device.phoneNumber.root.lstrip('+'), + notificationDestination="http://127.0.0.1:8001", + monitoringType=schemas.MonitoringType.LOCATION_REPORTING, + locationType=schemas.LocationType.LAST_KNOWN + ) + # subscription.msisdn = retrieve_location_request.device.phoneNumber.root.lstrip('+') + # monitoringType = schemas.MonitoringType.LOCATION_REPORTING + # locationType = schemas.LocationType.LAST_KNOWN + # locationType = schemas.LocationType.CURRENT_LOCATION + # maximumNumberOfReports = 1 + # repPeriod = schemas.DurationSec(root=20) # Note: diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 3a7b867..fbd4bee 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -114,8 +114,7 @@ class NetworkManagementInterface(ABC): def add_core_specific_location_parameters( self, - retrieve_location_request: schemas.RetrievalLocationRequest, - subscription: schemas.MonitoringEventSubscriptionRequest, + retrieve_location_request: schemas.RetrievalLocationRequest )-> schemas.MonitoringEventSubscriptionRequest: """ Placeholder for adding core-specific parameters to the location subscription. @@ -215,17 +214,17 @@ class NetworkManagementInterface(ABC): def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.MonitoringEventSubscriptionRequest: self.core_specific_monitoring_event_validation(retrieve_location_request) + subscription_3gpp = self.add_core_specific_location_parameters(retrieve_location_request) device = retrieve_location_request.device - subscription = schemas.MonitoringEventSubscriptionRequest( - externalId=device.networkAccessIdentifier, - ipv4Address=device.ipv4Address, - ipv6Addr=device.ipv6Address, - msisdn=device.phoneNumber, - notificationDestination= "http://test_server:8001") - mapped_3gpp_subscription = self.add_core_specific_location_parameters(retrieve_location_request,subscription) - return mapped_3gpp_subscription - - def _compute_camara_last_location_time(self, event_time_str: datetime, age_of_location_info_min : int = None) -> : + subscription_3gpp.externalId = device.networkAccessIdentifier + subscription_3gpp.ipv4Addr = device.ipv4Address + subscription_3gpp.ipv6Addr = device.ipv6Address + # subscription.msisdn = device.phoneNumber.root.lstrip('+') + # subscription.notificationDestination = "http://127.0.0.1:8001" + + return subscription_3gpp + + def _compute_camara_last_location_time(self, event_time_str: datetime, age_of_location_info_min : int = None) -> datetime: """ event_time_str: ISO 8601 string, e.g. "2025-06-18T12:30:00Z" age_of_location_info_min: unsigned int, age of location info in minutes diff --git a/tests/network/conftest.py b/tests/network/conftest.py new file mode 100644 index 0000000..d21ed9f --- /dev/null +++ b/tests/network/conftest.py @@ -0,0 +1,62 @@ +import pytest + +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, Device, MonitoringEventSubscriptionRequest + + + + +@pytest.fixture(scope="session", name="network_client") +def instantiate_network_client(): + """Fixture to create and share a network client across tests""" + client_specs = { + "network": { + "client_name": "open5gs", + "base_url": "http://127.0.0.1:8082/", + "scs_as_id": "af_1", + } + } + clients = sdkclient.create_clients_from(client_specs) + return clients.get("network") + + +# Test full input data from Camara Payload +# { +# "phoneNumber": "+1234567890", +# "networkAccessIdentifier": "user123@example.com", +# "ipv4Address": { +# "publicAddress": "198.51.100.10", +# "privateAddress": "10.0.0.1", +# "publicPort": 12345 +# }, +# "ipv6Address": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" +# } + +@pytest.fixture(scope="module") +def camara_payload_input_data() -> RetrievalLocationRequest: + """ + Fixture to provide input data for CAMARA payload. + This data is used in tests that require a specific payload structure. + """ + + return RetrievalLocationRequest(device=Device(phoneNumber="+1234567890")) + +# Sample output test data 3GPP MonitoringEventSubscription Request Payload +# { +# "msisdn": "+306912345678", +# "notificationDestination": "https://af.example.com/location_notifications", +# "monitoringType": "LOCATION_REPORTING", +# "locationType": "CURRENT_LOCATION" +# } +@pytest.fixture(scope="module", name="expected_output_data") +def monitoring_request_3gpp_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> MonitoringEventSubscriptionRequest: + """ + Fixture to provide output data for 3GPP monitoring event request payload. + """ + output_msisdn = camara_payload_input_data.device.phoneNumber.root.lstrip('+') + return MonitoringEventSubscriptionRequest( + msisdn=output_msisdn, + notificationDestination="http://127.0.0.1:8001", + monitoringType="LOCATION_REPORTING", + locationType="LAST_KNOWN_LOCATION" + ) \ No newline at end of file diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index e69de29..31e8e7f 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -0,0 +1,19 @@ + + +import pytest + +from sunrise6g_opensdk.common.sdk import Sdk as sdkclient +from sunrise6g_opensdk.network.core.common import CoreHttpError +from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface +from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest + + +from tests.network.conftest import instantiate_network_client, camara_payload_input_data + + +def test_camara_tf_3gpp_event(network_client : NetworkManagementInterface ,camara_payload_input_data: RetrievalLocationRequest, expected_output_data) -> None: + actual_result = network_client._build_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) + assert actual_result == expected_output_data + + +# def test_create_monitoring_event(): \ No newline at end of file -- GitLab From 4f5874a4ab4c9d1596f4dd5b46c9844614f38481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 20 Jun 2025 14:22:57 +0200 Subject: [PATCH 161/281] Add flavour_id as input parameter for i2edge adapter --- .../common/adapters_factory.py | 6 ++++- .../edgecloud/adapters/i2edge/client.py | 22 ++++++++----------- tests/edgecloud/test_cases.py | 1 + tests/edgecloud/test_e2e.py | 6 ++--- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 81d0e96..00fd7d6 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -29,9 +29,13 @@ from sunrise6g_opensdk.network.adapters.open5gs.client import ( def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): + if client_name == "i2edge": + if "flavour_id" not in kwargs: + raise ValueError("Missing required 'flavour_id' for i2edge client.") + edge_cloud_factory = { "aeros": lambda url, **kw: AerosClient(base_url=url, **kw), - "i2edge": lambda url: I2EdgeClient(base_url=url), + "i2edge": lambda url, **kw: I2EdgeClient(base_url=url, **kw), # "kubernetes": lambda url: kubernetesClient(base_url=url), Uncomment when import issues are solved } try: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index ad09952..903e7e8 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -33,8 +33,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): i2Edge Client """ - def __init__(self, base_url: str): + def __init__(self, base_url: str, flavour_id: str): self.base_url = base_url + self.flavour_id = flavour_id def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None @@ -156,15 +157,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def _select_best_flavour_for_app(self, zone_id) -> str: - """ - Selects the best flavour for the specified app requirements in a given zone. - """ - # list_of_flavours = self.get_edge_cloud_zones_details(zone_id) - # - # TODO - Harcoded - flavourId = "67f3a0b0e3184a85952e174d" - return flavourId + # def _select_best_flavour_for_app(self, zone_id) -> str: + # # list_of_flavours = self.get_edge_cloud_zones_details(zone_id) + # # + # return flavourId def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: appId = app_id @@ -172,15 +168,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): profile_data = app["profile_data"] appProviderId = profile_data["appProviderId"] appVersion = profile_data["appMetaData"]["version"] - # TODO: Iterate in the list; deploy the app in all zones zone_info = app_zones[0]["EdgeCloudZone"] zone_id = zone_info["edgeCloudZoneId"] - flavourId = self._select_best_flavour_for_app(zone_id=zone_id) + # TODO: atm the flavour id is specified as an input parameter + # flavourId = self._select_best_flavour_for_app(zone_id=zone_id) app_deploy_data = schemas.AppDeployData( appId=appId, appProviderId=appProviderId, appVersion=appVersion, - zoneInfo=schemas.ZoneInfo(flavourId=flavourId, zoneId=zone_id), + zoneInfo=schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), ) url = "{}/app/".format(self.base_url) payload = schemas.AppDeploy(app_deploy_data=app_deploy_data) diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index c634f7b..11e7aad 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -4,6 +4,7 @@ test_cases = [ "edgecloud": { "client_name": "i2edge", "base_url": "http://192.168.123.48:30769/", + "flavour_id": "67f3a0b0e3184a85952e174d", } }, # { diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 4365b9e..b534816 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -10,9 +10,9 @@ # - Sergio Giménez (sergio.gimenez@i2cat.net) ## """ -EdgeCloud Platform Integration Tests +EdgeCloud adapters Integration Tests -Validates the complete application lifecycle across multiple adapters: +Validates the complete application lifecycle: 1. Infrastructure (zone discovery) 2. Artefact management (create/delete) 3. Application lifecycle (onboard/deploy/undeploy/delete app onboarded) @@ -22,7 +22,7 @@ Key features: - Tests configuration available in test_config.py - Ensures proper resource cleanup - Uses shared test constants and CAMARA-compliant manifests -- Includes i2edge-specific tests where needed +- Includes artefact unit tests where needed """ import time -- GitLab From 8a8f1a24ac0ecb1845edd4ea00a1f03d8fa1e3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 20 Jun 2025 14:28:57 +0200 Subject: [PATCH 162/281] Add flavour_id in the unit test config --- tests/common/test_invoke_edgecloud_clients.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index 1839c27..7799495 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -8,6 +8,8 @@ EDGE_CLOUD_TEST_CASES = [ "edgecloud": { "client_name": "i2edge", "base_url": "http://test-nbi-i2edge.sunrise6g", + # Additional parameters for i2Edge client: + "flavour_id": "id", } }, { -- GitLab From 96569e2b28eefc398e52c321fdb56e92ccdd42ca Mon Sep 17 00:00:00 2001 From: Panagiotis Pavlidis Date: Fri, 20 Jun 2025 21:03:54 +0300 Subject: [PATCH 163/281] Implement TF logic and test cases --- .../network/core/network_interface.py | 6 +-- src/sunrise6g_opensdk/network/core/schemas.py | 4 +- tests/network/conftest.py | 47 +++++++++++++++++-- tests/network/test_create_monitoring_event.py | 16 ++++--- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index fbd4bee..ab2bb6c 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -258,10 +258,10 @@ class NetworkManagementInterface(ABC): raise NetworkPlatformError("Location information not found in monitoring event report") geo_area = monitoring_event_report.locationInfo.geographicArea report_event_time = monitoring_event_report.eventTime - ageOfLocationInfo = None + age_of_location_info = None if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: - ageOfLocationInfo = monitoring_event_report.locationInfo.ageOfLocationInfo.duration - last_location_time = self._compute_camara_last_location_time(report_event_time,ageOfLocationInfo) + age_of_location_info = monitoring_event_report.locationInfo.ageOfLocationInfo.duration + last_location_time = self._compute_camara_last_location_time(report_event_time,age_of_location_info) camara_polygon = schemas.Polygon(areaType=schemas.AreaType.polygon,boundary=geo_area.polygon.point_list) camara_loc_area = schemas.Area.model_validate(camara_polygon) diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 4c932b2..a35b73e 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -247,11 +247,11 @@ class GeographicalCoordinates(BaseModel): lon: float = Field(..., description="Longitude coordinate.") lat: float = Field(..., description="Latitude coordinate.") -class PointList(BaseModel): +class PointListNef(BaseModel): geographical_coords: list[GeographicalCoordinates] = Field(..., description="List of geographical coordinates defining the points.",min_length=3,max_length=15) class NefPolygon(BaseModel): - point_list: PointList = Field(..., description="List of points defining the polygon.") + point_list: PointListNef = Field(..., description="List of points defining the polygon.") class GeographicArea(BaseModel): polygon: NefPolygon | None = Field(None, description="Identifies a polygonal geographic area.") diff --git a/tests/network/conftest.py b/tests/network/conftest.py index d21ed9f..aebba46 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -1,7 +1,8 @@ import pytest +import datetime from sunrise6g_opensdk.common.sdk import Sdk as sdkclient -from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, Device, MonitoringEventSubscriptionRequest +from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, Device, MonitoringEventSubscriptionRequest, Location, AreaType, PointList, Point,Polygon @@ -48,7 +49,7 @@ def camara_payload_input_data() -> RetrievalLocationRequest: # "monitoringType": "LOCATION_REPORTING", # "locationType": "CURRENT_LOCATION" # } -@pytest.fixture(scope="module", name="expected_output_data") +@pytest.fixture(scope="module", name="expected_3gpp_output_data") def monitoring_request_3gpp_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> MonitoringEventSubscriptionRequest: """ Fixture to provide output data for 3GPP monitoring event request payload. @@ -59,4 +60,44 @@ def monitoring_request_3gpp_payload_output_data(camara_payload_input_data: Retri notificationDestination="http://127.0.0.1:8001", monitoringType="LOCATION_REPORTING", locationType="LAST_KNOWN_LOCATION" - ) \ No newline at end of file + ) + + +@pytest.fixture(scope="module", name="expected_camara_output_data") +def monitoring_request_camara_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> Location: + """ + Fixture to provide output data for 3GPP monitoring event request payload. + + Example: + + { + "lastLocationTime": "2023-10-27T15:30:00Z", + "area": { + "areaType": "POLYGON", + "boundary": [ + { + "latitude": 34.0522, + "longitude": -118.2437 + }, + { + "latitude": 34.0535, + "longitude": -118.2500 + }, + { + "latitude": 34.0480, + "longitude": -118.2520 + } + ] + } + } + """ + point1 = Point(latitude=34.0522, longitude=-118.2437) + point2 = Point(latitude=34.0535, longitude=-118.2500) + point3 = Point(latitude=34.0480, longitude=-118.2520) + + point_list = PointList(root=[point1, point2, point3]) + + polygon_area = Polygon(areaType=AreaType.polygon,boundary=point_list) + + location = Location(lastLocationTime="2023-10-27T15:30:00Z",area=polygon_area) + return location diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index 31e8e7f..41d0a15 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -5,15 +5,19 @@ import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.network.core.common import CoreHttpError from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface -from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest +from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, MonitoringEventSubscriptionRequest, Location from tests.network.conftest import instantiate_network_client, camara_payload_input_data -def test_camara_tf_3gpp_event(network_client : NetworkManagementInterface ,camara_payload_input_data: RetrievalLocationRequest, expected_output_data) -> None: +def test_camara_tf_3gpp_event(network_client : NetworkManagementInterface ,camara_payload_input_data: RetrievalLocationRequest, expected_3gpp_output_data: MonitoringEventSubscriptionRequest) -> None: actual_result = network_client._build_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) - assert actual_result == expected_output_data - - -# def test_create_monitoring_event(): \ No newline at end of file + assert actual_result == expected_3gpp_output_data, f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_3gpp_output_data}), but they were not." + +def test_create_monitoring_event(network_client : NetworkManagementInterface, camara_payload_input_data: RetrievalLocationRequest,expected_camara_output_data: Location ): + try: + actual_result = network_client.create_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) + assert actual_result == expected_camara_output_data + except CoreHttpError as e: + pytest.fail(f"Failed to retrieve event report: {e}") \ No newline at end of file -- GitLab From 27cff9c652b37ecce2621e976e9c2ea25e8c19ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Mon, 23 Jun 2025 09:36:24 +0200 Subject: [PATCH 164/281] get_qod_session returns CAMARA model --- .../network/core/base_network_client.py | 32 +++++++++++++------ src/sunrise6g_opensdk/network/core/schemas.py | 15 +++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index eff63d4..c830b6c 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -13,7 +13,6 @@ from itertools import product from typing import Dict from sunrise6g_opensdk import logger -from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError from sunrise6g_opensdk.network.core import common, schemas log = logger.get_logger(__name__) @@ -220,13 +219,9 @@ class BaseNetworkClient: subscription_info: schemas.AsSessionWithQoSSubscription = ( schemas.AsSessionWithQoSSubscription(**response) ) - subscription_url = subscription_info.self_.root - subscription_id = subscription_url.split("/")[-1] if subscription_url else None - if not subscription_id: - log.error("Failed to retrieve QoS session ID from response") - raise NetworkPlatformError("QoS session ID not found in response") + session_info = schemas.SessionInfo( - sessionId=schemas.SessionId(uuid.UUID(subscription_id)), + sessionId=schemas.SessionId(uuid.UUID(subscription_info.subscription_id)), qosStatus=schemas.QosStatus.REQUESTED, **session_info, ) @@ -242,11 +237,28 @@ class BaseNetworkClient: returns: Dictionary containing the details of the requested QoS session. """ - session = common.as_session_with_qos_get( + response = common.as_session_with_qos_get( self.base_url, self.scs_as_id, session_id=session_id ) - log.info(f"QoD session retrived successfully [id={session_id}]") - return session + subscription_info = schemas.AsSessionWithQoSSubscription(**response) + flowDesc = subscription_info.flowInfo[0].flowDescriptions[0] + serverIp = flowDesc.split("to ")[1].split("/")[0] + session_info = schemas.SessionInfo( + sessionId=schemas.SessionId(uuid.UUID(subscription_info.subscription_id)), + duration=subscription_info.usageThreshold.duration, + sink=subscription_info.notificationDestination, + qosProfile=subscription_info.qosReference, + device=schemas.Device( + ipv4Address=schemas.DeviceIpv4Addr1( + publicAddress=subscription_info.ueIpv4Addr, + privateAddress=subscription_info.ueIpv4Addr, + ), + ), + applicationServer=schemas.ApplicationServer( + ipv4Address=schemas.ApplicationServerIpv4Address(serverIp) + ), + ) + return session_info.model_dump() def delete_qod_session(self, session_id: str) -> None: """ diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 183e3a9..b6cda35 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -13,6 +13,11 @@ from uuid import UUID from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel from pydantic_extra_types.mac_address import MacAddress +from sunrise6g_opensdk.logger import setup_logger +from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError + +log = setup_logger(__name__) + class FlowDirection(Enum): """ @@ -163,6 +168,16 @@ class AsSessionWithQoSSubscription(BaseModel): sponsorInfo: SponsorInformation | None = None qosMonInfo: QosMonitoringInformationModel | None = None + @property + def subscription_id(self) -> str: + """ + Returns the subscription ID, which is the same as the self link. + """ + subscription_id = self.self_.root.split("/")[-1] if self.self_.root else None + if not subscription_id: + log.error("Failed to retrieve QoS session ID from response") + raise NetworkPlatformError("QoS session ID not found in response") + class SourceTrafficFilters(BaseModel): sourcePort: int -- GitLab From d08d9c3c1a99eabe847818a531411df8cbb40ab9 Mon Sep 17 00:00:00 2001 From: manar Date: Mon, 23 Jun 2025 15:55:21 +0200 Subject: [PATCH 165/281] Network-Adapters-Open5GCore: Add not impelemtned errors --- .../network/adapters/open5gcore/client.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index 1b1b785..ed73569 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -40,10 +40,22 @@ class NetworkManager(BaseNetworkClient): ) def add_core_specific_qod_parameters( - self, - session_info: schemas.CreateSession, - subscription: schemas.AsSessionWithQoSSubscription, + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, ) -> None: flow_id = qos_support_map[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) subscription.ueIpv4Addr = "192.168.6.1" # ToDo + + def add_core_specific_ti_parameters( + self, + traffic_influence_info: schemas.CreateTrafficInfluence, + subscription: schemas.TrafficInfluSub, + ): + raise NotImplementedError("add_core_specific_ti_parameters not implemented for Open5GCore") + + def core_specific_traffic_influence_validation( + self, traffic_influence_info: schemas.CreateTrafficInfluence + ) -> None: + raise NotImplementedError("core_specific_traffic_influence_validation not implemented for Open5GCore") \ No newline at end of file -- GitLab From 321c10d5fba3301c311dcea64320cd81b49553f9 Mon Sep 17 00:00:00 2001 From: manar Date: Mon, 23 Jun 2025 16:35:03 +0200 Subject: [PATCH 166/281] Network-Adapters-Open5GCore: Fix code quality --- .../network/adapters/open5gcore/client.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index ed73569..fd35e45 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -40,22 +40,26 @@ class NetworkManager(BaseNetworkClient): ) def add_core_specific_qod_parameters( - self, - session_info: schemas.CreateSession, - subscription: schemas.AsSessionWithQoSSubscription, + self, + session_info: schemas.CreateSession, + subscription: schemas.AsSessionWithQoSSubscription, ) -> None: flow_id = qos_support_map[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) subscription.ueIpv4Addr = "192.168.6.1" # ToDo def add_core_specific_ti_parameters( - self, - traffic_influence_info: schemas.CreateTrafficInfluence, - subscription: schemas.TrafficInfluSub, + self, + traffic_influence_info: schemas.CreateTrafficInfluence, + subscription: schemas.TrafficInfluSub, ): - raise NotImplementedError("add_core_specific_ti_parameters not implemented for Open5GCore") + raise NotImplementedError( + "add_core_specific_ti_parameters not implemented for Open5GCore" + ) def core_specific_traffic_influence_validation( - self, traffic_influence_info: schemas.CreateTrafficInfluence + self, traffic_influence_info: schemas.CreateTrafficInfluence ) -> None: - raise NotImplementedError("core_specific_traffic_influence_validation not implemented for Open5GCore") \ No newline at end of file + raise NotImplementedError( + "core_specific_traffic_influence_validation not implemented for Open5GCore" + ) -- GitLab From 68b3131e21d6d23c1bbeb541bf6a92d671aca0b5 Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Tue, 24 Jun 2025 11:15:49 +0300 Subject: [PATCH 167/281] Finalize tests and opensdk implementation --- .../network/core/network_interface.py | 23 ++++++------- tests/network/conftest.py | 33 ++++++++++--------- tests/network/test_create_monitoring_event.py | 2 +- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index ab2bb6c..a07b72d 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -14,7 +14,7 @@ import uuid from abc import ABC from itertools import product from typing import Dict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sunrise6g_opensdk import logger from sunrise6g_opensdk.network.clients.errors import NetworkPlatformError @@ -224,19 +224,18 @@ class NetworkManagementInterface(ABC): return subscription_3gpp - def _compute_camara_last_location_time(self, event_time_str: datetime, age_of_location_info_min : int = None) -> datetime: + def _compute_camara_last_location_time(self, event_time: datetime, age_of_location_info_min : int = None) -> datetime: """ event_time_str: ISO 8601 string, e.g. "2025-06-18T12:30:00Z" age_of_location_info_min: unsigned int, age of location info in minutes """ - event_time = datetime.fromisoformat(event_time_str.replace("Z", "+00:00")) if age_of_location_info_min is not None: last_location_time = event_time - timedelta(minutes=age_of_location_info_min) - return last_location_time + return last_location_time.replace(tzinfo=timezone.utc) else: - return event_time_str + return event_time.replace(tzinfo=timezone.utc) - def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> dict: + def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.Location: """ Creates a Monitoring Event subscription based on CAMARA Location API input. @@ -262,13 +261,15 @@ class NetworkManagementInterface(ABC): if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: age_of_location_info = monitoring_event_report.locationInfo.ageOfLocationInfo.duration last_location_time = self._compute_camara_last_location_time(report_event_time,age_of_location_info) - - camara_polygon = schemas.Polygon(areaType=schemas.AreaType.polygon,boundary=geo_area.polygon.point_list) - camara_loc_area = schemas.Area.model_validate(camara_polygon) + print(f"Last Location time is {last_location_time}") + camara_point_list : list[schemas.Point] = [] + for point in geo_area.polygon.point_list.geographical_coords: + camara_point_list.append(schemas.Point(latitude=point.lat,longitude=point.lon)) + camara_polygon = schemas.Polygon(areaType=schemas.AreaType.polygon,boundary=schemas.PointList(camara_point_list)) - camara_location = schemas.Location(area=camara_loc_area,lastLocationTime=last_location_time) + camara_location = schemas.Location(area=camara_polygon,lastLocationTime=last_location_time) - return camara_location.model_dump(mode="json") + return camara_location def create_qod_session(self, session_info: Dict) -> Dict: """ diff --git a/tests/network/conftest.py b/tests/network/conftest.py index aebba46..bbd387c 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -1,5 +1,4 @@ import pytest -import datetime from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, Device, MonitoringEventSubscriptionRequest, Location, AreaType, PointList, Point,Polygon @@ -13,7 +12,7 @@ def instantiate_network_client(): client_specs = { "network": { "client_name": "open5gs", - "base_url": "http://127.0.0.1:8082/", + "base_url": "http://127.0.0.1:8000/", "scs_as_id": "af_1", } } @@ -40,7 +39,7 @@ def camara_payload_input_data() -> RetrievalLocationRequest: This data is used in tests that require a specific payload structure. """ - return RetrievalLocationRequest(device=Device(phoneNumber="+1234567890")) + return RetrievalLocationRequest(device=Device(phoneNumber="+306912345678")) # Sample output test data 3GPP MonitoringEventSubscription Request Payload # { @@ -71,33 +70,37 @@ def monitoring_request_camara_payload_output_data(camara_payload_input_data: Ret Example: { - "lastLocationTime": "2023-10-27T15:30:00Z", + "lastLocationTime": "2025-06-23T20:47:22Z", "area": { "areaType": "POLYGON", "boundary": [ { - "latitude": 34.0522, - "longitude": -118.2437 + "latitude": 37.9838, + "longitude": 23.7275 }, { - "latitude": 34.0535, - "longitude": -118.2500 + "latitude": 37.98, + "longitude": 23.75 }, { - "latitude": 34.0480, - "longitude": -118.2520 + "latitude": 37.97, + "longitude": 23.73 + }, + { "latitude": 37.975, + "longtitude": 23.71 } ] } } """ - point1 = Point(latitude=34.0522, longitude=-118.2437) - point2 = Point(latitude=34.0535, longitude=-118.2500) - point3 = Point(latitude=34.0480, longitude=-118.2520) + point1 = Point(latitude=37.9838, longitude=23.7275) + point2 = Point(latitude=37.98, longitude=23.75) + point3 = Point(latitude=37.97, longitude=23.73) + point4 = Point(latitude=37.975, longitude=23.71) - point_list = PointList(root=[point1, point2, point3]) + point_list = PointList(root=[point1, point2, point3,point4]) polygon_area = Polygon(areaType=AreaType.polygon,boundary=point_list) - location = Location(lastLocationTime="2023-10-27T15:30:00Z",area=polygon_area) + location = Location(lastLocationTime="2025-06-23T20:47:22Z",area=polygon_area) return location diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index 41d0a15..e292cde 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -18,6 +18,6 @@ def test_camara_tf_3gpp_event(network_client : NetworkManagementInterface ,camar def test_create_monitoring_event(network_client : NetworkManagementInterface, camara_payload_input_data: RetrievalLocationRequest,expected_camara_output_data: Location ): try: actual_result = network_client.create_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) - assert actual_result == expected_camara_output_data + assert actual_result == expected_camara_output_data, f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_camara_output_data}), but they were not." except CoreHttpError as e: pytest.fail(f"Failed to retrieve event report: {e}") \ No newline at end of file -- GitLab From baa894a29d378c1525a81751c74f0142d8cdf38d Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Tue, 24 Jun 2025 14:22:28 +0300 Subject: [PATCH 168/281] Provide docstrings in test and interface --- .../network/core/network_interface.py | 10 ++++++++-- tests/network/conftest.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index a07b72d..1b570a1 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -226,8 +226,14 @@ class NetworkManagementInterface(ABC): def _compute_camara_last_location_time(self, event_time: datetime, age_of_location_info_min : int = None) -> datetime: """ - event_time_str: ISO 8601 string, e.g. "2025-06-18T12:30:00Z" - age_of_location_info_min: unsigned int, age of location info in minutes + Computes the last location time based on the event time and age of location info. + + args: + event_time: ISO 8601 datetime, e.g. "2025-06-18T12:30:00Z" + age_of_location_info_min: unsigned int, age of location info in minutes + + returns: + datetime object representing the last location time in UTC. """ if age_of_location_info_min is not None: last_location_time = event_time - timedelta(minutes=age_of_location_info_min) diff --git a/tests/network/conftest.py b/tests/network/conftest.py index bbd387c..f493197 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -65,7 +65,7 @@ def monitoring_request_3gpp_payload_output_data(camara_payload_input_data: Retri @pytest.fixture(scope="module", name="expected_camara_output_data") def monitoring_request_camara_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> Location: """ - Fixture to provide output data for 3GPP monitoring event request payload. + Fixture to provide output data for camara request payload. Example: -- GitLab From 623a881bd627834ca9229dc73c26faf2816302ae Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Tue, 24 Jun 2025 14:32:53 +0300 Subject: [PATCH 169/281] Adaptations after pre-commit rules --- .../network/clients/open5gs/client.py | 15 +- src/sunrise6g_opensdk/network/core/common.py | 5 +- .../network/core/network_interface.py | 76 +++-- src/sunrise6g_opensdk/network/core/schemas.py | 317 ++++++++++++++---- tests/network/conftest.py | 35 +- tests/network/test_create_monitoring_event.py | 41 ++- 6 files changed, 365 insertions(+), 124 deletions(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py index 6e8db63..f02ceb1 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -56,22 +56,27 @@ class NetworkManager(NetworkManagementInterface): flow_id = flow_id_mapping[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) - def core_specific_monitoring_event_validation(self, retrieve_location_request : schemas.RetrievalLocationRequest) -> None: + def core_specific_monitoring_event_validation( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> None: if retrieve_location_request.device is None: raise ValidationError( "Open5GS requires a device to be specified for location retrieval in NEF." ) - def add_core_specific_location_parameters(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.MonitoringEventSubscriptionRequest: + + def add_core_specific_location_parameters( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.MonitoringEventSubscriptionRequest: return schemas.MonitoringEventSubscriptionRequest( - msisdn=retrieve_location_request.device.phoneNumber.root.lstrip('+'), + msisdn=retrieve_location_request.device.phoneNumber.root.lstrip("+"), notificationDestination="http://127.0.0.1:8001", monitoringType=schemas.MonitoringType.LOCATION_REPORTING, - locationType=schemas.LocationType.LAST_KNOWN + locationType=schemas.LocationType.LAST_KNOWN, ) # subscription.msisdn = retrieve_location_request.device.phoneNumber.root.lstrip('+') # monitoringType = schemas.MonitoringType.LOCATION_REPORTING # locationType = schemas.LocationType.LAST_KNOWN - # locationType = schemas.LocationType.CURRENT_LOCATION + # locationType = schemas.LocationType.CURRENT_LOCATION # maximumNumberOfReports = 1 # repPeriod = schemas.DurationSec(root=20) diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index feda74f..51900b8 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -29,11 +29,12 @@ def _make_request(method: str, url: str, data=None): raise CoreHttpError(e) from e except requests.exceptions.ConnectionError as e: raise CoreHttpError("connection error") from e - # Monitoring Event Methods -def monitoring_event_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: +def monitoring_event_post( + base_url: str, scs_as_id: str, model_payload: BaseModel +) -> dict: data = model_payload.model_dump_json(exclude_none=True, by_alias=True) url = monitoring_event_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 1b570a1..3d449e5 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -12,9 +12,9 @@ ## import uuid from abc import ABC +from datetime import datetime, timedelta, timezone from itertools import product from typing import Dict -from datetime import datetime, timedelta, timezone from sunrise6g_opensdk import logger from sunrise6g_opensdk.network.clients.errors import NetworkPlatformError @@ -113,9 +113,8 @@ class NetworkManagementInterface(ABC): pass def add_core_specific_location_parameters( - self, - retrieve_location_request: schemas.RetrievalLocationRequest - )-> schemas.MonitoringEventSubscriptionRequest: + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.MonitoringEventSubscriptionRequest: """ Placeholder for adding core-specific parameters to the location subscription. This method should be overridden by subclasses to implement specific logic. @@ -152,7 +151,9 @@ class NetworkManagementInterface(ABC): # This method should be overridden by subclasses if needed pass - def core_specific_monitoring_event_validation(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> None: + def core_specific_monitoring_event_validation( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> None: """ Validates core-specific parameters for the monitoring event subscription. @@ -211,20 +212,26 @@ class NetworkManagementInterface(ABC): self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription - - def _build_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.MonitoringEventSubscriptionRequest: + + def _build_monitoring_event_subscription( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.MonitoringEventSubscriptionRequest: self.core_specific_monitoring_event_validation(retrieve_location_request) - subscription_3gpp = self.add_core_specific_location_parameters(retrieve_location_request) + subscription_3gpp = self.add_core_specific_location_parameters( + retrieve_location_request + ) device = retrieve_location_request.device subscription_3gpp.externalId = device.networkAccessIdentifier subscription_3gpp.ipv4Addr = device.ipv4Address subscription_3gpp.ipv6Addr = device.ipv6Address # subscription.msisdn = device.phoneNumber.root.lstrip('+') # subscription.notificationDestination = "http://127.0.0.1:8001" - + return subscription_3gpp - def _compute_camara_last_location_time(self, event_time: datetime, age_of_location_info_min : int = None) -> datetime: + def _compute_camara_last_location_time( + self, event_time: datetime, age_of_location_info_min: int = None + ) -> datetime: """ Computes the last location time based on the event time and age of location info. @@ -232,16 +239,20 @@ class NetworkManagementInterface(ABC): event_time: ISO 8601 datetime, e.g. "2025-06-18T12:30:00Z" age_of_location_info_min: unsigned int, age of location info in minutes - returns: + returns: datetime object representing the last location time in UTC. """ if age_of_location_info_min is not None: - last_location_time = event_time - timedelta(minutes=age_of_location_info_min) + last_location_time = event_time - timedelta( + minutes=age_of_location_info_min + ) return last_location_time.replace(tzinfo=timezone.utc) else: return event_time.replace(tzinfo=timezone.utc) - def create_monitoring_event_subscription(self, retrieve_location_request: schemas.RetrievalLocationRequest) -> schemas.Location: + def create_monitoring_event_subscription( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.Location: """ Creates a Monitoring Event subscription based on CAMARA Location API input. @@ -252,28 +263,45 @@ class NetworkManagementInterface(ABC): returns: dictionary containing the created subscription details, including its ID. """ - subscription = self._build_monitoring_event_subscription(retrieve_location_request) + subscription = self._build_monitoring_event_subscription( + retrieve_location_request + ) response = common.monitoring_event_post( self.base_url, self.scs_as_id, subscription ) monitoring_event_report = schemas.MonitoringEventReport(**response) if monitoring_event_report.locationInfo is None: - log.error("Failed to retrieve location information from monitoring event report") - raise NetworkPlatformError("Location information not found in monitoring event report") + log.error( + "Failed to retrieve location information from monitoring event report" + ) + raise NetworkPlatformError( + "Location information not found in monitoring event report" + ) geo_area = monitoring_event_report.locationInfo.geographicArea report_event_time = monitoring_event_report.eventTime age_of_location_info = None - if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: - age_of_location_info = monitoring_event_report.locationInfo.ageOfLocationInfo.duration - last_location_time = self._compute_camara_last_location_time(report_event_time,age_of_location_info) + if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: + age_of_location_info = ( + monitoring_event_report.locationInfo.ageOfLocationInfo.duration + ) + last_location_time = self._compute_camara_last_location_time( + report_event_time, age_of_location_info + ) print(f"Last Location time is {last_location_time}") - camara_point_list : list[schemas.Point] = [] + camara_point_list: list[schemas.Point] = [] for point in geo_area.polygon.point_list.geographical_coords: - camara_point_list.append(schemas.Point(latitude=point.lat,longitude=point.lon)) - camara_polygon = schemas.Polygon(areaType=schemas.AreaType.polygon,boundary=schemas.PointList(camara_point_list)) - - camara_location = schemas.Location(area=camara_polygon,lastLocationTime=last_location_time) + camara_point_list.append( + schemas.Point(latitude=point.lat, longitude=point.lon) + ) + camara_polygon = schemas.Polygon( + areaType=schemas.AreaType.polygon, + boundary=schemas.PointList(camara_point_list), + ) + + camara_location = schemas.Location( + area=camara_polygon, lastLocationTime=last_location_time + ) return camara_location diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index a35b73e..9c5da37 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -10,7 +10,15 @@ from ipaddress import IPv4Address, IPv6Address from typing import Annotated, Literal from uuid import UUID -from pydantic import AnyUrl, BaseModel, ConfigDict, Field, NonNegativeInt, RootModel, AnyHttpUrl +from pydantic import ( + AnyHttpUrl, + AnyUrl, + BaseModel, + ConfigDict, + Field, + NonNegativeInt, + RootModel, +) from pydantic_extra_types.mac_address import MacAddress @@ -214,88 +222,193 @@ class TrafficInfluSub(BaseModel): # Replace with a meaningful name ##Monitoring Event API + class DurationMin(BaseModel): - duration: int = Field(0,description="Unsigned integer identifying a period of time in units of minutes",ge=0) + duration: int = Field( + 0, + description="Unsigned integer identifying a period of time in units of minutes", + ge=0, + ) + class PlmnId(BaseModel): - mcc: str = Field(...,description="String encoding a Mobile Country Code, comprising of 3 digits.") - mnc: str = Field(...,description="String encoding a Mobile Network Code, comprising of 2 or 3 digits.") - -#The enumeration Accuracy represents a desired granularity of accuracy of the requested location information. -class Accuracy(str,Enum): - cgi_ecgi = "CGI_ECGI" # The AF requests to be notified using cell level location accuracy. - ta_ra = "TA_RA" # The AF requests to be notified using TA/RA level location accuracy. - geo_area = "GEO_AREA" # The AF requests to be notified using the geographical area accuracy. - civic_addr = "CIVIC_ADDR" # The AF requests to be notified using the civic address accuracy. #EDGEAPP - -#If locationType set to "LAST_KNOWN_LOCATION", the monitoring event request from AF shall be only for one-time monitoring request -class LocationType(str,Enum): - CURRENT_LOCATION = "CURRENT_LOCATION" # The AF requests to be notified for current location. - LAST_KNOWN = "LAST_KNOWN_LOCATION" # The AF requests to be notified for last known location. - -#This data type represents a monitoring event type. + mcc: str = Field( + ..., + description="String encoding a Mobile Country Code, comprising of 3 digits.", + ) + mnc: str = Field( + ..., + description="String encoding a Mobile Network Code, comprising of 2 or 3 digits.", + ) + + +# The enumeration Accuracy represents a desired granularity of accuracy of the requested location information. +class Accuracy(str, Enum): + cgi_ecgi = ( + "CGI_ECGI" # The AF requests to be notified using cell level location accuracy. + ) + ta_ra = ( + "TA_RA" # The AF requests to be notified using TA/RA level location accuracy. + ) + geo_area = "GEO_AREA" # The AF requests to be notified using the geographical area accuracy. + civic_addr = "CIVIC_ADDR" # The AF requests to be notified using the civic address accuracy. #EDGEAPP + + +# If locationType set to "LAST_KNOWN_LOCATION", the monitoring event request from AF shall be only for one-time monitoring request +class LocationType(str, Enum): + CURRENT_LOCATION = ( + "CURRENT_LOCATION" # The AF requests to be notified for current location. + ) + LAST_KNOWN = ( + "LAST_KNOWN_LOCATION" # The AF requests to be notified for last known location. + ) + + +# This data type represents a monitoring event type. class MonitoringType(str, Enum): LOCATION_REPORTING = "LOCATION_REPORTING" -class LocationFailureCause(str,Enum): - position_denied = "POSITIONING_DENIED" # Positioning is denied. - unsupported_by_ue = "UNSUPPORTED_BY_UE" # Positioning is not supported by UE. - not_registered_ue = "NOT_REGISTERED_UE" # UE is not registered. - unspecified = "UNSPECIFIED" # Unspecified cause. + +class LocationFailureCause(str, Enum): + position_denied = "POSITIONING_DENIED" # Positioning is denied. + unsupported_by_ue = "UNSUPPORTED_BY_UE" # Positioning is not supported by UE. + not_registered_ue = "NOT_REGISTERED_UE" # UE is not registered. + unspecified = "UNSPECIFIED" # Unspecified cause. + class GeographicalCoordinates(BaseModel): lon: float = Field(..., description="Longitude coordinate.") lat: float = Field(..., description="Latitude coordinate.") + class PointListNef(BaseModel): - geographical_coords: list[GeographicalCoordinates] = Field(..., description="List of geographical coordinates defining the points.",min_length=3,max_length=15) + geographical_coords: list[GeographicalCoordinates] = Field( + ..., + description="List of geographical coordinates defining the points.", + min_length=3, + max_length=15, + ) + class NefPolygon(BaseModel): - point_list: PointListNef = Field(..., description="List of points defining the polygon.") + point_list: PointListNef = Field( + ..., description="List of points defining the polygon." + ) + class GeographicArea(BaseModel): - polygon: NefPolygon | None = Field(None, description="Identifies a polygonal geographic area.") + polygon: NefPolygon | None = Field( + None, description="Identifies a polygonal geographic area." + ) + -#This data type represents the user location information which is sent from the NEF to the AF. +# This data type represents the user location information which is sent from the NEF to the AF. class LocationInfo(BaseModel): - ageOfLocationInfo: DurationMin | None = Field(None,description="Indicates the elapsed time since the last network contact of the UE.") + ageOfLocationInfo: DurationMin | None = Field( + None, + description="Indicates the elapsed time since the last network contact of the UE.", + ) cellId: str | None = Field(None, description="Cell ID where the UE is located.") - trackingAreaId: str | None = Field(None, description="TrackingArea ID where the UE is located.") + trackingAreaId: str | None = Field( + None, description="TrackingArea ID where the UE is located." + ) enodeBId: str | None = Field(None, description="eNodeB ID where the UE is located.") - routingAreaId: str | None = Field(None, description="Routing Area ID where the UE is located") + routingAreaId: str | None = Field( + None, description="Routing Area ID where the UE is located" + ) plmnId: PlmnId | None = Field(None, description="PLMN ID where the UE is located.") twanId: str | None = Field(None, description="TWAN ID where the UE is located.") - geographicArea: GeographicArea | None = Field(None,description="Identifies a geographic area of the user where the UE is located.") + geographicArea: GeographicArea | None = Field( + None, + description="Identifies a geographic area of the user where the UE is located.", + ) + class MonitoringEventSubscriptionRequest(BaseModel): - accuracy: Accuracy | None = Field(None,description="Accuracy represents a desired granularity of accuracy of the requested location information.") - externalId: str | None = Field(None, description="Identifies a user clause 4.6.2 TS 23.682 (optional)") - msisdn: str | None = Field(None,description="Identifies the MS internal PSTN/ISDN number allocated for a UE.") - ipv4Addr: IPv4Address | None = Field(None,description="Identifies the Ipv4 address.") - ipv6Addr: IPv6Address | None = Field(None,description="Identifies the Ipv6 address.") - notificationDestination: AnyHttpUrl = Field(..., description="URI of a notification destination that the T8 message shall be delivered to.") - monitoringType: MonitoringType = Field(..., description="Enumeration of monitoring type. Refer to clause 5.3.2.4.3.") - maximumNumberOfReports: int | None = Field(None, description="Identifies the maximum number of event reports to be generated by the AMF to the NEF and then the AF.") - monitorExpireTime: datetime | None = Field(None, description="Identifies the absolute time at which the related monitoring event request is considered to expire.") - locationType: LocationType | None = Field(None, description="Indicates whether the request is for Current Location, Initial Location, or Last Known Location.") - repPeriod: DurationSec | None = Field(None,description="Identifies the periodic time for the event reports.") - minimumReportInterval: DurationSec | None = Field(None,description="identifies a minimum time interval between Location Reporting notifications") + accuracy: Accuracy | None = Field( + None, + description="Accuracy represents a desired granularity of accuracy of the requested location information.", + ) + externalId: str | None = Field( + None, description="Identifies a user clause 4.6.2 TS 23.682 (optional)" + ) + msisdn: str | None = Field( + None, + description="Identifies the MS internal PSTN/ISDN number allocated for a UE.", + ) + ipv4Addr: IPv4Address | None = Field( + None, description="Identifies the Ipv4 address." + ) + ipv6Addr: IPv6Address | None = Field( + None, description="Identifies the Ipv6 address." + ) + notificationDestination: AnyHttpUrl = Field( + ..., + description="URI of a notification destination that the T8 message shall be delivered to.", + ) + monitoringType: MonitoringType = Field( + ..., description="Enumeration of monitoring type. Refer to clause 5.3.2.4.3." + ) + maximumNumberOfReports: int | None = Field( + None, + description="Identifies the maximum number of event reports to be generated by the AMF to the NEF and then the AF.", + ) + monitorExpireTime: datetime | None = Field( + None, + description="Identifies the absolute time at which the related monitoring event request is considered to expire.", + ) + locationType: LocationType | None = Field( + None, + description="Indicates whether the request is for Current Location, Initial Location, or Last Known Location.", + ) + repPeriod: DurationSec | None = Field( + None, description="Identifies the periodic time for the event reports." + ) + minimumReportInterval: DurationSec | None = Field( + None, + description="identifies a minimum time interval between Location Reporting notifications", + ) # This data type represents a monitoring event notification which is sent from the NEF to the AF. class MonitoringEventReport(BaseModel): - externalId: str | None = Field(None,description="Identifies a user, clause 4.6.2 TS 23.682") - msisdn: str | None = Field(None,description="Identifies the MS internal PSTN/ISDN number allocated for a UE.") - locationInfo: LocationInfo | None = Field(None, description="Indicates the user location related information.") - locFailureCause: LocationFailureCause | None = Field(None, description="Indicates the location positioning failure cause.") - monitoringType: MonitoringType = Field(..., description="Identifies the type of monitoring as defined in clause 5.3.2.4.3.") - eventTime: datetime | None = Field(None, description="Identifies when the event is detected or received. Shall be included for each group of UEs.") + externalId: str | None = Field( + None, description="Identifies a user, clause 4.6.2 TS 23.682" + ) + msisdn: str | None = Field( + None, + description="Identifies the MS internal PSTN/ISDN number allocated for a UE.", + ) + locationInfo: LocationInfo | None = Field( + None, description="Indicates the user location related information." + ) + locFailureCause: LocationFailureCause | None = Field( + None, description="Indicates the location positioning failure cause." + ) + monitoringType: MonitoringType = Field( + ..., + description="Identifies the type of monitoring as defined in clause 5.3.2.4.3.", + ) + eventTime: datetime | None = Field( + None, + description="Identifies when the event is detected or received. Shall be included for each group of UEs.", + ) + # This data type represents a monitoring notification which is sent from the NEF to the AF. class MonitoringNotification(BaseModel): - subscription: AnyHttpUrl = Field(..., description="Link to the subscription resource to which this notification is related.") - monitoringEventReports: list[MonitoringEventReport] | None = Field(None, description="Each element identifies a monitoring event report (optional).") - cancelInd: bool | None = Field(False,description="Indicates whether to request to cancel the corresponding monitoring subscription. Set to false or omitted otherwise.") + subscription: AnyHttpUrl = Field( + ..., + description="Link to the subscription resource to which this notification is related.", + ) + monitoringEventReports: list[MonitoringEventReport] | None = Field( + None, + description="Each element identifies a monitoring event report (optional).", + ) + cancelInd: bool | None = Field( + False, + description="Indicates whether to request to cancel the corresponding monitoring subscription. Set to false or omitted otherwise.", + ) ############################################################### @@ -381,43 +494,107 @@ class RetrievalLocationRequest(BaseModel): """ Request to retrieve the location of a device. Device is not required when using a 3-legged access token. """ - device: Annotated[Device | None, Field(None,description="End-user device able to connect to a mobile network.")] - maxAge: Annotated[int | None, Field(None, description="Maximum age of the location information which is accepted for the location retrieval (in seconds).")] - maxSurface: Annotated[int | None, Field(None,description="Maximum surface in square meters which is accepted by the client for the location retrieval.",ge=1,examples=[1000000])] + device: Annotated[ + Device | None, + Field(None, description="End-user device able to connect to a mobile network."), + ] + maxAge: Annotated[ + int | None, + Field( + None, + description="Maximum age of the location information which is accepted for the location retrieval (in seconds).", + ), + ] + maxSurface: Annotated[ + int | None, + Field( + None, + description="Maximum surface in square meters which is accepted by the client for the location retrieval.", + ge=1, + examples=[1000000], + ), + ] + + +class AreaType(str, Enum): + circle = "CIRCLE" # The area is defined as a circle. + polygon = "POLYGON" # The area is defined as a polygon. -class AreaType(str,Enum): - circle = "CIRCLE" # The area is defined as a circle. - polygon = "POLYGON" # The area is defined as a polygon. class Point(BaseModel): - latitude: Annotated[float,Field(description="Latitude component of a location.",examples=["50.735851"],ge=-90,le=90)] - longitude: Annotated[float,Field(..., description="Longitude component of location.",examples=["7.10066"],ge=-180,le=180)] + latitude: Annotated[ + float, + Field( + description="Latitude component of a location.", + examples=["50.735851"], + ge=-90, + le=90, + ), + ] + longitude: Annotated[ + float, + Field( + ..., + description="Longitude component of location.", + examples=["7.10066"], + ge=-180, + le=180, + ), + ] -class PointList(RootModel[Annotated[ - list[Point], - Field(min_length=3,max_length=15, description="List of points defining the area.")]]): + +class PointList( + RootModel[ + Annotated[ + list[Point], + Field( + min_length=3, + max_length=15, + description="List of points defining the area.", + ), + ] + ] +): pass + class Circle(BaseModel): areaType: Literal[AreaType.circle] center: Annotated[Point, Field(description="Center point of the circle.")] - radius: Annotated[float,Field(description="Radius of the circle.",ge=1)] + radius: Annotated[float, Field(description="Radius of the circle.", ge=1)] + class Polygon(BaseModel): areaType: Literal[AreaType.polygon] - boundary: Annotated[PointList, Field(description="List of points defining the polygon.")] + boundary: Annotated[ + PointList, Field(description="List of points defining the polygon.") + ] + Area = Annotated[Circle | Polygon, Field(discriminator="areaType")] -class LastLocationTime(RootModel[Annotated[ - datetime, - Field( description="Last date and time when the device was localized.",examples="2023-09-07T10:40:52Z")]]): + +class LastLocationTime( + RootModel[ + Annotated[ + datetime, + Field( + description="Last date and time when the device was localized.", + examples="2023-09-07T10:40:52Z", + ), + ] + ] +): pass + class Location(BaseModel): - lastLocationTime: Annotated[LastLocationTime, Field(description="Last known location time.")] - area: Annotated[Area,Field(description="Geographical area of the location.")] + lastLocationTime: Annotated[ + LastLocationTime, Field(description="Last known location time.") + ] + area: Annotated[Area, Field(description="Geographical area of the location.")] + class ApplicationServerIpv4Address(RootModel[str]): root: Annotated[ diff --git a/tests/network/conftest.py b/tests/network/conftest.py index f493197..e2a793a 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -1,9 +1,16 @@ import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient -from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, Device, MonitoringEventSubscriptionRequest, Location, AreaType, PointList, Point,Polygon - - +from sunrise6g_opensdk.network.core.schemas import ( + AreaType, + Device, + Location, + MonitoringEventSubscriptionRequest, + Point, + PointList, + Polygon, + RetrievalLocationRequest, +) @pytest.fixture(scope="session", name="network_client") @@ -32,6 +39,7 @@ def instantiate_network_client(): # "ipv6Address": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" # } + @pytest.fixture(scope="module") def camara_payload_input_data() -> RetrievalLocationRequest: """ @@ -41,6 +49,7 @@ def camara_payload_input_data() -> RetrievalLocationRequest: return RetrievalLocationRequest(device=Device(phoneNumber="+306912345678")) + # Sample output test data 3GPP MonitoringEventSubscription Request Payload # { # "msisdn": "+306912345678", @@ -49,21 +58,25 @@ def camara_payload_input_data() -> RetrievalLocationRequest: # "locationType": "CURRENT_LOCATION" # } @pytest.fixture(scope="module", name="expected_3gpp_output_data") -def monitoring_request_3gpp_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> MonitoringEventSubscriptionRequest: +def monitoring_request_3gpp_payload_output_data( + camara_payload_input_data: RetrievalLocationRequest, +) -> MonitoringEventSubscriptionRequest: """ Fixture to provide output data for 3GPP monitoring event request payload. """ - output_msisdn = camara_payload_input_data.device.phoneNumber.root.lstrip('+') + output_msisdn = camara_payload_input_data.device.phoneNumber.root.lstrip("+") return MonitoringEventSubscriptionRequest( msisdn=output_msisdn, notificationDestination="http://127.0.0.1:8001", monitoringType="LOCATION_REPORTING", - locationType="LAST_KNOWN_LOCATION" + locationType="LAST_KNOWN_LOCATION", ) @pytest.fixture(scope="module", name="expected_camara_output_data") -def monitoring_request_camara_payload_output_data(camara_payload_input_data: RetrievalLocationRequest) -> Location: +def monitoring_request_camara_payload_output_data( + camara_payload_input_data: RetrievalLocationRequest, +) -> Location: """ Fixture to provide output data for camara request payload. @@ -98,9 +111,9 @@ def monitoring_request_camara_payload_output_data(camara_payload_input_data: Ret point3 = Point(latitude=37.97, longitude=23.73) point4 = Point(latitude=37.975, longitude=23.71) - point_list = PointList(root=[point1, point2, point3,point4]) - - polygon_area = Polygon(areaType=AreaType.polygon,boundary=point_list) + point_list = PointList(root=[point1, point2, point3, point4]) + + polygon_area = Polygon(areaType=AreaType.polygon, boundary=point_list) - location = Location(lastLocationTime="2025-06-23T20:47:22Z",area=polygon_area) + location = Location(lastLocationTime="2025-06-23T20:47:22Z", area=polygon_area) return location diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index e292cde..d484452 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -1,23 +1,40 @@ - - import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.network.core.common import CoreHttpError from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface -from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest, MonitoringEventSubscriptionRequest, Location +from sunrise6g_opensdk.network.core.schemas import ( + Location, + MonitoringEventSubscriptionRequest, + RetrievalLocationRequest, +) +from tests.network.conftest import camara_payload_input_data, instantiate_network_client -from tests.network.conftest import instantiate_network_client, camara_payload_input_data +def test_camara_tf_3gpp_event( + network_client: NetworkManagementInterface, + camara_payload_input_data: RetrievalLocationRequest, + expected_3gpp_output_data: MonitoringEventSubscriptionRequest, +) -> None: + actual_result = network_client._build_monitoring_event_subscription( + retrieve_location_request=camara_payload_input_data + ) + assert ( + actual_result == expected_3gpp_output_data + ), f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_3gpp_output_data}), but they were not." -def test_camara_tf_3gpp_event(network_client : NetworkManagementInterface ,camara_payload_input_data: RetrievalLocationRequest, expected_3gpp_output_data: MonitoringEventSubscriptionRequest) -> None: - actual_result = network_client._build_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) - assert actual_result == expected_3gpp_output_data, f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_3gpp_output_data}), but they were not." - -def test_create_monitoring_event(network_client : NetworkManagementInterface, camara_payload_input_data: RetrievalLocationRequest,expected_camara_output_data: Location ): +def test_create_monitoring_event( + network_client: NetworkManagementInterface, + camara_payload_input_data: RetrievalLocationRequest, + expected_camara_output_data: Location, +): try: - actual_result = network_client.create_monitoring_event_subscription(retrieve_location_request=camara_payload_input_data) - assert actual_result == expected_camara_output_data, f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_camara_output_data}), but they were not." + actual_result = network_client.create_monitoring_event_subscription( + retrieve_location_request=camara_payload_input_data + ) + assert ( + actual_result == expected_camara_output_data + ), f"Expected actual_result ({actual_result}) \n to be equal to expected_result ({expected_camara_output_data}), but they were not." except CoreHttpError as e: - pytest.fail(f"Failed to retrieve event report: {e}") \ No newline at end of file + pytest.fail(f"Failed to retrieve event report: {e}") -- GitLab From 81ec2d543ee7ebf56e4d9253aed2370746ad1a0c Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Tue, 24 Jun 2025 20:04:39 +0300 Subject: [PATCH 170/281] Edge-Application management initial --- .../edgecloud/clients/aeros/client.py | 244 +++++++++++++++--- .../edgecloud/clients/i2edge/utils.py | 1 - tests/common/test_invoke_edgecloud_clients.py | 8 +- tests/edgecloud/test_config.py | 107 +++++++- tests/edgecloud/test_e2e.py | 87 +++---- 5 files changed, 349 insertions(+), 98 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py index a37fec6..7edebef 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py @@ -5,10 +5,14 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## +import uuid from typing import Any, Dict, List, Optional +import yaml + from sunrise6g_opensdk.edgecloud.clients.aeros import config from sunrise6g_opensdk.edgecloud.clients.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) @@ -24,6 +28,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str, **kwargs): self.base_url = base_url self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + self._app_store: Dict[str, Dict] = {} + self._deployed_services: Dict[str, List[str]] = {} # Overwrite config values if provided via kwargs if "aerOS_API_URL" in kwargs: @@ -42,39 +48,169 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app(self, app_manifest: Dict) -> Dict: # HLO-FE POST with TOSCA and app_id (service_id) - service_id = app_manifest.get("serviceId") - tosca_str = app_manifest.get("tosca") - aeros_client = ContinuumClient(self.base_url) - onboard_response = aeros_client.onboard_service( - service_id=service_id, tosca_str=tosca_str - ) - return {"appId": onboard_response["serviceId"]} + # service_id = app_manifest.get("serviceId") + # tosca_str = app_manifest.get("tosca") + # aeros_client = ContinuumClient(self.base_url) + # onboard_response = aeros_client.onboard_service( + # service_id=service_id, tosca_str=tosca_str + # ) + app_id = app_manifest.get("appId") + if not app_id: + raise EdgeCloudPlatformError("Missing 'appId' in app manifest") + + if app_id in self._app_store: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' already exists" + ) + + self._app_store[app_id] = app_manifest + self.logger.debug("Onboarded application with id: %s", app_id) + return {"appId": app_id} def get_all_onboarded_apps(self) -> List[Dict]: - aeros_client = ContinuumClient(self.base_url) - ngsild_params = "type=Service&format=simplified" - aeros_apps = aeros_client.query_entities(ngsild_params) - return [ - {"appId": service["id"], "name": service["name"]} for service in aeros_apps - ] - # return [{"appId": "1234-5678", "name": "TestApp"}] + # aeros_client = ContinuumClient(self.base_url) + # ngsild_params = "type=Service&format=simplified" + # aeros_apps = aeros_client.query_entities(ngsild_params) + # return [ + # {"appId": service["id"], "name": service["name"]} for service in aeros_apps + # ] + return list(self._app_store.values()) def get_onboarded_app(self, app_id: str) -> Dict: - aeros_client = ContinuumClient(self.base_url) - ngsild_params = "format=simplified" - aeros_app = aeros_client.query_entity(app_id, ngsild_params) - return {"appId": aeros_app["id"], "name": aeros_app["name"]} + # aeros_client = ContinuumClient(self.base_url) + # ngsild_params = "format=simplified" + # aeros_app = aeros_client.query_entity(app_id, ngsild_params) + # return {"appId": aeros_app["id"], "name": aeros_app["name"]} + if app_id not in self._app_store: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist" + ) + return self._app_store[app_id] def delete_onboarded_app(self, app_id: str) -> None: - print(f"Deleting application: {app_id}") + if app_id not in self._app_store: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist" + ) + del self._app_store[app_id] # TBD: Purge from continuum (make all ngsil-ld calls for servieId connected entities) # Should check if undeployed first + def _generate_service_id(self, app_id: str) -> str: + return f"ur n:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + + def _generate_tosca_yaml_dict( + self, app_manifest: Dict, app_zones: List[Dict] + ) -> Dict: + component = app_manifest.get("componentSpec", [{}])[0] + component_name = component.get("componentName", "application") + + image_path = app_manifest.get("appRepo", {}).get("imagePath", "") + # Extract image_file + image_file = image_path.split("/")[-1] + # Extract repository_url + if "/" in image_path: + repository_url = "/".join(image_path.split("/")[:-1]) + else: + repository_url = "docker_hub" + zone_id = ( + app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") + ) + ports = {} + for iface in component.get("networkInterfaces", []): + interface_id = iface.get("interfaceId", "default") + protocol = iface.get("protocol", "TCP").lower() + port = iface.get("port", 8080) + ports[interface_id] = { + "properties": {"protocol": [protocol], "source": port} + } + expose_ports = any( + iface.get("visibilityType") == "VISIBILITY_EXTERNAL" + for iface in component.get("networkInterfaces", []) + ) + + yaml_dict = { + "tosca_definitions_version": "tosca_simple_yaml_1_3", + "description": f"TOSCA for {app_manifest.get('name', 'application')}", + "node_templates": { + component_name: { + "type": "tosca.nodes.Container.Application", + "requirements": [ + { + "network": { + "properties": { + "ports": ports, + "exposePorts": expose_ports, + } + } + }, + {"host": {"node_filter": {"properties": {"id": zone_id}}}}, + ], + "artifacts": { + "application_image": { + "file": image_file, + "type": "tosca.artifacts.Deployment.Image.Container.Docker", + "repository": repository_url, + } + }, + "interfaces": { + "Standard": { + "create": { + "implementation": "application_image", + "inputs": {"cliArgs": [], "envVars": []}, + } + } + }, + } + }, + } + + return yaml_dict + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: # HLO-FE PUT with app_id (service_id) + # aeros_client = ContinuumClient(self.base_url) + # deploy_response = aeros_client.deploy_service(app_id) + # return {"deploy_name": deploy_response["serviceId"]} + # 1. Get app CAMARA manifest + app_manifest = self._app_store.get(app_id) + if not app_manifest: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist" + ) + + # 2. Generate unique service ID + service_id = self._generate_service_id(app_id) + # Dev test + # service_id = "service-my-id" + + # yaml_dict = { + # "serviceId": service_id, + # "tosca": app_manifest.get("tosca", "") + # } + + # 5. Convert dict to YAML string + yaml_dict = self._generate_tosca_yaml_dict(app_manifest, app_zones) + tosca_yaml = yaml.dump(yaml_dict, sort_keys=False) + print("Generated TOSCA YAML:") + print(tosca_yaml) + response = {"serviceId": service_id} # Mocked response + # 6. Instantiate client and call onboard_service aeros_client = ContinuumClient(self.base_url) - deploy_response = aeros_client.deploy_service(app_id) - return {"appInstanceId": deploy_response["serviceId"]} + response = aeros_client.onboard_service(service_id, tosca_yaml) + + if "serviceId" not in response: + raise EdgeCloudPlatformError( + "Invalid response from onboard_service: missing 'serviceId'" + ) + + # 7. Track deployment + if app_id not in self._deployed_services: + self._deployed_services[app_id] = [] + self._deployed_services[app_id].append(service_id) + + # 8. Return expected format + return {"appInstanceId": response["serviceId"]} def get_all_deployed_apps( self, @@ -82,22 +218,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: Optional[str] = None, region: Optional[str] = None, ) -> List[Dict]: - # FIXME: Get services in deployed state - aeros_client = ContinuumClient(self.base_url) - ngsild_params = 'type=Service&format=simplified&q=actionType=="DEPLOYED"' - if app_id: - ngsild_params += f'&q=service=="{app_id}"' - aeros_apps = aeros_client.query_entities(ngsild_params) - return [ - { - "appInstanceId": service["id"], - "status": - # scomponent["serviceComponentStatus"].split(":")[-1].lower() - service["actionType"], - } - for service in aeros_apps - ] - # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + # # FIXME: Get services in deployed state + # aeros_client = ContinuumClient(self.base_url) + # ngsild_params = 'type=Service&format=simplified&q=actionType=="DEPLOYED"' + # if app_id: + # ngsild_params += f'&q=service=="{app_id}"' + # aeros_apps = aeros_client.query_entities(ngsild_params) + # return [ + # { + # "appInstanceId": service["id"], + # "status": + # # scomponent["serviceComponentStatus"].split(":")[-1].lower() + # service["actionType"], + # } + # for service in aeros_apps + # ] + # # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + deployed = [] + for stored_app_id, instance_ids in self._deployed_services.items(): + for instance_id in instance_ids: + deployed.append({"appId": stored_app_id, "appInstanceId": instance_id}) + return deployed # def get_all_deployed_apps(self, # app_id: Optional[str] = None, @@ -118,9 +259,31 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: - # HLO-FE DELETE with app_id (service_id) + # 1. Locate app_id corresponding to this instance + found_app_id = None + for app_id, instances in self._deployed_services.items(): + if app_instance_id in instances: + found_app_id = app_id + break + + if not found_app_id: + raise EdgeCloudPlatformError( + f"No deployed app instance with ID '{app_instance_id}' found" + ) + + # 2. Call the external undeploy_service aeros_client = ContinuumClient(self.base_url) - _ = aeros_client.undeploy_service(app_instance_id) + try: + aeros_client.undeploy_service(app_instance_id) + except Exception as e: + raise EdgeCloudPlatformError( + f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" + ) + + # 3. Clean up internal tracking + self._deployed_services[found_app_id].remove(app_instance_id) + if not self._deployed_services[found_app_id]: + del self._deployed_services[found_app_id] def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None @@ -130,8 +293,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_domains = aeros_client.query_entities(ngsild_params) return [ { - "edgeCloudZoneId": domain["id"], + "zoneId": domain["id"], "status": domain["domainStatus"].split(":")[-1].lower(), + "geographyDetails": "NOT_USED", } for domain in aeros_domains ] diff --git a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py index 4fcbe68..8adaea1 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/i2edge/utils.py @@ -15,7 +15,6 @@ from typing import Optional, Union from uuid import UUID from src.edgecloud import logger - from sunrise6g_opensdk.edgecloud.api.routers.lcm.schemas import RequiredResources from sunrise6g_opensdk.edgecloud.core import utils as core_utils diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index c745200..0819ccb 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -13,11 +13,11 @@ EDGE_CLOUD_TEST_CASES = [ { "edgecloud": { "client_name": "aeros", - "base_url": "http://test-aeros.url", + "base_url": "https://ncsrd-mvp-domain.aeros-project.eu", # Additional parameters for aerOS client: - "aerOS_API_URL": "http://fake.api.url", - "aerOS_ACCESS_TOKEN": "fake-access", - "aerOS_HLO_TOKEN": "fake-hlo", + "aerOS_API_URL": "https://ncsrd-mvp-domain.aeros-project.eu", + "aerOS_ACCESS_TOKEN": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJzaTcxSzNkUm11UFIxY2RhT2daNVFtbGpUVlR6U3JQM0cyYlZNdEVDeUVjIn0.eyJleHAiOjE4MTcwMzUwMTksImlhdCI6MTczMDcyMTQxOSwianRpIjoiODk2ODhlODktNTRmOS00MzFhLTliZTUtOTQ5MmMxYjE0NDZiIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5jZi1tdnAtZG9tYWluLmFlcm9zLXByb2plY3QuZXUvYXV0aC9yZWFsbXMva2V5Y2xvYWNrLW9wZW5sZGFwIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImE5ZWY0ZTFiLTg5NTgtNGZkYS1hODQ5LTJlNjdlZmY3NjkzMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFlcm9zLXRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiOGM2MTJjMDYtYTE5MS00MjBmLTlmNTItZGU5OWZiYzJkODI3IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJDbG91ZEZlcnJvRG9tYWluIiwiZGVmYXVsdC1yb2xlcy1rZXljbG9hY2stb3BlbmxkYXAiLCJvZmZsaW5lX2FjY2VzcyIsIklvVCBzZXJ2aWNlIGRlcGxveWVyIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiI4YzYxMmMwNi1hMTkxLTQyMGYtOWY1Mi1kZTk5ZmJjMmQ4MjciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIERlcGxveWVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiaW90c2VydmljZWRlcGxveWVyMSIsImdpdmVuX25hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIiwiZmFtaWx5X25hbWUiOiJEZXBsb3llciJ9.XXM3HYVntCrSOsyJIKg-ATsqMigyQZhLMaeZtl9GqYPuISXfYl3AV0Hcs5w3n55J_-NOdlFnZdJgHlpEdB9LvxegagI4ZteoEZC72og9OdmzFV1ud4jPTrhGm7rbjCXs7bF-sGwGCKrLIs53PZPQiRcm1KxfN4RhBy3sL0Ff79QHkgvTbag-DQMrh5Y_NrTifrMrZ0i8JZD8AsRrHoi5zs7N2PXQ0zNv3n1dxxlWBKd46cWh3kutqNgTNV-s7YTde1FCSthKMcQLxe284qdFWAYlctzU5y4zLe-3VPxU7fH16jD7yAazTYdGVy4U0B5fPn_087ABjEf0oZmt40nuug", + "aerOS_HLO_TOKEN": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJzaTcxSzNkUm11UFIxY2RhT2daNVFtbGpUVlR6U3JQM0cyYlZNdEVDeUVjIn0.eyJleHAiOjE4MTcwMzUwMTksImlhdCI6MTczMDcyMTQxOSwianRpIjoiODk2ODhlODktNTRmOS00MzFhLTliZTUtOTQ5MmMxYjE0NDZiIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5jZi1tdnAtZG9tYWluLmFlcm9zLXByb2plY3QuZXUvYXV0aC9yZWFsbXMva2V5Y2xvYWNrLW9wZW5sZGFwIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImE5ZWY0ZTFiLTg5NTgtNGZkYS1hODQ5LTJlNjdlZmY3NjkzMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFlcm9zLXRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiOGM2MTJjMDYtYTE5MS00MjBmLTlmNTItZGU5OWZiYzJkODI3IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJDbG91ZEZlcnJvRG9tYWluIiwiZGVmYXVsdC1yb2xlcy1rZXljbG9hY2stb3BlbmxkYXAiLCJvZmZsaW5lX2FjY2VzcyIsIklvVCBzZXJ2aWNlIGRlcGxveWVyIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiI4YzYxMmMwNi1hMTkxLTQyMGYtOWY1Mi1kZTk5ZmJjMmQ4MjciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIERlcGxveWVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiaW90c2VydmljZWRlcGxveWVyMSIsImdpdmVuX25hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIiwiZmFtaWx5X25hbWUiOiJEZXBsb3llciJ9.XXM3HYVntCrSOsyJIKg-ATsqMigyQZhLMaeZtl9GqYPuISXfYl3AV0Hcs5w3n55J_-NOdlFnZdJgHlpEdB9LvxegagI4ZteoEZC72og9OdmzFV1ud4jPTrhGm7rbjCXs7bF-sGwGCKrLIs53PZPQiRcm1KxfN4RhBy3sL0Ff79QHkgvTbag-DQMrh5Y_NrTifrMrZ0i8JZD8AsRrHoi5zs7N2PXQ0zNv3n1dxxlWBKd46cWh3kutqNgTNV-s7YTde1FCSthKMcQLxe284qdFWAYlctzU5y4zLe-3VPxU7fH16jD7yAazTYdGVy4U0B5fPn_087ABjEf0oZmt40nuug", } }, # Uncomment once piedge import issues are fixed diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 76be859..a072d5c 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -20,10 +20,10 @@ the EdgeCloud Platform integration across different clients. # i2Edge variables ###################### # EdgeCloud Zone -ZONE_ID = "Omega" +ZONE_ID = "urn:ngsi-ld:Domain:NCSRD" # Artefact -ARTEFACT_ID = "i2edgechart-id-2" +ARTEFACT_ID = "aeros-id-1" ARTEFACT_NAME = "i2edgechart" REPO_NAME = "github-cesar" REPO_TYPE = "PUBLICREPO" @@ -32,13 +32,13 @@ REPO_URL = "https://cesarcajas.github.io/helm-charts-examples/" # Onboarding: CAMARA /app payload (only mandatory fields) APP_ONBOARD_MANIFEST = { "appId": ARTEFACT_ID, - "name": "i2edge-app-SDK", + "name": "aeros-SDK", "version": "1.0.0", - "appProvider": "i2CAT", + "appProvider": "ncsrd", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", - "imagePath": "https://example.com/my-app-image:1.0.0", + "imagePath": "https://example.com/nginx:latest", }, "requiredResources": { "infraKind": "kubernetes", @@ -58,7 +58,7 @@ APP_ONBOARD_MANIFEST = { }, "componentSpec": [ { - "componentName": "my-component", + "componentName": "aeros-comp", "networkInterfaces": [ { "interfaceId": "eth0", @@ -94,4 +94,97 @@ APP_ZONES = [ ###################### # aerOS variables ###################### -# TODO +AEROS_APP_ID = "urn:ngsi-ld:Service:sunriseapp2" +AEROS_TOSCA_DESCRIPTOR = { + "serviceId": AEROS_APP_ID, + "tosca": """ + tosca_definitions_version: tosca_simple_yaml_1_3 + description: TOSCA for network performance + node_templates: + influxdb: + type: tosca.nodes.Container.Application + requirements: + - network: + properties: + ports: + fastapi: + properties: + protocol: [tcp] + source: 8086 + exposePorts: true + - host: + node_filter: + properties: + id: "urn:ngsi-ld:InfrastructureElement:NCSRD:cebf2bd4d0ba" + artifacts: + influxdb-image: + file: p4lik4ri/influxdb + type: tosca.artifacts.Deployment.Image.Container.Docker + repository: docker_hub + interfaces: + Standard: + create: + implementation: influxdb-image + inputs: + envVars: + - INFLUXDB_BUCKET: some-bucket + - INFLUXDB_ORG: NCSRD + - INFLUXDB_USER: vpitsilis + - INFLUXDB_USER_PASSWORD: mypassword + """, +} + +AEROS_ZONE_ID = "urn:ngsi-ld:Domain:NCSRD" + +AEROS_TOSCA_DESCRIPTOR_2 = """ +tosca_definitions_version: tosca_simple_yaml_1_3 +description: A test service for testing TOSCA generation +node_templates: + auto-component: + artifacts: + nginx-image: + file: nginx + type: tosca.artifacts.Deployment.Image.Container.Docker + repository: docker_hub + interfaces: + Standard: + create: + implementation: application_image + inputs: + cliArgs: [] + envVars: [] + requirements: + - network: + properties: + ports: + port1: + properties: + protocol: + - tcp + source: 80 + port2: + properties: + protocol: + - tcp + source: 443 + exposePorts: false + - host: + node_filter: + capabilities: + - host: + properties: + cpu_arch: + equal: x64 + realtime: + equal: false + cpu_usage: + less_or_equal: '0.4' + mem_size: + greater_or_equal: '1' + energy_efficiency: + greater_or_equal: '10' + green: + greater_or_equal: '10' + properties: null + type: tosca.nodes.Container.Application +""" diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index beb5177..0f943fa 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -30,20 +30,12 @@ import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.edgecloud.clients.errors import EdgeCloudPlatformError -from sunrise6g_opensdk.edgecloud.clients.i2edge.client import ( - EdgeApplicationManager as I2EdgeClient, -) from tests.edgecloud.test_cases import test_cases -from tests.edgecloud.test_config import ( +from tests.edgecloud.test_config import ( # ARTEFACT_ID,; ARTEFACT_NAME,; REPO_NAME,; REPO_TYPE,; REPO_URL,; ZONE_ID, + AEROS_ZONE_ID, APP_ID, APP_ONBOARD_MANIFEST, APP_ZONES, - ARTEFACT_ID, - ARTEFACT_NAME, - REPO_NAME, - REPO_TYPE, - REPO_URL, - ZONE_ID, ) @@ -72,7 +64,7 @@ def test_get_edge_cloud_zones(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): +def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=AEROS_ZONE_ID): """ Test that get_edge_cloud_zone_details returns valid responses for each client. Since each client has different response formats, we only verify basic success criteria. @@ -94,22 +86,22 @@ def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): pytest.fail(f"Missing expected key in response: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_create_artefact(edgecloud_client): - if isinstance(edgecloud_client, I2EdgeClient): - try: - edgecloud_client._create_artefact( - artefact_id=ARTEFACT_ID, - artefact_name=ARTEFACT_NAME, - repo_name=REPO_NAME, - repo_type=REPO_TYPE, - repo_url=REPO_URL, - password=None, - token=None, - user_name=None, - ) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact creation failed unexpectedly: {e}") +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_create_artefact(edgecloud_client): +# if isinstance(edgecloud_client, I2EdgeClient): +# try: +# edgecloud_client._create_artefact( +# artefact_id=ARTEFACT_ID, +# artefact_name=ARTEFACT_NAME, +# repo_name=REPO_NAME, +# repo_type=REPO_TYPE, +# repo_url=REPO_URL, +# password=None, +# token=None, +# user_name=None, +# ) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"Artefact creation failed unexpectedly: {e}") @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -120,14 +112,18 @@ def test_onboard_app(edgecloud_client): pytest.fail(f"App onboarding failed unexpectedly: {e}") +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_timer_wait_15_seconds(edgecloud_client): +# time.sleep(15) + + @pytest.fixture(scope="module") def app_instance_id(edgecloud_client): try: output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) - deployed_app = {"appInstanceId": output["deploy_name"]} - assert "appInstanceId" in deployed_app - assert deployed_app["appInstanceId"] is not None - yield deployed_app["appInstanceId"] + assert "appInstanceId" in output + assert output["appInstanceId"] is not None + yield output["appInstanceId"] finally: pass @@ -150,18 +146,17 @@ def test_undeploy_app(edgecloud_client, app_instance_id): pytest.fail(f"App undeployment failed unexpectedly: {e}") -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_onboarded_app(edgecloud_client): - try: - edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) - except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") - - -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_delete_artefact(edgecloud_client): - if isinstance(edgecloud_client, I2EdgeClient): - try: - edgecloud_client._delete_artefact(artefact_id=ARTEFACT_ID) - except EdgeCloudPlatformError as e: - pytest.fail(f"Artefact deletion failed unexpectedly: {e}") +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_delete_onboarded_app(edgecloud_client): +# try: +# edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") + +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_delete_artefact(edgecloud_client): +# if isinstance(edgecloud_client, I2EdgeClient): +# try: +# edgecloud_client._delete_artefact(artefact_id=ARTEFACT_ID) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"Artefact deletion failed unexpectedly: {e}") -- GitLab From b5073e4bb04820318508498c8eda240ff663064d Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Wed, 25 Jun 2025 08:06:16 +0300 Subject: [PATCH 171/281] Remove unused imports and add comments --- src/sunrise6g_opensdk/network/clients/open5gs/client.py | 6 ++++++ src/sunrise6g_opensdk/network/core/network_interface.py | 1 + tests/network/conftest.py | 3 +++ tests/network/test_create_monitoring_event.py | 4 +++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/network/clients/open5gs/client.py b/src/sunrise6g_opensdk/network/clients/open5gs/client.py index f02ceb1..71c77b0 100644 --- a/src/sunrise6g_opensdk/network/clients/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/clients/open5gs/client.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- + +# Contributors: +# - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) +## from pydantic import ValidationError from sunrise6g_opensdk import logger @@ -59,6 +63,7 @@ class NetworkManager(NetworkManagementInterface): def core_specific_monitoring_event_validation( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> None: + """Check core specific elements that required for location retrieval in NEF.""" if retrieve_location_request.device is None: raise ValidationError( "Open5GS requires a device to be specified for location retrieval in NEF." @@ -67,6 +72,7 @@ class NetworkManager(NetworkManagementInterface): def add_core_specific_location_parameters( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> schemas.MonitoringEventSubscriptionRequest: + """Add core specific location parameters to support location retrieval scenario in NEF.""" return schemas.MonitoringEventSubscriptionRequest( msisdn=retrieve_location_request.device.phoneNumber.root.lstrip("+"), notificationDestination="http://127.0.0.1:8001", diff --git a/src/sunrise6g_opensdk/network/core/network_interface.py b/src/sunrise6g_opensdk/network/core/network_interface.py index 3d449e5..08d48cb 100644 --- a/src/sunrise6g_opensdk/network/core/network_interface.py +++ b/src/sunrise6g_opensdk/network/core/network_interface.py @@ -9,6 +9,7 @@ # Contributors: # - Reza Mosahebfard (reza.mosahebfard@i2cat.net) # - Ferran Cañellas (ferran.canellas@i2cat.net) +# - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) ## import uuid from abc import ABC diff --git a/tests/network/conftest.py b/tests/network/conftest.py index e2a793a..fdddb9a 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -1,3 +1,6 @@ +# Contributors: +# - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) +## import pytest from sunrise6g_opensdk.common.sdk import Sdk as sdkclient diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index d484452..3aab714 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -1,6 +1,8 @@ +# Contributors: +# - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) +## import pytest -from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.network.core.common import CoreHttpError from sunrise6g_opensdk.network.core.network_interface import NetworkManagementInterface from sunrise6g_opensdk.network.core.schemas import ( -- GitLab From ddc5608f6b9d8215a3bf24a06b6d208ad69d165c Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Wed, 25 Jun 2025 17:12:02 +0300 Subject: [PATCH 172/281] updated TFs for CAMARA payloads --- .../edgecloud/clients/aeros/client.py | 172 +++++++++--------- .../clients/aeros/continuum_client.py | 28 ++- tests/edgecloud/test_cases.py | 22 +-- tests/edgecloud/test_config.py | 99 +--------- tests/edgecloud/test_e2e.py | 27 +-- 5 files changed, 143 insertions(+), 205 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py index 7edebef..841e8f6 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/client.py @@ -30,6 +30,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) self._app_store: Dict[str, Dict] = {} self._deployed_services: Dict[str, List[str]] = {} + self._stopped_services: Dict[str, List[str]] = {} # Overwrite config values if provided via kwargs if "aerOS_API_URL" in kwargs: @@ -47,13 +48,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError("Missing 'aerOS_HLO_TOKEN'") def onboard_app(self, app_manifest: Dict) -> Dict: - # HLO-FE POST with TOSCA and app_id (service_id) - # service_id = app_manifest.get("serviceId") - # tosca_str = app_manifest.get("tosca") - # aeros_client = ContinuumClient(self.base_url) - # onboard_response = aeros_client.onboard_service( - # service_id=service_id, tosca_str=tosca_str - # ) app_id = app_manifest.get("appId") if not app_id: raise EdgeCloudPlatformError("Missing 'appId' in app manifest") @@ -68,23 +62,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return {"appId": app_id} def get_all_onboarded_apps(self) -> List[Dict]: - # aeros_client = ContinuumClient(self.base_url) - # ngsild_params = "type=Service&format=simplified" - # aeros_apps = aeros_client.query_entities(ngsild_params) - # return [ - # {"appId": service["id"], "name": service["name"]} for service in aeros_apps - # ] + self.logger.debug("Onboarded applications: %s", list(self._app_store.keys())) return list(self._app_store.values()) def get_onboarded_app(self, app_id: str) -> Dict: - # aeros_client = ContinuumClient(self.base_url) - # ngsild_params = "format=simplified" - # aeros_app = aeros_client.query_entity(app_id, ngsild_params) - # return {"appId": aeros_app["id"], "name": aeros_app["name"]} if app_id not in self._app_store: raise EdgeCloudPlatformError( f"Application with id '{app_id}' does not exist" ) + self.logger.debug("Retrieved application with id: %s", app_id) return self._app_store[app_id] def delete_onboarded_app(self, app_id: str) -> None: @@ -92,12 +78,22 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise EdgeCloudPlatformError( f"Application with id '{app_id}' does not exist" ) - del self._app_store[app_id] - # TBD: Purge from continuum (make all ngsil-ld calls for servieId connected entities) - # Should check if undeployed first + service_instances = self._stopped_services.get(app_id, []) + self.logger.debug( + "Deleting application with id: %s and instances: %s", + app_id, + service_instances, + ) + for service_instance in service_instances: + self._purge_deployed_app_from_continuum(service_instance) + self.logger.debug( + "successfully purged service instance: %s", service_instance + ) + del self._stopped_services[app_id] # Clean up stopped services + del self._app_store[app_id] # Remove from onboarded apps def _generate_service_id(self, app_id: str) -> str: - return f"ur n:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" def _generate_tosca_yaml_dict( self, app_manifest: Dict, app_zones: List[Dict] @@ -106,16 +102,23 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): component_name = component.get("componentName", "application") image_path = app_manifest.get("appRepo", {}).get("imagePath", "") - # Extract image_file image_file = image_path.split("/")[-1] - # Extract repository_url - if "/" in image_path: - repository_url = "/".join(image_path.split("/")[:-1]) - else: - repository_url = "docker_hub" + repository_url = ( + "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" + ) zone_id = ( app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") ) + + # Extract minNodeMemory + min_node_memory = ( + app_manifest.get("requiredResources", {}) + .get("applicationResources", {}) + .get("cpuPool", {}) + .get("topology", {}) + .get("minNodeMemory", 1024) + ) + ports = {} for iface in component.get("networkInterfaces", []): interface_id = iface.get("interfaceId", "default") @@ -124,6 +127,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ports[interface_id] = { "properties": {"protocol": [protocol], "source": port} } + expose_ports = any( iface.get("visibilityType") == "VISIBILITY_EXTERNAL" for iface in component.get("networkInterfaces", []) @@ -132,9 +136,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): yaml_dict = { "tosca_definitions_version": "tosca_simple_yaml_1_3", "description": f"TOSCA for {app_manifest.get('name', 'application')}", + "serviceOverlay": False, "node_templates": { component_name: { "type": "tosca.nodes.Container.Application", + "isJob": False, "requirements": [ { "network": { @@ -144,12 +150,38 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } } }, - {"host": {"node_filter": {"properties": {"id": zone_id}}}}, + { + "host": { + "node_filter": { + "capabilities": [ + { + "host": { + "properties": { + "cpu_arch": {"equal": "x64"}, + "realtime": {"equal": False}, + "cpu_usage": { + "less_or_equal": "0.1" + }, + "mem_size": { + "greater_or_equal": str( + min_node_memory + ) + }, + "domain_id": {"equal": zone_id}, + } + } + } + ], + "properties": None, + } + } + }, ], "artifacts": { "application_image": { "file": image_file, "type": "tosca.artifacts.Deployment.Image.Container.Docker", + "is_private": False, "repository": repository_url, } }, @@ -168,10 +200,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return yaml_dict def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - # HLO-FE PUT with app_id (service_id) - # aeros_client = ContinuumClient(self.base_url) - # deploy_response = aeros_client.deploy_service(app_id) - # return {"deploy_name": deploy_response["serviceId"]} # 1. Get app CAMARA manifest app_manifest = self._app_store.get(app_id) if not app_manifest: @@ -181,35 +209,28 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 2. Generate unique service ID service_id = self._generate_service_id(app_id) - # Dev test - # service_id = "service-my-id" - - # yaml_dict = { - # "serviceId": service_id, - # "tosca": app_manifest.get("tosca", "") - # } - # 5. Convert dict to YAML string + # 3. Convert dict to YAML string yaml_dict = self._generate_tosca_yaml_dict(app_manifest, app_zones) tosca_yaml = yaml.dump(yaml_dict, sort_keys=False) - print("Generated TOSCA YAML:") - print(tosca_yaml) - response = {"serviceId": service_id} # Mocked response - # 6. Instantiate client and call onboard_service + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_yaml) + + # 4. Instantiate client and call continuum to deploy service aeros_client = ContinuumClient(self.base_url) - response = aeros_client.onboard_service(service_id, tosca_yaml) + response = aeros_client.onboard_and_deploy_service(service_id, tosca_yaml) if "serviceId" not in response: raise EdgeCloudPlatformError( "Invalid response from onboard_service: missing 'serviceId'" ) - # 7. Track deployment + # 5. Track deployment if app_id not in self._deployed_services: self._deployed_services[app_id] = [] self._deployed_services[app_id].append(service_id) - # 8. Return expected format + # 6. Return expected format return {"appInstanceId": response["serviceId"]} def get_all_deployed_apps( @@ -218,45 +239,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: Optional[str] = None, region: Optional[str] = None, ) -> List[Dict]: - # # FIXME: Get services in deployed state - # aeros_client = ContinuumClient(self.base_url) - # ngsild_params = 'type=Service&format=simplified&q=actionType=="DEPLOYED"' - # if app_id: - # ngsild_params += f'&q=service=="{app_id}"' - # aeros_apps = aeros_client.query_entities(ngsild_params) - # return [ - # { - # "appInstanceId": service["id"], - # "status": - # # scomponent["serviceComponentStatus"].split(":")[-1].lower() - # service["actionType"], - # } - # for service in aeros_apps - # ] - # # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] deployed = [] for stored_app_id, instance_ids in self._deployed_services.items(): for instance_id in instance_ids: deployed.append({"appId": stored_app_id, "appInstanceId": instance_id}) return deployed - # def get_all_deployed_apps(self, - # app_id: Optional[str] = None, - # app_instance_id: Optional[str] = None, - # region: Optional[str] = None) -> List[Dict]: - # # FIXME: Get services in deployed state - # aeros_client = ContinuumClient(self.base_url) - # ngsild_params = "type=ServiceComponent&format=simplified" - # if app_id: - # ngsild_params += f'&q=service=="{app_id}"' - # aeros_apps = aeros_client.query_entities(ngsild_params) - # return [{ - # "appInstanceId": - # scomponent["id"], - # "status": - # scomponent["serviceComponentStatus"].split(":")[-1].lower() - # } for scomponent in aeros_apps] - # # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + def _purge_deployed_app_from_continuum(self, app_id: str) -> None: + aeros_client = ContinuumClient(self.base_url) + response = aeros_client.purge_service(app_id) + if response: + self.logger.debug("Purged deployed application with id: %s", app_id) + else: + raise EdgeCloudPlatformError( + f"Failed to purg service with id from the continuum '{app_id}'" + ) def undeploy_app(self, app_instance_id: str) -> None: # 1. Locate app_id corresponding to this instance @@ -278,10 +275,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except Exception as e: raise EdgeCloudPlatformError( f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" - ) + ) from e - # 3. Clean up internal tracking + # We could do it here with a little wait but better all instances in the same app are purged at once + # 3. Purge the deployed app from continuum + # self._purge_deployed_app_from_continuum(app_instance_id) + + # 4. Clean up internal tracking self._deployed_services[found_app_id].remove(app_instance_id) + # Add instance to _stopped_services to purge it later + if found_app_id not in self._stopped_services: + self._stopped_services[found_app_id] = [] + self._stopped_services[found_app_id].append(app_instance_id) + # If app has no instances left, remove it from deployed services if not self._deployed_services[found_app_id]: del self._deployed_services[found_app_id] @@ -300,8 +306,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): for domain in aeros_domains ] - # return [{"edgeCloudZoneId": "zone-1", "status": "active"}] - def get_edge_cloud_zones_details( self, zone_id: str, flavour_id: Optional[str] = None ) -> Dict: diff --git a/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py index eb8668f..e1d0d6e 100644 --- a/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/clients/aeros/continuum_client.py @@ -139,7 +139,7 @@ class ContinuumClient: return response.json() @catch_requests_exceptions - def onboard_service(self, service_id: str, tosca_str: str) -> dict: + def onboard_and_deploy_service(self, service_id: str, tosca_str: str) -> dict: """ Onboard (& deploy) service on aerOS continuum :input @@ -168,3 +168,29 @@ class ContinuumClient: response.text, ) return response.json() + + @catch_requests_exceptions + def purge_service(self, service_id: str) -> bool: + """ + Purge service from aerOS continuum + :input + @param service_id: the id of the service to be purged + :output + the purge result message from aerOS continuum + """ + purge_url = f"{self.api_url}/hlo_fe/services/{service_id}/purge" + response = requests.delete(purge_url, headers=self.hlo_headers, timeout=15) + if response is None: + return False + else: + if config.DEBUG: + self.logger.debug("Purge service URL: %s", purge_url) + self.logger.debug( + "Purge service response: %s %s", + response.status_code, + response.text, + ) + if response.status_code != 200: + self.logger.error("Failed to purge service: %s", response.text) + return False + return True diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index 62640a7..32a03ed 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -1,20 +1,20 @@ # -*- coding: utf-8 -*- test_cases = [ - { - "edgecloud": { - "client_name": "i2edge", - "base_url": "http://192.168.123.48:30769/", - } - }, # { # "edgecloud": { - # "client_name": "aeros", - # "base_url": "http://test-aeros.url", - # "aerOS_API_URL": "http://fake.api.url", - # "aerOS_ACCESS_TOKEN": "fake-access", - # "aerOS_HLO_TOKEN": "fake-hlo" + # "client_name": "i2edge", + # "base_url": "http://192.168.123.48:30769/", # } # }, + { + "edgecloud": { + "client_name": "aeros", + "base_url": "https://ncsrd-mvp-domain.aeros-project.eu", + "aerOS_API_URL": "https://ncsrd-mvp-domain.aeros-project.eu", + "aerOS_ACCESS_TOKEN": "", + "aerOS_HLO_TOKEN": "", + } + }, # { # "edgecloud": { # "client_name": "piedge", diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index a072d5c..2d72afa 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -23,7 +23,7 @@ the EdgeCloud Platform integration across different clients. ZONE_ID = "urn:ngsi-ld:Domain:NCSRD" # Artefact -ARTEFACT_ID = "aeros-id-1" +ARTEFACT_ID = "aeros-app-1" ARTEFACT_NAME = "i2edgechart" REPO_NAME = "github-cesar" REPO_TYPE = "PUBLICREPO" @@ -32,13 +32,13 @@ REPO_URL = "https://cesarcajas.github.io/helm-charts-examples/" # Onboarding: CAMARA /app payload (only mandatory fields) APP_ONBOARD_MANIFEST = { "appId": ARTEFACT_ID, - "name": "aeros-SDK", + "name": "aeros-SDK-app", "version": "1.0.0", "appProvider": "ncsrd", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", - "imagePath": "https://example.com/nginx:latest", + "imagePath": "docker.io/library/nginx:latest", }, "requiredResources": { "infraKind": "kubernetes", @@ -94,97 +94,4 @@ APP_ZONES = [ ###################### # aerOS variables ###################### -AEROS_APP_ID = "urn:ngsi-ld:Service:sunriseapp2" -AEROS_TOSCA_DESCRIPTOR = { - "serviceId": AEROS_APP_ID, - "tosca": """ - tosca_definitions_version: tosca_simple_yaml_1_3 - description: TOSCA for network performance - node_templates: - influxdb: - type: tosca.nodes.Container.Application - requirements: - - network: - properties: - ports: - fastapi: - properties: - protocol: [tcp] - source: 8086 - exposePorts: true - - host: - node_filter: - properties: - id: "urn:ngsi-ld:InfrastructureElement:NCSRD:cebf2bd4d0ba" - artifacts: - influxdb-image: - file: p4lik4ri/influxdb - type: tosca.artifacts.Deployment.Image.Container.Docker - repository: docker_hub - interfaces: - Standard: - create: - implementation: influxdb-image - inputs: - envVars: - - INFLUXDB_BUCKET: some-bucket - - INFLUXDB_ORG: NCSRD - - INFLUXDB_USER: vpitsilis - - INFLUXDB_USER_PASSWORD: mypassword - """, -} - AEROS_ZONE_ID = "urn:ngsi-ld:Domain:NCSRD" - -AEROS_TOSCA_DESCRIPTOR_2 = """ -tosca_definitions_version: tosca_simple_yaml_1_3 -description: A test service for testing TOSCA generation -node_templates: - auto-component: - artifacts: - nginx-image: - file: nginx - type: tosca.artifacts.Deployment.Image.Container.Docker - repository: docker_hub - interfaces: - Standard: - create: - implementation: application_image - inputs: - cliArgs: [] - envVars: [] - requirements: - - network: - properties: - ports: - port1: - properties: - protocol: - - tcp - source: 80 - port2: - properties: - protocol: - - tcp - source: 443 - exposePorts: false - - host: - node_filter: - capabilities: - - host: - properties: - cpu_arch: - equal: x64 - realtime: - equal: false - cpu_usage: - less_or_equal: '0.4' - mem_size: - greater_or_equal: '1' - energy_efficiency: - greater_or_equal: '10' - green: - greater_or_equal: '10' - properties: null - type: tosca.nodes.Container.Application -""" diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 0f943fa..ebc62e5 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -112,11 +112,6 @@ def test_onboard_app(edgecloud_client): pytest.fail(f"App onboarding failed unexpectedly: {e}") -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_timer_wait_15_seconds(edgecloud_client): -# time.sleep(15) - - @pytest.fixture(scope="module") def app_instance_id(edgecloud_client): try: @@ -134,8 +129,8 @@ def test_deploy_app(app_instance_id): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_30_seconds(edgecloud_client): - time.sleep(30) +def test_timer_wait_60_seconds(edgecloud_client): + time.sleep(60) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -146,12 +141,18 @@ def test_undeploy_app(edgecloud_client, app_instance_id): pytest.fail(f"App undeployment failed unexpectedly: {e}") -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_delete_onboarded_app(edgecloud_client): -# try: -# edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) -# except EdgeCloudPlatformError as e: -# pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_30_seconds(edgecloud_client): + time.sleep(30) + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_delete_onboarded_app(edgecloud_client): + try: + edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) + except EdgeCloudPlatformError as e: + pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") + # @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) # def test_delete_artefact(edgecloud_client): -- GitLab From d95042d7baffacfb4a43cb733e1caac7373bd46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 11:54:45 +0200 Subject: [PATCH 173/281] Delete disclaimer in test_config --- tests/edgecloud/test_config.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index c7f5112..c86ed2f 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -1,14 +1,4 @@ # -*- coding: utf-8 -*- -## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# -# This file is part of the Open SDK -# -# Contributors: -# - Adrián Pino Martínez (adrian.pino@i2cat.net) -# - Sergio Giménez (sergio.gimenez@i2cat.net) -## """ EdgeCloud Platform Test Configuration -- GitLab From e4593f1e5b2a67d358868394e8e542ddf6c77b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 12:05:13 +0200 Subject: [PATCH 174/281] Delete tmp file added by mistake --- .../edgecloud/core/schemas.py | 160 ------------------ 1 file changed, 160 deletions(-) delete mode 100644 src/sunrise6g_opensdk/edgecloud/core/schemas.py diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py deleted file mode 100644 index aff8f6b..0000000 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Pydantic data models for CAMARA Edge Application Management API (version 0.9.3-wip). -These schemas reflect the structure of requests and responses defined in the OpenAPI specification. -""" - -from enum import Enum -from typing import List, Optional, Union -from uuid import UUID - -from pydantic import BaseModel, Field - - -# --- ENUMS --- -class PackageType(str, Enum): - QCOW2 = "QCOW2" - OVA = "OVA" - CONTAINER = "CONTAINER" - HELM = "HELM" - - -class VisibilityType(str, Enum): - VISIBILITY_EXTERNAL = "VISIBILITY_EXTERNAL" - VISIBILITY_INTERNAL = "VISIBILITY_INTERNAL" - - -class Protocol(str, Enum): - TCP = "TCP" - UDP = "UDP" - ANY = "ANY" - - -class AppInstanceStatus(str, Enum): - READY = "ready" - INSTANTIATING = "instantiating" - FAILED = "failed" - TERMINATING = "terminating" - UNKNOWN = "unknown" - - -# --- NESTED SCHEMAS --- -class AppRepo(BaseModel): - type: str - imagePath: str - userName: Optional[str] - credentials: Optional[str] - authType: Optional[str] - checksum: Optional[str] - - -class NetworkInterface(BaseModel): - interfaceId: str = Field(..., pattern=r"^[A-Za-z][A-Za-z0-9_]{3,31}$") - protocol: Protocol - port: int = Field(..., ge=1, le=65535) - visibilityType: VisibilityType - - -class ComponentSpec(BaseModel): - componentName: str - networkInterfaces: List[NetworkInterface] - - -class CpuTopology(BaseModel): - minNumberOfNodes: int - minNodeCpu: int - minNodeMemory: int - - -class CpuPool(BaseModel): - numCPU: int - memory: int - topology: CpuTopology - - -class GpuTopology(BaseModel): - minNumberOfNodes: int - minNodeCpu: int - minNodeMemory: int - minNodeGpuMemory: int - - -class GpuPool(BaseModel): - numCPU: int - memory: int - gpuMemory: int - topology: GpuTopology - - -class KubernetesResources(BaseModel): - infraKind: str = Field("kubernetes", Literal=True) - applicationResources: Optional[dict] - isStandalone: Optional[bool] - version: Optional[str] - additionalStorage: Optional[str] - networking: Optional[dict] - addons: Optional[dict] - - -class VmResources(BaseModel): - infraKind: str = Field("virtualMachine", Literal=True) - numCPU: int - memory: int - additionalStorages: Optional[List[dict]] - gpu: Optional[dict] - - -class ContainerResources(BaseModel): - infraKind: str = Field("container", Literal=True) - numCPU: str # vCPU in formats like "1", "0.5", or "500m" - memory: int - storage: Optional[List[dict]] - gpu: Optional[dict] - - -class DockerComposeResources(BaseModel): - infraKind: str = Field("dockerCompose", Literal=True) - numCPU: int - memory: int - storage: Optional[List[dict]] - gpu: Optional[dict] - - -RequiredResources = Union[ - KubernetesResources, VmResources, ContainerResources, DockerComposeResources -] - - -# --- PRIMARY SCHEMAS --- -class AppManifest(BaseModel): - name: str = Field(..., pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") - version: str - appProvider: str = Field(..., pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$") - packageType: PackageType - appRepo: AppRepo - requiredResources: RequiredResources - componentSpec: List[ComponentSpec] - - -class AppInstanceDeploymentRequest(BaseModel): - name: str = Field(..., pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") - appId: UUID - edgeCloudZoneId: UUID - kubernetesClusterRef: Optional[UUID] = None - - -class ComponentEndpoint(BaseModel): - interfaceId: str - accessPoints: dict # Can be refined based on AccessEndpoint schema - - -class AppInstanceInfo(BaseModel): - name: str - appId: UUID - appInstanceId: UUID - appProvider: str - edgeCloudZoneId: UUID - status: AppInstanceStatus = AppInstanceStatus.UNKNOWN - componentEndpointInfo: Optional[List[ComponentEndpoint]] = None - kubernetesClusterRef: Optional[UUID] = None -- GitLab From cefb8113b9c8549624c924539d282b183ea9dd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 12:07:19 +0200 Subject: [PATCH 175/281] Add contributor to open5gs client --- src/sunrise6g_opensdk/network/adapters/open5gs/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py index 42eb471..2f2c7a0 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Contributors: +# - Ferran Cañellas (ferran.canellas@i2cat.net) # - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) ## from pydantic import ValidationError -- GitLab From f626301eeffcd58fcf0fd31ecfeb73ac0c1398b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 12:23:34 +0200 Subject: [PATCH 176/281] Fix typos --- src/sunrise6g_opensdk/network/core/base_network_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index 3c55a2c..39fb5b8 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -76,7 +76,7 @@ class BaseNetworkClient: Class for Network Resource Management. This class provides shared logic and extension points for different - Network 5G Cores (e.g., Open5GS, OAI, Open5GCopre-commit run --all-filesre) interacting with + Network 5G Cores (e.g., Open5GS, OAI, Open5GCore) interacting with NEF-like platforms using CAMARA APIs. """ @@ -157,7 +157,7 @@ class BaseNetworkClient: ValidationError: If the request information does not meet core-specific requirements. """ # Placeholder for core-specific validation logic - # This method should be overridden by subclasses if needed + # This method should be overwritten by subclasses if needed pass def _build_qod_subscription( @@ -371,4 +371,5 @@ class BaseNetworkClient: r = common.traffic_influence_get(self.base_url, self.scs_as_id) return [self._build_camara_ti(item) for item in r] - # Placeholder for other CAMARA APIs (e.g: Location-retrieval, etc.) + +# Placeholder for other CAMARA APIs -- GitLab From 2ab3c901a70d0fcd56952e1a2e5d7090c30eb86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 12:30:47 +0200 Subject: [PATCH 177/281] Add missing location-retrieval related functions --- .../network/core/base_network_client.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index 39fb5b8..e66e54a 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -10,10 +10,12 @@ # - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) ## import uuid +from datetime import datetime, timedelta, timezone from itertools import product from typing import Dict from sunrise6g_opensdk import logger +from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError from sunrise6g_opensdk.network.core import common, schemas log = logger.get_logger(__name__) @@ -227,6 +229,98 @@ class BaseNetworkClient: ) return camara_ti + def _build_monitoring_event_subscription( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.MonitoringEventSubscriptionRequest: + self.core_specific_monitoring_event_validation(retrieve_location_request) + subscription_3gpp = self.add_core_specific_location_parameters( + retrieve_location_request + ) + device = retrieve_location_request.device + subscription_3gpp.externalId = device.networkAccessIdentifier + subscription_3gpp.ipv4Addr = device.ipv4Address + subscription_3gpp.ipv6Addr = device.ipv6Address + # subscription.msisdn = device.phoneNumber.root.lstrip('+') + # subscription.notificationDestination = "http://127.0.0.1:8001" + + return subscription_3gpp + + def _compute_camara_last_location_time( + self, event_time: datetime, age_of_location_info_min: int = None + ) -> datetime: + """ + Computes the last location time based on the event time and age of location info. + + args: + event_time: ISO 8601 datetime, e.g. "2025-06-18T12:30:00Z" + age_of_location_info_min: unsigned int, age of location info in minutes + + returns: + datetime object representing the last location time in UTC. + """ + if age_of_location_info_min is not None: + last_location_time = event_time - timedelta( + minutes=age_of_location_info_min + ) + return last_location_time.replace(tzinfo=timezone.utc) + else: + return event_time.replace(tzinfo=timezone.utc) + + def create_monitoring_event_subscription( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.Location: + """ + Creates a Monitoring Event subscription based on CAMARA Location API input. + + args: + retrieve_location_request: Dictionary containing location retrieval details conforming to + the CAMARA Location API parameters. + + returns: + dictionary containing the created subscription details, including its ID. + """ + subscription = self._build_monitoring_event_subscription( + retrieve_location_request + ) + response = common.monitoring_event_post( + self.base_url, self.scs_as_id, subscription + ) + + monitoring_event_report = schemas.MonitoringEventReport(**response) + if monitoring_event_report.locationInfo is None: + log.error( + "Failed to retrieve location information from monitoring event report" + ) + raise NetworkPlatformError( + "Location information not found in monitoring event report" + ) + geo_area = monitoring_event_report.locationInfo.geographicArea + report_event_time = monitoring_event_report.eventTime + age_of_location_info = None + if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: + age_of_location_info = ( + monitoring_event_report.locationInfo.ageOfLocationInfo.duration + ) + last_location_time = self._compute_camara_last_location_time( + report_event_time, age_of_location_info + ) + log.debug(f"Last Location time is {last_location_time}") + camara_point_list: list[schemas.Point] = [] + for point in geo_area.polygon.point_list.geographical_coords: + camara_point_list.append( + schemas.Point(latitude=point.lat, longitude=point.lon) + ) + camara_polygon = schemas.Polygon( + areaType=schemas.AreaType.polygon, + boundary=schemas.PointList(camara_point_list), + ) + + camara_location = schemas.Location( + area=camara_polygon, lastLocationTime=last_location_time + ) + + return camara_location + def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. -- GitLab From 202b8a9c7e5370c14c290579a01ba7feca5ab877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 12:31:11 +0200 Subject: [PATCH 178/281] Fix typo --- src/sunrise6g_opensdk/network/core/base_network_client.py | 2 +- src/sunrise6g_opensdk/network/core/schemas.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index e66e54a..edb02ac 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -202,7 +202,7 @@ class BaseNetworkClient: ipv4Addr=str(device_ip), notificationDestination=sink_url, ) - subscription.add_flow_descriptor(flow_desriptor=flow_descriptor) + subscription.add_flow_descriptor(flow_descriptor=flow_descriptor) subscription.add_traffic_route(dnai=edge_zone) self.add_core_specific_ti_parameters(traffic_influence_data, subscription) diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 99d56e1..0832f18 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -221,11 +221,11 @@ class TrafficInfluSub(BaseModel): # Replace with a meaningful name ) suppFeat: str | None = None - def add_flow_descriptor(self, flow_desriptor: str): + def add_flow_descriptor(self, flow_descriptor: str): self.trafficFilters = list() self.trafficFilters.append( FlowInfo( - flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_desriptor] + flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_descriptor] ) ) -- GitLab From ee2b569e043b7269344e1b8340bfe3046b19c6b1 Mon Sep 17 00:00:00 2001 From: ppavlidis Date: Thu, 26 Jun 2025 14:15:06 +0300 Subject: [PATCH 179/281] Adjust network client instatiation in tests --- tests/network/test_create_monitoring_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index 347578a..c3b4b14 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -26,7 +26,7 @@ client_specs = { "scs_as_id": "af_1", } } -clients = sdkclient.create_clients_from(client_specs) +clients = sdkclient.create_adapters_from(client_specs) network_client: BaseNetworkClient = clients.get("network") -- GitLab From 9f520e8a961a6eb0bd006b652a6d5a4082c5b45a Mon Sep 17 00:00:00 2001 From: Panagiotis Pavlidis Date: Thu, 26 Jun 2025 17:44:34 +0300 Subject: [PATCH 180/281] Declare non supported methods for location retrieval according to each adapter #63 --- .../network/adapters/oai/client.py | 16 ++++++++++++++++ .../network/adapters/open5gcore/client.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index 43eac31..e6658c2 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -18,6 +18,8 @@ from sunrise6g_opensdk.network.core.schemas import ( FlowInfo, Snssai, TrafficInfluSub, + RetrievalLocationRequest, + MonitoringEventSubscriptionRequest, ) log = logger.get_logger(__name__) @@ -112,6 +114,20 @@ class NetworkManager(BaseNetworkClient): raise OaiValidationError( "OAI requires UE IPv4 Address to activate Traffic Influence" ) + + def core_specific_monitoring_event_validation( + self, retrieve_location_request: RetrievalLocationRequest + ) -> None: + raise NotImplementedError( + "core_specific_monitoring_event_validation not implemented for OAI" + ) + + def add_core_specific_location_parameters( + self, retrieve_location_request: RetrievalLocationRequest + ) -> MonitoringEventSubscriptionRequest: + raise NotImplementedError( + "add_core_specific_location_parameters not implemented for OAI" + ) def _retrieve_ue_ipv4(session_info: CreateSession): diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index fd35e45..9156313 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -63,3 +63,17 @@ class NetworkManager(BaseNetworkClient): raise NotImplementedError( "core_specific_traffic_influence_validation not implemented for Open5GCore" ) + + def core_specific_monitoring_event_validation( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> None: + raise NotImplementedError( + "core_specific_monitoring_event_validation not implemented for Open5GCore" + ) + + def add_core_specific_location_parameters( + self, retrieve_location_request: schemas.RetrievalLocationRequest + ) -> schemas.MonitoringEventSubscriptionRequest: + raise NotImplementedError( + "add_core_specific_location_parameters not implemented for Open5GCore" + ) -- GitLab From ddbd05263b9bc9f6a8b724e6d6610b8aa28fd09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 26 Jun 2025 17:09:07 +0200 Subject: [PATCH 181/281] Satisfy linters --- src/sunrise6g_opensdk/network/adapters/oai/client.py | 8 ++++---- .../network/adapters/open5gcore/client.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index e6658c2..e3f3d12 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -16,10 +16,10 @@ from sunrise6g_opensdk.network.core.schemas import ( CreateSession, CreateTrafficInfluence, FlowInfo, + MonitoringEventSubscriptionRequest, + RetrievalLocationRequest, Snssai, TrafficInfluSub, - RetrievalLocationRequest, - MonitoringEventSubscriptionRequest, ) log = logger.get_logger(__name__) @@ -114,11 +114,11 @@ class NetworkManager(BaseNetworkClient): raise OaiValidationError( "OAI requires UE IPv4 Address to activate Traffic Influence" ) - + def core_specific_monitoring_event_validation( self, retrieve_location_request: RetrievalLocationRequest ) -> None: - raise NotImplementedError( + raise NotImplementedError( "core_specific_monitoring_event_validation not implemented for OAI" ) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index 9156313..4dd5da7 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -63,11 +63,11 @@ class NetworkManager(BaseNetworkClient): raise NotImplementedError( "core_specific_traffic_influence_validation not implemented for Open5GCore" ) - + def core_specific_monitoring_event_validation( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> None: - raise NotImplementedError( + raise NotImplementedError( "core_specific_monitoring_event_validation not implemented for Open5GCore" ) -- GitLab From e6d03e70daf703771f49961fa3b3707e771cc5c0 Mon Sep 17 00:00:00 2001 From: Laskaratos Dimitris Date: Fri, 27 Jun 2025 12:54:57 +0300 Subject: [PATCH 182/281] Hotfixes --- requirements.txt | 2 + .../common/adapters_factory.py | 67 +- .../edgecloud/adapters/kubernetes/client.py | 268 +++-- .../adapters/kubernetes/lib/core/__init__.py | 0 .../kubernetes/lib/core/piedge_encoder.py | 213 ++++ .../kubernetes/lib/models/__init__.py | 36 + .../lib/models/activate_secured_slice.py | 88 ++ .../kubernetes/lib/models/app_delete.py | 59 ++ .../kubernetes/lib/models/app_manifest.py | 276 +++++ .../lib/models/app_manifest_app_repo.py | 232 +++++ .../lib/models/app_manifest_component_spec.py | 110 ++ .../models/app_manifest_network_interfaces.py | 178 ++++ .../kubernetes/lib/models/appdeploy.py | 59 ++ .../kubernetes/lib/models/apps_response.py | 62 ++ .../lib/models/apps_response_apps.py | 59 ++ .../kubernetes/lib/models/appupdate.py | 59 ++ .../lib/models/artifact_exists_model.py | 158 +++ .../kubernetes/lib/models/base_model_.py | 73 ++ .../lib/models/copy_artifact_model.py | 258 +++++ .../kubernetes/lib/models/deploy_app.py | 85 ++ .../lib/models/deploy_service_function.py | 369 +++++++ .../lib/models/deployedapps_response.py | 201 ++++ .../lib/models/deployedapps_response_apps.py | 36 + .../lib/models/edge_cloud_provider.py | 33 + .../lib/models/edge_cloud_region.py | 33 + .../kubernetes/lib/models/edge_cloud_zone.py | 189 ++++ .../lib/models/edge_cloud_zone_id.py | 33 + .../lib/models/edge_cloud_zone_name.py | 33 + .../lib/models/edge_cloud_zone_status.py | 40 + .../kubernetes/lib/models/edge_cloud_zones.py | 39 + .../lib/models/env_parameter_name.py | 59 ++ .../kubernetes/lib/models/env_parameters.py | 111 ++ .../kubernetes/lib/models/gpu_info.py | 92 ++ .../lib/models/helm_install_model.py | 140 +++ .../kubernetes/lib/models/nodes_response.py | 199 ++++ .../kubernetes/lib/models/operating_system.py | 192 ++++ .../lib/models/required_resources.py | 170 +++ ...service_function_deregistration_request.py | 61 ++ .../service_function_registration_request.py | 179 ++++ .../kubernetes/lib/models/services_query.py | 88 ++ .../adapters/kubernetes/lib/models/uri.py | 33 + .../adapters/kubernetes/lib/models/volume.py | 109 ++ .../lib/models/volume_mount_deploy.py | 80 ++ .../adapters/kubernetes/lib/type_util.py | 32 + .../edgecloud/adapters/kubernetes/lib/util.py | 144 +++ .../adapters/kubernetes/lib/utils/__init__.py | 0 .../lib/utils/artifact_connector.py | 26 + .../lib/utils/auxiliary_functions.py | 43 + .../kubernetes/lib/utils/connector_db.py | 278 +++++ .../lib/utils/kubernetes_connector.py | 965 ++++++++++++++++++ tests/common/test_invoke_edgecloud_clients.py | 18 +- tests/edgecloud/test_cases.py | 31 +- tests/edgecloud/test_config.py | 46 +- tests/edgecloud/test_e2e.py | 64 +- 54 files changed, 6350 insertions(+), 158 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/__init__.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/__init__.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/activate_secured_slice.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_delete.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appdeploy.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response_apps.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appupdate.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/artifact_exists_model.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/copy_artifact_model.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_app.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_service_function.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response_apps.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_provider.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_region.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_id.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_name.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_status.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zones.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameter_name.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/helm_install_model.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/nodes_response.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/operating_system.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_deregistration_request.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_registration_request.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/uri.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume_mount_deploy.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/type_util.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/util.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/__init__.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/auxiliary_functions.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py diff --git a/requirements.txt b/requirements.txt index 82e4568..61bfe3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -111,3 +111,5 @@ wcwidth==0.2.13 webencodings==0.5.1 yarg==0.1.9 zipp==3.23.0 +pymongo +kubernetes diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 00fd7d6..78190bf 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -15,17 +15,20 @@ from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) -from sunrise6g_opensdk.network.adapters.oai.client import ( - NetworkManager as OaiCoreClient, -) -from sunrise6g_opensdk.network.adapters.open5gcore.client import ( - NetworkManager as Open5GCoreClient, -) -from sunrise6g_opensdk.network.adapters.open5gs.client import ( - NetworkManager as Open5GSClient, +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import ( + EdgeApplicationManager as kubernetesClient ) +# from sunrise6g_opensdk.network.adapters.oai.client import ( +# NetworkManager as OaiCoreClient, +# ) +# from sunrise6g_opensdk.network.adapters.open5gcore.client import ( +# NetworkManager as Open5GCoreClient, +# ) +# from sunrise6g_opensdk.network.adapters.open5gs.client import ( +# NetworkManager as Open5GSClient, +# ) -# from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import EdgeApplicationManager as kubernetesClient +# def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): @@ -36,7 +39,7 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): edge_cloud_factory = { "aeros": lambda url, **kw: AerosClient(base_url=url, **kw), "i2edge": lambda url, **kw: I2EdgeClient(base_url=url, **kw), - # "kubernetes": lambda url: kubernetesClient(base_url=url), Uncomment when import issues are solved + "kubernetes": lambda url, **kw: kubernetesClient(base_url=url, **kw), } try: return edge_cloud_factory[client_name](base_url, **kwargs) @@ -46,28 +49,28 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): ) -def _network_adapters_factory(client_name: str, base_url: str, **kwargs): - if "scs_as_id" not in kwargs: - raise ValueError("Missing required 'scs_as_id' for network adapters.") - scs_as_id = kwargs.pop("scs_as_id") +# def _network_adapters_factory(client_name: str, base_url: str, **kwargs): +# if "scs_as_id" not in kwargs: +# raise ValueError("Missing required 'scs_as_id' for network adapters.") +# scs_as_id = kwargs.pop("scs_as_id") - network_factory = { - "open5gs": lambda url, scs_id, **kw: Open5GSClient( - base_url=url, scs_as_id=scs_id, **kw - ), - "oai": lambda url, scs_id, **kw: OaiCoreClient( - base_url=url, scs_as_id=scs_id, **kw - ), - "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( - base_url=url, scs_as_id=scs_id, **kw - ), - } - try: - return network_factory[client_name](base_url, scs_as_id, **kwargs) - except KeyError: - raise ValueError( - f"Invalid network client '{client_name}'. Available: {list(network_factory)}" - ) +# network_factory = { +# "open5gs": lambda url, scs_id, **kw: Open5GSClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# "oai": lambda url, scs_id, **kw: OaiCoreClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# } +# try: +# return network_factory[client_name](base_url, scs_as_id, **kwargs) +# except KeyError: +# raise ValueError( +# f"Invalid network client '{client_name}'. Available: {list(network_factory)}" +# ) # def _oran_adapters_factory(client_name: str, base_url: str): @@ -77,7 +80,7 @@ def _network_adapters_factory(client_name: str, base_url: str, **kwargs): class AdaptersFactory: _domain_factories = { "edgecloud": _edgecloud_adapters_factory, - "network": _network_adapters_factory, + # "network": _network_adapters_factory, # "oran": _oran_adapters_factory, } diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 35407a9..be77f96 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -1,37 +1,86 @@ -# -*- coding: utf-8 -*- # Mocked API for testing purposes import logging import os from typing import Dict, List, Optional +from kubernetes.client import V1Deployment -from edgecloud.core.edgecloud_interface import EdgeCloudManagementInterface -from swagger_server.core.kubernetes_encoder import deploy_service_function -from swagger_server.models.deploy_service_function import DeployServiceFunction -from swagger_server.models.service_function_registration_request import ( +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.core.piedge_encoder import ( + deploy_service_function, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.app_manifest import ( + AppManifest, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deploy_service_function import ( + DeployServiceFunction, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.service_function_registration_request import ( ServiceFunctionRegistrationRequest, ) -from swagger_server.utils import connector_db, kubernetes_connector - -kubernetes_ip = os.environ["EDGE_CLOUD_ADAPTER"] -edge_cloud_provider = os.environ["PLATFORM_PROVIDER"] +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.connector_db import ( + ConnectorDB, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.kubernetes_connector import ( + KubernetesConnector, +) +from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( + EdgeCloudManagementInterface, +) class EdgeApplicationManager(EdgeCloudManagementInterface): - def onboard_app(self, app_manifest: Dict) -> Dict: + + def __init__(self, base_url: str, **kwargs): + self.kubernetes_host = base_url + self.edge_cloud_provider = kwargs.get("PLATFORM_PROVIDER") + kubernetes_token = kwargs.get("KUBERNETES_MASTER_TOKEN") + kubernetes_port = kwargs.get("KUBERNETES_MASTER_PORT") + storage_uri = kwargs.get("EMP_STORAGE_URI") + username = kwargs.get("KUBERNETES_USERNAME") + if base_url is not None and base_url != "": + self.k8s_connector = KubernetesConnector( + ip=self.kubernetes_host, + port=kubernetes_port, + token=kubernetes_token, + username=username, + ) + if storage_uri is not None: + self.connector_db = ConnectorDB(storage_uri) + + def onboard_app(self, app_manifest: AppManifest) -> Dict: print(f"Submitting application: {app_manifest}") logging.info("Extracting variables from payload...") + app_id = app_manifest.get("appId") app_name = app_manifest.get("name") image = app_manifest.get("appRepo").get("imagePath") - sf = ServiceFunctionRegistrationRequest( - service_function_image=image, service_function_name=app_name + package_type = app_manifest.get("packageType") + network_interfaces = app_manifest.get("componentSpec")[0].get( + "networkInterfaces" + ) + ports = [] + for ni in network_interfaces: + ports.append(ni.get("port")) + insert_doc = ServiceFunctionRegistrationRequest( + service_function_id = app_id, + service_function_image=image, + service_function_name=app_name, + service_function_type=package_type, + application_ports=ports, + ) + result = self.connector_db.insert_document_service_function( + insert_doc.to_dict() ) - return sf + if type(result) is str: + return result + return {"appId": str(result.inserted_id)} def get_all_onboarded_apps(self) -> List[Dict]: logging.info("Retrieving all registered apps from database...") - app_list = connector_db.get_documents_from_collection( + db_list = self.connector_db.get_documents_from_collection( collection_input="service_functions" ) + app_list = [] + for sf in db_list: + app_list.append(self.__transform_to_camara(sf)) return app_list # return [{"appId": "1234-5678", "name": "TestApp"}] @@ -39,37 +88,55 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): logging.info( "Searching for registered app with ID: " + app_id + " in database..." ) - app = connector_db.get_documents_from_collection( + app = self.connector_db.get_documents_from_collection( "service_functions", input_type="_id", input_value=app_id ) - return app + if len(app) > 0: + return self.__transform_to_camara(app[0]) + else: + return [] def delete_onboarded_app(self, app_id: str) -> None: - logging.info("Deleting registered app with ID: " + app_id + " from database...") - result = connector_db.delete_document_service_function(app_id) - return result - # print(f"Deleting application: {app_id}") + result, code = self.connector_db.delete_document_service_function(_id=app_id) + print(f"Removing application metadata: {app_id}") + return code - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: + def deploy_app(self, body: dict) -> Dict: logging.info( - "Searching for registered app with ID: " + app_id + " in database..." + "Searching for registered app with ID: " + body.get('appId') + " in database..." ) - app = connector_db.get_documents_from_collection( - "service_functions", input_type="_id", input_value=app_id + app = self.connector_db.get_documents_from_collection( + "service_functions", input_type="_id", input_value=body.get('appId') ) - success_response = [] + # success_response = [] + result = None + response = None + if len(app) < 1: + return "Application with ID: " + body.get('appId') + " not found", 404 if app is not None: - for zone in app_zones: - sf = DeployServiceFunction( - service_function_name=app.get("name"), - service_function_instance_name=app.get("name") - + zone.get("edgeCloudZoneName"), - location=zone.get("edgeCloudZoneName"), + sf = DeployServiceFunction( + service_function_name=app[0].get("name"), + service_function_instance_name=body.get('name'), + # location=body.get('edgeCloudZoneId'), ) - result = deploy_service_function(service_function=sf) - success_response.append(result) - # return {"appInstanceId": "abcd-efgh"} - return success_response + result = deploy_service_function( + service_function=sf, + connector_db=self.connector_db, + kubernetes_connector=self.k8s_connector, + ) + if type(result) is V1Deployment: + response = {} + response['name'] = body.get('name') + response['appId']= app[0].get('_id') + response['appInstanceId'] = result.metadata.uid + response['appProvider'] = app[0].get('appProvider') + response['status'] = 'unknown' + response['componentEndpointInfo']= {} + response['kubernetesClusterRef'] = '' + response['edgeCloudZoneId'] = body.get('edgeCloudZoneId') + else: + response = {'Error': result} + return response def get_all_deployed_apps( self, @@ -78,37 +145,61 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): region: Optional[str] = None, ) -> List[Dict]: logging.info("Retreiving all deployed apps in the edge cloud platform") - # response = kubernetes_connector.get_deployed_service_functions() # Flake8 error: declared but not used - return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + deployments = self.k8s_connector.get_deployed_service_functions( + self.connector_db + ) + response = [] + for deployment in deployments: + item = {} + item['name'] = deployment.get('service_function_catalogue_name') + item['appId'] = deployment.get('id') + item['appProvider'] = deployment.get('appProvider') + item["appInstanceId"] = deployment.get("uid") + item["status"] = deployment.get("status") + interfaces = [] + for port in deployment.get('ports'): + access_point = {'port': port} + interfaces.append({'interfaceId' : '','accessPoints': access_point}) + item["componentEndpointInfo"] = interfaces + item["kubernetesClusterRef"] = "" + item["edgeCloudZoneId"] = {} + response.append(item) + return response + # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: logging.info( "Searching for deployed app with ID: " + app_instance_id + " in database..." ) print(f"Deleting app instance: {app_instance_id}") - # deployed_service_function_name_=auxiliary_functions.prepare_name_for_k8s(deployed_service_function_name) - sfs = kubernetes_connector.get_deployed_service_functions() + sfs = self.k8s_connector.get_deployed_service_functions(self.connector_db) response = "App instance with ID [" + app_instance_id + "] not found" - for service_fun in sfs.items: - if service_fun["uid"] == app_instance_id: - response = kubernetes_connector.delete_service_function( - service_fun["service_function_instance_name"] + for service_fun in sfs: + if service_fun["appInstanceId"] == app_instance_id: + self.k8s_connector.delete_service_function( + self.connector_db, service_fun["service_function_instance_name"] + ) + response = ( + "App instance with ID [" + + app_instance_id + + "] successfully removed" ) + break return response def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None ) -> List[Dict]: - nodes_response = kubernetes_connector.get_PoPs() + nodes_response = self.k8s_connector.get_PoPs() zone_list = [] - for node in nodes_response.json().get("nodes"): + for node in nodes_response: zone = {} zone["edgeCloudZoneId"] = node.get("uid") zone["edgeCloudZoneName"] = node.get("name") zone["edgeCloudZoneStatus"] = node.get("status") - zone["edgeCloudProvider"] = edge_cloud_provider + zone["edgeCloudProvider"] = self.edge_cloud_provider zone["edgeCloudRegion"] = node.get("location") zone_list.append(zone) return zone_list @@ -116,38 +207,53 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones_details( self, zone_id: str, flavour_id: Optional[str] = None ) -> Dict: - # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API - return { - "zoneId": zone_id, - "reservedComputeResources": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": "4", - "memory": 8192, - } - ], - "computeResourceQuotaLimits": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": "8", - "memory": 16384, - } - ], - "flavoursSupported": [ - { - "flavourId": "medium-x86", - "cpuArchType": "ISA_X86_64", - "supportedOSTypes": [ - { - "architecture": "x86_64", - "distribution": "UBUNTU", - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - } - ], - "numCPU": 4, - "memorySize": 8192, - "storageSize": 100, - } - ], - } + nodes = self.k8s_connector.get_node_details() + node_details = None + for item in nodes.get('items'): + if item.get('metadata').get('uid')==zone_id: + node_details = item + break + labels = node_details.get("metadata").get("labels") + status = node_details.get("status") + arch_type = labels.get("beta.kubernetes.io/arch") + computeResourceQuotaLimits = [ + { + "cpuArchType": arch_type, + "numCPU": status.get("capacity").get("cpu"), + "memory": status.get("capacity").get("memory") + # "memory": int(status.get("capacity").get("memory")) / (1024 * 1024), + } + ] + reservedComputeResources = [ + { + "cpuArchType": arch_type, + "numCPU": status.get("allocatable").get("cpu"), + "memory": status.get("allocatable").get("memory") + # "memory": int(status.get("allocatable").get("memory")) / (1024 * 1024), + } + ] + flavoursSupported = [] + node_details["computeResourceQuotaLimits"] = computeResourceQuotaLimits + node_details["reservedComputeResources"] = reservedComputeResources + node_details["flavoursSupported"] = flavoursSupported + node_details["zoneId"] = zone_id + return node_details + + def __transform_to_camara(self, app_data): + app = {} + app["appId"] = app_data.get("_id") + app["name"] = app_data.get("name") + app["packageType"] = app_data.get("type") + appRepo = {"imagePath": app_data.get("image")} + app["appRepo"] = appRepo + networkInterfaces = [] + for port in app_data.get("application_ports"): + port_spec = {"protocol": "TCP", "port": port} + networkInterfaces.append(port_spec) + app["componentSpec"] = [ + { + "componentName": app_data.get("name"), + "networkInterfaces": networkInterfaces, + } + ] + return app diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py new file mode 100644 index 0000000..f2c1616 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py @@ -0,0 +1,213 @@ +from kubernetes.client import V1Deployment +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deploy_service_function import ( # noqa: E501 + DeployServiceFunction, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils import auxiliary_functions +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.connector_db import ( + ConnectorDB, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.kubernetes_connector import ( + KubernetesConnector, +) + +driver = None + +def deploy_service_function( + service_function: DeployServiceFunction, + connector_db: ConnectorDB, + kubernetes_connector: KubernetesConnector, + paas_name=None, +): + + # descriptor_paas_input["scaling_type"]="minimize_cost" + # print(descriptor_paas_input) + # we need to create the descriptor_paas_ needed for deployment + # search if app exists in the catalogue + + ser_function_ = connector_db.get_documents_from_collection("service_functions", input_type="name", + input_value=service_function.service_function_name) + if not ser_function_: + return "The given service function does not exist in the catalogue", 404 + + + # search if node exists in the node catalogue + # if service_function.location is not None: + # node_ = connector_db.get_documents_from_collection("points_of_presence", input_type="location", + # input_value=service_function.location) + # if not node_: + # return "The given location does not exist in the node catalogue", 404 + + final_deploy_descriptor = {} + # final_deploy_descriptor["name"]=app_[0]["name"] + + # deployed_name= app_[0]["name"] + "emp"+ descriptor_paas_input["paas_input_name"] + + + deployed_name = service_function.service_function_instance_name + + + deployed_name= auxiliary_functions.prepare_name(deployed_name, driver) + + final_deploy_descriptor["name"] = deployed_name + + final_deploy_descriptor["count-min"] = 1 if service_function.count_min is None else service_function.count_min + final_deploy_descriptor["count-max"] = 1 if service_function.count_max is None else service_function.count_max + + if final_deploy_descriptor["count-min"]>final_deploy_descriptor["count-max"]: + final_deploy_descriptor["count-min"]=final_deploy_descriptor["count-max"] + + if service_function.location is not None: + final_deploy_descriptor["location"] = service_function.location + + containers = [] + con_ = {} + con_["image"] = ser_function_[0]["image"] + + if "privileged" in ser_function_[0]: + + con_["privileged"]=ser_function_[0]["privileged"] + + + #con_["imagePullPolicy"] = "Always" + #ports + application_ports = ser_function_[0]["application_ports"] + con_["application_ports"] = application_ports + + if service_function.all_node_ports is not None: + + if service_function.all_node_ports==False and service_function.node_ports is None: + return "Please provide the application ports in the field exposed_ports or all_node_ports==true", 400 + + if service_function.all_node_ports: + con_["exposed_ports"] = application_ports + else: + + exposed_ports = auxiliary_functions.return_equal_ignore_order(application_ports, + service_function.node_ports) + if exposed_ports: + con_["exposed_ports"] = exposed_ports + # application_ports = ser_function_[0]["application_ports"] + # con_["application_ports"] = application_ports + # containers.append(con_) + else: + if service_function.node_ports is not None: + exposed_ports = auxiliary_functions.return_equal_ignore_order(application_ports, + service_function.node_ports) + if exposed_ports: + + con_["exposed_ports"] = exposed_ports + containers.append(con_) + + final_deploy_descriptor["containers"] = containers + #final_deploy_descriptor["restartPolicy"] = "Always" + + #check volumes!! + req_volumes = [] + if "required_volumes" in ser_function_[0]: + if ser_function_[0]["required_volumes"] is not None: + for required_volumes in ser_function_[0]["required_volumes"]: + req_volumes.append(required_volumes["name"]) + vol_mount = [] + volume_input = [] + + + if service_function.volume_mounts is not None: + for volume_mounts in service_function.volume_mounts: + + vo_in = {} + + vo_in["name"] = volume_mounts.name + vo_in["storage"] = volume_mounts.storage + volume_input.append(vo_in) + vol_mount.append(volume_mounts.name) + if (len(vol_mount) != len(req_volumes)): + return "The selected service function requires " + str(len(req_volumes)) +" volume/ volumes ", 400 + else: + if ser_function_[0].get("required_volumes") is not None: + + result = auxiliary_functions.equal_ignore_order(req_volumes, vol_mount) + + if result is False: + return "The selected service function requires " + str(len(req_volumes)) +" volumes. Please check volume names", 400 + else: + volumes=[] + for vol in ser_function_[0]["required_volumes"]: + for vol_re in service_function.volume_mounts: + vol_={} + if vol["name"]==vol_re.name: + vol_["name"]=vol_re.name + vol_["storage"]=vol_re.storage + vol_["path"]=vol["path"] + if "hostpath" in vol: + vol_["hostpath"] = vol["hostpath"] + volumes.append(vol_) + final_deploy_descriptor["volumes"] = volumes + + #check env parameters: + req_env_parameters = [] + + if "required_env_parameters" in ser_function_[0]: + if ser_function_[0]["required_env_parameters"] is not None: + for required_env_parameters in ser_function_[0]["required_env_parameters"]: + req_env_parameters.append(required_env_parameters["name"]) + env_names = [] + env_input = [] + if service_function.env_parameters is not None: + for env_parameters in service_function.env_parameters: + env_in = {} + + env_in["name"] = env_parameters.name + if env_parameters.value is not None: + env_in["value"] = env_parameters.value + elif env_parameters.value_ref is not None: + env_in["value_ref"] = env_parameters.value_ref + env_input.append(env_in) + env_names.append(env_parameters.name) + if (len(env_names) != len(req_env_parameters)): + return "The selected service function requires " + str(len(req_env_parameters)) + " env parameters", 400 + else: + if ser_function_[0].get("required_env_parameters") is not None: + + result = auxiliary_functions.equal_ignore_order(req_env_parameters, env_names) + + if result is False: + return "The selected service function requires " + str( + len(req_env_parameters)) + " env parameters. Please check names of env parameters", 400 + else: + #EnvParameters to dict + paremeters = [] + for reqenv in ser_function_[0].get("required_env_parameters"): + for env_in in service_function.env_parameters: + reqenv_ = {} + if reqenv["name"] == env_in.name: + reqenv_["name"] = env_in.name + if env_in.value is not None: + reqenv_["value"] = env_in.value + elif env_in.value_ref is not None: + reqenv_["value_ref"] = env_in.value_ref + paremeters.append(reqenv_) + final_deploy_descriptor["env_parameters"] = paremeters + + + response = kubernetes_connector.deploy_service_function(final_deploy_descriptor) + # insert it to mongo db + deployed_service_function_db = {} + deployed_service_function_db["service_function_name"] = ser_function_[0]["name"] + if service_function.location is not None: + deployed_service_function_db["location"] = service_function.location + deployed_service_function_db["instance_name"] = deployed_name + + if "volumes" in final_deploy_descriptor: + deployed_service_function_db["volumes"] = final_deploy_descriptor["volumes"] + if "env_parameters" in final_deploy_descriptor: + deployed_service_function_db["env_parameters"] = final_deploy_descriptor["env_parameters"] + + # if "Conflict" not in response: + if "location" not in deployed_service_function_db: + deployed_service_function_db["location"]= "Node is selected by the K8s scheduler" + if type(response) is V1Deployment: + deployed_service_function_db['uid'] = response.metadata.uid + connector_db.insert_document_deployed_service_function(document=deployed_service_function_db) + return response + else: + return "error instantiating application" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/__init__.py new file mode 100644 index 0000000..a185716 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/__init__.py @@ -0,0 +1,36 @@ +# coding: utf-8 + +# flake8: noqa +from __future__ import absolute_import + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.app_delete import AppDelete +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.apps_response import ( + AppsResponse, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.apps_response_apps import ( + AppsResponseApps, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.appupdate import Appupdate +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deploy_service_function import ( + DeployServiceFunction, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deployedapps_response import ( + DeployedappsResponse, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deployedapps_response_apps import ( + DeployedappsResponseApps, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.env_parameters import ( + EnvParameters, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.nodes_response import ( + NodesResponse, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.service_function_deregistration_request import ( + ServiceFunctionDeregistrationRequest, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.service_function_registration_request import ( + ServiceFunctionRegistrationRequest, +) + +# import models into model package diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/activate_secured_slice.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/activate_secured_slice.py new file mode 100644 index 0000000..bca3755 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/activate_secured_slice.py @@ -0,0 +1,88 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class ActivateSecuredSlice(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, slice_name: str = None, service_functions_names: List[str] = None + ): # noqa: E501 + """ActivateSecuredSlice - a model defined in Swagger + + :param slice_name: The slice_name of this ActivateSecuredSlice. # noqa: E501 + :type slice_name: str + :param service_functions_names: The service_functions_names of this ActivateSecuredSlice. # noqa: E501 + :type service_functions_names: List[str] + """ + self.swagger_types = {"slice_name": str, "service_functions_names": List[str]} + + self.attribute_map = { + "slice_name": "slice_name", + "service_functions_names": "service_functions_names", + } + self._slice_name = slice_name + self._service_functions_names = service_functions_names + + @classmethod + def from_dict(cls, dikt) -> "ActivateSecuredSlice": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The activateSecuredSlice of this ActivateSecuredSlice. # noqa: E501 + :rtype: ActivateSecuredSlice + """ + return util.deserialize_model(dikt, cls) + + @property + def slice_name(self) -> str: + """Gets the slice_name of this ActivateSecuredSlice. + + + :return: The slice_name of this ActivateSecuredSlice. + :rtype: str + """ + return self._slice_name + + @slice_name.setter + def slice_name(self, slice_name: str): + """Sets the slice_name of this ActivateSecuredSlice. + + + :param slice_name: The slice_name of this ActivateSecuredSlice. + :type slice_name: str + """ + + self._slice_name = slice_name + + @property + def service_functions_names(self) -> List[str]: + """Gets the service_functions_names of this ActivateSecuredSlice. + + + :return: The service_functions_names of this ActivateSecuredSlice. + :rtype: List[str] + """ + return self._service_functions_names + + @service_functions_names.setter + def service_functions_names(self, service_functions_names: List[str]): + """Sets the service_functions_names of this ActivateSecuredSlice. + + + :param service_functions_names: The service_functions_names of this ActivateSecuredSlice. + :type service_functions_names: List[str] + """ + + self._service_functions_names = service_functions_names diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_delete.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_delete.py new file mode 100644 index 0000000..06a0ed5 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_delete.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class AppDelete(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, paas_service_name: str = None): # noqa: E501 + """AppDelete - a model defined in Swagger + + :param paas_service_name: The paas_service_name of this AppDelete. # noqa: E501 + :type paas_service_name: str + """ + self.swagger_types = {"paas_service_name": str} + + self.attribute_map = {"paas_service_name": "paas_service_name"} + self._paas_service_name = paas_service_name + + @classmethod + def from_dict(cls, dikt) -> "AppDelete": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The appDelete of this AppDelete. # noqa: E501 + :rtype: AppDelete + """ + return util.deserialize_model(dikt, cls) + + @property + def paas_service_name(self) -> str: + """Gets the paas_service_name of this AppDelete. + + + :return: The paas_service_name of this AppDelete. + :rtype: str + """ + return self._paas_service_name + + @paas_service_name.setter + def paas_service_name(self, paas_service_name: str): + """Sets the paas_service_name of this AppDelete. + + + :param paas_service_name: The paas_service_name of this AppDelete. + :type paas_service_name: str + """ + + self._paas_service_name = paas_service_name diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py new file mode 100644 index 0000000..ff991ad --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py @@ -0,0 +1,276 @@ +# coding: utf-8 + +from __future__ import absolute_import + +import re # noqa: F401,E501 +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.app_manifest_app_repo import ( # noqa: F401,E501 + AppManifestAppRepo, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.app_manifest_component_spec import ( # noqa: F401,E501 + AppManifestComponentSpec, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.operating_system import ( # noqa: F401,E501 + OperatingSystem, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.required_resources import ( # noqa: F401,E501 + RequiredResources, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.util import deserialize_model + + +class AppManifest(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + name: str = None, + version: int = None, + package_type: str = None, + operating_system: OperatingSystem = None, + app_repo: AppManifestAppRepo = None, + required_resources: RequiredResources = None, + component_spec: List[AppManifestComponentSpec] = None, + ): # noqa: E501 + """AppManifest - a model defined in Swagger + + :param name: The name of this AppManifest. # noqa: E501 + :type name: str + :param version: The version of this AppManifest. # noqa: E501 + :type version: int + :param package_type: The package_type of this AppManifest. # noqa: E501 + :type package_type: str + :param operating_system: The operating_system of this AppManifest. # noqa: E501 + :type operating_system: OperatingSystem + :param app_repo: The app_repo of this AppManifest. # noqa: E501 + :type app_repo: AppManifestAppRepo + :param required_resources: The required_resources of this AppManifest. # noqa: E501 + :type required_resources: RequiredResources + :param component_spec: The component_spec of this AppManifest. # noqa: E501 + :type component_spec: List[AppManifestComponentSpec] + """ + self.swagger_types = { + "name": str, + "version": int, + "package_type": str, + "operating_system": OperatingSystem, + "app_repo": AppManifestAppRepo, + "required_resources": RequiredResources, + "component_spec": List[AppManifestComponentSpec], + } + + self.attribute_map = { + "name": "name", + "version": "version", + "package_type": "packageType", + "operating_system": "operatingSystem", + "app_repo": "appRepo", + "required_resources": "requiredResources", + "component_spec": "componentSpec", + } + self._name = name + self._version = version + self._package_type = package_type + self._operating_system = operating_system + self._app_repo = app_repo + self._required_resources = required_resources + self._component_spec = component_spec + + @classmethod + def from_dict(cls, dikt) -> "AppManifest": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The AppManifest of this AppManifest. # noqa: E501 + :rtype: AppManifest + """ + return deserialize_model(dikt, cls) + + @property + def name(self) -> str: + """Gets the name of this AppManifest. + + Name of the application. # noqa: E501 + + :return: The name of this AppManifest. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this AppManifest. + + Name of the application. # noqa: E501 + + :param name: The name of this AppManifest. + :type name: str + """ + if name is None: + raise ValueError( + "Invalid value for `name`, must not be `None`" + ) # noqa: E501 + + self._name = name + + @property + def version(self) -> int: + """Gets the version of this AppManifest. + + Application version information # noqa: E501 + + :return: The version of this AppManifest. + :rtype: int + """ + return self._version + + @version.setter + def version(self, version: int): + """Sets the version of this AppManifest. + + Application version information # noqa: E501 + + :param version: The version of this AppManifest. + :type version: int + """ + if version is None: + raise ValueError( + "Invalid value for `version`, must not be `None`" + ) # noqa: E501 + + self._version = version + + @property + def package_type(self) -> str: + """Gets the package_type of this AppManifest. + + Format of the application image package # noqa: E501 + + :return: The package_type of this AppManifest. + :rtype: str + """ + return self._package_type + + @package_type.setter + def package_type(self, package_type: str): + """Sets the package_type of this AppManifest. + + Format of the application image package # noqa: E501 + + :param package_type: The package_type of this AppManifest. + :type package_type: str + """ + allowed_values = ["QCOW2", "OVA", "CONTAINER", "HELM"] # noqa: E501 + if package_type not in allowed_values: + raise ValueError( + "Invalid value for `package_type` ({0}), must be one of {1}".format( + package_type, allowed_values + ) + ) + + self._package_type = package_type + + @property + def operating_system(self) -> OperatingSystem: + """Gets the operating_system of this AppManifest. + + + :return: The operating_system of this AppManifest. + :rtype: OperatingSystem + """ + return self._operating_system + + @operating_system.setter + def operating_system(self, operating_system: OperatingSystem): + """Sets the operating_system of this AppManifest. + + + :param operating_system: The operating_system of this AppManifest. + :type operating_system: OperatingSystem + """ + + self._operating_system = operating_system + + @property + def app_repo(self) -> AppManifestAppRepo: + """Gets the app_repo of this AppManifest. + + + :return: The app_repo of this AppManifest. + :rtype: AppManifestAppRepo + """ + return self._app_repo + + @app_repo.setter + def app_repo(self, app_repo: AppManifestAppRepo): + """Sets the app_repo of this AppManifest. + + + :param app_repo: The app_repo of this AppManifest. + :type app_repo: AppManifestAppRepo + """ + if app_repo is None: + raise ValueError( + "Invalid value for `app_repo`, must not be `None`" + ) # noqa: E501 + + self._app_repo = app_repo + + @property + def required_resources(self) -> RequiredResources: + """Gets the required_resources of this AppManifest. + + + :return: The required_resources of this AppManifest. + :rtype: RequiredResources + """ + return self._required_resources + + @required_resources.setter + def required_resources(self, required_resources: RequiredResources): + """Sets the required_resources of this AppManifest. + + + :param required_resources: The required_resources of this AppManifest. + :type required_resources: RequiredResources + """ + if required_resources is None: + raise ValueError( + "Invalid value for `required_resources`, must not be `None`" + ) # noqa: E501 + + self._required_resources = required_resources + + @property + def component_spec(self) -> List[AppManifestComponentSpec]: + """Gets the component_spec of this AppManifest. + + Information defined in \"appRepo\" point to the application descriptor e.g. Helm chart, docker-compose yaml file etc. The descriptor may contain one or more containers and their associated meta-data. A component refers to additional details about these containers to expose the instances of the containers to external client applications. App provider can define one or more components (via the associated network port) in componentSpec corresponding to the containers in helm charts or docker-compose yaml file as part of app descriptors. # noqa: E501 + + :return: The component_spec of this AppManifest. + :rtype: List[AppManifestComponentSpec] + """ + return self._component_spec + + @component_spec.setter + def component_spec(self, component_spec: List[AppManifestComponentSpec]): + """Sets the component_spec of this AppManifest. + + Information defined in \"appRepo\" point to the application descriptor e.g. Helm chart, docker-compose yaml file etc. The descriptor may contain one or more containers and their associated meta-data. A component refers to additional details about these containers to expose the instances of the containers to external client applications. App provider can define one or more components (via the associated network port) in componentSpec corresponding to the containers in helm charts or docker-compose yaml file as part of app descriptors. # noqa: E501 + + :param component_spec: The component_spec of this AppManifest. + :type component_spec: List[AppManifestComponentSpec] + """ + if component_spec is None: + raise ValueError( + "Invalid value for `component_spec`, must not be `None`" + ) # noqa: E501 + + self._component_spec = component_spec diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py new file mode 100644 index 0000000..9236520 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py @@ -0,0 +1,232 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.uri import ( # noqa: F401,E501 + Uri, +) + + +class AppManifestAppRepo(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + type: str = None, + image_path: Uri = None, + user_name: str = None, + credentials: str = None, + auth_type: str = None, + checksum: str = None, + ): # noqa: E501 + """AppManifestAppRepo - a model defined in Swagger + + :param type: The type of this AppManifestAppRepo. # noqa: E501 + :type type: str + :param image_path: The image_path of this AppManifestAppRepo. # noqa: E501 + :type image_path: Uri + :param user_name: The user_name of this AppManifestAppRepo. # noqa: E501 + :type user_name: str + :param credentials: The credentials of this AppManifestAppRepo. # noqa: E501 + :type credentials: str + :param auth_type: The auth_type of this AppManifestAppRepo. # noqa: E501 + :type auth_type: str + :param checksum: The checksum of this AppManifestAppRepo. # noqa: E501 + :type checksum: str + """ + self.swagger_types = { + "type": str, + "image_path": Uri, + "user_name": str, + "credentials": str, + "auth_type": str, + "checksum": str, + } + + self.attribute_map = { + "type": "type", + "image_path": "imagePath", + "user_name": "userName", + "credentials": "credentials", + "auth_type": "authType", + "checksum": "checksum", + } + self._type = type + self._image_path = image_path + self._user_name = user_name + self._credentials = credentials + self._auth_type = auth_type + self._checksum = checksum + + @classmethod + def from_dict(cls, dikt) -> "AppManifestAppRepo": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The AppManifest_appRepo of this AppManifestAppRepo. # noqa: E501 + :rtype: AppManifestAppRepo + """ + return util.deserialize_model(dikt, cls) + + @property + def type(self) -> str: + """Gets the type of this AppManifestAppRepo. + + Application repository and image URI information. PUBLICREPO is used of public urls like github, helm repo etc. PRIVATEREPO is used for private repo managed by the application developer. Private repo can be accessed by using the app developer provided userName and password. Password is recommended to be the personal access token created by developer e.g. in Github repo. # noqa: E501 + + :return: The type of this AppManifestAppRepo. + :rtype: str + """ + return self._type + + @type.setter + def type(self, type: str): + """Sets the type of this AppManifestAppRepo. + + Application repository and image URI information. PUBLICREPO is used of public urls like github, helm repo etc. PRIVATEREPO is used for private repo managed by the application developer. Private repo can be accessed by using the app developer provided userName and password. Password is recommended to be the personal access token created by developer e.g. in Github repo. # noqa: E501 + + :param type: The type of this AppManifestAppRepo. + :type type: str + """ + allowed_values = ["PRIVATEREPO", "PUBLICREPO"] # noqa: E501 + if type not in allowed_values: + raise ValueError( + "Invalid value for `type` ({0}), must be one of {1}".format( + type, allowed_values + ) + ) + + self._type = type + + @property + def image_path(self) -> Uri: + """Gets the image_path of this AppManifestAppRepo. + + + :return: The image_path of this AppManifestAppRepo. + :rtype: Uri + """ + return self._image_path + + @image_path.setter + def image_path(self, image_path: Uri): + """Sets the image_path of this AppManifestAppRepo. + + + :param image_path: The image_path of this AppManifestAppRepo. + :type image_path: Uri + """ + if image_path is None: + raise ValueError( + "Invalid value for `image_path`, must not be `None`" + ) # noqa: E501 + + self._image_path = image_path + + @property + def user_name(self) -> str: + """Gets the user_name of this AppManifestAppRepo. + + Username to acces the Helm chart, docker-compose file or VM image repository # noqa: E501 + + :return: The user_name of this AppManifestAppRepo. + :rtype: str + """ + return self._user_name + + @user_name.setter + def user_name(self, user_name: str): + """Sets the user_name of this AppManifestAppRepo. + + Username to acces the Helm chart, docker-compose file or VM image repository # noqa: E501 + + :param user_name: The user_name of this AppManifestAppRepo. + :type user_name: str + """ + + self._user_name = user_name + + @property + def credentials(self) -> str: + """Gets the credentials of this AppManifestAppRepo. + + Password or personal access token created by developer to acces the app repository. API users can generate a personal access token e.g. docker clients to use them as password. # noqa: E501 + + :return: The credentials of this AppManifestAppRepo. + :rtype: str + """ + return self._credentials + + @credentials.setter + def credentials(self, credentials: str): + """Sets the credentials of this AppManifestAppRepo. + + Password or personal access token created by developer to acces the app repository. API users can generate a personal access token e.g. docker clients to use them as password. # noqa: E501 + + :param credentials: The credentials of this AppManifestAppRepo. + :type credentials: str + """ + + self._credentials = credentials + + @property + def auth_type(self) -> str: + """Gets the auth_type of this AppManifestAppRepo. + + The credentials can also be formatted as a Basic auth or Bearer auth in HTTP \"Authorization\" header. # noqa: E501 + + :return: The auth_type of this AppManifestAppRepo. + :rtype: str + """ + return self._auth_type + + @auth_type.setter + def auth_type(self, auth_type: str): + """Sets the auth_type of this AppManifestAppRepo. + + The credentials can also be formatted as a Basic auth or Bearer auth in HTTP \"Authorization\" header. # noqa: E501 + + :param auth_type: The auth_type of this AppManifestAppRepo. + :type auth_type: str + """ + allowed_values = ["DOCKER", "HTTP_BASIC", "HTTP_BEARER", "NONE"] # noqa: E501 + if auth_type not in allowed_values: + raise ValueError( + "Invalid value for `auth_type` ({0}), must be one of {1}".format( + auth_type, allowed_values + ) + ) + + self._auth_type = auth_type + + @property + def checksum(self) -> str: + """Gets the checksum of this AppManifestAppRepo. + + MD5 checksum for VM and file-based images, sha256 digest for containers # noqa: E501 + + :return: The checksum of this AppManifestAppRepo. + :rtype: str + """ + return self._checksum + + @checksum.setter + def checksum(self, checksum: str): + """Sets the checksum of this AppManifestAppRepo. + + MD5 checksum for VM and file-based images, sha256 digest for containers # noqa: E501 + + :param checksum: The checksum of this AppManifestAppRepo. + :type checksum: str + """ + + self._checksum = checksum diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py new file mode 100644 index 0000000..4c55b75 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py @@ -0,0 +1,110 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.app_manifest_network_interfaces import ( # noqa: F401,E501 + AppManifestNetworkInterfaces, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class AppManifestComponentSpec(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + component_name: str = None, + network_interfaces: List[AppManifestNetworkInterfaces] = None, + ): # noqa: E501 + """AppManifestComponentSpec - a model defined in Swagger + + :param component_name: The component_name of this AppManifestComponentSpec. # noqa: E501 + :type component_name: str + :param network_interfaces: The network_interfaces of this AppManifestComponentSpec. # noqa: E501 + :type network_interfaces: List[AppManifestNetworkInterfaces] + """ + self.swagger_types = { + "component_name": str, + "network_interfaces": List[AppManifestNetworkInterfaces], + } + + self.attribute_map = { + "component_name": "componentName", + "network_interfaces": "networkInterfaces", + } + self._component_name = component_name + self._network_interfaces = network_interfaces + + @classmethod + def from_dict(cls, dikt) -> "AppManifestComponentSpec": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The AppManifest_componentSpec of this AppManifestComponentSpec. # noqa: E501 + :rtype: AppManifestComponentSpec + """ + return util.deserialize_model(dikt, cls) + + @property + def component_name(self) -> str: + """Gets the component_name of this AppManifestComponentSpec. + + Component name must be unique with an application # noqa: E501 + + :return: The component_name of this AppManifestComponentSpec. + :rtype: str + """ + return self._component_name + + @component_name.setter + def component_name(self, component_name: str): + """Sets the component_name of this AppManifestComponentSpec. + + Component name must be unique with an application # noqa: E501 + + :param component_name: The component_name of this AppManifestComponentSpec. + :type component_name: str + """ + if component_name is None: + raise ValueError( + "Invalid value for `component_name`, must not be `None`" + ) # noqa: E501 + + self._component_name = component_name + + @property + def network_interfaces(self) -> List[AppManifestNetworkInterfaces]: + """Gets the network_interfaces of this AppManifestComponentSpec. + + Each application component exposes some ports either for external users or for inter component communication. Application provider is required to specify which ports are to be exposed and the type of traffic that will flow through these ports.The underlying platform may assign a dynamic port against the \"extPort\" that the application clients will use to connect with edge application instance. # noqa: E501 + + :return: The network_interfaces of this AppManifestComponentSpec. + :rtype: List[AppManifestNetworkInterfaces] + """ + return self._network_interfaces + + @network_interfaces.setter + def network_interfaces( + self, network_interfaces: List[AppManifestNetworkInterfaces] + ): + """Sets the network_interfaces of this AppManifestComponentSpec. + + Each application component exposes some ports either for external users or for inter component communication. Application provider is required to specify which ports are to be exposed and the type of traffic that will flow through these ports.The underlying platform may assign a dynamic port against the \"extPort\" that the application clients will use to connect with edge application instance. # noqa: E501 + + :param network_interfaces: The network_interfaces of this AppManifestComponentSpec. + :type network_interfaces: List[AppManifestNetworkInterfaces] + """ + if network_interfaces is None: + raise ValueError( + "Invalid value for `network_interfaces`, must not be `None`" + ) # noqa: E501 + + self._network_interfaces = network_interfaces diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py new file mode 100644 index 0000000..811d809 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py @@ -0,0 +1,178 @@ +# coding: utf-8 + +from __future__ import absolute_import + +import re # noqa: F401,E501 +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class AppManifestNetworkInterfaces(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + interface_id: str = None, + protocol: str = None, + port: int = None, + visibility_type: str = None, + ): # noqa: E501 + """AppManifestNetworkInterfaces - a model defined in Swagger + + :param interface_id: The interface_id of this AppManifestNetworkInterfaces. # noqa: E501 + :type interface_id: str + :param protocol: The protocol of this AppManifestNetworkInterfaces. # noqa: E501 + :type protocol: str + :param port: The port of this AppManifestNetworkInterfaces. # noqa: E501 + :type port: int + :param visibility_type: The visibility_type of this AppManifestNetworkInterfaces. # noqa: E501 + :type visibility_type: str + """ + self.swagger_types = { + "interface_id": str, + "protocol": str, + "port": int, + "visibility_type": str, + } + + self.attribute_map = { + "interface_id": "interfaceId", + "protocol": "protocol", + "port": "port", + "visibility_type": "visibilityType", + } + self._interface_id = interface_id + self._protocol = protocol + self._port = port + self._visibility_type = visibility_type + + @classmethod + def from_dict(cls, dikt) -> "AppManifestNetworkInterfaces": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The AppManifest_networkInterfaces of this AppManifestNetworkInterfaces. # noqa: E501 + :rtype: AppManifestNetworkInterfaces + """ + return util.deserialize_model(dikt, cls) + + @property + def interface_id(self) -> str: + """Gets the interface_id of this AppManifestNetworkInterfaces. + + Each Port and corresponding traffic protocol exposed by the component is identified by a name. Application client on user device requires this to uniquley idenify the interface. # noqa: E501 + + :return: The interface_id of this AppManifestNetworkInterfaces. + :rtype: str + """ + return self._interface_id + + @interface_id.setter + def interface_id(self, interface_id: str): + """Sets the interface_id of this AppManifestNetworkInterfaces. + + Each Port and corresponding traffic protocol exposed by the component is identified by a name. Application client on user device requires this to uniquley idenify the interface. # noqa: E501 + + :param interface_id: The interface_id of this AppManifestNetworkInterfaces. + :type interface_id: str + """ + if interface_id is None: + raise ValueError( + "Invalid value for `interface_id`, must not be `None`" + ) # noqa: E501 + + self._interface_id = interface_id + + @property + def protocol(self) -> str: + """Gets the protocol of this AppManifestNetworkInterfaces. + + Defines the IP transport communication protocol i.e., TCP, UDP or ANY # noqa: E501 + + :return: The protocol of this AppManifestNetworkInterfaces. + :rtype: str + """ + return self._protocol + + @protocol.setter + def protocol(self, protocol: str): + """Sets the protocol of this AppManifestNetworkInterfaces. + + Defines the IP transport communication protocol i.e., TCP, UDP or ANY # noqa: E501 + + :param protocol: The protocol of this AppManifestNetworkInterfaces. + :type protocol: str + """ + allowed_values = ["TCP", "UDP", "ANY"] # noqa: E501 + if protocol not in allowed_values: + raise ValueError( + "Invalid value for `protocol` ({0}), must be one of {1}".format( + protocol, allowed_values + ) + ) + + self._protocol = protocol + + @property + def port(self) -> int: + """Gets the port of this AppManifestNetworkInterfaces. + + Port number exposed by the component. Edge Cloud Provider may generate a dynamic port towards the component instance which forwards external traffic to the component port. # noqa: E501 + + :return: The port of this AppManifestNetworkInterfaces. + :rtype: int + """ + return self._port + + @port.setter + def port(self, port: int): + """Sets the port of this AppManifestNetworkInterfaces. + + Port number exposed by the component. Edge Cloud Provider may generate a dynamic port towards the component instance which forwards external traffic to the component port. # noqa: E501 + + :param port: The port of this AppManifestNetworkInterfaces. + :type port: int + """ + if port is None: + raise ValueError( + "Invalid value for `port`, must not be `None`" + ) # noqa: E501 + + self._port = port + + @property + def visibility_type(self) -> str: + """Gets the visibility_type of this AppManifestNetworkInterfaces. + + Defines whether the interface is exposed to outer world or not i.e., external, or internal. If this is set to \"external\", then it is exposed to external applications otherwise it is exposed internally to edge application components within edge cloud. When exposed to external world, an external dynamic port is assigned for UC traffic and mapped to the extPort # noqa: E501 + + :return: The visibility_type of this AppManifestNetworkInterfaces. + :rtype: str + """ + return self._visibility_type + + @visibility_type.setter + def visibility_type(self, visibility_type: str): + """Sets the visibility_type of this AppManifestNetworkInterfaces. + + Defines whether the interface is exposed to outer world or not i.e., external, or internal. If this is set to \"external\", then it is exposed to external applications otherwise it is exposed internally to edge application components within edge cloud. When exposed to external world, an external dynamic port is assigned for UC traffic and mapped to the extPort # noqa: E501 + + :param visibility_type: The visibility_type of this AppManifestNetworkInterfaces. + :type visibility_type: str + """ + allowed_values = ["VISIBILITY_EXTERNAL", "VISIBILITY_INTERNAL"] # noqa: E501 + if visibility_type not in allowed_values: + raise ValueError( + "Invalid value for `visibility_type` ({0}), must be one of {1}".format( + visibility_type, allowed_values + ) + ) + + self._visibility_type = visibility_type diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appdeploy.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appdeploy.py new file mode 100644 index 0000000..f56101c --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appdeploy.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class Appdeploy(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, apps: str = None): # noqa: E501 + """Appdeploy - a model defined in Swagger + + :param apps: The apps of this Appdeploy. # noqa: E501 + :type apps: str + """ + self.swagger_types = {"apps": str} + + self.attribute_map = {"apps": "apps"} + self._apps = apps + + @classmethod + def from_dict(cls, dikt) -> "Appdeploy": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The appdeploy of this Appdeploy. # noqa: E501 + :rtype: Appdeploy + """ + return util.deserialize_model(dikt, cls) + + @property + def apps(self) -> str: + """Gets the apps of this Appdeploy. + + + :return: The apps of this Appdeploy. + :rtype: str + """ + return self._apps + + @apps.setter + def apps(self, apps: str): + """Sets the apps of this Appdeploy. + + + :param apps: The apps of this Appdeploy. + :type apps: str + """ + + self._apps = apps diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response.py new file mode 100644 index 0000000..f16dbb3 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response.py @@ -0,0 +1,62 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.apps_response_apps import ( # noqa: F401,E501 + AppsResponseApps, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class AppsResponse(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, apps: List[AppsResponseApps] = None): # noqa: E501 + """AppsResponse - a model defined in Swagger + + :param apps: The apps of this AppsResponse. # noqa: E501 + :type apps: List[AppsResponseApps] + """ + self.swagger_types = {"apps": List[AppsResponseApps]} + + self.attribute_map = {"apps": "apps"} + self._apps = apps + + @classmethod + def from_dict(cls, dikt) -> "AppsResponse": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The appsResponse of this AppsResponse. # noqa: E501 + :rtype: AppsResponse + """ + return util.deserialize_model(dikt, cls) + + @property + def apps(self) -> List[AppsResponseApps]: + """Gets the apps of this AppsResponse. + + + :return: The apps of this AppsResponse. + :rtype: List[AppsResponseApps] + """ + return self._apps + + @apps.setter + def apps(self, apps: List[AppsResponseApps]): + """Sets the apps of this AppsResponse. + + + :param apps: The apps of this AppsResponse. + :type apps: List[AppsResponseApps] + """ + + self._apps = apps diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response_apps.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response_apps.py new file mode 100644 index 0000000..74bcba5 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/apps_response_apps.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class AppsResponseApps(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, id: str = None): # noqa: E501 + """AppsResponseApps - a model defined in Swagger + + :param id: The id of this AppsResponseApps. # noqa: E501 + :type id: str + """ + self.swagger_types = {"id": str} + + self.attribute_map = {"id": "id"} + self._id = id + + @classmethod + def from_dict(cls, dikt) -> "AppsResponseApps": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The appsResponse_apps of this AppsResponseApps. # noqa: E501 + :rtype: AppsResponseApps + """ + return util.deserialize_model(dikt, cls) + + @property + def id(self) -> str: + """Gets the id of this AppsResponseApps. + + + :return: The id of this AppsResponseApps. + :rtype: str + """ + return self._id + + @id.setter + def id(self, id: str): + """Sets the id of this AppsResponseApps. + + + :param id: The id of this AppsResponseApps. + :type id: str + """ + + self._id = id diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appupdate.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appupdate.py new file mode 100644 index 0000000..46d5493 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/appupdate.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class Appupdate(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, command: str = None): # noqa: E501 + """Appupdate - a model defined in Swagger + + :param command: The command of this Appupdate. # noqa: E501 + :type command: str + """ + self.swagger_types = {"command": str} + + self.attribute_map = {"command": "command"} + self._command = command + + @classmethod + def from_dict(cls, dikt) -> "Appupdate": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The appupdate of this Appupdate. # noqa: E501 + :rtype: Appupdate + """ + return util.deserialize_model(dikt, cls) + + @property + def command(self) -> str: + """Gets the command of this Appupdate. + + + :return: The command of this Appupdate. + :rtype: str + """ + return self._command + + @command.setter + def command(self, command: str): + """Sets the command of this Appupdate. + + + :param command: The command of this Appupdate. + :type command: str + """ + + self._command = command diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/artifact_exists_model.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/artifact_exists_model.py new file mode 100644 index 0000000..87d6fc3 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/artifact_exists_model.py @@ -0,0 +1,158 @@ +from __future__ import absolute_import + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class ArtifactExistsModel(Model): + + def __init__( + self, + registry_url: str = None, + artefact_name: str = None, + artefact_tag: str = None, + username: str = None, + password: str = None, + ): + + self.swagger_types = { + "registry_url": str, + "artefact_name": str, + "artefact_tag": str, + "username": str, + "password": str, + } + + self.attribute_map = { + "registry_url": "registry_url", + "artefact_name": "artefact_name", + "artefact_tag": "artefact_tag", + "username": "username", + "password": "password", + } + self._registry_url = registry_url + self._artefact_name = artefact_name + self._artefact_tag = artefact_tag + self._username = username + self._password = password + + @classmethod + def from_dict(cls, dikt) -> "ArtifactExistsModel": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The CopyArtifactModel. # noqa: E501 + :rtype: CopyArtifactModel + """ + return util.deserialize_model(dikt, cls) + + @classmethod + def to_dict(self) -> dict: + dict_object = {} + if self.registry_url is not None: + dict_object["registry_url"] = self.registry_url + if self.artefact_name is not None: + dict_object["artefact_name"] = self.artefact_name + if self.artefact_tag is not None: + dict_object["artefact_tag"] = self.artefact_tag + if self.username is not None: + dict_object["username"] = self.username + if self.password is not None: + dict_object["password"] = self.password + return dict_object + + @property + def registry_url(self) -> str: + """Gets the registry_url of this ArtifactExistsModel. + + :return: The registry_url of this ArtifactExistsModel. + :rtype: str + """ + return self._registry_url + + @registry_url.setter + def registry_url(self, registry_url: str): + """Sets the registry_url of this ArtifactExistsModel. + + :param name: The registry_url of this ArtifactExistsModel. + :type name: str + """ + + self._registry_url = registry_url + + @property + def artefact_name(self) -> str: + """Gets the artefact_name of this ArtifactExistsModel. + + :return: The artefact_name of this ArtifactExistsModel. + :rtype: str + """ + return self._artefact_name + + @artefact_name.setter + def artefact_name(self, artefact_name: str): + """Sets the artefact_name of this ArtifactExistsModel. + + :param name: The artefact_name of this ArtifactExistsModel. + :type name: str + """ + + self._artefact_name = artefact_name + + @property + def artefact_tag(self) -> str: + """Gets the artefact_tag of this ArtifactExistsModel. + + :return: The artefact_tag of this ArtifactExistsModel. + :rtype: str + """ + return self._artefact_tag + + @artefact_tag.setter + def artefact_tag(self, artefact_tag: str): + """Sets the artefact_tag of this ArtifactExistsModel. + + :param name: The artefact_tag of this ArtifactExistsModel. + :type name: str + """ + + self._artefact_tag = artefact_tag + + @property + def username(self) -> str: + """Gets the username of this ArtifactExistsModel. + + :return: The username of this ArtifactExistsModel. + :rtype: str + """ + return self._username + + @username.setter + def username(self, username: str): + """Sets the username of this ArtifactExistsModel. + + :param name: The username of this ArtifactExistsModel. + :type name: str + """ + + self._username = username + + @property + def password(self) -> str: + """Gets the password of this ArtifactExistsModel. + + :return: The password of this ArtifactExistsModel. + :rtype: str + """ + return self._password + + @password.setter + def password(self, password: str): + """Sets the password of this ArtifactExistsModel. + + :param name: The password of this ArtifactExistsModel. + :type name: str + """ + + self._password = password diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py new file mode 100644 index 0000000..6c720cb --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py @@ -0,0 +1,73 @@ +import pprint +import typing + +import six + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util + +T = typing.TypeVar("T") + + +class Model(object): + # swaggerTypes: The key is attribute name and the + # value is attribute type. + swagger_types = {} + + # attributeMap: The key is attribute name and the + # value is json key in definition. + attribute_map = {} + + @classmethod + def from_dict(cls: typing.Type[T], dikt) -> T: + """Returns the dict as a model""" + return util.deserialize_model(dikt, cls) + + def to_dict(self): + """Returns the model properties as a dict + + :rtype: dict + """ + result = {} + + for attr, _ in six.iteritems(self.swagger_types): + value = getattr(self, attr) + if isinstance(value, list): + result[attr] = list( + map(lambda x: x.to_dict() if hasattr(x, "to_dict") else x, value) + ) + elif hasattr(value, "to_dict"): + result[attr] = value.to_dict() + elif isinstance(value, dict): + result[attr] = dict( + map( + lambda item: ( + (item[0], item[1].to_dict()) + if hasattr(item[1], "to_dict") + else item + ), + value.items(), + ) + ) + else: + result[attr] = value + + return result + + def to_str(self): + """Returns the string representation of the model + + :rtype: str + """ + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + return self.__dict__ == other.__dict__ + + def __ne__(self, other): + """Returns true if both objects are not equal""" + return not self == other diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/copy_artifact_model.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/copy_artifact_model.py new file mode 100644 index 0000000..aa26bb7 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/copy_artifact_model.py @@ -0,0 +1,258 @@ +from __future__ import absolute_import + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class CopyArtifactModel(Model): + + def __init__( + self, + src_registry: str, + src_image_name: str, + src_image_tag: str, + dst_registry: str, + dst_image_name: str = None, + dst_image_tag: str = None, + src_username: str = None, + src_password: str = None, + dst_username: str = None, + dst_password: str = None, + ): + + self.swagger_types = { + "src_registry": str, + "src_image_name": str, + "src_image_tag": str, + "dst_registry": str, + "dst_image_name": str, + "dst_image_tag": str, + "src_username": str, + "src_password": str, + "dst_username": str, + "dst_password": str, + } + + self.attribute_map = { + "src_registry": "src_registry", + "src_image_name": "src_image_name", + "src_image_tag": "src_image_tag", + "dst_registry": "dst_registry", + "dst_image_name": "dst_image_name", + "dst_image_tag": "dst_image_tag", + "src_username": "src_username", + "src_password": "src_password", + "dst_username": "dst_username", + "dst_password": "dst_password", + } + self._src_registry = src_registry + self._src_image_name = src_image_name + self._src_image_tag = src_image_tag + self._dst_registry = dst_registry + self._dst_image_name = dst_image_name + self._dst_image_tag = dst_image_tag + self._src_username = src_username + self._src_password = src_password + self._dst_username = dst_username + self._dst_password = dst_password + + @classmethod + def from_dict(cls, dikt) -> "CopyArtifactModel": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The CopyArtifactModel. # noqa: E501 + :rtype: CopyArtifactModel + """ + return util.deserialize_model(dikt, cls) + + @property + def src_registry(self) -> str: + """Gets the src_registry of this CopyArtifactModel. + + :return: The src_registry of this CopyArtifactModel. + :rtype: str + """ + return self._src_registry + + @src_registry.setter + def src_registry(self, src_registry: str): + """Sets the src_registry of this CopyArtifactModel. + + :param name: The src_registry of this CopyArtifactModel. + :type name: str + """ + + self._src_registry = src_registry + + @property + def src_image_name(self) -> str: + """Gets the _src_image_name of this CopyArtifactModel. + + :return: The _src_image_name of this CopyArtifactModel. + :rtype: str + """ + return self._src_image_name + + @src_image_name.setter + def src_image_name(self, src_image_name: str): + """Sets the src_image_name of this CopyArtifactModel. + + :param name: The src_image_name of this CopyArtifactModel. + :type name: str + """ + + self._src_image_name = src_image_name + + @property + def src_image_tag(self) -> str: + """Gets the src_image_tag of this CopyArtifactModel. + + :return: The src_image_tag of this CopyArtifactModel. + :rtype: str + """ + return self._src_image_tag + + @src_image_tag.setter + def src_image_tag(self, src_image_tag: str): + """Sets the src_image_tag of this CopyArtifactModel. + + :param name: The src_image_tag of this CopyArtifactModel. + :type name: str + """ + + self._src_image_tag = src_image_tag + + @property + def dst_registry(self) -> str: + """Gets the dst_registry of this CopyArtifactModel. + + :return: The dst_registry of this CopyArtifactModel. + :rtype: str + """ + return self._dst_registry + + @dst_registry.setter + def dst_registry(self, dst_registry: str): + """Sets the dst_registry of this CopyArtifactModel. + + :param name: The dst_registry of this CopyArtifactModel. + :type name: str + """ + + self._dst_registry = dst_registry + + @property + def dst_image_name(self) -> str: + """Gets the dst_image_name of this CopyArtifactModel. + + :return: The dst_image_name of this CopyArtifactModel. + :rtype: str + """ + return self._dst_image_name + + @dst_image_name.setter + def dst_image_name(self, dst_image_name: str): + """Sets the dst_image_name of this CopyArtifactModel. + + :param name: The dst_image_name of this CopyArtifactModel. + :type name: str + """ + + self._dst_image_name = dst_image_name + + @property + def dst_image_tag(self) -> str: + """Gets the dst_image_tag of this CopyArtifactModel. + + :return: The dst_image_tag of this CopyArtifactModel. + :rtype: str + """ + return self._dst_image_tag + + @dst_image_tag.setter + def dst_image_tag(self, dst_image_tag: str): + """Sets the dst_image_tag of this CopyArtifactModel. + + :param name: The dst_image_tag of this CopyArtifactModel. + :type name: str + """ + + self._dst_image_tag = dst_image_tag + + @property + def src_username(self) -> str: + """Gets the src_username of this CopyArtifactModel. + + :return: The src_username of this CopyArtifactModel. + :rtype: str + """ + return self._src_username + + @src_username.setter + def src_username(self, src_username: str): + """Sets the src_username of this CopyArtifactModel. + + :param name: The src_username of this CopyArtifactModel. + :type name: str + """ + + self._src_username = src_username + + @property + def src_password(self) -> str: + """Gets the src_password of this CopyArtifactModel. + + :return: The src_password of this CopyArtifactModel. + :rtype: str + """ + return self._src_password + + @src_password.setter + def src_password(self, src_password: str): + """Sets the src_password of this CopyArtifactModel. + + :param name: The src_password of this CopyArtifactModel. + :type name: str + """ + + self._src_password = src_password + + @property + def dst_username(self) -> str: + """Gets the dst_username of this CopyArtifactModel. + + :return: The dst_username of this CopyArtifactModel. + :rtype: str + """ + return self._dst_username + + @dst_username.setter + def dst_username(self, dst_username: str): + """Sets the dst_username of this CopyArtifactModel. + + :param name: The dst_username of this CopyArtifactModel. + :type name: str + """ + + self._dst_username = dst_username + + @property + def dst_password(self) -> str: + """Gets the dst_password of this CopyArtifactModel. + + :return: The dst_password of this CopyArtifactModel. + :rtype: str + """ + return self._dst_password + + @dst_password.setter + def dst_password(self, dst_password: str): + """Sets the dst_password of this CopyArtifactModel. + + :param name: The dst_password of this CopyArtifactModel. + :type name: str + """ + + self._dst_password = dst_password diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_app.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_app.py new file mode 100644 index 0000000..b78eb72 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_app.py @@ -0,0 +1,85 @@ +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import List + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_zone import ( + EdgeCloudZone, +) + + +class DeployApp(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, app_id: str, app_zones: List[EdgeCloudZone]): # noqa: E501 + """EdgeCloudZone - a model defined in Swagger + + :param appId: The appId of this DeployApp. # noqa: E501 + :type appId: Deploy App Id + :param edge_cloud_zone_name: The edge_cloud_zone_name of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_zone_name: EdgeCloudZoneName + + """ + self.swagger_types = {"app_id": str, "app_zones": List} + + self.attribute_map = {"app_id": "appId", "app_zones": "appZones"} + self._app_id = app_id + self._app_zones = app_zones + + @classmethod + def from_dict(cls, dikt) -> "DeployApp": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZone of this EdgeCloudZone. # noqa: E501 + :rtype: EdgeCloudZone + """ + return util.deserialize_model(dikt, cls) + + @property + def app_id(self) -> str: + """Gets the edge_cloud_zone_id of this EdgeCloudZone. + + + :return: The edge_cloud_zone_id of this EdgeCloudZone. + :rtype: EdgeCloudZoneId + """ + return self._app_id + + @app_id.setter + def app_id(self, app_id: str): + """Sets the edge_cloud_zone_id of this EdgeCloudZone. + + + :param edge_cloud_zone_id: The edge_cloud_zone_id of this EdgeCloudZone. + :type edge_cloud_zone_id: EdgeCloudZoneId + """ + + self._app_id = app_id + + @property + def app_zones(self) -> List[EdgeCloudZone]: + """Gets the edge_cloud_zone_name of this EdgeCloudZone. + + + :return: The edge_cloud_zone_name of this EdgeCloudZone. + :rtype: EdgeCloudZoneName + """ + return self._app_zones + + @app_zones.setter + def app_zones(self, app_zones: List[EdgeCloudZone]): + """Sets the edge_cloud_zone_name of this EdgeCloudZone. + + + :param edge_cloud_zone_name: The edge_cloud_zone_name of this EdgeCloudZone. + :type edge_cloud_zone_name: EdgeCloudZoneName + """ + + self._app_zones = app_zones diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_service_function.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_service_function.py new file mode 100644 index 0000000..e89936f --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deploy_service_function.py @@ -0,0 +1,369 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.env_parameters import ( # noqa: F401,E501 + EnvParameters, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.volume_mount_deploy import ( # noqa: F401,E501 + VolumeMountDeploy, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.util import deserialize_model + + +class DeployServiceFunction(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + service_function_name: str = None, + service_function_instance_name: str = None, + volume_mounts: List[VolumeMountDeploy] = None, + autoscaling_metric: str = None, + autoscaling_policy: str = None, + count_min: int = None, + count_max: int = None, + location: str = None, + all_node_ports: bool = None, + monitoring_services: bool = None, + node_ports: List[int] = None, + env_parameters: List[EnvParameters] = None, + ): # noqa: E501 + """DeployServiceFunction - a model defined in Swagger + + :param service_function_name: The service_function_name of this DeployServiceFunction. # noqa: E501 + :type service_function_name: str + :param service_function_instance_name: The service_function_instance_name of this DeployServiceFunction. # noqa: E501 + :type service_function_instance_name: str + :param volume_mounts: The volume_mounts of this DeployServiceFunction. # noqa: E501 + :type volume_mounts: List[VolumeMountDeploy] + :param autoscaling_metric: The autoscaling_metric of this DeployServiceFunction. # noqa: E501 + :type autoscaling_metric: str + :param autoscaling_policy: The autoscaling_policy of this DeployServiceFunction. # noqa: E501 + :type autoscaling_policy: str + :param count_min: The count_min of this DeployServiceFunction. # noqa: E501 + :type count_min: int + :param count_max: The count_max of this DeployServiceFunction. # noqa: E501 + :type count_max: int + :param location: The location of this DeployServiceFunction. # noqa: E501 + :type location: str + :param all_node_ports: The all_node_ports of this DeployServiceFunction. # noqa: E501 + :type all_node_ports: bool + :param monitoring_services: The monitoring_services of this DeployServiceFunction. # noqa: E501 + :type monitoring_services: bool + :param node_ports: The node_ports of this DeployServiceFunction. # noqa: E501 + :type node_ports: List[int] + :param env_parameters: The env_parameters of this DeployServiceFunction. # noqa: E501 + :type env_parameters: List[EnvParameters] + """ + self.swagger_types = { + "service_function_name": str, + "service_function_instance_name": str, + "volume_mounts": List[VolumeMountDeploy], + "autoscaling_metric": str, + "autoscaling_policy": str, + "count_min": int, + "count_max": int, + "location": str, + "all_node_ports": bool, + "monitoring_services": bool, + "node_ports": List[int], + "env_parameters": List[EnvParameters], + } + + self.attribute_map = { + "service_function_name": "service_function_name", + "service_function_instance_name": "service_function_instance_name", + "volume_mounts": "volume_mounts", + "autoscaling_metric": "autoscaling_metric", + "autoscaling_policy": "autoscaling_policy", + "count_min": "count_min", + "count_max": "count_max", + "location": "location", + "all_node_ports": "all_node_ports", + "monitoring_services": "monitoring_services", + "node_ports": "node_ports", + "env_parameters": "env_parameters", + } + self._service_function_name = service_function_name + self._service_function_instance_name = service_function_instance_name + self._volume_mounts = volume_mounts + self._autoscaling_metric = autoscaling_metric + self._autoscaling_policy = autoscaling_policy + self._count_min = count_min + self._count_max = count_max + self._location = location + self._all_node_ports = all_node_ports + self._monitoring_services = monitoring_services + self._node_ports = node_ports + self._env_parameters = env_parameters + + @classmethod + def from_dict(cls, dikt) -> "DeployServiceFunction": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The deployServiceFunction of this DeployServiceFunction. # noqa: E501 + :rtype: DeployServiceFunction + """ + return deserialize_model(dikt, cls) + + @property + def service_function_name(self) -> str: + """Gets the service_function_name of this DeployServiceFunction. + + + :return: The service_function_name of this DeployServiceFunction. + :rtype: str + """ + return self._service_function_name + + @service_function_name.setter + def service_function_name(self, service_function_name: str): + """Sets the service_function_name of this DeployServiceFunction. + + + :param service_function_name: The service_function_name of this DeployServiceFunction. + :type service_function_name: str + """ + + self._service_function_name = service_function_name + + @property + def service_function_instance_name(self) -> str: + """Gets the service_function_instance_name of this DeployServiceFunction. + + + :return: The service_function_instance_name of this DeployServiceFunction. + :rtype: str + """ + return self._service_function_instance_name + + @service_function_instance_name.setter + def service_function_instance_name(self, service_function_instance_name: str): + """Sets the service_function_instance_name of this DeployServiceFunction. + + + :param service_function_instance_name: The service_function_instance_name of this DeployServiceFunction. + :type service_function_instance_name: str + """ + + self._service_function_instance_name = service_function_instance_name + + @property + def volume_mounts(self) -> List[VolumeMountDeploy]: + """Gets the volume_mounts of this DeployServiceFunction. + + + :return: The volume_mounts of this DeployServiceFunction. + :rtype: List[VolumeMountDeploy] + """ + return self._volume_mounts + + @volume_mounts.setter + def volume_mounts(self, volume_mounts: List[VolumeMountDeploy]): + """Sets the volume_mounts of this DeployServiceFunction. + + + :param volume_mounts: The volume_mounts of this DeployServiceFunction. + :type volume_mounts: List[VolumeMountDeploy] + """ + + self._volume_mounts = volume_mounts + + @property + def autoscaling_metric(self) -> str: + """Gets the autoscaling_metric of this DeployServiceFunction. + + + :return: The autoscaling_metric of this DeployServiceFunction. + :rtype: str + """ + return self._autoscaling_metric + + @autoscaling_metric.setter + def autoscaling_metric(self, autoscaling_metric: str): + """Sets the autoscaling_metric of this DeployServiceFunction. + + + :param autoscaling_metric: The autoscaling_metric of this DeployServiceFunction. + :type autoscaling_metric: str + """ + + self._autoscaling_metric = autoscaling_metric + + @property + def autoscaling_policy(self) -> str: + """Gets the autoscaling_policy of this DeployServiceFunction. + + + :return: The autoscaling_policy of this DeployServiceFunction. + :rtype: str + """ + return self._autoscaling_policy + + @autoscaling_policy.setter + def autoscaling_policy(self, autoscaling_policy: str): + """Sets the autoscaling_policy of this DeployServiceFunction. + + + :param autoscaling_policy: The autoscaling_policy of this DeployServiceFunction. + :type autoscaling_policy: str + """ + + self._autoscaling_policy = autoscaling_policy + + @property + def count_min(self) -> int: + """Gets the count_min of this DeployServiceFunction. + + + :return: The count_min of this DeployServiceFunction. + :rtype: int + """ + return self._count_min + + @count_min.setter + def count_min(self, count_min: int): + """Sets the count_min of this DeployServiceFunction. + + + :param count_min: The count_min of this DeployServiceFunction. + :type count_min: int + """ + + self._count_min = count_min + + @property + def count_max(self) -> int: + """Gets the count_max of this DeployServiceFunction. + + + :return: The count_max of this DeployServiceFunction. + :rtype: int + """ + return self._count_max + + @count_max.setter + def count_max(self, count_max: int): + """Sets the count_max of this DeployServiceFunction. + + + :param count_max: The count_max of this DeployServiceFunction. + :type count_max: int + """ + + self._count_max = count_max + + @property + def location(self) -> str: + """Gets the location of this DeployServiceFunction. + + + :return: The location of this DeployServiceFunction. + :rtype: str + """ + return self._location + + @location.setter + def location(self, location: str): + """Sets the location of this DeployServiceFunction. + + + :param location: The location of this DeployServiceFunction. + :type location: str + """ + + self._location = location + + @property + def all_node_ports(self) -> bool: + """Gets the all_node_ports of this DeployServiceFunction. + + + :return: The all_node_ports of this DeployServiceFunction. + :rtype: bool + """ + return self._all_node_ports + + @all_node_ports.setter + def all_node_ports(self, all_node_ports: bool): + """Sets the all_node_ports of this DeployServiceFunction. + + + :param all_node_ports: The all_node_ports of this DeployServiceFunction. + :type all_node_ports: bool + """ + + self._all_node_ports = all_node_ports + + @property + def monitoring_services(self) -> bool: + """Gets the monitoring_services of this DeployServiceFunction. + + + :return: The monitoring_services of this DeployServiceFunction. + :rtype: bool + """ + return self._monitoring_services + + @monitoring_services.setter + def monitoring_services(self, monitoring_services: bool): + """Sets the monitoring_services of this DeployServiceFunction. + + + :param monitoring_services: The monitoring_services of this DeployServiceFunction. + :type monitoring_services: bool + """ + + self._monitoring_services = monitoring_services + + @property + def node_ports(self) -> List[int]: + """Gets the node_ports of this DeployServiceFunction. + + + :return: The node_ports of this DeployServiceFunction. + :rtype: List[int] + """ + return self._node_ports + + @node_ports.setter + def node_ports(self, node_ports: List[int]): + """Sets the node_ports of this DeployServiceFunction. + + + :param node_ports: The node_ports of this DeployServiceFunction. + :type node_ports: List[int] + """ + + self._node_ports = node_ports + + @property + def env_parameters(self) -> List[EnvParameters]: + """Gets the env_parameters of this DeployServiceFunction. + + + :return: The env_parameters of this DeployServiceFunction. + :rtype: List[EnvParameters] + """ + return self._env_parameters + + @env_parameters.setter + def env_parameters(self, env_parameters: List[EnvParameters]): + """Sets the env_parameters of this DeployServiceFunction. + + + :param env_parameters: The env_parameters of this DeployServiceFunction. + :type env_parameters: List[EnvParameters] + """ + + self._env_parameters = env_parameters diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response.py new file mode 100644 index 0000000..3046299 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response.py @@ -0,0 +1,201 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class DeployedappsResponse(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + nodeid: str = None, + nodename: str = None, + paasid: str = None, + paasname: str = None, + status: str = None, + exposedports: List[int] = None, + ): # noqa: E501 + """DeployedappsResponse - a model defined in Swagger + + :param nodeid: The nodeid of this DeployedappsResponse. # noqa: E501 + :type nodeid: str + :param nodename: The nodename of this DeployedappsResponse. # noqa: E501 + :type nodename: str + :param paasid: The paasid of this DeployedappsResponse. # noqa: E501 + :type paasid: str + :param paasname: The paasname of this DeployedappsResponse. # noqa: E501 + :type paasname: str + :param status: The status of this DeployedappsResponse. # noqa: E501 + :type status: str + :param exposedports: The exposedports of this DeployedappsResponse. # noqa: E501 + :type exposedports: List[int] + """ + self.swagger_types = { + "nodeid": str, + "nodename": str, + "paasid": str, + "paasname": str, + "status": str, + "exposedports": List[int], + } + + self.attribute_map = { + "nodeid": "nodeid", + "nodename": "nodename", + "paasid": "paasid", + "paasname": "paasname", + "status": "status", + "exposedports": "exposedports", + } + self._nodeid = nodeid + self._nodename = nodename + self._paasid = paasid + self._paasname = paasname + self._status = status + self._exposedports = exposedports + + @classmethod + def from_dict(cls, dikt) -> "DeployedappsResponse": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The deployedappsResponse of this DeployedappsResponse. # noqa: E501 + :rtype: DeployedappsResponse + """ + return util.deserialize_model(dikt, cls) + + @property + def nodeid(self) -> str: + """Gets the nodeid of this DeployedappsResponse. + + + :return: The nodeid of this DeployedappsResponse. + :rtype: str + """ + return self._nodeid + + @nodeid.setter + def nodeid(self, nodeid: str): + """Sets the nodeid of this DeployedappsResponse. + + + :param nodeid: The nodeid of this DeployedappsResponse. + :type nodeid: str + """ + + self._nodeid = nodeid + + @property + def nodename(self) -> str: + """Gets the nodename of this DeployedappsResponse. + + + :return: The nodename of this DeployedappsResponse. + :rtype: str + """ + return self._nodename + + @nodename.setter + def nodename(self, nodename: str): + """Sets the nodename of this DeployedappsResponse. + + + :param nodename: The nodename of this DeployedappsResponse. + :type nodename: str + """ + + self._nodename = nodename + + @property + def paasid(self) -> str: + """Gets the paasid of this DeployedappsResponse. + + + :return: The paasid of this DeployedappsResponse. + :rtype: str + """ + return self._paasid + + @paasid.setter + def paasid(self, paasid: str): + """Sets the paasid of this DeployedappsResponse. + + + :param paasid: The paasid of this DeployedappsResponse. + :type paasid: str + """ + + self._paasid = paasid + + @property + def paasname(self) -> str: + """Gets the paasname of this DeployedappsResponse. + + + :return: The paasname of this DeployedappsResponse. + :rtype: str + """ + return self._paasname + + @paasname.setter + def paasname(self, paasname: str): + """Sets the paasname of this DeployedappsResponse. + + + :param paasname: The paasname of this DeployedappsResponse. + :type paasname: str + """ + + self._paasname = paasname + + @property + def status(self) -> str: + """Gets the status of this DeployedappsResponse. + + + :return: The status of this DeployedappsResponse. + :rtype: str + """ + return self._status + + @status.setter + def status(self, status: str): + """Sets the status of this DeployedappsResponse. + + + :param status: The status of this DeployedappsResponse. + :type status: str + """ + + self._status = status + + @property + def exposedports(self) -> List[int]: + """Gets the exposedports of this DeployedappsResponse. + + + :return: The exposedports of this DeployedappsResponse. + :rtype: List[int] + """ + return self._exposedports + + @exposedports.setter + def exposedports(self, exposedports: List[int]): + """Sets the exposedports of this DeployedappsResponse. + + + :param exposedports: The exposedports of this DeployedappsResponse. + :type exposedports: List[int] + """ + + self._exposedports = exposedports diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response_apps.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response_apps.py new file mode 100644 index 0000000..2b2ef97 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/deployedapps_response_apps.py @@ -0,0 +1,36 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.deployedapps_response import ( # noqa: F401,E501 + DeployedappsResponse, +) + + +class DeployedappsResponseApps(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """DeployedappsResponseApps - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "DeployedappsResponseApps": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The deployedappsResponse_apps of this DeployedappsResponseApps. # noqa: E501 + :rtype: DeployedappsResponseApps + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_provider.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_provider.py new file mode 100644 index 0000000..6b9c2c8 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_provider.py @@ -0,0 +1,33 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EdgeCloudProvider(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """EdgeCloudProvider - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudProvider": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudProvider of this EdgeCloudProvider. # noqa: E501 + :rtype: EdgeCloudProvider + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_region.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_region.py new file mode 100644 index 0000000..9b2f330 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_region.py @@ -0,0 +1,33 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EdgeCloudRegion(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """EdgeCloudRegion - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudRegion": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudRegion of this EdgeCloudRegion. # noqa: E501 + :rtype: EdgeCloudRegion + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone.py new file mode 100644 index 0000000..ceb42ef --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone.py @@ -0,0 +1,189 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_provider import ( # noqa: F401,E501 + EdgeCloudProvider, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_region import ( # noqa: F401,E501 + EdgeCloudRegion, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_zone_id import ( # noqa: F401,E501 + EdgeCloudZoneId, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_zone_name import ( # noqa: F401,E501 + EdgeCloudZoneName, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_zone_status import ( # noqa: F401,E501 + EdgeCloudZoneStatus, +) + + +class EdgeCloudZone(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + edge_cloud_zone_id: str = None, + edge_cloud_zone_name: str = None, + edge_cloud_zone_status: str = None, + edge_cloud_provider: str = None, + edge_cloud_region: str = None, + ): # noqa: E501 + """EdgeCloudZone - a model defined in Swagger + + :param edge_cloud_zone_id: The edge_cloud_zone_id of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_zone_id: EdgeCloudZoneId + :param edge_cloud_zone_name: The edge_cloud_zone_name of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_zone_name: EdgeCloudZoneName + :param edge_cloud_zone_status: The edge_cloud_zone_status of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_zone_status: EdgeCloudZoneStatus + :param edge_cloud_provider: The edge_cloud_provider of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_provider: EdgeCloudProvider + :param edge_cloud_region: The edge_cloud_region of this EdgeCloudZone. # noqa: E501 + :type edge_cloud_region: EdgeCloudRegion + """ + self.swagger_types = { + "edge_cloud_zone_id": str, + "edge_cloud_zone_name": str, + "edge_cloud_zone_status": str, + "edge_cloud_provider": str, + "edge_cloud_region": str, + } + + self.attribute_map = { + "edge_cloud_zone_id": "edgeCloudZoneId", + "edge_cloud_zone_name": "edgeCloudZoneName", + "edge_cloud_zone_status": "edgeCloudZoneStatus", + "edge_cloud_provider": "edgeCloudProvider", + "edge_cloud_region": "edgeCloudRegion", + } + self._edge_cloud_zone_id = edge_cloud_zone_id + self._edge_cloud_zone_name = edge_cloud_zone_name + self._edge_cloud_zone_status = edge_cloud_zone_status + self._edge_cloud_provider = edge_cloud_provider + self._edge_cloud_region = edge_cloud_region + + @classmethod + def from_dict(cls, dikt) -> str: + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZone of this EdgeCloudZone. # noqa: E501 + :rtype: EdgeCloudZone + """ + return util.deserialize_model(dikt, cls) + + @property + def edge_cloud_zone_id(self) -> str: + """Gets the edge_cloud_zone_id of this EdgeCloudZone. + + + :return: The edge_cloud_zone_id of this EdgeCloudZone. + :rtype: EdgeCloudZoneId + """ + return self._edge_cloud_zone_id + + @edge_cloud_zone_id.setter + def edge_cloud_zone_id(self, edge_cloud_zone_id: str): + """Sets the edge_cloud_zone_id of this EdgeCloudZone. + + + :param edge_cloud_zone_id: The edge_cloud_zone_id of this EdgeCloudZone. + :type edge_cloud_zone_id: EdgeCloudZoneId + """ + + self._edge_cloud_zone_id = edge_cloud_zone_id + + @property + def edge_cloud_zone_name(self) -> str: + """Gets the edge_cloud_zone_name of this EdgeCloudZone. + + + :return: The edge_cloud_zone_name of this EdgeCloudZone. + :rtype: EdgeCloudZoneName + """ + return self._edge_cloud_zone_name + + @edge_cloud_zone_name.setter + def edge_cloud_zone_name(self, edge_cloud_zone_name: str): + """Sets the edge_cloud_zone_name of this EdgeCloudZone. + + + :param edge_cloud_zone_name: The edge_cloud_zone_name of this EdgeCloudZone. + :type edge_cloud_zone_name: EdgeCloudZoneName + """ + + self._edge_cloud_zone_name = edge_cloud_zone_name + + @property + def edge_cloud_zone_status(self) -> str: + """Gets the edge_cloud_zone_status of this EdgeCloudZone. + + + :return: The edge_cloud_zone_status of this EdgeCloudZone. + :rtype: EdgeCloudZoneStatus + """ + return self._edge_cloud_zone_status + + @edge_cloud_zone_status.setter + def edge_cloud_zone_status(self, edge_cloud_zone_status: str): + """Sets the edge_cloud_zone_status of this EdgeCloudZone. + + + :param edge_cloud_zone_status: The edge_cloud_zone_status of this EdgeCloudZone. + :type edge_cloud_zone_status: EdgeCloudZoneStatus + """ + + self._edge_cloud_zone_status = edge_cloud_zone_status + + @property + def edge_cloud_provider(self) -> str: + """Gets the edge_cloud_provider of this EdgeCloudZone. + + + :return: The edge_cloud_provider of this EdgeCloudZone. + :rtype: EdgeCloudProvider + """ + return self._edge_cloud_provider + + @edge_cloud_provider.setter + def edge_cloud_provider(self, edge_cloud_provider: str): + """Sets the edge_cloud_provider of this EdgeCloudZone. + + + :param edge_cloud_provider: The edge_cloud_provider of this EdgeCloudZone. + :type edge_cloud_provider: EdgeCloudProvider + """ + + self._edge_cloud_provider = edge_cloud_provider + + @property + def edge_cloud_region(self) -> str: + """Gets the edge_cloud_region of this EdgeCloudZone. + + + :return: The edge_cloud_region of this EdgeCloudZone. + :rtype: EdgeCloudRegion + """ + return self._edge_cloud_region + + @edge_cloud_region.setter + def edge_cloud_region(self, edge_cloud_region: str): + """Sets the edge_cloud_region of this EdgeCloudZone. + + + :param edge_cloud_region: The edge_cloud_region of this EdgeCloudZone. + :type edge_cloud_region: EdgeCloudRegion + """ + + self._edge_cloud_region = edge_cloud_region diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_id.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_id.py new file mode 100644 index 0000000..ad0eda6 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_id.py @@ -0,0 +1,33 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EdgeCloudZoneId(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """EdgeCloudZoneId - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudZoneId": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZoneId of this EdgeCloudZoneId. # noqa: E501 + :rtype: EdgeCloudZoneId + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_name.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_name.py new file mode 100644 index 0000000..d1a2882 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_name.py @@ -0,0 +1,33 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EdgeCloudZoneName(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """EdgeCloudZoneName - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudZoneName": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZoneName of this EdgeCloudZoneName. # noqa: E501 + :rtype: EdgeCloudZoneName + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_status.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_status.py new file mode 100644 index 0000000..5381e08 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zone_status.py @@ -0,0 +1,40 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EdgeCloudZoneStatus(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + """ + allowed enum values + """ + ACTIVE = "active" + INACTIVE = "inactive" + UNKNOWN = "unknown" + + def __init__(self): # noqa: E501 + """EdgeCloudZoneStatus - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudZoneStatus": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZoneStatus of this EdgeCloudZoneStatus. # noqa: E501 + :rtype: EdgeCloudZoneStatus + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zones.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zones.py new file mode 100644 index 0000000..4171997 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/edge_cloud_zones.py @@ -0,0 +1,39 @@ +# coding: utf-8 + +from __future__ import ( + absolute_import +) + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import ( + util +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import ( + Model +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.edge_cloud_zone import ( # noqa: F401,E501 + EdgeCloudZone, +) + + +class EdgeCloudZones(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """EdgeCloudZones - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "EdgeCloudZones": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The EdgeCloudZones of this EdgeCloudZones. # noqa: E501 + :rtype: EdgeCloudZones + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameter_name.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameter_name.py new file mode 100644 index 0000000..48acdd7 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameter_name.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class EnvParameterName(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, name: str = None): # noqa: E501 + """EnvParameterName - a model defined in Swagger + + :param name: The name of this EnvParameterName. # noqa: E501 + :type name: str + """ + self.swagger_types = {"name": str} + + self.attribute_map = {"name": "name"} + self._name = name + + @classmethod + def from_dict(cls, dikt) -> "EnvParameterName": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The env_parameter_name of this EnvParameterName. # noqa: E501 + :rtype: EnvParameterName + """ + return util.deserialize_model(dikt, cls) + + @property + def name(self) -> str: + """Gets the name of this EnvParameterName. + + + :return: The name of this EnvParameterName. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this EnvParameterName. + + + :param name: The name of this EnvParameterName. + :type name: str + """ + + self._name = name diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py new file mode 100644 index 0000000..d6d4cca --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py @@ -0,0 +1,111 @@ +from __future__ import absolute_import +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import ( + util, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import ( + Model, +) + + +class EnvParameters(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, name: str = None, value: str = None, value_ref: str = None + ): # noqa: E501 + """EnvParameters - a model defined in Swagger + + :param name: The name of this EnvParameters. # noqa: E501 + :type name: str + :param value: The value of this EnvParameters. # noqa: E501 + :type value: str + :param value_ref: The value_ref of this EnvParameters. # noqa: E501 + :type value_ref: str + """ + self.swagger_types = {"name": str, "value": str, "value_ref": str} + + self.attribute_map = { + "name": "name", + "value": "value", + "value_ref": "value_ref", + } + self._name = name + self._value = value + self._value_ref = value_ref + + @classmethod + def from_dict(cls, dikt) -> "EnvParameters": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The env_parameters of this EnvParameters. # noqa: E501 + :rtype: EnvParameters + """ + return util.deserialize_model(dikt, cls) + + @property + def name(self) -> str: + """Gets the name of this EnvParameters. + + + :return: The name of this EnvParameters. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this EnvParameters. + + + :param name: The name of this EnvParameters. + :type name: str + """ + + self._name = name + + @property + def value(self) -> str: + """Gets the value of this EnvParameters. + + + :return: The value of this EnvParameters. + :rtype: str + """ + return self._value + + @value.setter + def value(self, value: str): + """Sets the value of this EnvParameters. + + + :param value: The value of this EnvParameters. + :type value: str + """ + + self._value = value + + @property + def value_ref(self) -> str: + """Gets the value_ref of this EnvParameters. + + + :return: The value_ref of this EnvParameters. + :rtype: str + """ + return self._value_ref + + @value_ref.setter + def value_ref(self, value_ref: str): + """Sets the value_ref of this EnvParameters. + + + :param value_ref: The value_ref of this EnvParameters. + :type value_ref: str + """ + + self._value_ref = value_ref diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py new file mode 100644 index 0000000..1fab789 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py @@ -0,0 +1,92 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class GpuInfo(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, gpu_memory: int = None, num_gpu: int = None): # noqa: E501 + """GpuInfo - a model defined in Swagger + + :param gpu_memory: The gpu_memory of this GpuInfo. # noqa: E501 + :type gpu_memory: int + :param num_gpu: The num_gpu of this GpuInfo. # noqa: E501 + :type num_gpu: int + """ + self.swagger_types = {"gpu_memory": int, "num_gpu": int} + + self.attribute_map = {"gpu_memory": "gpuMemory", "num_gpu": "numGPU"} + self._gpu_memory = gpu_memory + self._num_gpu = num_gpu + + @classmethod + def from_dict(cls, dikt) -> "GpuInfo": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The GpuInfo of this GpuInfo. # noqa: E501 + :rtype: GpuInfo + """ + return util.deserialize_model(dikt, cls) + + @property + def gpu_memory(self) -> int: + """Gets the gpu_memory of this GpuInfo. + + GPU memory in mega bytes # noqa: E501 + + :return: The gpu_memory of this GpuInfo. + :rtype: int + """ + return self._gpu_memory + + @gpu_memory.setter + def gpu_memory(self, gpu_memory: int): + """Sets the gpu_memory of this GpuInfo. + + GPU memory in mega bytes # noqa: E501 + + :param gpu_memory: The gpu_memory of this GpuInfo. + :type gpu_memory: int + """ + if gpu_memory is None: + raise ValueError( + "Invalid value for `gpu_memory`, must not be `None`" + ) # noqa: E501 + + self._gpu_memory = gpu_memory + + @property + def num_gpu(self) -> int: + """Gets the num_gpu of this GpuInfo. + + Number of GPUs # noqa: E501 + + :return: The num_gpu of this GpuInfo. + :rtype: int + """ + return self._num_gpu + + @num_gpu.setter + def num_gpu(self, num_gpu: int): + """Sets the num_gpu of this GpuInfo. + + Number of GPUs # noqa: E501 + + :param num_gpu: The num_gpu of this GpuInfo. + :type num_gpu: int + """ + if num_gpu is None: + raise ValueError( + "Invalid value for `num_gpu`, must not be `None`" + ) # noqa: E501 + + self._num_gpu = num_gpu diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/helm_install_model.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/helm_install_model.py new file mode 100644 index 0000000..b00998b --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/helm_install_model.py @@ -0,0 +1,140 @@ +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class HelmInstall(Model): + def __init__( + self, + uri: str, + deployment_name: str, + repo_username: str = None, + repo_password: str = None, + ): # noqa: E501 + """HelmInstallModel - a model defined in Swagger + + :param name: The name of this HelmInstall. # noqa: E501 + :type name: str + :param hostname: The hostname of this HelmInstall. # noqa: E501 + :type hostname: str + :param ip: The ip of this HelmInstall. # noqa: E501 + :type ip: str + :param password: The password of this HelmInstall. # noqa: E501 + :type password: str + """ + self.swagger_types = { + "uri": str, + "deployment_name": str, + "repo_username": str, + "repo_password": str, + } + + self.attribute_map = { + "uri": "uri", + "deployment_name": "deployment_name", + "repo_username": "repo_username", + "repo_password": "repo_password", + } + self._uri = uri + self._deployment_name = deployment_name + self._repo_password = repo_password + self._repo_username = repo_username + + @classmethod + def from_dict(cls, dikt) -> "HelmInstall": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The addNode of this AddNode. # noqa: E501 + :rtype: AddNode + """ + return util.deserialize_model(dikt, cls) + + @property + def uri(self) -> str: + """Gets the uri of a HelmInstallModel + + + :return: The uri of this HelmInstallModel. + :rtype: str + """ + return self._uri + + @uri.setter + def uri(self, uri: str): + """Sets the name of this HelmInstallModel. + + + :param name: The name of this HelmInstallModel. + :type name: str + """ + + self._uri = uri + + @property + def deployment_name(self) -> str: + """Gets the deployment_name of this HelmInstallModel. + + + :return: The deployment_name of this HelmInstallModel. + :rtype: str + """ + return self._deployment_name + + @deployment_name.setter + def deployment_name(self, deployment_name: str): + """Sets the hostname of this HelmInstallModel. + + + :param hostname: The hostname of this HelmInstallModel. + :type hostname: str + """ + + self._deployment_name = deployment_name + + @property + def repo_username(self) -> str: + """Gets the repo_username of this HelmInstallModel. + + + :return: The repo_username of this HelmInstallModel. + :rtype: str + """ + return self._repo_username + + @repo_username.setter + def repo_username(self, repo_username: str): + """Sets the repo_username of this HelmInstallModel. + + + :param repo_username: The repo_username of this HelmInstallModel. + :type repo_username: str + """ + + self._repo_username = repo_username + + @property + def repo_password(self) -> str: + """Gets the repo_username of this HelmInstallModel. + + + :return: The repo_username of this HelmInstallModel. + :rtype: str + """ + return self._repo_password + + @repo_password.setter + def repo_password(self, repo_password: str): + """Sets the repo_password of this HelmInstallModel. + + + :param repo_password: The repo_password of this HelmInstallModel. + :type repo_password: str + """ + + self._repo_password = repo_password diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/nodes_response.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/nodes_response.py new file mode 100644 index 0000000..c7ce840 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/nodes_response.py @@ -0,0 +1,199 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class NodesResponse(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + id: str = None, + name: str = None, + location: str = None, + serial: str = None, + node_type: str = None, + status: str = None, + ): # noqa: E501 + """NodesResponse - a model defined in Swagger + + :param id: The id of this NodesResponse. # noqa: E501 + :type id: str + :param name: The name of this NodesResponse. # noqa: E501 + :type name: str + :param location: The location of this NodesResponse. # noqa: E501 + :type location: str + :param serial: The serial of this NodesResponse. # noqa: E501 + :type serial: str + :param node_type: The node_type of this NodesResponse. # noqa: E501 + :type node_type: str + """ + self.swagger_types = { + "id": str, + "name": str, + "location": str, + "serial": str, + "node_type": str, + "status": str, + } + + self.attribute_map = { + "id": "id", + "name": "name", + "location": "location", + "serial": "serial", + "node_type": "node_type", + "status": "status", + } + self._id = id + self._name = name + self._location = location + self._serial = serial + self._node_type = node_type + self._status = status + + @classmethod + def from_dict(cls, dikt) -> "NodesResponse": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The nodesResponse of this NodesResponse. # noqa: E501 + :rtype: NodesResponse + """ + return util.deserialize_model(dikt, cls) + + @property + def id(self) -> str: + """Gets the id of this NodesResponse. + + + :return: The id of this NodesResponse. + :rtype: str + """ + return self._id + + @id.setter + def id(self, id: str): + """Sets the id of this NodesResponse. + + + :param id: The id of this NodesResponse. + :type id: str + """ + + self._id = id + + @property + def name(self) -> str: + """Gets the name of this NodesResponse. + + + :return: The name of this NodesResponse. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this NodesResponse. + + + :param name: The name of this NodesResponse. + :type name: str + """ + + self._name = name + + @property + def location(self) -> str: + """Gets the location of this NodesResponse. + + + :return: The location of this NodesResponse. + :rtype: str + """ + return self._location + + @location.setter + def location(self, location: str): + """Sets the location of this NodesResponse. + + + :param location: The location of this NodesResponse. + :type location: str + """ + + self._location = location + + @property + def serial(self) -> str: + """Gets the serial of this NodesResponse. + + + :return: The serial of this NodesResponse. + :rtype: str + """ + return self._serial + + @serial.setter + def serial(self, serial: str): + """Sets the serial of this NodesResponse. + + + :param serial: The serial of this NodesResponse. + :type serial: str + """ + + self._serial = serial + + @property + def node_type(self) -> str: + """Gets the node_type of this NodesResponse. + + + :return: The node_type of this NodesResponse. + :rtype: str + """ + return self._node_type + + @node_type.setter + def node_type(self, node_type: str): + """Sets the node_type of this NodesResponse. + + + :param node_type: The node_type of this NodesResponse. + :type node_type: str + """ + + self._node_type = node_type + + @property + def status(self) -> str: + """Gets the node_type of this NodesResponse. + + + :return: The node_type of this NodesResponse. + :rtype: str + """ + return self._status + + @status.setter + def status(self, status: str): + """Sets the node_type of this NodesResponse. + + + :param node_type: The node_type of this NodesResponse. + :type node_type: str + """ + + self._status = status diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/operating_system.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/operating_system.py new file mode 100644 index 0000000..1cac8ae --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/operating_system.py @@ -0,0 +1,192 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class OperatingSystem(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + architecture: str = None, + family: str = None, + version: str = None, + license: str = None, + ): # noqa: E501 + """OperatingSystem - a model defined in Swagger + + :param architecture: The architecture of this OperatingSystem. # noqa: E501 + :type architecture: str + :param family: The family of this OperatingSystem. # noqa: E501 + :type family: str + :param version: The version of this OperatingSystem. # noqa: E501 + :type version: str + :param license: The license of this OperatingSystem. # noqa: E501 + :type license: str + """ + self.swagger_types = { + "architecture": str, + "family": str, + "version": str, + "license": str, + } + + self.attribute_map = { + "architecture": "architecture", + "family": "family", + "version": "version", + "license": "license", + } + self._architecture = architecture + self._family = family + self._version = version + self._license = license + + @classmethod + def from_dict(cls, dikt) -> "OperatingSystem": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The OperatingSystem of this OperatingSystem. # noqa: E501 + :rtype: OperatingSystem + """ + return util.deserialize_model(dikt, cls) + + @property + def architecture(self) -> str: + """Gets the architecture of this OperatingSystem. + + Type of the OS Architecture # noqa: E501 + + :return: The architecture of this OperatingSystem. + :rtype: str + """ + return self._architecture + + @architecture.setter + def architecture(self, architecture: str): + """Sets the architecture of this OperatingSystem. + + Type of the OS Architecture # noqa: E501 + + :param architecture: The architecture of this OperatingSystem. + :type architecture: str + """ + allowed_values = ["x86_64", "x86"] # noqa: E501 + if architecture not in allowed_values: + raise ValueError( + "Invalid value for `architecture` ({0}), must be one of {1}".format( + architecture, allowed_values + ) + ) + + self._architecture = architecture + + @property + def family(self) -> str: + """Gets the family of this OperatingSystem. + + Family to which OS belongs # noqa: E501 + + :return: The family of this OperatingSystem. + :rtype: str + """ + return self._family + + @family.setter + def family(self, family: str): + """Sets the family of this OperatingSystem. + + Family to which OS belongs # noqa: E501 + + :param family: The family of this OperatingSystem. + :type family: str + """ + allowed_values = ["RHEL", "UBUNTU", "COREOS", "WINDOWS", "OTHER"] # noqa: E501 + if family not in allowed_values: + raise ValueError( + "Invalid value for `family` ({0}), must be one of {1}".format( + family, allowed_values + ) + ) + + self._family = family + + @property + def version(self) -> str: + """Gets the version of this OperatingSystem. + + Version of the OS # noqa: E501 + + :return: The version of this OperatingSystem. + :rtype: str + """ + return self._version + + @version.setter + def version(self, version: str): + """Sets the version of this OperatingSystem. + + Version of the OS # noqa: E501 + + :param version: The version of this OperatingSystem. + :type version: str + """ + allowed_values = [ + "OS_VERSION_UBUNTU_2204_LTS", + "OS_VERSION_RHEL_8", + "OS_MS_WINDOWS_2022", + "OTHER", + ] # noqa: E501 + if version not in allowed_values: + raise ValueError( + "Invalid value for `version` ({0}), must be one of {1}".format( + version, allowed_values + ) + ) + + self._version = version + + @property + def license(self) -> str: + """Gets the license of this OperatingSystem. + + License needed to activate the OS # noqa: E501 + + :return: The license of this OperatingSystem. + :rtype: str + """ + return self._license + + @license.setter + def license(self, license: str): + """Sets the license of this OperatingSystem. + + License needed to activate the OS # noqa: E501 + + :param license: The license of this OperatingSystem. + :type license: str + """ + allowed_values = [ + "OS_LICENSE_TYPE_FREE", + "OS_LICENSE_TYPE_ON_DEMAND", + "OTHER", + ] # noqa: E501 + if license not in allowed_values: + raise ValueError( + "Invalid value for `license` ({0}), must be one of {1}".format( + license, allowed_values + ) + ) + + self._license = license diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py new file mode 100644 index 0000000..4892057 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py @@ -0,0 +1,170 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.gpu_info import ( # noqa: F401,E501 + GpuInfo, +) + + +class RequiredResources(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + num_cpu: int = None, + memory: int = None, + storage: int = None, + gpu: List[GpuInfo] = None, + ): # noqa: E501 + """RequiredResources - a model defined in Swagger + + :param num_cpu: The num_cpu of this RequiredResources. # noqa: E501 + :type num_cpu: int + :param memory: The memory of this RequiredResources. # noqa: E501 + :type memory: int + :param storage: The storage of this RequiredResources. # noqa: E501 + :type storage: int + :param gpu: The gpu of this RequiredResources. # noqa: E501 + :type gpu: List[GpuInfo] + """ + self.swagger_types = { + "num_cpu": int, + "memory": int, + "storage": int, + "gpu": List[GpuInfo], + } + + self.attribute_map = { + "num_cpu": "numCPU", + "memory": "memory", + "storage": "storage", + "gpu": "gpu", + } + self._num_cpu = num_cpu + self._memory = memory + self._storage = storage + self._gpu = gpu + + @classmethod + def from_dict(cls, dikt) -> "RequiredResources": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The RequiredResources of this RequiredResources. # noqa: E501 + :rtype: RequiredResources + """ + return util.deserialize_model(dikt, cls) + + @property + def num_cpu(self) -> int: + """Gets the num_cpu of this RequiredResources. + + Number of virtual CPUs # noqa: E501 + + :return: The num_cpu of this RequiredResources. + :rtype: int + """ + return self._num_cpu + + @num_cpu.setter + def num_cpu(self, num_cpu: int): + """Sets the num_cpu of this RequiredResources. + + Number of virtual CPUs # noqa: E501 + + :param num_cpu: The num_cpu of this RequiredResources. + :type num_cpu: int + """ + if num_cpu is None: + raise ValueError( + "Invalid value for `num_cpu`, must not be `None`" + ) # noqa: E501 + + self._num_cpu = num_cpu + + @property + def memory(self) -> int: + """Gets the memory of this RequiredResources. + + Memory in giga bytes # noqa: E501 + + :return: The memory of this RequiredResources. + :rtype: int + """ + return self._memory + + @memory.setter + def memory(self, memory: int): + """Sets the memory of this RequiredResources. + + Memory in giga bytes # noqa: E501 + + :param memory: The memory of this RequiredResources. + :type memory: int + """ + if memory is None: + raise ValueError( + "Invalid value for `memory`, must not be `None`" + ) # noqa: E501 + + self._memory = memory + + @property + def storage(self) -> int: + """Gets the storage of this RequiredResources. + + Storage in giga bytes # noqa: E501 + + :return: The storage of this RequiredResources. + :rtype: int + """ + return self._storage + + @storage.setter + def storage(self, storage: int): + """Sets the storage of this RequiredResources. + + Storage in giga bytes # noqa: E501 + + :param storage: The storage of this RequiredResources. + :type storage: int + """ + if storage is None: + raise ValueError( + "Invalid value for `storage`, must not be `None`" + ) # noqa: E501 + + self._storage = storage + + @property + def gpu(self) -> List[GpuInfo]: + """Gets the gpu of this RequiredResources. + + Number of GPUs # noqa: E501 + + :return: The gpu of this RequiredResources. + :rtype: List[GpuInfo] + """ + return self._gpu + + @gpu.setter + def gpu(self, gpu: List[GpuInfo]): + """Sets the gpu of this RequiredResources. + + Number of GPUs # noqa: E501 + + :param gpu: The gpu of this RequiredResources. + :type gpu: List[GpuInfo] + """ + + self._gpu = gpu diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_deregistration_request.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_deregistration_request.py new file mode 100644 index 0000000..e4f8afa --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_deregistration_request.py @@ -0,0 +1,61 @@ +# coding: utf-8 + +from __future__ import absolute_import + + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import ( + util +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import ( + Model +) + + +class ServiceFunctionDeregistrationRequest(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, service_function_name: str = None): # noqa: E501 + """ServiceFunctionDeregistrationRequest - a model defined in Swagger + + :param service_function_name: The service_function_name of this ServiceFunctionDeregistrationRequest. # noqa: E501 + :type service_function_name: str + """ + self.swagger_types = {"service_function_name": str} + + self.attribute_map = {"service_function_name": "service_function_name"} + self._service_function_name = service_function_name + + @classmethod + def from_dict(cls, dikt) -> "ServiceFunctionDeregistrationRequest": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The ServiceFunctionDeregistrationRequest of this ServiceFunctionDeregistrationRequest. # noqa: E501 + :rtype: ServiceFunctionDeregistrationRequest + """ + return util.deserialize_model(dikt, cls) + + @property + def service_function_name(self) -> str: + """Gets the service_function_name of this ServiceFunctionDeregistrationRequest. + + + :return: The service_function_name of this ServiceFunctionDeregistrationRequest. + :rtype: str + """ + return self._service_function_name + + @service_function_name.setter + def service_function_name(self, service_function_name: str): + """Sets the service_function_name of this ServiceFunctionDeregistrationRequest. + + + :param service_function_name: The service_function_name of this ServiceFunctionDeregistrationRequest. + :type service_function_name: str + """ + + self._service_function_name = service_function_name diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_registration_request.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_registration_request.py new file mode 100644 index 0000000..480e2a4 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/service_function_registration_request.py @@ -0,0 +1,179 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from typing import List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.util import deserialize_model + + +class ServiceFunctionRegistrationRequest(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, + service_function_id: str = None, + service_function_name: str = None, + service_function_image: str = None, + service_function_type: str = None, + application_ports: List[int] = None, + ): # noqa: E501 + """ServiceFunctionRegistrationRequest - a model defined in Swagger + + :param service_function_name: The service_function_name of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type service_function_name: str + :param service_function_image: The service_function_image of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type service_function_image: str + :param service_function_type: The service_function_type of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type service_function_type: str + :param application_ports: The application_ports of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type application_ports: List[int] + :param autoscaling_policies: The autoscaling_policies of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type autoscaling_policies: List[AutoscalingPolicy] + :param required_volumes: The required_volumes of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type required_volumes: List[Volume] + :param required_env_parameters: The required_env_parameters of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type required_env_parameters: List[EnvParameterName] + :param privileged: The privileged of this ServiceFunctionRegistrationRequest. # noqa: E501 + :type privileged: bool + """ + self.swagger_types = { + 'service_function_id': str, + "service_function_name": str, + "service_function_image": str, + "service_function_type": str, + "application_ports": List[int], + } + + self.attribute_map = { + "service_function_id": "service_function_id", + "service_function_name": "service_function_name", + "service_function_image": "service_function_image", + "service_function_type": "service_function_type", + "application_ports": "application_ports", + } + self._service_function_id = service_function_id + self._service_function_name = service_function_name + self._service_function_image = service_function_image + self._service_function_type = service_function_type + self._application_ports = application_ports + + @classmethod + def from_dict(cls, dikt) -> "ServiceFunctionRegistrationRequest": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The ServiceFunctionRegistrationRequest of this ServiceFunctionRegistrationRequest. # noqa: E501 + :rtype: ServiceFunctionRegistrationRequest + """ + return deserialize_model(dikt, cls) + + @property + def service_function_id(self) -> str: + """Gets the service_function_name of this ServiceFunctionRegistrationRequest. + + + :return: The service_function_name of this ServiceFunctionRegistrationRequest. + :rtype: str + """ + return self._service_function_id + + @service_function_id.setter + def service_function_id(self, service_function_id: str): + """Sets the service_function_name of this ServiceFunctionRegistrationRequest. + + + :param service_function_name: The service_function_name of this ServiceFunctionRegistrationRequest. + :type service_function_name: str + """ + + self._service_function_id = service_function_id + + @property + def service_function_name(self) -> str: + """Gets the service_function_name of this ServiceFunctionRegistrationRequest. + + + :return: The service_function_name of this ServiceFunctionRegistrationRequest. + :rtype: str + """ + return self._service_function_name + + @service_function_name.setter + def service_function_name(self, service_function_name: str): + """Sets the service_function_name of this ServiceFunctionRegistrationRequest. + + + :param service_function_name: The service_function_name of this ServiceFunctionRegistrationRequest. + :type service_function_name: str + """ + + self._service_function_name = service_function_name + + @property + def service_function_image(self) -> str: + """Gets the service_function_image of this ServiceFunctionRegistrationRequest. + + + :return: The service_function_image of this ServiceFunctionRegistrationRequest. + :rtype: str + """ + return self._service_function_image + + @service_function_image.setter + def service_function_image(self, service_function_image: str): + """Sets the service_function_image of this ServiceFunctionRegistrationRequest. + + + :param service_function_image: The service_function_image of this ServiceFunctionRegistrationRequest. + :type service_function_image: str + """ + + self._service_function_image = service_function_image + + @property + def service_function_type(self) -> str: + """Gets the service_function_type of this ServiceFunctionRegistrationRequest. + + + :return: The service_function_type of this ServiceFunctionRegistrationRequest. + :rtype: str + """ + return self._service_function_type + + @service_function_type.setter + def service_function_type(self, service_function_type: str): + """Sets the service_function_type of this ServiceFunctionRegistrationRequest. + + + :param service_function_type: The service_function_type of this ServiceFunctionRegistrationRequest. + :type service_function_type: str + """ + + self._service_function_type = service_function_type + + @property + def application_ports(self) -> List[int]: + """Gets the application_ports of this ServiceFunctionRegistrationRequest. + + + :return: The application_ports of this ServiceFunctionRegistrationRequest. + :rtype: List[int] + """ + return self._application_ports + + @application_ports.setter + def application_ports(self, application_ports: List[int]): + """Sets the application_ports of this ServiceFunctionRegistrationRequest. + + + :param application_ports: The application_ports of this ServiceFunctionRegistrationRequest. + :type application_ports: List[int] + """ + + self._application_ports = application_ports diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py new file mode 100644 index 0000000..a1642c8 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py @@ -0,0 +1,88 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class ServicesQuery(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, service_consumer_id: str = None, query_string: str = None + ): # noqa: E501 + """ServicesQuery - a model defined in Swagger + + :param service_consumer_id: The service_consumer_id of this ServicesQuery. # noqa: E501 + :type service_consumer_id: str + :param query_string: The query_string of this ServicesQuery. # noqa: E501 + :type query_string: str + """ + self.swagger_types = {"service_consumer_id": str, "query_string": str} + + self.attribute_map = { + "service_consumer_id": "serviceConsumerId", + "query_string": "queryString", + } + self._service_consumer_id = service_consumer_id + self._query_string = query_string + + @classmethod + def from_dict(cls, dikt) -> "ServicesQuery": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The servicesQuery of this ServicesQuery. # noqa: E501 + :rtype: ServicesQuery + """ + return util.deserialize_model(dikt, cls) + + @property + def service_consumer_id(self) -> str: + """Gets the service_consumer_id of this ServicesQuery. + + + :return: The service_consumer_id of this ServicesQuery. + :rtype: str + """ + return self._service_consumer_id + + @service_consumer_id.setter + def service_consumer_id(self, service_consumer_id: str): + """Sets the service_consumer_id of this ServicesQuery. + + + :param service_consumer_id: The service_consumer_id of this ServicesQuery. + :type service_consumer_id: str + """ + + self._service_consumer_id = service_consumer_id + + @property + def query_string(self) -> str: + """Gets the query_string of this ServicesQuery. + + + :return: The query_string of this ServicesQuery. + :rtype: str + """ + return self._query_string + + @query_string.setter + def query_string(self, query_string: str): + """Sets the query_string of this ServicesQuery. + + + :param query_string: The query_string of this ServicesQuery. + :type query_string: str + """ + + self._query_string = query_string diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/uri.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/uri.py new file mode 100644 index 0000000..ffc6f37 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/uri.py @@ -0,0 +1,33 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class Uri(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self): # noqa: E501 + """Uri - a model defined in Swagger""" + self.swagger_types = {} + + self.attribute_map = {} + + @classmethod + def from_dict(cls, dikt) -> "Uri": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The Uri of this Uri. # noqa: E501 + :rtype: Uri + """ + return util.deserialize_model(dikt, cls) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py new file mode 100644 index 0000000..53e0c87 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py @@ -0,0 +1,109 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from datetime import date, datetime # noqa: F401 +from typing import Dict, List # noqa: F401 + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class Volume(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__( + self, name: str = None, path: str = None, hostpath: str = None + ): # noqa: E501 + """Volume - a model defined in Swagger + + :param name: The name of this Volume. # noqa: E501 + :type name: str + :param path: The path of this Volume. # noqa: E501 + :type path: str + :param hostpath: The hostpath of this Volume. # noqa: E501 + :type hostpath: str + """ + self.swagger_types = {"name": str, "path": str, "hostpath": str} + + self.attribute_map = {"name": "name", "path": "path", "hostpath": "hostpath"} + self._name = name + self._path = path + self._hostpath = hostpath + + @classmethod + def from_dict(cls, dikt) -> "Volume": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The volume of this Volume. # noqa: E501 + :rtype: Volume + """ + return util.deserialize_model(dikt, cls) + + @property + def name(self) -> str: + """Gets the name of this Volume. + + + :return: The name of this Volume. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this Volume. + + + :param name: The name of this Volume. + :type name: str + """ + + self._name = name + + @property + def path(self) -> str: + """Gets the path of this Volume. + + + :return: The path of this Volume. + :rtype: str + """ + return self._path + + @path.setter + def path(self, path: str): + """Sets the path of this Volume. + + + :param path: The path of this Volume. + :type path: str + """ + + self._path = path + + @property + def hostpath(self) -> str: + """Gets the hostpath of this Volume. + + + :return: The hostpath of this Volume. + :rtype: str + """ + return self._hostpath + + @hostpath.setter + def hostpath(self, hostpath: str): + """Sets the hostpath of this Volume. + + + :param hostpath: The hostpath of this Volume. + :type hostpath: str + """ + + self._hostpath = hostpath diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume_mount_deploy.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume_mount_deploy.py new file mode 100644 index 0000000..6ccace3 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume_mount_deploy.py @@ -0,0 +1,80 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model + + +class VolumeMountDeploy(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, name: str = None, storage: str = None): # noqa: E501 + """VolumeMountDeploy - a model defined in Swagger + + :param name: The name of this VolumeMountDeploy. # noqa: E501 + :type name: str + :param storage: The storage of this VolumeMountDeploy. # noqa: E501 + :type storage: str + """ + self.swagger_types = {"name": str, "storage": str} + + self.attribute_map = {"name": "name", "storage": "storage"} + self._name = name + self._storage = storage + + @classmethod + def from_dict(cls, dikt) -> "VolumeMountDeploy": + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The volume_mount_deploy of this VolumeMountDeploy. # noqa: E501 + :rtype: VolumeMountDeploy + """ + return util.deserialize_model(dikt, cls) + + @property + def name(self) -> str: + """Gets the name of this VolumeMountDeploy. + + + :return: The name of this VolumeMountDeploy. + :rtype: str + """ + return self._name + + @name.setter + def name(self, name: str): + """Sets the name of this VolumeMountDeploy. + + + :param name: The name of this VolumeMountDeploy. + :type name: str + """ + + self._name = name + + @property + def storage(self) -> str: + """Gets the storage of this VolumeMountDeploy. + + + :return: The storage of this VolumeMountDeploy. + :rtype: str + """ + return self._storage + + @storage.setter + def storage(self, storage: str): + """Sets the storage of this VolumeMountDeploy. + + + :param storage: The storage of this VolumeMountDeploy. + :type storage: str + """ + + self._storage = storage diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/type_util.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/type_util.py new file mode 100644 index 0000000..fe67381 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/type_util.py @@ -0,0 +1,32 @@ +# coding: utf-8 + +import sys + +if sys.version_info < (3, 7): + import typing + + def is_generic(klass): + """Determine whether klass is a generic class""" + return type(klass) is typing.GenericMeta + + def is_dict(klass): + """Determine whether klass is a Dict""" + return klass.__extra__ is dict + + def is_list(klass): + """Determine whether klass is a List""" + return klass.__extra__ is list + +else: + + def is_generic(klass): + """Determine whether klass is a generic class""" + return hasattr(klass, "__origin__") + + def is_dict(klass): + """Determine whether klass is a Dict""" + return klass.__origin__ is dict + + def is_list(klass): + """Determine whether klass is a List""" + return klass.__origin__ is list diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/util.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/util.py new file mode 100644 index 0000000..8e00fa4 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/util.py @@ -0,0 +1,144 @@ +import datetime + +import six + +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import type_util + + +def _deserialize(data, klass): + """Deserializes dict, list, str into an object. + + :param data: dict, list or str. + :param klass: class literal, or string of class name. + + :return: object. + """ + if data is None: + return None + + if klass in six.integer_types or klass in (float, str, bool, bytearray): + return _deserialize_primitive(data, klass) + elif klass == object: + return _deserialize_object(data) + elif klass == datetime.date: + return deserialize_date(data) + elif klass == datetime.datetime: + return deserialize_datetime(data) + elif type_util.is_generic(klass): + if type_util.is_list(klass): + return _deserialize_list(data, klass.__args__[0]) + if type_util.is_dict(klass): + return _deserialize_dict(data, klass.__args__[1]) + else: + return deserialize_model(data, klass) + + +def _deserialize_primitive(data, klass): + """Deserializes to primitive type. + + :param data: data to deserialize. + :param klass: class literal. + + :return: int, long, float, str, bool. + :rtype: int | long | float | str | bool + """ + try: + value = klass(data) + except UnicodeEncodeError: + value = six.u(data) + except TypeError: + value = data + return value + + +def _deserialize_object(value): + """Return an original value. + + :return: object. + """ + return value + + +def deserialize_date(string): + """Deserializes string to date. + + :param string: str. + :type string: str + :return: date. + :rtype: date + """ + try: + from dateutil.parser import parse + + return parse(string).date() + except ImportError: + return string + + +def deserialize_datetime(string): + """Deserializes string to datetime. + + The string should be in iso8601 datetime format. + + :param string: str. + :type string: str + :return: datetime. + :rtype: datetime + """ + try: + from dateutil.parser import parse + + return parse(string) + except ImportError: + return string + + +def deserialize_model(data, klass): + """Deserializes list or dict to model. + + :param data: dict, list. + :type data: dict | list + :param klass: class literal. + :return: model object. + """ + instance = klass() + + if not instance.swagger_types: + return data + + for attr, attr_type in six.iteritems(instance.swagger_types): + if ( + data is not None + and instance.attribute_map[attr] in data + and isinstance(data, (list, dict)) + ): + value = data[instance.attribute_map[attr]] + setattr(instance, attr, _deserialize(value, attr_type)) + + return instance + + +def _deserialize_list(data, boxed_type): + """Deserializes a list and its elements. + + :param data: list to deserialize. + :type data: list + :param boxed_type: class literal. + + :return: deserialized list. + :rtype: list + """ + return [_deserialize(sub_data, boxed_type) for sub_data in data] + + +def _deserialize_dict(data, boxed_type): + """Deserializes a dict and its elements. + + :param data: dict to deserialize. + :type data: dict + :param boxed_type: class literal. + + :return: deserialized dict. + :rtype: dict + """ + return {k: _deserialize(v, boxed_type) for k, v in six.iteritems(data)} diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py new file mode 100644 index 0000000..4e82f04 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py @@ -0,0 +1,26 @@ +import logging +import os + +import requests + +artifact_manager_host = os.environ["ARTIFACT_MANAGER_ADDRESS"] + + +def artifact_exists(body): + logging.info("Contacting Artifact Manager") + # body = json.dumps(body) + headers = {"Content-Type": "application/json"} + response = requests.post( + artifact_manager_host + "/artefact-exists/", headers=headers, json=body + ) + return response + + +def copy_artifact(body): + logging.info("Submitting artifact to Artifact Manager") + # body = json.dumps(body) + headers = {"Content-Type": "application/json"} + response = requests.post( + artifact_manager_host + "/copy-artefact", headers=headers, json=body + ) + return response diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/auxiliary_functions.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/auxiliary_functions.py new file mode 100644 index 0000000..e03f25d --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/auxiliary_functions.py @@ -0,0 +1,43 @@ +def equal_ignore_order(a, b): + """Use only when elements are neither hashable nor sortable!""" + unmatched = list(b) + for element in a: + try: + unmatched.remove(element) + except ValueError: + return False + return not unmatched + + +def check_availability(element, collection: iter): + return element in collection + + +def return_equal_ignore_order(a, b): + """Use only when elements are neither hashable nor sortable!""" + equal = [] + for element in a: + # if b is not None: + + if element in b: + equal.append(element) + return equal + + +def prepare_name_for_k8s(name): + name = name.lower() + # deployed_name = deployed_name.replace("-", "") + name = name.replace("_", "") + deployed_name_ = "".join([i for i in name if not i.isdigit()]) + return deployed_name_ + + +def prepare_name(name, driver): + if driver != "docker": + name = name.lower() + # deployed_name = deployed_name.replace("-", "") + name = name.replace("_", "") + deployed_name_ = "".join([i for i in name if not i.isdigit()]) + return deployed_name_.rstrip("-") + else: + return name diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py new file mode 100644 index 0000000..6d5ca53 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py @@ -0,0 +1,278 @@ +from typing import List + +import pymongo +from bson.objectid import ObjectId + +storage_url = None + + +class ConnectorDB: + def __init__(self, host): + self._storage_url = host + self.mydb_mongo = "pi-edge" + + # def insert_document_k8s_platform(self, document=None, _id=None): + # collection = "kubernetes_platforms" + # myclient = pymongo.MongoClient(self._storage_url) + # mydbmongo = myclient[self.mydb_mongo] + # mycol = mydbmongo[collection] + + # myquery = {"name": document["kubernetes_platform_name"]} + # mydoc = mycol.find_one(myquery) + # # keeps the last record (contains registrationStatus) + # if mydoc is not None: + # raise Exception( + # "Already Registered: Platform name", + # document["kubernetes_platform_name"], + # ) + # try: + # insert_doc = {} + # insert_doc["name"] = document["kubernetes_platform_name"] + # insert_doc["auth_credentials"] = document["kubernetes_auth_credentials"] + # mycol.insert_one(insert_doc) + # except Exception as ce_: + # raise Exception("An exception occurred :", ce_) + + # def delete_document_k8s_platform(self, document=None, _id=None): + # collection = "kubernetes_platforms" + # myclient = pymongo.MongoClient(self._storage_url) + # mydbmongo = myclient[self.mydb_mongo] + # mycol = mydbmongo[collection] + + # myquery = {"name": document["kubernetes_platform_name"]} + # mydoc = mycol.find_one(myquery) + + # # keeps the last record (contains registrationStatus) + # if mydoc is None: + # raise Exception( + # "Not found: Platform name", document["kubernetes_platform_name"] + # ) + # try: + # delete_doc = {} + # delete_doc["name"] = document["kubernetes_platform_name"] + # mycol.delete_one(delete_doc) + # except Exception as ce_: + # raise Exception("An exception occurred :", ce_) + + def insert_document_deployed_service_function(self, document=None, _id=None): + + collection = "deployed_service_functions" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = { + "name": document["service_function_name"], + "location": document["location"], + "instance_name": document["instance_name"], + } + mydoc = mycol.find_one(myquery) + # keeps the last record (contains registrationStatus) + if mydoc is not None: + return "Requested service function (with this name) already deployed and exists in deployed_apps database" + # raise Exception("Already Registered: PaaS name", document["paas_name"]) + try: + insert_doc = {} + insert_doc['_id'] = document['id'] + insert_doc["name"] = document["service_function_name"] + # insert_doc["type"] = document["paas_type"] + insert_doc["location"] = document["location"] + insert_doc["instance_name"] = document["instance_name"] + + if "scaling_type" in document: + insert_doc["scaling_type"] = document["scaling_type"] + # + if "monitoring_service_URL" in document: + insert_doc["monitoring_service_URL"] = document[ + "monitoring_service_URL" + ] + + if "paas_name" in document: + insert_doc["paas_name"] = document["paas_name"] + + # TODO: IS IT NEEDED? + # if "volumes" in document: + # insert_doc["volumes"] = document["volumes"] + + # if "env_parameters" in document: + # insert_doc["env_parameters"] = document["env_parameters"] + + mycol.insert_one(insert_doc) + return "Deployed service function registered successfully" + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def delete_document_deployed_service_functions(self, document=None, _id=None): + collection = "deployed_service_functions" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = {"instance_name": document["instance_name"]} + mydoc = mycol.find_one(myquery) + + # keeps the last record (contains registrationStatus) + if mydoc is None: + return "Deployed Service function not found in the database" + # raise Exception("Not found: PaaS name", document["paas_name"]) + try: + delete_doc = {} + delete_doc["instance_name"] = document["instance_name"] + mycol.delete_one(delete_doc) + return "Deployed Service function deleted successfully" + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def insert_document_service_function(self, document=None, _id=None): + # print(document) + collection = "service_functions" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = {"name": document["service_function_name"]} + mydoc = mycol.find_one(myquery) + + if mydoc is not None: + return "Service function already exists in the catalogue" + + insert_doc = {} + insert_doc["_id"] = document['service_function_id'] + insert_doc["name"] = document["service_function_name"] + insert_doc["type"] = document["service_function_type"] + insert_doc["image"] = document["service_function_image"] + if document.get("application_ports") is not None: + insert_doc["application_ports"] = document.get("application_ports") + if document.get("autoscaling_policies") is not None: + insert_doc["autoscaling_policies"] = document.get("autoscaling_policies") + # if "required_volumes" in document: + # insert_doc["required_volumes"] = document["required_volumes"] + # if "privileged" in document: + # insert_doc["privileged"] = document["privileged"] + # insert_doc["required_env_parameters"] = document["required_env_parameters"] + result = mycol.insert_one(insert_doc) + return result + + # ##TODO!!!!! + # def update_document_service_function(document=None, _id=None): + + # collection = "service_functions" + # myclient = pymongo.MongoClient(storage_url) + # mydbmongo = myclient[mydb_mongo] + # mycol = mydbmongo[collection] + + # myquery = {"name": document["service_function_name"]} + # mydoc = mycol.find_one(myquery) + + # if mydoc is not None: + + # try: + # myquery = {"name": document["service_function_name"]} + # newvalues = {"$set": {"autoscaling_policies": document["autoscaling_policies"]}} + # mycol.update_one(myquery,newvalues) + # return "Service function updated successfully" + # except Exception as ce_: + # raise Exception("An exception occurred :", ce_) + # else: + # return "Service function not found in the catalogue" + + def delete_document_service_function( + self, service_function_input_name=None, _id: str = None + ): + + # _id = ObjectId(_id) + collection = "service_functions" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = {"_id": _id} + print(myquery) + mydoc = mycol.find_one(myquery) + + if mydoc is None: + return "Service function not found in the database", 404 + try: + delete_doc = {} + delete_doc["_id"] = _id + mycol.delete_one(delete_doc) + return "Service function deregistered successfully", 200 + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def delete_document_paas_service(self, paas_service_input_name=None, _id=None): + collection = "paas_services" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + myquery = {"name": paas_service_input_name} + mydoc = mycol.find_one(myquery) + + if mydoc is None: + return "PaaS Service not found in the database" + try: + delete_doc = {} + delete_doc["name"] = paas_service_input_name + mycol.delete_one(delete_doc) + return "PaaS Service deregistered successfully" + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def delete_document_deployed_paas_service(self, document=None, _id=None): + collection = "deployed_paas_services" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = {"instance_name": document["instance_name"]} + mydoc = mycol.find_one(myquery) + + if mydoc is None: + return "Deployed PaaS service not found in the database" + try: + delete_doc = {} + delete_doc["instance_name"] = document["instance_name"] + mycol.delete_one(delete_doc) + return "Deployed PaaS Service deleted successfully" + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def insert_document_nodes(self, document=None, _id=None): + collection = "points_of_presence" + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + myquery = {"name": document["name"]} + mydoc = mycol.find_one(myquery) + # keeps the last record (contains registrationStatus) + if mydoc is not None: + return ("Already Registered: Node name", document["name"]) + try: + mycol.insert_one(document) + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + + def get_documents_from_collection( + self, collection_input, input_type=None, input_value=None + ) -> List[dict]: + collection = collection_input + myclient = pymongo.MongoClient(self._storage_url) + mydbmongo = myclient[self.mydb_mongo] + mycol = mydbmongo[collection] + + try: + mydoc_ = [] + for x in mycol.find(): + x["_id"] = str(x["_id"]) + if input_type is not None: + if input_value == x[input_type]: + mydoc_.append(x) + break + else: + mydoc_.append(x) + + return mydoc_ + except Exception as ce_: + raise Exception("An exception occurred :", ce_) + diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py new file mode 100644 index 0000000..0d08c03 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -0,0 +1,965 @@ +from __future__ import ( + print_function, +) +import requests + +from kubernetes import ( + client, +) +from kubernetes.client.rest import ( + ApiException, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils import ( + auxiliary_functions, +) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.connector_db import ( + ConnectorDB, +) + +configuration = client.Configuration() + + +class KubernetesConnector: + def __init__(self, ip, port, token, username): + master_node_ip = ip + master_node_port = port + username = username + self.token_k8s = token + if port is None: + self.host = master_node_ip + else: + self.host = "https://"+master_node_ip + ":" + master_node_port + configuration.api_key["authorization"] = self.token_k8s + configuration.api_key_prefix["authorization"] = "Bearer" + + configuration.host = self.host + + configuration.username = username + configuration.verify_ssl = False + self.v1 = client.CoreV1Api(client.ApiClient(configuration)) + + # config.lod + # client.Configuration.set_default(configuration) + # Defining host is optional and default to http://localhost + # Enter a context with an instance of the API kubernetes.client + with client.ApiClient(configuration) as api_client: + # Create an instance of the API class + self.api_instance = client.AdmissionregistrationApi(api_client) + self.api_instance_appsv1 = client.AppsV1Api(api_client) + self.api_instance_apiregv1 = client.ApiregistrationV1Api(api_client) + self.api_instance_v1autoscale = client.AutoscalingV1Api(api_client) + # self.api_instance_v2beta1autoscale = client.AutoscalingV2beta1Api( + # api_client + # ) + # self.api_instance_v2beta2autoscale = client.AutoscalingV2beta1Api( + # api_client + # ) + self.api_instance_corev1api = client.CoreV1Api(api_client) + self.api_instance_storagev1api = client.StorageV1Api(api_client) + self.api_instance_batchv1 = client.BatchV1Api(api_client) + + self.api_custom = client.CustomObjectsApi(api_client) + try: + self.api_instance.get_api_group() + except ApiException as e: + print( + "Exception when calling AdmissionregistrationApi->get_api_group: %s\n" + % e + ) + + def get_node_details(self): + try: + url = self.host + "/api/v1/nodes" + headers = {"Authorization": "Bearer " + self.token_k8s} + x = requests.get(url, headers=headers, verify=False) + node_details = x.json() + return node_details + except requests.exceptions.HTTPError as e: + # logging.error(traceback.format_exc()) + return "Exception when calling Kubernetes API:" + e.args + + def get_PoP_statistics(self, nodeName): + + #x1 = v1.list_node().to_dict() + + try: + url = self.host + "/api/v1/nodes" + headers = {"Authorization": "Bearer " + self.token_k8s} + x = requests.get(url, headers=headers, verify=False) + x1=x.json() + except requests.exceptions.HTTPError as e: + # logging.error(traceback.format_exc()) + return ("Exception when calling CoreV1Api->/api/v1/namespaces/sunrise6g/persistentvolumeclaims: %s\n" % e) + k8s_nodes = self.api_custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes") + + + # client.models.v1_node_list.V1NodeList + # kubernetes.client.models.v1_node_list.V1NodeList\ + + + pop_output= {} + for pop in x1['items']: + + name=pop['metadata']['name'] + if name==nodeName: + pop_output["nodeName"]=name + pop_output["nodeId"]=pop['metadata']['uid'] + pop_output["nodeLocation"]= pop['metadata']['labels']['location'] + + node_addresses={} + node_addresses["nodeHostName"]=pop['status']['addresses'][1]['address'] + node_addresses["nodeExternalIP"]=pop['status']['addresses'][0]['address'] + node_addresses["nodeInternalIP"]=pop['metadata']['annotations'].get('projectcalico.org/IPv4VXLANTunnelAddr') + pop_output["nodeAddresses"]=node_addresses + + + node_conditions={} + for condition in pop['status']['conditions']: + type=condition['type'] + node_type="node"+type + node_conditions[node_type] = condition['status'] + pop_output["nodeConditions"] = node_conditions + + node_capacity= {} + node_capacity["nodeCPUCap"]=pop['status']['capacity']['cpu'] + memory=pop['status']['capacity']['memory'] + memory_size=len(memory) + node_capacity["nodeMemoryCap"]=memory[:memory_size - 2] + node_capacity["nodeMemoryCapMU"]=memory[-2:] + storage = pop['status']['capacity']['ephemeral-storage'] + storage_size = len(storage) + node_capacity["nodeStorageCap"]=storage[:storage_size - 2] + node_capacity["nodeStorageCapMU"]=storage[-2:] + node_capacity["nodeMaxNoofPods"]=pop['status']['capacity']['pods'] + pop_output["nodeCapacity"] = node_capacity + + node_allocatable_resources= {} + node_allocatable_resources["nodeCPUCap"] = pop['status']['allocatable']['cpu'] + memory = pop['status']['allocatable']['memory'] + memory_size = len(memory) + node_allocatable_resources["nodeMemoryCap"] = memory[:memory_size - 2] + node_allocatable_resources["nodeMemoryCapMU"] = memory[-2:] + storage = pop['status']['allocatable']['ephemeral-storage'] + storage_size = len(storage) + node_allocatable_resources["nodeStorageCap"] = storage[:storage_size - 2] + node_allocatable_resources["nodeStorageCapMU"] = storage[-2:] + # node_allocatable_resources["nodeMaxNoofPods"] = pop['status']['allocatable']['pods'] + pop_output["nodeAllocatableResources"] = node_allocatable_resources + + + #calculate usage + for stats in k8s_nodes['items']: + if stats['metadata']['name']==nodeName: + node_usage={} + cpu=stats['usage']['cpu'] + cpu_size=len(cpu) + memory=stats['usage']['memory'] + memory_size = len(memory) + + node_usage["nodeMemoryInUse"]=memory[:memory_size - 2] + node_usage["nodeMemoryInUseMU"]=memory[-2:] + node_usage["nodeMemoryUsage"]=int(node_usage["nodeMemoryInUse"])/int(node_capacity["nodeMemoryCap"]) + node_usage["nodeCPUInUse"]=cpu[:cpu_size - 1] + node_usage["nodeCPUInUseMU"]=cpu[-1:] + node_usage["nodeCPUUsage"]=int(node_usage["nodeCPUInUse"])/(int(node_capacity["nodeCPUCap"])*1000) + pop_output["nodeUsage"] = node_usage + + + node_general_info={} + node_general_info["nodeOS"]=pop['status']['nodeInfo']['osImage'] + node_general_info["nodeKubernetesVersion"]=pop['status']['nodeInfo']['kernelVersion'] + node_general_info["nodecontainerRuntimeVersion"]=pop['status']['nodeInfo']['containerRuntimeVersion'] + node_general_info["nodeKernelVersion"]=pop['status']['nodeInfo']['kernelVersion'] + node_general_info["nodeArchitecture"]=pop['status']['nodeInfo']['architecture'] + pop_output["nodeGeneralInfo"] = node_general_info + + return pop_output + + + def get_PoPs(self): + + try: + pops_ = [] + x1 = self.v1.list_node() + for node in x1.items: + pop_ = {} + pop_["name"] = node.metadata.name + pop_["uid"] = node.metadata.uid + pop_["location"] = node.metadata.labels.get("location") + pop_["serial"] = node.status.addresses[0].address + pop_["node_type"] = node.metadata.labels.get("node_type") + pop_["status"] = ( + "active" + if node.status.conditions[-1].status == "True" + else "inactive" + ) + # pop_= NodesResponse(id=uid,name=name,location=location,serial=address, node_type=node_type, status=ready_status) + pops_.append(pop_) + return pops_ + # url = host + "/api/v1/nodes" + # headers = {"Authorization": "Bearer " + token_k8s} + # x=requests.get(url, headers=headers, verify=False) + # x1 = x.json() + except requests.exceptions.HTTPError as e: + # logging.error(traceback.format_exc()) + return ( + "Exception when calling CoreV1Api->/api/v1/namespaces/sunrise6g/persistentvolumeclaims: %s\n" + % e + ) + # + + + def delete_service_function(self, connector_db: ConnectorDB, service_function_name): + + deleted_app = self.api_instance_appsv1.delete_namespaced_deployment(name=service_function_name, namespace="sunrise6g") + + deleted_service = self.v1.delete_namespaced_service(name=service_function_name, namespace="sunrise6g") + + hpa_list = self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler("sunrise6g") + + #hpas=hpa_list["items"] + + for hpa in hpa_list.items: + if hpa.metadata.name==service_function_name: + deleted_hpa = self.api_instance_v1autoscale.delete_namespaced_horizontal_pod_autoscaler(name=service_function_name, namespace="sunrise6g") + break + #deletevolume + volume_list = self.v1.list_namespaced_persistent_volume_claim("sunrise6g") + for volume in volume_list.items: + name_v=service_function_name+str("-") + if name_v in volume.metadata.name: + deleted_pv = self.v1.delete_persistent_volume( + name=volume.spec.volume_name) + + deleted_pvc = self.v1.delete_namespaced_persistent_volume_claim( + name=volume.metadata.name, namespace="sunrise6g") + + doc = {} + doc["instance_name"] = service_function_name + sf = connector_db.delete_document_deployed_service_functions(document=doc) + + + def deploy_service_function(self, descriptor_service_function): + #deploys a Deployment yaml file, a service, a pvc and a hpa + + if "volumes" in descriptor_service_function: + for volume in descriptor_service_function["volumes"]: + #first solution (python k8s client arises error without reason!) + #body_volume = create_pvc(descriptor_service_function["name"], volume) + #api_response_pvc = v1.create_namespaced_persistent_volume_claim("sunrise6g", body_volume) + + + # #deploy pv + # print("deploy pv") + # try: + # url = host + "/api/v1/persistentvolumes" + # body_volume = create_pv_dict(descriptor_service_function["name"], volume) + # + # + # headers = {"Authorization": "Bearer " + token_k8s} + # x = requests.post(url, headers=headers, json=body_volume, verify=False) + # print (x.status_code) + # except requests.exceptions.HTTPError as e: + # # logging.error(traceback.format_exc()) + # return ("Exception when calling CoreV1Api->/api/v1/persistentvolumes: %s\n" % e) + + + #deploy pvc + + if volume.get("hostpath") is None: + try: + url = self.host+"/api/v1/namespaces/sunrise6g/persistentvolumeclaims" + body_volume = self.create_pvc_dict(descriptor_service_function["name"], volume) + headers = {"Authorization": "Bearer "+self.token_k8s} + requests.post(url, headers=headers, json=body_volume, verify=False) + except requests.exceptions.HTTPError as e: + # logging.error(traceback.format_exc()) + return ("Exception when calling CoreV1Api->/api/v1/namespaces/sunrise6g/persistentvolumeclaims: %s\n" % e) + + #api_response_pvc = api_instance_corev1api.create_namespaced_persistent_volume_claim + body_deployment = self.create_deployment(descriptor_service_function) + body_service = self.create_service(descriptor_service_function) + + try: + api_response_deployment = self.api_instance_appsv1.create_namespaced_deployment("sunrise6g", body_deployment) + #api_response_service = api_instance_apiregv1.create_api_service(body_service) + api_response_service=self.v1.create_namespaced_service("sunrise6g",body_service) + if "autoscaling_policies" in descriptor_service_function: + #V1 AUTOSCALER + body_hpa = self.create_hpa(descriptor_service_function) + self.api_instance_v1autoscale.create_namespaced_horizontal_pod_autoscaler("sunrise6g",body_hpa) + # V2beta1 AUTOSCALER + #body_hpa = create_hpa(descriptor_paas) + #api_instance_v2beta1autoscale.create_namespaced_horizontal_pod_autoscaler("sunrise6g",body_hpa) + + + return api_response_deployment + except ApiException as e: + #logging.error(traceback.format_exc()) + return "Exception when calling AppsV1Api->create_namespaced_deployment or ApiregistrationV1Api->create_api_service: %s\n" % e + # Exception("An exception occurred : ", e) + + def create_deployment(self, descriptor_service_function): + + + metadata = client.V1ObjectMeta(name=descriptor_service_function["name"]) + # selector + dict_label = {} + dict_label['sunrise6g'] = descriptor_service_function["name"] + selector = client.V1LabelSelector(match_labels=dict_label) + + # create spec + + # spec.selector=selector + # replicas + # spec.replicas=descriptor_paas("count-min") + # template + + metadata_spec = client.V1ObjectMeta(labels=dict_label) + + # template spec + containers = [] + for container in descriptor_service_function["containers"]: + #privileged + if "privileged" in container: + security_context = client.V1SecurityContext(privileged=container["privileged"]) + else: + security_context = None + ports = [] + for port_id in container["application_ports"]: + port_ = client.V1ContainerPort(container_port=port_id) + ports.append(port_) + + #check env_parameters + envs = [] + + if "env_parameters" in descriptor_service_function: + if descriptor_service_function["env_parameters"] is not None: + + for env in descriptor_service_function["env_parameters"]: + if "value" in env: + env_=client.V1EnvVar(name=env["name"], value=env["value"]) + elif "value_ref" in env: + #env_name_ should based on paas_instance_name + if "paas_name" in descriptor_service_function: + #check if value is something like: http://edgex-core-data:48080 + + env_split = env["value_ref"].split(":") + + if "@" not in env["value_ref"]: #meaning that it is reffering to a running paas!!!!! + + if len(env_split) > 2: #case http://edgex-core-data:48080 + prefix=env_split[0] #http + final_env = env_split[1] #//edgex-core-data or edgex-core-data + split2=final_env.split("//") + if len(split2)>=2: + final_env=split2[1] + port_env = env_split[2] #48080 + env_= auxiliary_functions.prepare_name_for_k8s(str(descriptor_service_function["paas_name"]+str("-")+final_env)) + + env_name_=prefix + ":" + "//"+ env_ + ":" + port_env + + elif len(env_split)>1: #case edgex-core-data:48080 + final_env = env_split[0] + port_env=env_split[1] + env_ = auxiliary_functions.prepare_name_for_k8s(str(descriptor_service_function["paas_name"] + str("-") + final_env)) + env_name_ = env_ + ":" + port_env + else: #case edgex-core-data + final_env = env_split[0] + env_name_= auxiliary_functions.prepare_name_for_k8s(str(descriptor_service_function["paas_name"]+str("-")+final_env)) + env_ = client.V1EnvVar(name=env["name"], value=env_name_) + + envs.append(env_) + + #create volumes + volumes=[] + volume_mounts=[] + if "volumes" in descriptor_service_function: + if descriptor_service_function["volumes"] is not None: + + for volume in descriptor_service_function["volumes"]: + + if volume.get("hostpath") is None: + + pvc=client.V1PersistentVolumeClaimVolumeSource(claim_name=str(descriptor_service_function["name"]+str("-")+volume["name"])) + #volume_=client.V1Volume(name=volume["name"], persistent_volume_claim=pvc) + volume_=client.V1Volume(name=str(descriptor_service_function["name"]+str("-")+volume["name"]), persistent_volume_claim=pvc) + + volumes.append(volume_) + + else: + hostpath=client.V1HostPathVolumeSource(path=volume["hostpath"]) + volume_ = client.V1Volume(name=str(descriptor_service_function["name"] + str("-") + volume["name"]),host_path=hostpath) + volumes.append(volume_) + + volume_mount = client.V1VolumeMount( + name=str(descriptor_service_function["name"] + str("-") + volume["name"]), + mount_path=volume["path"]) + volume_mounts.append(volume_mount) + + if "autoscaling_policies" in descriptor_service_function: + limits_dict = {} + request_dict = {} + for auto_scale_policy in descriptor_service_function["autoscaling_policies"]: + limits_dict[auto_scale_policy["metric"]]=auto_scale_policy["limit"] + request_dict[auto_scale_policy["metric"]]=auto_scale_policy["request"] + + + resources= client.V1ResourceRequirements(limits=limits_dict, requests=request_dict) + if not envs: + con = client.V1Container(name=descriptor_service_function["name"], image=container["image"], ports=ports, image_pull_policy='Always', + resources=resources, volume_mounts=volume_mounts if volume_mounts else None, security_context=security_context) + else: + con = client.V1Container(name=descriptor_service_function["name"], image=container["image"], + ports=ports, image_pull_policy='Always', + resources=resources, env=envs, volume_mounts=volume_mounts if volume_mounts else None, security_context=security_context ) + else: + if not envs: + con = client.V1Container(name=descriptor_service_function["name"], image=container["image"], ports=ports, image_pull_policy='Always', + volume_mounts=volume_mounts if volume_mounts else None, security_context=security_context ) + else: + con = client.V1Container(name=descriptor_service_function["name"], image=container["image"], image_pull_policy='Always', + ports=ports, env=envs, volume_mounts=volume_mounts if volume_mounts else None, security_context=security_context) + + containers.append(con) + + + node_selector_dict = {} + if "location" in descriptor_service_function: + node_selector_dict['nodeName'] = descriptor_service_function["location"] + + template_spec_ = client.V1PodSpec(containers=containers, node_selector=node_selector_dict, hostname=descriptor_service_function["name"], restart_policy='Always', + volumes=None if not volumes else volumes) + else: + template_spec_ = client.V1PodSpec(containers=containers, + hostname=descriptor_service_function["name"], restart_policy='Always', + volumes=None if not volumes else volumes) + + template = client.V1PodTemplateSpec(metadata=metadata_spec, spec=template_spec_) + + spec = client.V1DeploymentSpec(selector=selector, template=template, replicas=descriptor_service_function["count-min"]) + + body = client.V1Deployment(api_version="apps/v1", kind="Deployment", metadata=metadata, spec=spec) + return body + + def create_service(self, descriptor_service_function): + dict_label = {} + dict_label['sunrise6g'] = descriptor_service_function["name"] + metadata = client.V1ObjectMeta(name=descriptor_service_function["name"], labels=dict_label) + + # spec + + + if "exposed_ports" in descriptor_service_function["containers"][0]: #create NodePort svc object + ports=[] + hepler=0 + for port_id in descriptor_service_function["containers"][0]["exposed_ports"]: + + # if "grafana" in descriptor_service_function["name"]: + # ports_=client.V1ServicePort(port=port_id, + # node_port=31000, + # target_port=port_id, name=str(port_id)) + # else: + # ports_ = client.V1ServicePort(port=port_id, + # # node_port=descriptor_paas["containers"][0]["exposed_ports"][hepler], + # target_port=port_id, name=str(port_id)) + ports_ = client.V1ServicePort(port=port_id, + target_port=port_id, name=str(port_id)) + ports.append(ports_) + hepler=hepler+1 + spec=client.V1ServiceSpec(selector=dict_label, ports=ports, type="NodePort") + #body = client.V1Service(api_version="v1", kind="Service", metadata=metadata, spec=spec) + else: #create ClusterIP svc object + ports = [] + for port_id in descriptor_service_function["containers"][0]["application_ports"]: + ports_ = client.V1ServicePort(port=port_id, + target_port=port_id, name=str(port_id)) + ports.append(ports_) + spec = client.V1ServiceSpec(selector=dict_label, ports=ports, type="ClusterIP") + body = client.V1Service(api_version="v1", kind="Service", metadata=metadata, spec=spec) + + return body + + + def create_pvc(name, volumes): + dict_label = {} + name_vol=name+str("-")+volumes["name"] + dict_label['sunrise6g'] = name_vol + #metadata = client.V1ObjectMeta(name=name_vol) + metadata = client.V1ObjectMeta(name=name_vol, labels=dict_label) + api_version = 'v1', + kind = 'PersistentVolumeClaim', + spec = client.V1PersistentVolumeClaimSpec( + access_modes=[ + 'ReadWriteMany' + ], + resources=client.V1ResourceRequirements( + requests={ + 'storage': volumes["storage"] + } + ) + ) + body=client.V1PersistentVolumeClaim(api_version="v1", kind=kind, metadata=metadata, spec=spec) + + return body + + + def create_pvc_dict(name, volumes, storage_class='microk8s-hostpath', volume_name=None): + name_vol = name + str("-") + volumes["name"] + # body={} + # body["api_version"]="v1" + # body["kind"]="PersistentVolumeClaim" + # metadata={} + # labels={} + body={"api_version": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "labels": {"sunrise6g": name_vol}, + "name": name_vol}, + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": volumes["storage"]}}, + "storageClassName": storage_class + } + } + + if volume_name is not None: + body["spec"]["volume_name"] = volume_name + + return body + + def create_pv_dict(name, volumes, storage_class, node=None): + name_vol = name + "-" + volumes["name"] + + body = { + "apiVersion": "v1", + "kind": "PersistentVolume", + "metadata": { + "name": name_vol, + "labels": { + "sunrise6g": name_vol, + } + }, + "spec": { + "capacity": { + "storage": volumes["storage"] + }, + "volumeMode": "Filesystem", + "accessModes": [ + "ReadWriteOnce" + ], + "persistentVolumeReclaimPolicy": "Delete", + "storageClassName": storage_class, + "hostPath": { + "path": "/mnt/" + name_vol, + "type": "DirectoryOrCreate" + } + } + } + + if node is not None: + body["spec"]["nodeAffinity"] = { + "required": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "kubernetes.io/hostname", + "operator": "In", + "values": [ + node + ] + } + ] + } + ] + } + } + + return body + + def check_for_update_hpas(self, deployed_hpas): + + for hpa in deployed_hpas: + for catalogue_policy in hpa["catalogue_policy"]: + if catalogue_policy["policy"]==hpa["deployed_scaling_type"]: + for metrics in catalogue_policy["monitoring_metrics"]: + + if metrics["metric_name"]== hpa["deployed_metric"]: + + if metrics["catalogue_util"]!=hpa["deployed_util"]: #need to update hpa + desc_paas={} + desc_paas["name"]=hpa["name"] + desc_paas["count-max"]=hpa["max"] + desc_paas["count-min"]=hpa["min"] + policy={} + policy["limit"]=metrics["catalogue_limit"] + policy["request"]=metrics["catalogue_request"] + policy["util_percent"]=metrics["catalogue_util"] + policy["metric_name"]=metrics["metric_name"] + policies=[] + policies.append(policy) + desc_paas["autoscaling_policies"]=policies + body_hpa = self.create_hpa(desc_paas) + self.api_instance_v1autoscale.patch_namespaced_horizontal_pod_autoscaler(namespace="sunrise6g", + name=desc_paas["name"], + body=body_hpa) + break + break + + + def create_hpa(descriptor_service_function): + + + #V1!!!!!!! + + dict_label = {} + dict_label['name'] = descriptor_service_function["name"] + metadata = client.V1ObjectMeta(name=descriptor_service_function["name"], labels=dict_label) + + + # spec + + scale_target=client.V1CrossVersionObjectReference(api_version="apps/v1", kind="Deployment", name= descriptor_service_function["name"]) + + #todo!!!! check 0 gt an exoume kai cpu k ram auto dn tha einai auto to version! + spec=client.V1HorizontalPodAutoscalerSpec(max_replicas=descriptor_service_function["count-max"], + min_replicas=descriptor_service_function["count-min"], + target_cpu_utilization_percentage=int(descriptor_service_function["autoscaling_policies"][0]["util_percent"]), + scale_target_ref=scale_target) + body = client.V1HorizontalPodAutoscaler(api_version="autoscaling/v1", kind="HorizontalPodAutoscaler", metadata=metadata, spec=spec) + + + #V2BETA1 K8S API IMPLEMENTATION!!!! + + # dict_label = {} + # dict_label['name'] = descriptor_paas["name"] + # metadata = client.V1ObjectMeta(name=descriptor_paas["name"], labels=dict_label) + # + # # spec + # + # scale_target = client.V2beta1CrossVersionObjectReference(api_version="extensions/v1beta1", kind="Deployment", + # name=descriptor_paas["name"]) + # + # metrics=[] + # + # for metric in descriptor_paas["autoscaling_policies"]: + + # resource_=client.V2beta1ResourceMetricSource(name=metric["metric"],target_average_utilization=int(metric["util_percent"])) + # metric_=client.V2beta1MetricSpec(type="Resource", resource=resource_) + # metrics.append(metric_) + # + # + # spec = client.V2beta1HorizontalPodAutoscalerSpec(max_replicas=descriptor_paas["count-max"], + # min_replicas=descriptor_paas["count-min"], + # metrics=metrics, + # scale_target_ref=scale_target) + # body = client.V2beta1HorizontalPodAutoscaler(api_version="autoscaling/v2beta1", kind="HorizontalPodAutoscaler", + # metadata=metadata, spec=spec) + + + #V2BETA2 K8S API IMPLEMENTATION!!!! + + # dict_label = {} + # dict_label['name'] = descriptor_paas["name"] + # metadata = client.V1ObjectMeta(name=descriptor_paas["name"], labels=dict_label) + # + # # spec + # + # scale_target = client.V2beta2CrossVersionObjectReference(api_version="apps/v1", kind="Deployment", + # name=descriptor_paas["name"]) + # + # metrics = [] + # + # for metric in descriptor_paas["autoscaling_policies"]: + # + # target=client.V2beta2MetricTarget(average_utilization=int(metric["util_percent"]),type="Utilization") + # resource_ = client.V2beta2ResourceMetricSource(name=metric["metric"], + # target=target) + # metric_ = client.V2beta2MetricSpec(type="Resource", resource=resource_) + # metrics.append(metric_) + # + # spec = client.V2beta2HorizontalPodAutoscalerSpec(max_replicas=descriptor_paas["count-max"], + # min_replicas=descriptor_paas["count-min"], + # metrics=metrics, + # scale_target_ref=scale_target) + # body = client.V2beta2HorizontalPodAutoscaler(api_version="autoscaling/v2beta2", kind="HorizontalPodAutoscaler", + # metadata=metadata, spec=spec) + + return body + + def get_deployed_dataspace_connector(self, instance_name): + label_selector = {} + api_response = self.api_instance_appsv1.list_namespaced_deployment("sunrise6g") + + api_response_service = self.v1.list_namespaced_service("sunrise6g") + app_ = {} + for app in api_response.items: + metadata = app.metadata + spec = app.spec + status = app.status + + dataspace_name=instance_name+"-dataspace-connector" + + if dataspace_name==metadata.name: + + app_["dataspace_connector_name"] = metadata.name + + if app_: # if app_ is not empty + + if (status.available_replicas is not None) and (status.ready_replicas is not None): + if status.available_replicas >= 1 and status.ready_replicas >= 1: + app_["status"] = "running" + app_["replicas"] = status.ready_replicas + else: + app_["status"] = "not_running" + app_["replicas"] = 0 + else: + app_["status"] = "not_running" + app_["replicas"] = 0 + + for app_service in api_response_service.items: + + metadata_svc = app_service.metadata + + spec_svc = app_service.spec + svc_ports = [] + if metadata_svc.name == app_["dataspace_connector_name"]: + app_["internal_ip"]=spec_svc.cluster_ip + for port in spec_svc.ports: + port_ = {} + if port.node_port is not None: + + port_["exposed_port"] = port.node_port + port_["protocol"] = port.protocol + port_["application_port"] = port.port + svc_ports.append(port_) + else: + port_["protocol"] = port.protocol + port_["application_port"] = port.port + svc_ports.append(port_) + app_["ports"] = svc_ports + break + return app_ + return app_ + + + def get_deployed_service_functions(self, connector_db: ConnectorDB): + label_selector={} + deployed_hpas=self.get_deployed_hpas(connector_db) + # + + #SHOULD UNCOMMENT IT IF WE WOULD LIKE LIVE UPDATE OF A RUNNING PAAS SERVICE + # if deployed_hpas: + # check_for_update_hpas(deployed_hpas) + ########## + api_response = self.api_instance_appsv1.list_namespaced_deployment("sunrise6g") + + api_response_service= self.v1.list_namespaced_service("sunrise6g") + api_response_pvc= self.v1.list_namespaced_persistent_volume_claim("sunrise6g") + + + # + # hpa_list = api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler("sunrise6g") + # api_response_pod = v1.list_namespaced_pod("sunrise6g") + # + apps=[] + for app in api_response.items: + metadata=app.metadata + spec=app.spec + status=app.status + app_={} + apps_col = connector_db.get_documents_from_collection(collection_input="service_functions") + deployed_apps_col = connector_db.get_documents_from_collection(collection_input="deployed_service_functions") + actual_name=None + for app_col in deployed_apps_col: + if metadata.name == app_col["instance_name"]: + app_["service_function_instance_name"] =app_col["instance_name"] + actual_name =app_col["name"] + app_["appInstanceId"] = app_col["_id"] + if "monitoring_service_URL" in app_col: + app_["monitoring_service_URL"]=app_col["monitoring_service_URL"] + if "paas_name" in app_col: + app_["paas_name"] = app_col["paas_name"] + break + for app_col in apps_col: + if actual_name == app_col["name"]: + app_["service_function_catalogue_name"] =app_col["name"] + #app_["appid"] = app_col["_id"] + break + + #find volumes! + for app_col in apps_col: + if app_col.get("required_volumes") is not None: + volumes_=[] + for volume in app_col["required_volumes"]: + for item in api_response_pvc.items: + name_v=str("-")+volume["name"] + if name_v in item.metadata.name and metadata.name in item.metadata.name: + volumes_.append(item.metadata.name) + app_["volumes"] =volumes_ + break + break + if app_: #if app_ is not empty + + if (status.available_replicas is not None) and (status.ready_replicas is not None): + if status.available_replicas>=1 and status.ready_replicas>=1: + app_["status"]="running" + app_["replicas"] = status.ready_replicas + else: + app_["status"] = "not_running" + app_["replicas"] = 0 + else: + app_["status"] = "not_running" + app_["replicas"] = 0 + + + #we need to find the compute node + if spec.template.spec.node_selector is not None: # giati kati mporei na min exei node selector + if "location" in spec.template.spec.node_selector.keys(): + location=spec.template.spec.node_selector["location"] + nodes=connector_db.get_documents_from_collection(collection_input="points_of_presence") + for node in nodes: + if location==node["location"]: + app_["node_name"] = node["name"] + app_["node_id"] = node["_id"] + app_["location"]=node["location"] + break + + for app_service in api_response_service.items: + metadata_svc=app_service.metadata + spec_svc=app_service.spec + svc_ports = [] + if metadata_svc.name == app_["service_function_instance_name"]: + + for port in spec_svc.ports: + port_={} + if port.node_port is not None: + + port_["exposed_port"]=port.node_port + port_["protocol"]=port.protocol + port_["application_port"]=port.port + svc_ports.append(port_) + else: + port_["protocol"] = port.protocol + port_["application_port"] = port.port + svc_ports.append(port_) + app_["ports"]=svc_ports + break + + + apps.append(app_) + + return apps + + + def get_deployed_hpas(self, connector_db: ConnectorDB): + label_selector={} + + #APPV1 Implementation! + api_response = self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler("sunrise6g") + + hpas=[] + for hpa in api_response.items: + metadata=hpa.metadata + spec=hpa.spec + hpa_={} + + deployed_hpas_col = connector_db.get_documents_from_collection(collection_input="deployed_apps") + apps_col = connector_db.get_documents_from_collection(collection_input="paas_services") + + actual_name=None + for hpa_col in deployed_hpas_col: + if metadata.name == hpa_col["deployed_name"]: + hpa_["name"] = metadata.name + if "scaling_type" in hpa_col: + hpa_["deployed_scaling_type"] =hpa_col["scaling_type"] + + actual_name= hpa_col["name"] + break + for app_col in apps_col: + if actual_name == app_col["name"]: + hpa_["paascataloguename"] =app_col["name"] + hpa_["appid"] = app_col["_id"] + if "autoscaling_policies" in app_col: + pol = [] + for autoscaling_ in app_col["autoscaling_policies"]: + + + metric_=[] + for auto_metric in autoscaling_["monitoring_metrics"]: + hpa__={} + # if auto_metric["metric_name"]=="cpu": #TODO!! CHANGE IT FOR v1beta2 etc.....!!!!! (only cpu wokrs now) + hpa__["catalogue_util"] = auto_metric["util_percent"] + hpa__["metric_name"] = auto_metric["metric_name"] + hpa__["catalogue_limit"] = auto_metric["limit"] + hpa__["catalogue_request"] = auto_metric["request"] + metric_.append(hpa__) + #pol["monitoring_metrics"]= metric_ + + polic={} + polic["policy"]=autoscaling_["policy"] + polic["monitoring_metrics"] = metric_ + pol.append(polic) + + + hpa_["catalogue_policy"]=pol + break + + if hpa_: #if hpa_ is empty + hpa_["min"]=spec.min_replicas + hpa_["max"] = spec.max_replicas + hpa_["deployed_util"] = spec.target_cpu_utilization_percentage + hpa_["deployed_metric"] = "cpu" + + hpas.append(hpa_) + + return hpas + + + def is_job_completed(self, job_name): + job = self.api_instance_batchv1.read_namespaced_job(name=job_name, namespace="sunrise6g") + if job.status.succeeded is not None and job.status.succeeded > 0: + return True + return False + + #Create storageClass resource for a node - useless for now + def create_immediate_storageclass(self, node=None): + api_version = 'storage.k8s.io/v1' + kind = 'StorageClass' + name = 'immediate-storageclass' + provisioner = 'microk8s.io/hostpath' + reclaim_policy = 'Delete' + volume_binding_mode = 'Immediate' + + metadata = client.V1ObjectMeta(name=name) + + # match_label_expressions = client.V1TopologySelectorLabelRequirement(key='kubernetes.io/hostname', values=[node.name]) + # + # topology_selector_term = client.V1TopologySelectorTerm([match_label_expressions]) + + # storage_class = client.V1StorageClass(api_version=api_version, kind=kind, metadata=metadata, provisioner=provisioner + # , volume_binding_mode=volume_binding_mode, reclaim_policy=reclaim_policy + # , allowed_topologies=[topology_selector_term]) + + storage_class = client.V1StorageClass(api_version=api_version, kind=kind, metadata=metadata, provisioner=provisioner + , volume_binding_mode=volume_binding_mode, reclaim_policy=reclaim_policy) + + try: + api_response = self.api_instance_storagev1api.create_storage_class(body=storage_class) + except ApiException as e: + print("Exception when calling StorageV1Api->create_storage_class: %s\n" % e) + + + def immediate_storage_class_exists(self): + try: + storage_classes = self.api_instance_storagev1api.list_storage_class().items() + + for sc in storage_classes: + if sc.metadata.name == "immediate-storageclass": + return True + + return False + + except ApiException as e: + return (f"Exception when calling StorageV1Api->list_storage_class: {e}") \ No newline at end of file diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index 7799495..32dfe04 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -23,12 +23,18 @@ EDGE_CLOUD_TEST_CASES = [ } }, # Uncomment once kubernetes import issues are fixed - # { - # "edgecloud": { - # "client_name": "kubernetes", - # "base_url": "http://test-kubernetes.url" - # } - # } + { + "edgecloud": { + "client_name": "kubernetes", + "base_url": "", + # Additional parameters for K8s client: + "PLATFORM_PROVIDER": "ICOM", + "KUBERNETES_MASTER_TOKEN": "12345", + "KUBERNETES_MASTER_PORT": "16443", + "KUBERNETES_USERNAME": "user", + # 'EMP_STORAGE_URI': 'http://test.com' + } + }, ] diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index 11e7aad..d631a53 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- test_cases = [ - { - "edgecloud": { - "client_name": "i2edge", - "base_url": "http://192.168.123.48:30769/", - "flavour_id": "67f3a0b0e3184a85952e174d", - } - }, + # { + # "edgecloud": { + # "client_name": "i2edge", + # "base_url": "http://192.168.123.48:30769/", + # "flavour_id": "67f3a0b0e3184a85952e174d", + # } + # }, # { # "edgecloud": { # "client_name": "aeros", @@ -17,9 +17,16 @@ test_cases = [ # } # }, # { - # "edgecloud": { - # "client_name": "kubernetes", - # "base_url": "http://test-kubernetes.url" - # } - # } + { + "edgecloud": { + "client_name": "kubernetes", + "base_url": "http://146.124.106.200/k8s", + # Additional parameters for K8s client: + "PLATFORM_PROVIDER": "ICOM", + "KUBERNETES_MASTER_TOKEN": "T3FRNnNVK25FY3I5ZHlNYmxrSEFpd2VPcW5WTlliTnRVNVo3bitNY1B3az0K", + # "KUBERNETES_MASTER_PORT": "80", + "KUBERNETES_USERNAME": "user", + 'EMP_STORAGE_URI': 'mongodb://146.124.106.200:32411' + } + }, ] diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 727da1c..45ba4b1 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -20,7 +20,8 @@ the EdgeCloud Platform integration across different adapters. # i2Edge variables ###################### # EdgeCloud Zone -ZONE_ID = "Omega" +# ZONE_ID = "Omega" +ZONE_ID = '999b7746-d2e2-4bb4-96e6-f1e895adef0c' # Artefact ARTEFACT_ID = "i2edgechart-id-2" @@ -89,7 +90,48 @@ APP_ZONES = [ ###################### # kubernetes variables ###################### -# TODO + +K8S_ONBOARDED_APP_NAME = 'nginx' +K8S_APP_ID = '3fa85f64-5717-4562-b3fc-2c963f66afa6' +K8S_DEPLOY_PAYLOAD = { + "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name":"nginx-test", + "edgeCloudZoneId": + "zorro-solutions", + "kubernetesClusterRef": "" + } +K8S_APP_MANIFEST = { + 'appId': '3fa85f64-5717-4562-b3fc-2c963f66afa6', + "name": "nginx", + "version": "1", + "packageType": "QCOW2", + "appProvider": "Nginx Inc.", + "appRepo": { + "imagePath": "nginx", + "type": "PRIVATEREPO" + }, + "componentSpec": [ + { + "componentName": "nginx", + "networkInterfaces": [ + { + "protocol": "TCP", + "port": 80, + "interfaceId": "Uj6qThvzkegxa3L4b88", + "visibilityType": "VISIBILITY_EXTERNAL" + }, + { + "protocol": "TCP", + "port": 443, + "interfaceId": "Uj6qThvzkegxa3L4b88", + "visibilityType": "VISIBILITY_EXTERNAL" + } + ] + } + ] +} + + ###################### # aerOS variables diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index b534816..1a241fc 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -44,9 +44,12 @@ from tests.edgecloud.test_config import ( REPO_TYPE, REPO_URL, ZONE_ID, + K8S_APP_MANIFEST, + K8S_APP_ID, + K8S_ONBOARDED_APP_NAME, + K8S_DEPLOY_PAYLOAD ) - @pytest.fixture(scope="module", name="edgecloud_client") def instantiate_edgecloud_client(request): """Fixture to create and share an edgecloud client across tests""" @@ -54,7 +57,6 @@ def instantiate_edgecloud_client(request): adapters = sdkclient.create_adapters_from(adapter_specs) return adapters.get("edgecloud") - def id_func(val): return val["edgecloud"]["client_name"] @@ -65,10 +67,13 @@ def test_get_edge_cloud_zones(edgecloud_client): zones = edgecloud_client.get_edge_cloud_zones() assert isinstance(zones, list) for zone in zones: - assert "zoneId" in zone - assert "geographyDetails" in zone + assert "edgeCloudZoneId" in zone + assert "edgeCloudZoneName" in zone + assert "edgeCloudZoneStatus" in zone + assert "edgeCloudProvider" in zone + assert "edgeCloudRegion" in zone except EdgeCloudPlatformError as e: - pytest.fail(f"Failed to retrieve zones: {e}") + pytest.fail("Failed to retrieve zones: ", e) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -85,9 +90,9 @@ def test_get_edge_cloud_zones_details(edgecloud_client, zone_id=ZONE_ID): assert len(zone_details) > 0, "Zone details should not be empty" except EdgeCloudPlatformError as e: - pytest.fail(f"Failed to retrieve zone details: {e}") + pytest.fail("Failed to retrieve zone details: ",e) except KeyError as e: - pytest.fail(f"Missing expected key in response: {e}") + pytest.fail("Missing expected key in response: ",e) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -111,16 +116,24 @@ def test_create_artefact(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_onboard_app(edgecloud_client): try: - edgecloud_client.onboard_app(APP_ONBOARD_MANIFEST) + edgecloud_client.onboard_app(K8S_APP_MANIFEST) + except EdgeCloudPlatformError as e: + pytest.fail("App onboarding failed unexpectedly: ", e) + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_get_onboarded_app(edgecloud_client): + try: + app = edgecloud_client.get_onboarded_app(K8S_APP_ID) + assert app.get('name')==K8S_ONBOARDED_APP_NAME except EdgeCloudPlatformError as e: - pytest.fail(f"App onboarding failed unexpectedly: {e}") + pytest.fail("App onboarding failed unexpectedly: ", e) @pytest.fixture(scope="module") def app_instance_id(edgecloud_client): try: - output = edgecloud_client.deploy_app(APP_ID, APP_ZONES) - deployed_app = {"appInstanceId": output["deploy_name"]} + output = edgecloud_client.deploy_app(K8S_DEPLOY_PAYLOAD) + deployed_app = {"appInstanceId": output["appInstanceId"]} assert "appInstanceId" in deployed_app assert deployed_app["appInstanceId"] is not None yield deployed_app["appInstanceId"] @@ -128,29 +141,30 @@ def app_instance_id(edgecloud_client): pass -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_deploy_app(app_instance_id): - assert app_instance_id is not None +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_deploy_app(app_instance_id): +# assert app_instance_id is not None -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_30_seconds(edgecloud_client): - time.sleep(30) +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_timer_wait_30_seconds(edgecloud_client): +# time.sleep(30) -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_undeploy_app(edgecloud_client, app_instance_id): - try: - edgecloud_client.undeploy_app(app_instance_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"App undeployment failed unexpectedly: {e}") +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_undeploy_app(edgecloud_client, app_instance_id): +# try: +# edgecloud_client.undeploy_app(app_instance_id) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"App undeployment failed unexpectedly: {e}") @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_delete_onboarded_app(edgecloud_client): try: - edgecloud_client.delete_onboarded_app(app_id=APP_ONBOARD_MANIFEST["appId"]) - except EdgeCloudPlatformError as e: + result = edgecloud_client.delete_onboarded_app(app_id=K8S_APP_ID) + assert result == 200 + except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding deletion failed unexpectedly: {e}") -- GitLab From f4b46f27c9ccfe62a64cbe2f84de91f3e3f8d204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 27 Jun 2025 13:19:31 +0200 Subject: [PATCH 183/281] Fix issues in adapters factory --- .../common/adapters_factory.py | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index a5395fa..0561d02 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -18,18 +18,15 @@ from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import ( EdgeApplicationManager as kubernetesClient, ) - -# from sunrise6g_opensdk.network.adapters.oai.client import ( -# NetworkManager as OaiCoreClient, -# ) -# from sunrise6g_opensdk.network.adapters.open5gcore.client import ( -# NetworkManager as Open5GCoreClient, -# ) -# from sunrise6g_opensdk.network.adapters.open5gs.client import ( -# NetworkManager as Open5GSClient, -# ) - -# +from sunrise6g_opensdk.network.adapters.oai.client import ( + NetworkManager as OaiCoreClient, +) +from sunrise6g_opensdk.network.adapters.open5gcore.client import ( + NetworkManager as Open5GCoreClient, +) +from sunrise6g_opensdk.network.adapters.open5gs.client import ( + NetworkManager as Open5GSClient, +) def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): @@ -50,28 +47,28 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): ) -# def _network_adapters_factory(client_name: str, base_url: str, **kwargs): -# if "scs_as_id" not in kwargs: -# raise ValueError("Missing required 'scs_as_id' for network adapters.") -# scs_as_id = kwargs.pop("scs_as_id") +def _network_adapters_factory(client_name: str, base_url: str, **kwargs): + if "scs_as_id" not in kwargs: + raise ValueError("Missing required 'scs_as_id' for network adapters.") + scs_as_id = kwargs.pop("scs_as_id") -# network_factory = { -# "open5gs": lambda url, scs_id, **kw: Open5GSClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# "oai": lambda url, scs_id, **kw: OaiCoreClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# } -# try: -# return network_factory[client_name](base_url, scs_as_id, **kwargs) -# except KeyError: -# raise ValueError( -# f"Invalid network client '{client_name}'. Available: {list(network_factory)}" -# ) + network_factory = { + "open5gs": lambda url, scs_id, **kw: Open5GSClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "oai": lambda url, scs_id, **kw: OaiCoreClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( + base_url=url, scs_as_id=scs_id, **kw + ), + } + try: + return network_factory[client_name](base_url, scs_as_id, **kwargs) + except KeyError: + raise ValueError( + f"Invalid network client '{client_name}'. Available: {list(network_factory)}" + ) # def _oran_adapters_factory(client_name: str, base_url: str): @@ -81,7 +78,7 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): class AdaptersFactory: _domain_factories = { "edgecloud": _edgecloud_adapters_factory, - # "network": _network_adapters_factory, + "network": _network_adapters_factory, # "oran": _oran_adapters_factory, } -- GitLab From 9d35b1d1d298e7d852a72d369d3c47d7bfadfae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 27 Jun 2025 13:20:38 +0200 Subject: [PATCH 184/281] Update requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index f494738..fd7b171 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,6 +45,7 @@ jupyter_client==8.6.3 jupyter_core==5.8.1 jupyterlab_pygments==0.3.0 keyring==25.6.0 +kubernetes==33.1.0 kubernetes markdown-it-py==3.0.0 MarkupSafe==3.0.2 @@ -81,6 +82,7 @@ pydantic_core==2.33.1 pydub==0.25.1 pyflakes==3.2.0 Pygments==2.19.1 +pymongo==4.13.2 pymongo pyproject_hooks==1.2.0 pytest==8.3.2 -- GitLab From 8e96ff8c3ba1befd482c377bb4dbe53c7b06e70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 27 Jun 2025 13:21:33 +0200 Subject: [PATCH 185/281] Delete duplicated requirements --- requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fd7b171..5fd1737 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,6 @@ jupyter_core==5.8.1 jupyterlab_pygments==0.3.0 keyring==25.6.0 kubernetes==33.1.0 -kubernetes markdown-it-py==3.0.0 MarkupSafe==3.0.2 matplotlib-inline==0.1.7 @@ -83,7 +82,6 @@ pydub==0.25.1 pyflakes==3.2.0 Pygments==2.19.1 pymongo==4.13.2 -pymongo pyproject_hooks==1.2.0 pytest==8.3.2 pytest-cov==6.0.0 -- GitLab From 06291477f1f52cb18fdd7ab1825e5488c523e1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 27 Jun 2025 13:52:55 +0200 Subject: [PATCH 186/281] Satisfy linters --- .../adapters/kubernetes/lib/models/env_parameters.py | 8 ++------ .../kubernetes/lib/utils/kubernetes_connector.py | 12 +++--------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py index 73010e6..423d49e 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py @@ -1,11 +1,7 @@ from __future__ import absolute_import -from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import ( - util, -) -from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import ( - Model, -) +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib import util +from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.models.base_model_ import Model class EnvParameters(Model): diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index ff04f3b..addfa3d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -1,14 +1,8 @@ -from __future__ import ( - print_function, -) +from __future__ import print_function import requests -from kubernetes import ( - client, -) -from kubernetes.client.rest import ( - ApiException, -) +from kubernetes import client +from kubernetes.client.rest import ApiException from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils import ( auxiliary_functions, -- GitLab From 341ed32905dcd1f731898962fe3ea2dfbe5aca0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 27 Jun 2025 13:57:57 +0200 Subject: [PATCH 187/281] Delete --filter-files from precommit hook & CI linter tasks --- .github/workflows/ci.yaml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 692c332..e1a8cb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: run: pip install -r requirements.txt - name: isort check - run: isort src tests --check --profile black --filter-files + run: isort src tests --check --profile black - name: black check run: black src tests --check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0757ac2..eb59a2c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: hooks: - id: isort name: isort (python) - args: ["--profile", "black", "--filter-files"] + args: ["--profile", "black"] - repo: https://github.com/psf/black rev: 25.1.0 hooks: -- GitLab From 69cb6f3402e55dd3fbe9de6034c4d27badbd44c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Sat, 28 Jun 2025 14:29:57 +0200 Subject: [PATCH 188/281] Update edgecloud e2e test. Uncomment deploy step. Add temporary i2Edge zone schema assertion. --- tests/edgecloud/test_e2e.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 2e8cfb6..9f8a607 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -57,14 +57,20 @@ def test_get_edge_cloud_zones(edgecloud_client): try: zones = edgecloud_client.get_edge_cloud_zones() assert isinstance(zones, list) - for zone in zones: - assert "edgeCloudZoneId" in zone - assert "edgeCloudZoneName" in zone - assert "edgeCloudZoneStatus" in zone - assert "edgeCloudProvider" in zone - assert "edgeCloudRegion" in zone + # TODO: Harmonise zone schema to match CAMARA schemas across all clients + if edgecloud_client.client_name == "i2edge": + for zone in zones: + assert "zoneId" in zone + assert "geographyDetails" in zone + else: + for zone in zones: + assert "edgeCloudZoneId" in zone + assert "edgeCloudZoneName" in zone + assert "edgeCloudZoneStatus" in zone + assert "edgeCloudProvider" in zone + assert "edgeCloudRegion" in zone except EdgeCloudPlatformError as e: - pytest.fail("Failed to retrieve zones: ", e) + pytest.fail(f"Failed to retrieve zones: {e}") @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -132,9 +138,9 @@ def app_instance_id(edgecloud_client): pass -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_deploy_app(app_instance_id): -# assert app_instance_id is not None +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_deploy_app(app_instance_id): + assert app_instance_id is not None @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -- GitLab From 9690eb539aedc5e3d10c1e2ac8db1a9eabebc11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Sat, 28 Jun 2025 14:36:29 +0200 Subject: [PATCH 189/281] Delete misleading copyright disclaimer --- src/sunrise6g_opensdk/common/adapters_factory.py | 3 --- src/sunrise6g_opensdk/common/sdk.py | 3 --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 3 --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py | 3 --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py | 3 --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py | 3 --- src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py | 3 --- src/sunrise6g_opensdk/network/adapters/oai/client.py | 2 -- tests/edgecloud/test_e2e.py | 3 --- 9 files changed, 26 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 0561d02..228f2ed 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/common/sdk.py b/src/sunrise6g_opensdk/common/sdk.py index 0709050..f451ea1 100644 --- a/src/sunrise6g_opensdk/common/sdk.py +++ b/src/sunrise6g_opensdk/common/sdk.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 903e7e8..ea1bc65 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index f22eca0..752a951 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index c0d522f..d4be3d5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py index 4fcbe68..3a3e70b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 7dd2c6f..d9ae6ce 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index e3f3d12..b56a5d5 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright (c) 2025 Netsoft Group, EURECOM. -# All rights reserved. # # This file is part of the Open SDK # diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 9f8a607..7b5d25e 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: -- GitLab From 9db3568940602e6ccb90831f270797a84291b191 Mon Sep 17 00:00:00 2001 From: Laskaratos Dimitris Date: Mon, 30 Jun 2025 11:49:04 +0300 Subject: [PATCH 190/281] ng tttttt "Delete --filter-files from precommit hook & CI linter tasks" This reverts commit 341ed32905dcd1f731898962fe3ea2dfbe5aca0a. --- .github/workflows/ci.yaml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e1a8cb9..692c332 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: run: pip install -r requirements.txt - name: isort check - run: isort src tests --check --profile black + run: isort src tests --check --profile black --filter-files - name: black check run: black src tests --check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb59a2c..0757ac2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: hooks: - id: isort name: isort (python) - args: ["--profile", "black"] + args: ["--profile", "black", "--filter-files"] - repo: https://github.com/psf/black rev: 25.1.0 hooks: -- GitLab From 600c2a7489c90adac1362e1e330def5584fd5860 Mon Sep 17 00:00:00 2001 From: Laskaratos Dimitris Date: Mon, 30 Jun 2025 13:40:31 +0300 Subject: [PATCH 191/281] Minor fixes in K8s adapter --- .../common/adapters_factory.py | 62 +++++----- .../edgecloud/adapters/kubernetes/client.py | 9 +- .../kubernetes/lib/core/piedge_encoder.py | 2 +- .../kubernetes/lib/utils/connector_db.py | 2 +- .../lib/utils/kubernetes_connector.py | 114 +++++------------- tests/edgecloud/test_cases.py | 1 + tests/edgecloud/test_config.py | 1 + tests/edgecloud/test_e2e.py | 5 +- 8 files changed, 76 insertions(+), 120 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 228f2ed..5c700f7 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -15,15 +15,15 @@ from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import ( EdgeApplicationManager as kubernetesClient, ) -from sunrise6g_opensdk.network.adapters.oai.client import ( - NetworkManager as OaiCoreClient, -) -from sunrise6g_opensdk.network.adapters.open5gcore.client import ( - NetworkManager as Open5GCoreClient, -) -from sunrise6g_opensdk.network.adapters.open5gs.client import ( - NetworkManager as Open5GSClient, -) +# from sunrise6g_opensdk.network.adapters.oai.client import ( +# NetworkManager as OaiCoreClient, +# ) +# from sunrise6g_opensdk.network.adapters.open5gcore.client import ( +# NetworkManager as Open5GCoreClient, +# ) +# from sunrise6g_opensdk.network.adapters.open5gs.client import ( +# NetworkManager as Open5GSClient, +# ) def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): @@ -44,28 +44,28 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): ) -def _network_adapters_factory(client_name: str, base_url: str, **kwargs): - if "scs_as_id" not in kwargs: - raise ValueError("Missing required 'scs_as_id' for network adapters.") - scs_as_id = kwargs.pop("scs_as_id") +# def _network_adapters_factory(client_name: str, base_url: str, **kwargs): +# if "scs_as_id" not in kwargs: +# raise ValueError("Missing required 'scs_as_id' for network adapters.") +# scs_as_id = kwargs.pop("scs_as_id") - network_factory = { - "open5gs": lambda url, scs_id, **kw: Open5GSClient( - base_url=url, scs_as_id=scs_id, **kw - ), - "oai": lambda url, scs_id, **kw: OaiCoreClient( - base_url=url, scs_as_id=scs_id, **kw - ), - "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( - base_url=url, scs_as_id=scs_id, **kw - ), - } - try: - return network_factory[client_name](base_url, scs_as_id, **kwargs) - except KeyError: - raise ValueError( - f"Invalid network client '{client_name}'. Available: {list(network_factory)}" - ) +# network_factory = { +# "open5gs": lambda url, scs_id, **kw: Open5GSClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# "oai": lambda url, scs_id, **kw: OaiCoreClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( +# base_url=url, scs_as_id=scs_id, **kw +# ), +# } +# try: +# return network_factory[client_name](base_url, scs_as_id, **kwargs) +# except KeyError: +# raise ValueError( +# f"Invalid network client '{client_name}'. Available: {list(network_factory)}" +# ) # def _oran_adapters_factory(client_name: str, base_url: str): @@ -75,7 +75,7 @@ def _network_adapters_factory(client_name: str, base_url: str, **kwargs): class AdaptersFactory: _domain_factories = { "edgecloud": _edgecloud_adapters_factory, - "network": _network_adapters_factory, + # "network": _network_adapters_factory, # "oran": _oran_adapters_factory, } diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 3873310..8f315d1 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -36,12 +36,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): kubernetes_port = kwargs.get("KUBERNETES_MASTER_PORT") storage_uri = kwargs.get("EMP_STORAGE_URI") username = kwargs.get("KUBERNETES_USERNAME") + namespace = kwargs.get('K8S_NAMESPACE') if base_url is not None and base_url != "": self.k8s_connector = KubernetesConnector( ip=self.kubernetes_host, port=kubernetes_port, token=kubernetes_token, username=username, + namespace=namespace ) if storage_uri is not None: self.connector_db = ConnectorDB(storage_uri) @@ -126,6 +128,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): connector_db=self.connector_db, kubernetes_connector=self.k8s_connector, ) + if type(result) is V1Deployment: response = {} response["name"] = body.get("name") @@ -146,7 +149,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: Optional[str] = None, region: Optional[str] = None, ) -> List[Dict]: - logging.info("Retreiving all deployed apps in the edge cloud platform") + logging.info("Retrieving all deployed apps in the edge cloud platform") deployments = self.k8s_connector.get_deployed_service_functions( self.connector_db ) @@ -154,9 +157,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): for deployment in deployments: item = {} item["name"] = deployment.get("service_function_catalogue_name") - item["appId"] = deployment.get("id") + item["appId"] = deployment.get("appId") item["appProvider"] = deployment.get("appProvider") - item["appInstanceId"] = deployment.get("uid") + item["appInstanceId"] = deployment.get("appInstanceId") item["status"] = deployment.get("status") interfaces = [] for port in deployment.get("ports"): diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py index 2ba8953..f81d9c2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py @@ -220,7 +220,7 @@ def deploy_service_function( "Node is selected by the K8s scheduler" ) if type(response) is V1Deployment: - deployed_service_function_db["uid"] = response.metadata.uid + deployed_service_function_db["_id"] = response.metadata.uid connector_db.insert_document_deployed_service_function( document=deployed_service_function_db ) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py index 015a749..ceaa108 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py @@ -72,7 +72,7 @@ class ConnectorDB: # raise Exception("Already Registered: PaaS name", document["paas_name"]) try: insert_doc = {} - insert_doc["_id"] = document["id"] + insert_doc["_id"] = document["_id"] insert_doc["name"] = document["service_function_name"] # insert_doc["type"] = document["paas_type"] insert_doc["location"] = document["location"] diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index addfa3d..1634654 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -15,10 +15,11 @@ configuration = client.Configuration() class KubernetesConnector: - def __init__(self, ip, port, token, username): + def __init__(self, ip, port, token, username, namespace): master_node_ip = ip master_node_port = port username = username + self.namespace = 'default' if namespace is None else namespace self.token_k8s = token if port is None: self.host = master_node_ip @@ -227,16 +228,16 @@ class KubernetesConnector: def delete_service_function(self, connector_db: ConnectorDB, service_function_name): self.api_instance_appsv1.delete_namespaced_deployment( - name=service_function_name, namespace="sunrise6g" + name=service_function_name, namespace=self.namespace ) self.v1.delete_namespaced_service( - name=service_function_name, namespace="sunrise6g" + name=service_function_name, namespace=self.namespace ) hpa_list = ( self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( - "sunrise6g" + self.namespace ) ) @@ -245,18 +246,18 @@ class KubernetesConnector: for hpa in hpa_list.items: if hpa.metadata.name == service_function_name: self.api_instance_v1autoscale.delete_namespaced_horizontal_pod_autoscaler( - name=service_function_name, namespace="sunrise6g" + name=service_function_name, namespace=self.namespace ) break # deletevolume - volume_list = self.v1.list_namespaced_persistent_volume_claim("sunrise6g") + volume_list = self.v1.list_namespaced_persistent_volume_claim(self.namespace) for volume in volume_list.items: name_v = service_function_name + str("-") if name_v in volume.metadata.name: self.v1.delete_persistent_volume(name=volume.spec.volume_name) self.v1.delete_namespaced_persistent_volume_claim( - name=volume.metadata.name, namespace="sunrise6g" + name=volume.metadata.name, namespace=self.namespace ) doc = {} @@ -292,7 +293,7 @@ class KubernetesConnector: try: url = ( self.host - + "/api/v1/namespaces/sunrise6g/persistentvolumeclaims" + + "/api/v1/namespaces/"+self.namespace+"/persistentvolumeclaims" ) body_volume = self.create_pvc_dict( descriptor_service_function["name"], volume @@ -304,7 +305,7 @@ class KubernetesConnector: except requests.exceptions.HTTPError as e: # logging.error(traceback.format_exc()) return ( - "Exception when calling CoreV1Api->/api/v1/namespaces/sunrise6g/persistentvolumeclaims: %s\n" + "Exception when calling CoreV1Api->/api/v1/namespaces/"+self.namespace+"/persistentvolumeclaims: %s\n" % e ) @@ -315,16 +316,16 @@ class KubernetesConnector: try: api_response_deployment = ( self.api_instance_appsv1.create_namespaced_deployment( - "sunrise6g", body_deployment + self.namespace, body_deployment ) ) # api_response_service = api_instance_apiregv1.create_api_service(body_service) - self.v1.create_namespaced_service("sunrise6g", body_service) + self.v1.create_namespaced_service(self.namespace, body_service) if "autoscaling_policies" in descriptor_service_function: # V1 AUTOSCALER body_hpa = self.create_hpa(descriptor_service_function) self.api_instance_v1autoscale.create_namespaced_horizontal_pod_autoscaler( - "sunrise6g", body_hpa + self.namespace, body_hpa ) # V2beta1 AUTOSCALER # body_hpa = create_hpa(descriptor_paas) @@ -341,7 +342,7 @@ class KubernetesConnector: def create_deployment(self, descriptor_service_function): metadata = client.V1ObjectMeta(name=descriptor_service_function["name"]) - dict_label = {"sunrise6g": descriptor_service_function["name"]} + dict_label = {self.namespace: descriptor_service_function["name"]} selector = client.V1LabelSelector(match_labels=dict_label) metadata_spec = client.V1ObjectMeta(labels=dict_label) @@ -507,7 +508,7 @@ class KubernetesConnector: def create_service(self, descriptor_service_function): dict_label = {} - dict_label["sunrise6g"] = descriptor_service_function["name"] + dict_label[self.namespace] = descriptor_service_function["name"] metadata = client.V1ObjectMeta( name=descriptor_service_function["name"], labels=dict_label ) @@ -558,10 +559,10 @@ class KubernetesConnector: return body - def create_pvc(name, volumes): + def create_pvc(self, name, volumes): dict_label = {} name_vol = name + str("-") + volumes["name"] - dict_label["sunrise6g"] = name_vol + dict_label[self.namespace] = name_vol # metadata = client.V1ObjectMeta(name=name_vol) metadata = client.V1ObjectMeta(name=name_vol, labels=dict_label) # api_version = ("v1",) @@ -578,7 +579,7 @@ class KubernetesConnector: return body - def create_pvc_dict( + def create_pvc_dict(self, name, volumes, storage_class="microk8s-hostpath", volume_name=None ): name_vol = name + str("-") + volumes["name"] @@ -590,7 +591,7 @@ class KubernetesConnector: body = { "api_version": "v1", "kind": "PersistentVolumeClaim", - "metadata": {"labels": {"sunrise6g": name_vol}, "name": name_vol}, + "metadata": {"labels": {self.namespace: name_vol}, "name": name_vol}, "spec": { "accessModes": ["ReadWriteOnce"], "resources": {"requests": {"storage": volumes["storage"]}}, @@ -603,7 +604,7 @@ class KubernetesConnector: return body - def create_pv_dict(name, volumes, storage_class, node=None): + def create_pv_dict(self, name, volumes, storage_class, node=None): name_vol = name + "-" + volumes["name"] body = { @@ -612,7 +613,7 @@ class KubernetesConnector: "metadata": { "name": name_vol, "labels": { - "sunrise6g": name_vol, + self.namespace: name_vol, }, }, "spec": { @@ -670,7 +671,7 @@ class KubernetesConnector: desc_paas["autoscaling_policies"] = policies body_hpa = self.create_hpa(desc_paas) self.api_instance_v1autoscale.patch_namespaced_horizontal_pod_autoscaler( - namespace="sunrise6g", + namespace=self.namespace, name=desc_paas["name"], body=body_hpa, ) @@ -711,67 +712,12 @@ class KubernetesConnector: spec=spec, ) - # V2BETA1 K8S API IMPLEMENTATION!!!! - - # dict_label = {} - # dict_label['name'] = descriptor_paas["name"] - # metadata = client.V1ObjectMeta(name=descriptor_paas["name"], labels=dict_label) - # - # # spec - # - # scale_target = client.V2beta1CrossVersionObjectReference(api_version="extensions/v1beta1", kind="Deployment", - # name=descriptor_paas["name"]) - # - # metrics=[] - # - # for metric in descriptor_paas["autoscaling_policies"]: - - # resource_=client.V2beta1ResourceMetricSource(name=metric["metric"],target_average_utilization=int(metric["util_percent"])) - # metric_=client.V2beta1MetricSpec(type="Resource", resource=resource_) - # metrics.append(metric_) - # - # - # spec = client.V2beta1HorizontalPodAutoscalerSpec(max_replicas=descriptor_paas["count-max"], - # min_replicas=descriptor_paas["count-min"], - # metrics=metrics, - # scale_target_ref=scale_target) - # body = client.V2beta1HorizontalPodAutoscaler(api_version="autoscaling/v2beta1", kind="HorizontalPodAutoscaler", - # metadata=metadata, spec=spec) - - # V2BETA2 K8S API IMPLEMENTATION!!!! - - # dict_label = {} - # dict_label['name'] = descriptor_paas["name"] - # metadata = client.V1ObjectMeta(name=descriptor_paas["name"], labels=dict_label) - # - # # spec - # - # scale_target = client.V2beta2CrossVersionObjectReference(api_version="apps/v1", kind="Deployment", - # name=descriptor_paas["name"]) - # - # metrics = [] - # - # for metric in descriptor_paas["autoscaling_policies"]: - # - # target=client.V2beta2MetricTarget(average_utilization=int(metric["util_percent"]),type="Utilization") - # resource_ = client.V2beta2ResourceMetricSource(name=metric["metric"], - # target=target) - # metric_ = client.V2beta2MetricSpec(type="Resource", resource=resource_) - # metrics.append(metric_) - # - # spec = client.V2beta2HorizontalPodAutoscalerSpec(max_replicas=descriptor_paas["count-max"], - # min_replicas=descriptor_paas["count-min"], - # metrics=metrics, - # scale_target_ref=scale_target) - # body = client.V2beta2HorizontalPodAutoscaler(api_version="autoscaling/v2beta2", kind="HorizontalPodAutoscaler", - # metadata=metadata, spec=spec) - return body def get_deployed_dataspace_connector(self, instance_name): - api_response = self.api_instance_appsv1.list_namespaced_deployment("sunrise6g") + api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) - api_response_service = self.v1.list_namespaced_service("sunrise6g") + api_response_service = self.v1.list_namespaced_service(self.namespace) app_ = {} for app in api_response.items: metadata = app.metadata @@ -826,9 +772,9 @@ class KubernetesConnector: def get_deployed_service_functions(self, connector_db: ConnectorDB): self.get_deployed_hpas(connector_db) - api_response = self.api_instance_appsv1.list_namespaced_deployment("sunrise6g") - api_response_service = self.v1.list_namespaced_service("sunrise6g") - api_response_pvc = self.v1.list_namespaced_persistent_volume_claim("sunrise6g") + api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) + api_response_service = self.v1.list_namespaced_service(self.namespace) + api_response_pvc = self.v1.list_namespaced_persistent_volume_claim(self.namespace) apps_col = connector_db.get_documents_from_collection( collection_input="service_functions" @@ -871,6 +817,8 @@ class KubernetesConnector: for app_col in apps_col: if actual_name == app_col["name"]: app_["service_function_catalogue_name"] = app_col["name"] + app_['appId'] = app_col['_id'] + app_['appProvider'] = app_col.get('appProvider') break # find volumes! @@ -945,7 +893,7 @@ class KubernetesConnector: # APPV1 Implementation! api_response = ( self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( - "sunrise6g" + self.namespace ) ) @@ -1010,7 +958,7 @@ class KubernetesConnector: def is_job_completed(self, job_name): job = self.api_instance_batchv1.read_namespaced_job( - name=job_name, namespace="sunrise6g" + name=job_name, namespace=self.namespace ) if job.status.succeeded is not None and job.status.succeeded > 0: return True diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index c52f3e2..d5710da 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -27,6 +27,7 @@ test_cases = [ # "KUBERNETES_MASTER_PORT": "80", "KUBERNETES_USERNAME": "user", "EMP_STORAGE_URI": "mongodb://146.124.106.200:32411", + "K8S_NAMESPACE": "sunrise6g" } }, ] diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 444bd66..756048d 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -124,6 +124,7 @@ CONFIG = { "kubernetes": { "K8S_ONBOARDED_APP_NAME": "nginx", "K8S_APP_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + 'ZONE_ID': '999b7746-d2e2-4bb4-96e6-f1e895adef0c', "K8S_DEPLOY_PAYLOAD": { "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "nginx-test", diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 7b5d25e..80b7558 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -122,7 +122,10 @@ def test_onboard_app(edgecloud_client): def app_instance_id(edgecloud_client): config = CONFIG[edgecloud_client.client_name] try: - output = edgecloud_client.deploy_app(config["APP_ID"], config["APP_ZONES"]) + if edgecloud_client.client_name == "kubernetes": + output = edgecloud_client.deploy_app(config["K8S_DEPLOY_PAYLOAD"]) + else: + output = edgecloud_client.deploy_app(config["APP_ID"], config["APP_ZONES"]) if edgecloud_client.client_name == "i2edge": app_instance_id = output.get("deploy_name") -- GitLab From 9f0f28e46a8173370d9b6685215c4158293b7727 Mon Sep 17 00:00:00 2001 From: Laskaratos Dimitris Date: Mon, 30 Jun 2025 13:41:03 +0300 Subject: [PATCH 192/281] Minor fixes in K8s adapter --- .../common/adapters_factory.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 5c700f7..228f2ed 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -15,15 +15,15 @@ from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( from sunrise6g_opensdk.edgecloud.adapters.kubernetes.client import ( EdgeApplicationManager as kubernetesClient, ) -# from sunrise6g_opensdk.network.adapters.oai.client import ( -# NetworkManager as OaiCoreClient, -# ) -# from sunrise6g_opensdk.network.adapters.open5gcore.client import ( -# NetworkManager as Open5GCoreClient, -# ) -# from sunrise6g_opensdk.network.adapters.open5gs.client import ( -# NetworkManager as Open5GSClient, -# ) +from sunrise6g_opensdk.network.adapters.oai.client import ( + NetworkManager as OaiCoreClient, +) +from sunrise6g_opensdk.network.adapters.open5gcore.client import ( + NetworkManager as Open5GCoreClient, +) +from sunrise6g_opensdk.network.adapters.open5gs.client import ( + NetworkManager as Open5GSClient, +) def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): @@ -44,28 +44,28 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): ) -# def _network_adapters_factory(client_name: str, base_url: str, **kwargs): -# if "scs_as_id" not in kwargs: -# raise ValueError("Missing required 'scs_as_id' for network adapters.") -# scs_as_id = kwargs.pop("scs_as_id") +def _network_adapters_factory(client_name: str, base_url: str, **kwargs): + if "scs_as_id" not in kwargs: + raise ValueError("Missing required 'scs_as_id' for network adapters.") + scs_as_id = kwargs.pop("scs_as_id") -# network_factory = { -# "open5gs": lambda url, scs_id, **kw: Open5GSClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# "oai": lambda url, scs_id, **kw: OaiCoreClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( -# base_url=url, scs_as_id=scs_id, **kw -# ), -# } -# try: -# return network_factory[client_name](base_url, scs_as_id, **kwargs) -# except KeyError: -# raise ValueError( -# f"Invalid network client '{client_name}'. Available: {list(network_factory)}" -# ) + network_factory = { + "open5gs": lambda url, scs_id, **kw: Open5GSClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "oai": lambda url, scs_id, **kw: OaiCoreClient( + base_url=url, scs_as_id=scs_id, **kw + ), + "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( + base_url=url, scs_as_id=scs_id, **kw + ), + } + try: + return network_factory[client_name](base_url, scs_as_id, **kwargs) + except KeyError: + raise ValueError( + f"Invalid network client '{client_name}'. Available: {list(network_factory)}" + ) # def _oran_adapters_factory(client_name: str, base_url: str): @@ -75,7 +75,7 @@ def _edgecloud_adapters_factory(client_name: str, base_url: str, **kwargs): class AdaptersFactory: _domain_factories = { "edgecloud": _edgecloud_adapters_factory, - # "network": _network_adapters_factory, + "network": _network_adapters_factory, # "oran": _oran_adapters_factory, } -- GitLab From 8c3938ccdfcef03825a01f986efb544d83f1f5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 12:44:57 +0200 Subject: [PATCH 193/281] Satisfy linters --- .../edgecloud/adapters/kubernetes/client.py | 6 ++-- .../lib/utils/kubernetes_connector.py | 31 ++++++++++++------- tests/edgecloud/test_cases.py | 2 +- tests/edgecloud/test_config.py | 2 +- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 8f315d1..fd9a01d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -36,14 +36,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): kubernetes_port = kwargs.get("KUBERNETES_MASTER_PORT") storage_uri = kwargs.get("EMP_STORAGE_URI") username = kwargs.get("KUBERNETES_USERNAME") - namespace = kwargs.get('K8S_NAMESPACE') + namespace = kwargs.get("K8S_NAMESPACE") if base_url is not None and base_url != "": self.k8s_connector = KubernetesConnector( ip=self.kubernetes_host, port=kubernetes_port, token=kubernetes_token, username=username, - namespace=namespace + namespace=namespace, ) if storage_uri is not None: self.connector_db = ConnectorDB(storage_uri) @@ -128,7 +128,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): connector_db=self.connector_db, kubernetes_connector=self.k8s_connector, ) - + if type(result) is V1Deployment: response = {} response["name"] = body.get("name") diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index 1634654..d71b18b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -19,7 +19,7 @@ class KubernetesConnector: master_node_ip = ip master_node_port = port username = username - self.namespace = 'default' if namespace is None else namespace + self.namespace = "default" if namespace is None else namespace self.token_k8s = token if port is None: self.host = master_node_ip @@ -293,7 +293,9 @@ class KubernetesConnector: try: url = ( self.host - + "/api/v1/namespaces/"+self.namespace+"/persistentvolumeclaims" + + "/api/v1/namespaces/" + + self.namespace + + "/persistentvolumeclaims" ) body_volume = self.create_pvc_dict( descriptor_service_function["name"], volume @@ -305,8 +307,9 @@ class KubernetesConnector: except requests.exceptions.HTTPError as e: # logging.error(traceback.format_exc()) return ( - "Exception when calling CoreV1Api->/api/v1/namespaces/"+self.namespace+"/persistentvolumeclaims: %s\n" - % e + "Exception when calling CoreV1Api->/api/v1/namespaces/" + + self.namespace + + "/persistentvolumeclaims: %s\n" % e ) # api_response_pvc = api_instance_corev1api.create_namespaced_persistent_volume_claim @@ -579,8 +582,8 @@ class KubernetesConnector: return body - def create_pvc_dict(self, - name, volumes, storage_class="microk8s-hostpath", volume_name=None + def create_pvc_dict( + self, name, volumes, storage_class="microk8s-hostpath", volume_name=None ): name_vol = name + str("-") + volumes["name"] # body={} @@ -715,7 +718,9 @@ class KubernetesConnector: return body def get_deployed_dataspace_connector(self, instance_name): - api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) + api_response = self.api_instance_appsv1.list_namespaced_deployment( + self.namespace + ) api_response_service = self.v1.list_namespaced_service(self.namespace) app_ = {} @@ -772,9 +777,13 @@ class KubernetesConnector: def get_deployed_service_functions(self, connector_db: ConnectorDB): self.get_deployed_hpas(connector_db) - api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) + api_response = self.api_instance_appsv1.list_namespaced_deployment( + self.namespace + ) api_response_service = self.v1.list_namespaced_service(self.namespace) - api_response_pvc = self.v1.list_namespaced_persistent_volume_claim(self.namespace) + api_response_pvc = self.v1.list_namespaced_persistent_volume_claim( + self.namespace + ) apps_col = connector_db.get_documents_from_collection( collection_input="service_functions" @@ -817,8 +826,8 @@ class KubernetesConnector: for app_col in apps_col: if actual_name == app_col["name"]: app_["service_function_catalogue_name"] = app_col["name"] - app_['appId'] = app_col['_id'] - app_['appProvider'] = app_col.get('appProvider') + app_["appId"] = app_col["_id"] + app_["appProvider"] = app_col.get("appProvider") break # find volumes! diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index d5710da..d210f6e 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -27,7 +27,7 @@ test_cases = [ # "KUBERNETES_MASTER_PORT": "80", "KUBERNETES_USERNAME": "user", "EMP_STORAGE_URI": "mongodb://146.124.106.200:32411", - "K8S_NAMESPACE": "sunrise6g" + "K8S_NAMESPACE": "sunrise6g", } }, ] diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index 756048d..decd66d 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -124,7 +124,7 @@ CONFIG = { "kubernetes": { "K8S_ONBOARDED_APP_NAME": "nginx", "K8S_APP_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - 'ZONE_ID': '999b7746-d2e2-4bb4-96e6-f1e895adef0c', + "ZONE_ID": "999b7746-d2e2-4bb4-96e6-f1e895adef0c", "K8S_DEPLOY_PAYLOAD": { "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "nginx-test", -- GitLab From 3b569ede0d29d4ecb276d4b8e0667e64232e106a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 12:54:09 +0200 Subject: [PATCH 194/281] Anonymize test config variables --- tests/edgecloud/test_cases.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/edgecloud/test_cases.py b/tests/edgecloud/test_cases.py index d210f6e..8e51c1c 100644 --- a/tests/edgecloud/test_cases.py +++ b/tests/edgecloud/test_cases.py @@ -3,30 +3,30 @@ test_cases = [ # { # "edgecloud": { # "client_name": "i2edge", - # "base_url": "http://192.168.123.48:30769/", - # "flavour_id": "67f3a0b0e3184a85952e174d", + # "base_url": "http://X.Y.Z.T:PORT/", + # "flavour_id": "<>", # } # }, # { # "edgecloud": { # "client_name": "aeros", - # "base_url": "http://test-aeros.url", - # "aerOS_API_URL": "http://fake.api.url", - # "aerOS_ACCESS_TOKEN": "fake-access", - # "aerOS_HLO_TOKEN": "fake-hlo" + # "base_url": "http://X.Y.Z.T:PORT/", + # "aerOS_API_URL": "http://A.B.C.D:PORT", + # "aerOS_ACCESS_TOKEN": "<>", + # "aerOS_HLO_TOKEN": "<>" # } # }, # { { "edgecloud": { "client_name": "kubernetes", - "base_url": "http://146.124.106.200/k8s", + "base_url": "http://X.Y.Z.T:PORT/", # Additional parameters for K8s client: - "PLATFORM_PROVIDER": "ICOM", - "KUBERNETES_MASTER_TOKEN": "T3FRNnNVK25FY3I5ZHlNYmxrSEFpd2VPcW5WTlliTnRVNVo3bitNY1B3az0K", + "PLATFORM_PROVIDER": "<>", + "KUBERNETES_MASTER_TOKEN": "<>", # "KUBERNETES_MASTER_PORT": "80", - "KUBERNETES_USERNAME": "user", - "EMP_STORAGE_URI": "mongodb://146.124.106.200:32411", + "KUBERNETES_USERNAME": "<>", + "EMP_STORAGE_URI": "mongodb://A.B.C.D:PORT", "K8S_NAMESPACE": "sunrise6g", } }, -- GitLab From 00b26f8939552967784eea44e4b78e847864ead0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 12:55:59 +0200 Subject: [PATCH 195/281] Delete old commented line --- tests/common/test_invoke_edgecloud_clients.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/common/test_invoke_edgecloud_clients.py b/tests/common/test_invoke_edgecloud_clients.py index 1b4c28b..edb660f 100644 --- a/tests/common/test_invoke_edgecloud_clients.py +++ b/tests/common/test_invoke_edgecloud_clients.py @@ -22,7 +22,6 @@ EDGE_CLOUD_TEST_CASES = [ "aerOS_HLO_TOKEN": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJzaTcxSzNkUm11UFIxY2RhT2daNVFtbGpUVlR6U3JQM0cyYlZNdEVDeUVjIn0.eyJleHAiOjE4MTcwMzUwMTksImlhdCI6MTczMDcyMTQxOSwianRpIjoiODk2ODhlODktNTRmOS00MzFhLTliZTUtOTQ5MmMxYjE0NDZiIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5jZi1tdnAtZG9tYWluLmFlcm9zLXByb2plY3QuZXUvYXV0aC9yZWFsbXMva2V5Y2xvYWNrLW9wZW5sZGFwIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImE5ZWY0ZTFiLTg5NTgtNGZkYS1hODQ5LTJlNjdlZmY3NjkzMyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFlcm9zLXRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiOGM2MTJjMDYtYTE5MS00MjBmLTlmNTItZGU5OWZiYzJkODI3IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJDbG91ZEZlcnJvRG9tYWluIiwiZGVmYXVsdC1yb2xlcy1rZXljbG9hY2stb3BlbmxkYXAiLCJvZmZsaW5lX2FjY2VzcyIsIklvVCBzZXJ2aWNlIGRlcGxveWVyIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiI4YzYxMmMwNi1hMTkxLTQyMGYtOWY1Mi1kZTk5ZmJjMmQ4MjciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIERlcGxveWVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiaW90c2VydmljZWRlcGxveWVyMSIsImdpdmVuX25hbWUiOiJJb1Qgc2VydmljZSBkZXBsb3llciAxIiwiZmFtaWx5X25hbWUiOiJEZXBsb3llciJ9.XXM3HYVntCrSOsyJIKg-ATsqMigyQZhLMaeZtl9GqYPuISXfYl3AV0Hcs5w3n55J_-NOdlFnZdJgHlpEdB9LvxegagI4ZteoEZC72og9OdmzFV1ud4jPTrhGm7rbjCXs7bF-sGwGCKrLIs53PZPQiRcm1KxfN4RhBy3sL0Ff79QHkgvTbag-DQMrh5Y_NrTifrMrZ0i8JZD8AsRrHoi5zs7N2PXQ0zNv3n1dxxlWBKd46cWh3kutqNgTNV-s7YTde1FCSthKMcQLxe284qdFWAYlctzU5y4zLe-3VPxU7fH16jD7yAazTYdGVy4U0B5fPn_087ABjEf0oZmt40nuug", } }, - # Uncomment once kubernetes import issues are fixed { "edgecloud": { "client_name": "kubernetes", -- GitLab From 2fb33840a3f9ba264d187721ea3c8f3b6ed4be33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:10:31 +0200 Subject: [PATCH 196/281] Fix typo in example --- examples/example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example.py b/examples/example.py index 18cc32c..21dbc18 100644 --- a/examples/example.py +++ b/examples/example.py @@ -30,7 +30,7 @@ def main(): # print(zones) # Network - # print("Testing network client function: EXAMPLE_FUNCTION:") + # print("Testing network client function: 'get_qod_session'") # network_client.get_qod_session(session_id="example_session_id") -- GitLab From a6dbccb0f3f64c88dfed52bdc5b287e6ec623ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:10:48 +0200 Subject: [PATCH 197/281] Delete copyright disclaimer --- src/sunrise6g_opensdk/logger.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/sunrise6g_opensdk/logger.py b/src/sunrise6g_opensdk/logger.py index 14c3f6b..c98117c 100644 --- a/src/sunrise6g_opensdk/logger.py +++ b/src/sunrise6g_opensdk/logger.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- ## -# Copyright 2025-present by Software Networks Area, i2CAT. -# All rights reserved. -# # This file is part of the Open SDK # # Contributors: -- GitLab From 37647e2d41ac0fecac20b62418222abdbd3a743d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:14:19 +0200 Subject: [PATCH 198/281] Add decorator "requires_capability" to check supported functs per core --- .../network/adapters/open5gcore/client.py | 37 ++++--------------- .../network/adapters/open5gs/client.py | 16 +------- .../network/core/base_network_client.py | 24 +++++++++++- src/sunrise6g_opensdk/network/core/common.py | 35 ++++++++++++++++-- 4 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index 4dd5da7..c0b8a2a 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -20,6 +20,13 @@ qos_support_map = { class NetworkManager(BaseNetworkClient): + """ + This client implements the BaseNetworkClient and translates the + CAMARA APIs into specific HTTP requests understandable by the Open5GCore NEF API. + """ + + capabilities = {"qod"} + def __init__(self, base_url: str, scs_as_id: str): if not base_url: raise ValueError("base_url is required and cannot be empty.") @@ -47,33 +54,3 @@ class NetworkManager(BaseNetworkClient): flow_id = qos_support_map[session_info.qosProfile.root] subscription.flowInfo = build_flows(flow_id, session_info) subscription.ueIpv4Addr = "192.168.6.1" # ToDo - - def add_core_specific_ti_parameters( - self, - traffic_influence_info: schemas.CreateTrafficInfluence, - subscription: schemas.TrafficInfluSub, - ): - raise NotImplementedError( - "add_core_specific_ti_parameters not implemented for Open5GCore" - ) - - def core_specific_traffic_influence_validation( - self, traffic_influence_info: schemas.CreateTrafficInfluence - ) -> None: - raise NotImplementedError( - "core_specific_traffic_influence_validation not implemented for Open5GCore" - ) - - def core_specific_monitoring_event_validation( - self, retrieve_location_request: schemas.RetrievalLocationRequest - ) -> None: - raise NotImplementedError( - "core_specific_monitoring_event_validation not implemented for Open5GCore" - ) - - def add_core_specific_location_parameters( - self, retrieve_location_request: schemas.RetrievalLocationRequest - ) -> schemas.MonitoringEventSubscriptionRequest: - raise NotImplementedError( - "add_core_specific_location_parameters not implemented for Open5GCore" - ) diff --git a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py index 2f2c7a0..d356960 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py @@ -23,14 +23,10 @@ class NetworkManager(BaseNetworkClient): """ This client implements the BaseNetworkClient and translates the CAMARA APIs into specific HTTP requests understandable by the Open5GS NEF API. - - Invloved partners and their roles in this implementation: - - I2CAT: Responsible for the CAMARA QoD API and its mapping to the - 3GPP AsSessionWithQoS API exposed by Open5GS NEF. - - NCSRD: Responsible for the CAMARA Location API and its mapping to the - 3GPP Monitoring Event API exposed Open5GS NEF. """ + capabilities = {"qod", "location_retrieval"} + def __init__(self, base_url: str, scs_as_id): """ Initializes the Open5GS Client. @@ -86,11 +82,3 @@ class NetworkManager(BaseNetworkClient): # locationType = schemas.LocationType.CURRENT_LOCATION # maximumNumberOfReports = 1 # repPeriod = schemas.DurationSec(root=20) - - -# Note: -# As this class is inheriting from BaseNetworkClient, it is -# expected to implement all the abstract methods defined in that interface. -# -# In case this network adapter doesn't support a specific method, it should -# be marked as NotImplementedError. diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index edb02ac..82018db 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -17,6 +17,7 @@ from typing import Dict from sunrise6g_opensdk import logger from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError from sunrise6g_opensdk.network.core import common, schemas +from sunrise6g_opensdk.network.core.common import requires_capability log = logger.get_logger(__name__) @@ -85,6 +86,7 @@ class BaseNetworkClient: base_url: str scs_as_id: str + @requires_capability("qod") def add_core_specific_qod_parameters( self, session_info: schemas.CreateSession, @@ -96,6 +98,7 @@ class BaseNetworkClient: """ pass + @requires_capability("traffic_influence") def add_core_specific_ti_parameters( self, traffic_influence_info: schemas.CreateTrafficInfluence, @@ -107,6 +110,7 @@ class BaseNetworkClient: """ pass + @requires_capability("location_retrieval") def add_core_specific_location_parameters( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> schemas.MonitoringEventSubscriptionRequest: @@ -116,6 +120,7 @@ class BaseNetworkClient: """ pass + @requires_capability("qod") def core_specific_qod_validation(self, session_info: schemas.CreateSession) -> None: """ Validates core-specific parameters for the session creation. @@ -130,6 +135,7 @@ class BaseNetworkClient: # This method should be overridden by subclasses if needed pass + @requires_capability("traffic_influence") def core_specific_traffic_influence_validation( self, traffic_influence_info: schemas.CreateTrafficInfluence ) -> None: @@ -146,6 +152,7 @@ class BaseNetworkClient: # This method should be overridden by subclasses if needed pass + @requires_capability("location_retrieval") def core_specific_monitoring_event_validation( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> None: @@ -162,6 +169,7 @@ class BaseNetworkClient: # This method should be overwritten by subclasses if needed pass + @requires_capability("qod") def _build_qod_subscription( self, session_info: Dict ) -> schemas.AsSessionWithQoSSubscription: @@ -181,6 +189,7 @@ class BaseNetworkClient: self.add_core_specific_qod_parameters(valid_session_info, subscription) return subscription + @requires_capability("traffic_influence") def _build_ti_subscription(self, traffic_influence_info: Dict): traffic_influence_data = schemas.CreateTrafficInfluence.model_validate( traffic_influence_info @@ -208,6 +217,7 @@ class BaseNetworkClient: self.add_core_specific_ti_parameters(traffic_influence_data, subscription) return subscription + @requires_capability("traffic_influence") def _build_camara_ti(self, trafficInflSub: Dict): traffic_influence_data = schemas.TrafficInfluSub.model_validate(trafficInflSub) @@ -229,6 +239,7 @@ class BaseNetworkClient: ) return camara_ti + @requires_capability("location_retrieval") def _build_monitoring_event_subscription( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> schemas.MonitoringEventSubscriptionRequest: @@ -245,6 +256,7 @@ class BaseNetworkClient: return subscription_3gpp + @requires_capability("location_retrieval") def _compute_camara_last_location_time( self, event_time: datetime, age_of_location_info_min: int = None ) -> datetime: @@ -266,6 +278,7 @@ class BaseNetworkClient: else: return event_time.replace(tzinfo=timezone.utc) + @requires_capability("location_retrieval") def create_monitoring_event_subscription( self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> schemas.Location: @@ -321,6 +334,7 @@ class BaseNetworkClient: return camara_location + @requires_capability("qod") def create_qod_session(self, session_info: Dict) -> Dict: """ Creates a QoS session based on CAMARA QoD API input. @@ -347,6 +361,7 @@ class BaseNetworkClient: ) return session_info.model_dump() + @requires_capability("qod") def get_qod_session(self, session_id: str) -> Dict: """ Retrieves details of a specific Quality on Demand (QoS) session. @@ -380,6 +395,7 @@ class BaseNetworkClient: ) return session_info.model_dump() + @requires_capability("qod") def delete_qod_session(self, session_id: str) -> None: """ Deletes a specific Quality on Demand (QoS) session. @@ -395,6 +411,7 @@ class BaseNetworkClient: ) log.info(f"QoD session deleted successfully [id={session_id}]") + @requires_capability("traffic_influence") def create_traffic_influence_resource(self, traffic_influence_info: Dict) -> Dict: """ Creates a Traffic Influence resource based on CAMARA TI API input. @@ -421,6 +438,7 @@ class BaseNetworkClient: traffic_influence_info["trafficInfluenceID"] = subscription_id return traffic_influence_info + @requires_capability("traffic_influence") def put_traffic_influence_resource( self, resource_id: str, traffic_influence_info: Dict ) -> Dict: @@ -441,6 +459,7 @@ class BaseNetworkClient: traffic_influence_info["trafficInfluenceID"] = resource_id return traffic_influence_info + @requires_capability("traffic_influence") def delete_traffic_influence_resource(self, resource_id: str) -> None: """ Deletes a specific Traffic Influence resource. @@ -454,6 +473,7 @@ class BaseNetworkClient: common.traffic_influence_delete(self.base_url, self.scs_as_id, resource_id) return + @requires_capability("traffic_influence") def get_individual_traffic_influence_resource(self, resource_id: str) -> Dict: nef_response = common.traffic_influence_get( self.base_url, self.scs_as_id, resource_id @@ -461,9 +481,9 @@ class BaseNetworkClient: camara_ti = self._build_camara_ti(nef_response) return camara_ti + @requires_capability("traffic_influence") def get_all_traffic_influence_resource(self) -> list[Dict]: r = common.traffic_influence_get(self.base_url, self.scs_as_id) return [self._build_camara_ti(item) for item in r] - -# Placeholder for other CAMARA APIs + # Placeholder for additional CAMARA APIs diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index 675120e..7193f25 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -30,6 +30,37 @@ def _make_request(method: str, url: str, data=None): raise CoreHttpError("connection error") from e +class CapabilityNotSupported(Exception): + """Raised when a requested capability is not supported by the core.""" + + pass + + +def requires_capability(feature: str): + def decorator(func): + def wrapper(self, *args, **kwargs): + if feature not in self.capabilities: + # Client name is derived from the module + module_path = self.__module__.split(".") + try: + client_name = module_path[module_path.index("adapters") + 1] + except (ValueError, IndexError): + client_name = self.__class__.__name__ + + raise CapabilityNotSupported( + f"Functionality '{feature}' is nos supported by {client_name}" + ) + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +class CoreHttpError(Exception): + pass + + # Monitoring Event Methods def monitoring_event_post( base_url: str, scs_as_id: str, model_payload: BaseModel @@ -116,7 +147,3 @@ def traffic_influence_build_url(base_url: str, scs_as_id: str, session_id: str = return f"{url}/{session_id}" else: return url - - -class CoreHttpError(Exception): - pass -- GitLab From 8ed583d479903f55c711d6128d15948d11828687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:15:04 +0200 Subject: [PATCH 199/281] Add capabilities definition in OAI to be able to use the decorator --- .../network/adapters/oai/client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index b56a5d5..5f6d0d9 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -25,13 +25,14 @@ supportedQos = ["qos-e", "qos-s", "qos-m", "qos-l"] class NetworkManager(BaseNetworkClient): + """ + This client implements the BaseNetworkClient and translates the + CAMARA APIs into specific HTTP requests understandable by the OAI NEF API. + """ + + capabilities = {"qod", "traffic_influence"} + def __init__(self, base_url: str, scs_as_id: str = None): - """ - Initialize Network Client for OAI Core Network - The currently supported features are: - - QoD - - Traffic Influence - """ try: super().__init__() self.base_url = base_url -- GitLab From f0c67db0c3f7a01864ca9bac6b451d249e3096f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:16:18 +0200 Subject: [PATCH 200/281] Delete supported functions definition - no longer needed --- .../network/adapters/oai/client.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index 5f6d0d9..9f0f435 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -14,8 +14,6 @@ from sunrise6g_opensdk.network.core.schemas import ( CreateSession, CreateTrafficInfluence, FlowInfo, - MonitoringEventSubscriptionRequest, - RetrievalLocationRequest, Snssai, TrafficInfluSub, ) @@ -114,20 +112,6 @@ class NetworkManager(BaseNetworkClient): "OAI requires UE IPv4 Address to activate Traffic Influence" ) - def core_specific_monitoring_event_validation( - self, retrieve_location_request: RetrievalLocationRequest - ) -> None: - raise NotImplementedError( - "core_specific_monitoring_event_validation not implemented for OAI" - ) - - def add_core_specific_location_parameters( - self, retrieve_location_request: RetrievalLocationRequest - ) -> MonitoringEventSubscriptionRequest: - raise NotImplementedError( - "add_core_specific_location_parameters not implemented for OAI" - ) - def _retrieve_ue_ipv4(session_info: CreateSession): return session_info.device.ipv4Address.root.privateAddress -- GitLab From d5dd5dede55dfd2e2badfa8a34da802274ad6cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:17:08 +0200 Subject: [PATCH 201/281] Harmonize the file description in all network clients --- src/sunrise6g_opensdk/network/adapters/oai/client.py | 1 - .../network/adapters/open5gcore/client.py | 7 +++++++ src/sunrise6g_opensdk/network/adapters/open5gs/client.py | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index 9f0f435..32cefcf 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- ## # diff --git a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py index c0b8a2a..8068081 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gcore/client.py @@ -1,4 +1,11 @@ # -*- coding: utf-8 -*- +## +# +# This file is part of the Open SDK +# +# Contributors: +# - Manar Zaboub (manar.zaboub@fokus.fraunhofer.de) +## from pydantic import ValidationError from sunrise6g_opensdk import logger diff --git a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py index d356960..f6040f2 100644 --- a/src/sunrise6g_opensdk/network/adapters/open5gs/client.py +++ b/src/sunrise6g_opensdk/network/adapters/open5gs/client.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- - +## +# +# This file is part of the Open SDK +# # Contributors: # - Ferran Cañellas (ferran.canellas@i2cat.net) # - Panagiotis Pavlidis (p.pavlidis@iit.demokritos.gr) -- GitLab From d298ed9cabf45b8bf821d31fb449170ca23a999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 30 Jun 2025 13:33:56 +0200 Subject: [PATCH 202/281] Update pyproject version from 0.9.9 to 1.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f1b339e..f216fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "0.9.9" +version = "1.0.1" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", -- GitLab From ebd6326e1184f8d57ae6618eb5c85398c838ec68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 1 Jul 2025 11:40:25 +0200 Subject: [PATCH 203/281] Hot fix kubernetes connector. Add TODO to UID --- .../edgecloud/adapters/kubernetes/client.py | 1 + .../lib/utils/kubernetes_connector.py | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index fd9a01d..e7ec478 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -215,6 +215,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): nodes = self.k8s_connector.get_node_details() node_details = None for item in nodes.get("items"): + # TODO: Fix uid stuff if item.get("metadata").get("uid") == zone_id: node_details = item break diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index d71b18b..2ed3709 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -1,5 +1,7 @@ from __future__ import print_function +from urllib.parse import urlparse + import requests from kubernetes import client from kubernetes.client.rest import ApiException @@ -16,15 +18,16 @@ configuration = client.Configuration() class KubernetesConnector: def __init__(self, ip, port, token, username, namespace): - master_node_ip = ip - master_node_port = port - username = username - self.namespace = "default" if namespace is None else namespace + parsed_url = urlparse(ip) # ip can be full URL or just IP + + scheme = parsed_url.scheme or "https" + host = parsed_url.hostname or ip + port = port or parsed_url.port or "6443" + + self.host = f"{scheme}://{host}:{port}" + self.namespace = namespace if namespace else "default" self.token_k8s = token - if port is None: - self.host = master_node_ip - else: - self.host = "https://" + master_node_ip + ":" + master_node_port + configuration.api_key["authorization"] = self.token_k8s configuration.api_key_prefix["authorization"] = "Bearer" -- GitLab From 1959b1e845eae59a8d95610ba8934e9c035f26ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 1 Jul 2025 14:34:41 +0200 Subject: [PATCH 204/281] Surpress unverified HTTPs warning in Kubernetes adapter --- .../adapters/kubernetes/lib/utils/kubernetes_connector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index 2ed3709..4d778f5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -3,6 +3,7 @@ from __future__ import print_function from urllib.parse import urlparse import requests +import urllib3 from kubernetes import client from kubernetes.client.rest import ApiException @@ -13,6 +14,8 @@ from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.utils.connector_db impo ConnectorDB, ) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + configuration = client.Configuration() -- GitLab From 17a846dbbec487709aa48ea4ceeab5f2ee8d90c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferran=20Ca=C3=B1ellas?= Date: Tue, 1 Jul 2025 14:45:59 +0200 Subject: [PATCH 205/281] More QoD fixes --- .../network/core/base_network_client.py | 5 +++-- src/sunrise6g_opensdk/network/core/schemas.py | 1 + tests/network/test_cases.py | 2 +- tests/network/test_create_qod_session.py | 13 ++++++++----- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index 82018db..7d45257 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -380,8 +380,8 @@ class BaseNetworkClient: serverIp = flowDesc.split("to ")[1].split("/")[0] session_info = schemas.SessionInfo( sessionId=schemas.SessionId(uuid.UUID(subscription_info.subscription_id)), - duration=subscription_info.usageThreshold.duration, - sink=subscription_info.notificationDestination, + duration=subscription_info.usageThreshold.duration.root, + sink=subscription_info.notificationDestination.root, qosProfile=subscription_info.qosReference, device=schemas.Device( ipv4Address=schemas.DeviceIpv4Addr1( @@ -392,6 +392,7 @@ class BaseNetworkClient: applicationServer=schemas.ApplicationServer( ipv4Address=schemas.ApplicationServerIpv4Address(serverIp) ), + qosStatus=schemas.QosStatus.AVAILABLE, ) return session_info.model_dump() diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 0832f18..630faa9 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -185,6 +185,7 @@ class AsSessionWithQoSSubscription(BaseModel): if not subscription_id: log.error("Failed to retrieve QoS session ID from response") raise NetworkPlatformError("QoS session ID not found in response") + return subscription_id class SourceTrafficFilters(BaseModel): diff --git a/tests/network/test_cases.py b/tests/network/test_cases.py index e971e21..a62c786 100644 --- a/tests/network/test_cases.py +++ b/tests/network/test_cases.py @@ -10,7 +10,7 @@ test_cases = [ { "network": { "client_name": "open5gs", - "base_url": "http://192.168.124.233:8082/", + "base_url": "http://192.168.124.233:8002/", "scs_as_id": "scs", } }, diff --git a/tests/network/test_create_qod_session.py b/tests/network/test_create_qod_session.py index 58c0e0b..c1377d5 100644 --- a/tests/network/test_create_qod_session.py +++ b/tests/network/test_create_qod_session.py @@ -31,7 +31,10 @@ def test_valid_input_open5gs(network_client: BaseNetworkClient): camara_session = { "duration": 3600, "device": { - "ipv4Address": {"publicAddress": "10.45.0.3", "privateAddress": "10.45.0.3"} + "ipv4Address": { + "publicAddress": "10.45.0.10", + "privateAddress": "10.45.0.10", + } }, "applicationServer": {"ipv4Address": "10.45.0.1"}, "devicePorts": {"ranges": [{"from": 0, "to": 65535}]}, @@ -48,8 +51,8 @@ def qod_session_id(network_client: BaseNetworkClient): "duration": 3600, "device": { "ipv4Address": { - "publicAddress": "10.45.0.3", - "privateAddress": "10.45.0.3", + "publicAddress": "10.45.0.10", + "privateAddress": "10.45.0.10", } }, "applicationServer": {"ipv4Address": "10.45.0.1"}, @@ -79,8 +82,8 @@ def test_create_qod_session(qod_session_id): @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_5_seconds(network_client): - time.sleep(5) +def test_timer_wait_60_seconds(network_client): + time.sleep(60) @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -- GitLab From 092e659501c600742126eafb8c26cc2f6f2d2f78 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Tue, 15 Jul 2025 13:39:07 +0200 Subject: [PATCH 206/281] feature/add-edgecloud-gsma-i2edge: add gsma methods --- .../edgecloud/adapters/i2edge/client.py | 165 +++++++++++++++++- .../edgecloud/adapters/i2edge/common.py | 8 +- .../edgecloud/core/edgecloud_interface.py | 149 ++++++++++++++++ 3 files changed, 314 insertions(+), 8 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index ea1bc65..c4ebcbe 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -6,8 +6,11 @@ # - Adrián Pino Martínez (adrian.pino@i2cat.net) # - Sergio Giménez (sergio.gimenez@i2cat.net) ## +from copy import deepcopy from typing import Dict, List, Optional +from requests import Response + from sunrise6g_opensdk import logger from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, @@ -82,8 +85,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): repo_user_name=user_name, ) try: - i2edge_post_multiform_data(url, payload) + response = i2edge_post_multiform_data(url, payload) log.info("Artifact added successfully") + return response except I2EdgeError as e: raise e @@ -108,8 +112,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def _delete_artefact(self, artefact_id: str): url = "{}/artefact".format(self.base_url) try: - i2edge_delete(url, artefact_id) + response = i2edge_delete(url, artefact_id) log.info("Artifact deleted successfully") + return response except I2EdgeError as e: raise e @@ -124,7 +129,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) payload = schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) - i2edge_post(url, payload) + response = i2edge_post(url, payload) + return response except I2EdgeError as e: raise e except KeyError as e: @@ -133,7 +139,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def delete_onboarded_app(self, app_id: str) -> None: url = "{}/application/onboarding".format(self.base_url) try: - i2edge_delete(url, app_id) + response = i2edge_delete(url, app_id) + return response except I2EdgeError as e: raise e @@ -238,3 +245,153 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.info("App instance deleted successfully") except I2EdgeError as e: raise e + + # GSMA FM + + # AvailabilityZoneInfoSynchronization + + def get_edge_cloud_zone_details_gsma( + self, federation_context_id: str, zone_id: str + ) -> Dict: + url = "{}/zone/{}".format(self.base_url, zone_id) + params = {} + try: + response = i2edge_get(url, params=params) + log.info("Zone metadata retrieved successfully") + return response + except I2EdgeError as e: + raise e + + # ArtefactManagement + + def create_artefact_gsma( + self, federation_context_id: str, request_body: Dict + ) -> Dict: + try: + artefact_id = request_body["artefactId"] + artefact_name = request_body["artefactName"] + repo_data = request_body["artefactRepoLocation"] + + transformed = { + "artefact_id": artefact_id, + "artefact_name": artefact_name, + "repo_name": repo_data.get("repoName", "unknown-repo"), + "repo_type": request_body.get("repoType", "PUBLICREPO"), + "repo_url": repo_data["repoURL"], + "user_name": repo_data.get("userName"), + "password": repo_data.get("password"), + "token": repo_data.get("token"), + } + + response = self._create_artefact(**transformed) + # if response.status_code == 201: + # gsma_format_response = Response() + # gsma_format_response.status_code = 200 + # gsma_format_response._content = b'{"response": "Artefact uploaded successfully"}' + # gsma_format_response.headers["Content-Type"] = "application/json" + # gsma_format_response.encoding = "utf-8" + # gsma_format_response.url = response.url + # gsma_format_response.request = response.request + # return gsma_format_response + return response + except KeyError as e: + raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") + + def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + try: + response = self._get_artefact(artefact_id) + return response + except KeyError as e: + raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") + + def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): + try: + response = self._delete_artefact(artefact_id) + return response + except KeyError as e: + raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") + + # ApplicationOnboardingManagement + + def onboard_app_gsma( + self, federation_context_id: str, request_body: dict + ) -> Response: + body = deepcopy(request_body) + try: + body["app_id"] = body.pop("appId") + body.pop("edgeAppFQDN", None) + data = body + payload = schemas.ApplicationOnboardingRequest(profile_data=data) + url = "{}/application/onboarding".format(self.base_url) + response = i2edge_post(url, payload) + return response + except KeyError as e: + raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") + + def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + try: + response = self.get_onboarded_app(app_id) + return response + except KeyError as e: + raise I2EdgeError(f"Missing appId in GSMA payload: {e}") + + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + try: + response = self.delete_onboarded_app(app_id) + return response + except KeyError as e: + raise I2EdgeError(f"Missing appId in GSMA payload: {e}") + + # ApplicationDeploymentManagement + + def deploy_app_gsma( + self, federation_context_id: str, idempotency_key: str, request_body: dict + ): + body = deepcopy(request_body) + try: + zone_id = body.get("zoneInfo").get("zoneId") + app_deploy_data = schemas.AppDeployData( + appId=body.get("appId"), + appProviderId=body.get("appProviderId"), + appVersion=body.get("appVersion"), + zoneInfo=schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), + ) + payload = schemas.AppDeploy( + app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} + ) + print(payload) + url = "{}/application_instance".format(self.base_url) + response = i2edge_post(url, payload) + return response + except KeyError as e: + raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") + + def get_deployed_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + try: + url = "{}/application_instance/{}/{}".format( + self.base_url, zone_id, app_instance_id + ) + response = i2edge_get(url) + return response + except KeyError as e: + raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") + + def undeploy_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + try: + url = "{}/application_instance".format(self.base_url) + response = i2edge_delete(url, app_instance_id) + return response + except KeyError as e: + raise I2EdgeError(f"Missing appInstanceId in GSMA payload: {e}") diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 752a951..b023b5c 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -45,7 +45,7 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: try: response = requests.post(url, data=json_payload, headers=headers) response.raise_for_status() - return response.json() + return response except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) @@ -62,7 +62,7 @@ def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: try: response = requests.post(url, data=payload_in_str, headers=headers) response.raise_for_status() - return response.json() + return response except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) @@ -76,7 +76,7 @@ def i2edge_delete(url: str, id: str) -> dict: query = "{}/{}".format(url, id) response = requests.delete(query, headers=headers) response.raise_for_status() - return response.json() + return response except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to undeploy app: {}. Detail: {}".format(i2edge_err_msg, e) @@ -89,7 +89,7 @@ def i2edge_get(url: str, params: Optional[dict]): try: response = requests.get(url, params=params, headers=headers) response.raise_for_status() - return response.json() + return response except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to get apps: {}. Detail: {}".format(i2edge_err_msg, e) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index d9ae6ce..00dd95e 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -118,3 +118,152 @@ class EdgeCloudManagementInterface(ABC): :return: Dictionary with Edge Cloud Zone details. """ pass + + # --- GSMA-specific methods --- + + # AvailabilityZoneInfoSynchronization + + @abstractmethod + def get_edge_cloud_zone_details_gsma( + self, federation_context_id: str, zone_id: str + ) -> Dict: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP. + + :param federation_context_id: Identifier of the federation context. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with Edge Cloud Zone details. + """ + pass + + # ArtefactManagement + + @abstractmethod + def create_artefact_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Uploads application artefact on partner OP. Artefact is a zip file + containing scripts and/or packaging files like Terraform or Helm + which are required to create an instance of an application + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with artefact information. + :return: Details with artefact deployment info. + """ + pass + + @abstractmethod + def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + """ + Retrieves details about an artefact + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact details. + """ + pass + + @abstractmethod + def delete_artefact_gsma( + self, federation_context_id: str, artefact_id: str + ) -> Dict: + """ + Removes an artefact from partners OP. + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact deletion details. + """ + pass + + # ApplicationOnboardingManagement + + @abstractmethod + def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + """ + Submits an application details to a partner OP. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with onboarding info. + :return: Dictionary with onboarding details. + """ + pass + + @abstractmethod + def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + """ + Retrieves application details from partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + @abstractmethod + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + """ + Deboards an application from specific partner OP zones + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + # ApplicationDeploymentManagement + + @abstractmethod + def deploy_app_gsma( + self, federation_context_id: str, idempotency_key: str, request_body: dict + ): + """ + Instantiates an application on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param idempotency_key: Idempotency key. + :return: + """ + pass + + @abstractmethod + def get_deployed_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Retrieves an application instance details from partner OP. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass + + @abstractmethod + def undeploy_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Terminate an application instance on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass -- GitLab From 560f70534a4fc3ae978b9df2f3583ce0344b6020 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Tue, 15 Jul 2025 18:00:45 +0200 Subject: [PATCH 207/281] feature/add-edgecloud-gsma-i2edge: add get all deployed apps method, patch method structure --- .../edgecloud/adapters/i2edge/client.py | 35 +++++++++++++-- .../edgecloud/adapters/i2edge/common.py | 17 ++++++++ .../edgecloud/core/edgecloud_interface.py | 43 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index c4ebcbe..be39857 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -248,6 +248,17 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # GSMA FM + # FederationManagement + + def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> Dict: + url = "{}/zones".format(self.base_url) + params = {} + try: + response = i2edge_get(url, params=params) + return response + except I2EdgeError as e: + raise e + # AvailabilityZoneInfoSynchronization def get_edge_cloud_zone_details_gsma( @@ -257,7 +268,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): params = {} try: response = i2edge_get(url, params=params) - log.info("Zone metadata retrieved successfully") return response except I2EdgeError as e: raise e @@ -335,6 +345,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") + def patch_onboarded_app_gsma( + self, federation_context_id: str, app_id: str, request_body: dict + ) -> Dict: + pass + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): try: response = self.delete_onboarded_app(app_id) @@ -359,7 +374,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): payload = schemas.AppDeploy( app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} ) - print(payload) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) return response @@ -377,11 +391,26 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id ) - response = i2edge_get(url) + params = {} + response = i2edge_get(url, params=params) return response except KeyError as e: raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") + def get_all_deployed_apps_gsma( + self, + federation_context_id: str, + app_id: str, + app_provider: str, + ): + try: + url = "{}/application_instances".format(self.base_url) + params = {} + response = i2edge_get(url, params=params) + return response + except KeyError as e: + raise I2EdgeError(f"Error retrieving apps: {e}") + def undeploy_app_gsma( self, federation_context_id: str, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index b023b5c..12b6508 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -53,6 +53,23 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: raise I2EdgeError(err_msg) +def i2edge_put(url: str, model_payload: BaseModel) -> dict: + headers = { + "Content-Type": "application/json", + "accept": "application/json", + } + json_payload = json.dumps(model_payload.model_dump(mode="json")) + try: + response = requests.put(url, data=json_payload, headers=headers) + response.raise_for_status() + return response + except requests.exceptions.HTTPError as e: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to patch: {}. Detail: {}".format(i2edge_err_msg, e) + log.error(err_msg) + raise I2EdgeError(err_msg) + + def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: headers = { "accept": "application/json", diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 00dd95e..7ae7ede 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -121,6 +121,18 @@ class EdgeCloudManagementInterface(ABC): # --- GSMA-specific methods --- + # FederationManagement + + @abstractmethod + def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: + """ + Retrieves details of Zones + + :param federation_context_id: Identifier of the federation context. + :return: List. + """ + pass + # AvailabilityZoneInfoSynchronization @abstractmethod @@ -204,6 +216,20 @@ class EdgeCloudManagementInterface(ABC): """ pass + @abstractmethod + def patch_onboarded_app_gsma( + self, federation_context_id: str, app_id: str, request_body: dict + ) -> Dict: + """ + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + @abstractmethod def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): """ @@ -249,6 +275,23 @@ class EdgeCloudManagementInterface(ABC): """ pass + @abstractmethod + def get_all_deployed_apps_gsma( + self, + federation_context_id: str, + app_id: str, + app_provider: str, + ): + """ + Retrieves all application instance of partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_provider: App provider + :return: + """ + pass + @abstractmethod def undeploy_app_gsma( self, -- GitLab From ce5cc96166ad2bebe2637d5663cfc36f783e5922 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Thu, 17 Jul 2025 16:22:10 +0200 Subject: [PATCH 208/281] feature/add-edgecloud-gsma-i2edge: add gsma structure returns --- .../edgecloud/adapters/i2edge/client.py | 188 +++++++++++++++++- .../edgecloud/adapters/i2edge/common.py | 1 + .../edgecloud/core/edgecloud_interface.py | 1 + 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index be39857..64dfeb5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -5,7 +5,9 @@ # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) # - Sergio Giménez (sergio.gimenez@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) ## +import json from copy import deepcopy from typing import Dict, List, Optional @@ -36,6 +38,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def __init__(self, base_url: str, flavour_id: str): self.base_url = base_url self.flavour_id = flavour_id + self.content_type_gsma = "application/json" + self.encoding_gsma = "utf-8" def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None @@ -294,15 +298,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } response = self._create_artefact(**transformed) - # if response.status_code == 201: - # gsma_format_response = Response() - # gsma_format_response.status_code = 200 - # gsma_format_response._content = b'{"response": "Artefact uploaded successfully"}' - # gsma_format_response.headers["Content-Type"] = "application/json" - # gsma_format_response.encoding = "utf-8" - # gsma_format_response.url = response.url - # gsma_format_response.request = response.request - # return gsma_format_response + if response.status_code == 201: + return self._build_custom_gsma_response( + status_code=200, + content={"response": "Artefact uploaded successfully"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") @@ -310,6 +314,35 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: try: response = self._get_artefact(artefact_id) + if response.status_code == 200: + response_json = response.json() + print(response_json) + content = { + "artefactId": response_json.get("artefact_id"), + "appProviderId": "Ihs0gCqO65SHTz", + "artefactName": response_json.get("name"), + "artefactDescription": "string", + "artefactVersionInfo": response_json.get("version"), + "artefactVirtType": "VM_TYPE", + "artefactFileName": "stringst", + "artefactFileFormat": "ZIP", + "artefactDescriptorType": "HELM", + "repoType": response_json.get("repo_type"), + "artefactRepoLocation": { + "repoURL": response_json.get("repo_url"), + "userName": response_json.get("repo_user_name"), + "password": response_json.get("repo_password"), + "token": response_json.get("repo_token"), + }, + } + return self._build_custom_gsma_response( + status_code=200, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") @@ -317,6 +350,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): try: response = self._delete_artefact(artefact_id) + if response.status_code == 200: + return self._build_custom_gsma_response( + status_code=200, + content='{"response": "Artefact deletion successful"}', + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") @@ -334,6 +376,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): payload = schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) + if response.status_code == 200: + return self._build_custom_gsma_response( + status_code=200, + content={"response": "Application onboarded successfully"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") @@ -341,6 +392,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: try: response = self.get_onboarded_app(app_id) + if response.status_code == 200: + response_json = response.json() + profile_data = response_json.get("profile_data") + content = { + "appId": profile_data.get("app_id"), + "appProviderId": "string", + "appDeploymentZones": profile_data.get("appDeploymentZones"), + "appMetaData": profile_data.get("appMetadata"), + "appQoSProfile": profile_data.get("appQoSProfile"), + "appComponentSpecs": profile_data.get("appComponentSpecs"), + } + return self._build_custom_gsma_response( + status_code=200, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") @@ -353,6 +423,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): try: response = self.delete_onboarded_app(app_id) + if response.status_code == 200: + return self._build_custom_gsma_response( + status_code=200, + content={"response": "App deletion successful"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") @@ -376,6 +455,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) + if response.status_code == 202: + response_json = response.json() + content = { + "zoneId": response_json.get("zoneID"), + "appInstIdentifier": response_json.get("app_instance_id"), + } + return self._build_custom_gsma_response( + status_code=202, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") @@ -393,6 +486,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) params = {} response = i2edge_get(url, params=params) + if response.status_code == 200: + response_json = response.json() + content = { + "appInstanceState": response_json.get("appInstanceState"), + "accesspointInfo": response_json.get("accesspointInfo"), + } + return self._build_custom_gsma_response( + status_code=200, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") @@ -407,6 +514,32 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instances".format(self.base_url) params = {} response = i2edge_get(url, params=params) + if response.status_code == 200: + response_json = response.json() + response_list = [] + for item in response_json: + content = [ + { + "zoneId": item.get("app_spec") + .get("nodeSelector") + .get("feature.node.kubernetes.io/zoneID"), + "appInstanceInfo": [ + { + "appInstIdentifier": item.get("app_instance_id"), + "appInstanceState": item.get("deploy_status"), + } + ], + } + ] + response_list.append(content) + return self._build_custom_gsma_response( + status_code=200, + content=response_list, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Error retrieving apps: {e}") @@ -421,6 +554,43 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) + if response.status_code == 200: + return self._build_custom_gsma_response( + status_code=200, + content={ + "response": "Application instance termination request accepted" + }, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except KeyError as e: raise I2EdgeError(f"Missing appInstanceId in GSMA payload: {e}") + + # GSMA Support methods + + def _build_custom_gsma_response( + self, + status_code: int, + content: str | bytes | dict | list, + headers: dict = None, + encoding: str = None, + url: str = None, + request=None, + ) -> Response: + response = Response() + response.status_code = status_code + if isinstance(content, (dict, list)): + content = json.dumps(content) + response._content = ( + content.encode(encoding or "utf-8") if isinstance(content, str) else content + ) + response.headers.update(headers or {}) + response.encoding = encoding or "utf-8" + if url: + response.url = url + if request: + response.request = request + return response diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 12b6508..16a7017 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -5,6 +5,7 @@ # # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) ## import json from typing import Optional diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 7ae7ede..7d620cb 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -5,6 +5,7 @@ # # Contributors: # - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) ## from abc import ABC, abstractmethod from typing import Dict, List, Optional -- GitLab From e165c58a6a77490e5a812ceb4754ecd3d89ed1b8 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Fri, 18 Jul 2025 13:57:16 +0200 Subject: [PATCH 209/281] feature/add-edgecloud-gsma-i2edge: add extra methods --- examples/example.py | 257 +++++++++++++++++- .../common/adapters_schemas.py | 28 ++ .../edgecloud/adapters/aeros/client.py | 201 ++++++++++++++ .../edgecloud/adapters/i2edge/client.py | 102 ++++++- .../edgecloud/adapters/kubernetes/client.py | 202 ++++++++++++++ .../edgecloud/core/edgecloud_interface.py | 24 ++ 6 files changed, 805 insertions(+), 9 deletions(-) create mode 100644 src/sunrise6g_opensdk/common/adapters_schemas.py diff --git a/examples/example.py b/examples/example.py index 21dbc18..5a8a16f 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,4 +1,6 @@ # from sunrise6g_opensdk import Sdk as sdkclient # For PyPI users +import time + from sunrise6g_opensdk.common.sdk import Sdk as sdkclient # For developers @@ -6,8 +8,9 @@ def main(): # The module that imports the SDK package, must specify which adapters will be used: adapter_specs = { "edgecloud": { - "client_name": "kubernetes", - "base_url": "http://IP:PORT", + "client_name": "i2edge", + "base_url": "http://192.168.123.48:30769", + "flavour_id": "67f3a0b0e3184a85952e174d", }, "network": { "client_name": "open5gs", @@ -20,18 +23,256 @@ def main(): edgecloud_client = adapters.get("edgecloud") network_client = adapters.get("network") - print("EdgeCloud client ready to be used:", edgecloud_client) + print("EdgeCloud client ready to be used:", edgecloud_client.__dict__) print("Network client ready to be used:", network_client) # Examples: # EdgeCloud - # print("Testing edgecloud client function: get_edge_cloud_zones:") - # zones = edgecloud_client.get_edge_cloud_zones() - # print(zones) + + # FederationManagement + + zones_list = edgecloud_client.get_edge_cloud_zones_list_gsma() + print(zones_list) + print(zones_list.status_code) + print(zones_list.json()) + + zones = edgecloud_client.get_edge_cloud_zones_gsma("federation_context_id") + print(zones) + print(zones.status_code) + print(zones.json()) + + # AvailabilityZoneInfoSynchronization + + zones_info = edgecloud_client.availability_zone_info_gsma( + "federation_context_id", {"request_body": "value"} + ) + print(zones_info) + print(zones_info.status_code) + print(zones_info.json()) + + zone_id = "Omega" + zones = edgecloud_client.get_edge_cloud_zone_details_gsma( + "federation_context_id", zone_id + ) + print(zones) + print(zones.status_code) + print(zones.json()) + + # ArtefactManager + + request_body = { + "artefactId": "i2edgechart", + "appProviderId": "string", + "artefactName": "i2edgechart", + "artefactVersionInfo": "string", + "artefactDescription": "string", + "artefactVirtType": "VM_TYPE", + "artefactFileName": "string", + "artefactFileFormat": "WINZIP", + "artefactDescriptorType": "HELM", + "repoType": "PUBLICREPO", + "artefactRepoLocation": { + "repoURL": "https://cesarcajas.github.io/helm-charts-examples/", + "userName": "string", + "password": "string", + "token": "string", + }, + "artefactFile": "string", + "componentSpec": [ + { + "componentName": "string", + "images": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + "numOfInstances": 0, + "restartPolicy": "RESTART_POLICY_ALWAYS", + "commandLineParams": {"command": ["string"], "commandArgs": ["string"]}, + "exposedInterfaces": [ + { + "interfaceId": "string", + "commProtocol": "TCP", + "commPort": 0, + "visibilityType": "VISIBILITY_EXTERNAL", + "network": "string", + "InterfaceName": "string", + } + ], + "computeResourceProfile": { + "cpuArchType": "ISA_X86_64", + "numCPU": { + "whole": {"value": 2}, + "decimal": {"value": 0.5}, + "millivcpu": {"value": "500m"}, + }, + "memory": 0, + "diskStorage": 0, + "gpu": [ + { + "gpuVendorType": "GPU_PROVIDER_NVIDIA", + "gpuModeName": "string", + "gpuMemory": 0, + "numGPU": 0, + } + ], + "vpu": 0, + "fpga": 0, + "hugepages": [{"pageSize": "2MB", "number": 0}], + "cpuExclusivity": True, + }, + "compEnvParams": [ + { + "envVarName": "string", + "envValueType": "USER_DEFINED", + "envVarValue": "string", + "envVarSrc": "string", + } + ], + "deploymentConfig": { + "configType": "DOCKER_COMPOSE", + "contents": "string", + }, + "persistentVolumes": [ + { + "volumeSize": "10Gi", + "volumeMountPath": "string", + "volumeName": "string", + "ephemeralType": False, + "accessMode": "RW", + "sharingPolicy": "EXCLUSIVE", + } + ], + } + ], + } + artefact = edgecloud_client.create_artefact_gsma( + "federation_context_id", request_body + ) + print(artefact) + print(artefact.status_code) + print(artefact.json()) + + artefact_id = "i2edgechart" + get_artefact = edgecloud_client.get_artefact_gsma( + "federation_context_id", artefact_id + ) + print(get_artefact) + print(get_artefact.status_code) + print(get_artefact.json()) + + # ApplicationOnboardingManager + + request_body = { + "appId": "demo-app-id", + "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", + "appDeploymentZones": [ + "Dmgoc-y2zv97lar0UKqQd53aS6MCTTdoGMY193yvRBYgI07zOAIktN2b9QB2THbl5Gqvbj5Zp92vmNeg7v4M" + ], + "appMetaData": { + "appName": "pj1iEkprop", + "version": "string", + "appDescription": "stringstringstri", + "mobilitySupport": False, + "accessToken": "MfxADOjxDgBhMrqmBeG8XdQFLp2XviG3cZ_LM7uQKc9b", + "category": "IOT", + }, + "appQoSProfile": { + "latencyConstraints": "NONE", + "bandwidthRequired": 1, + "multiUserClients": "APP_TYPE_SINGLE_USER", + "noOfUsersPerAppInst": 1, + "appProvisioning": True, + }, + "appComponentSpecs": [ + { + "serviceNameNB": "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", + "serviceNameEW": "iDm08OZN", + "componentName": "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", + "artefactId": "i2edgechart", + } + ], + "appStatusCallbackLink": "string", + "edgeAppFQDN": "string", + } + onboard_app = edgecloud_client.onboard_app_gsma( + "federation_context_id", request_body + ) + print(onboard_app) + print(onboard_app.status_code) + print(onboard_app.json()) + + app_id = "demo-app-id" + get_onboarded_app = edgecloud_client.get_onboarded_app_gsma( + "federation_context_id", app_id + ) + print(get_onboarded_app) + print(get_onboarded_app.status_code) + print(get_onboarded_app.json()) + + idempotency_key = "idempotency-key" + request_body = { + "appId": "demo-app-id", + "appVersion": "string", + "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", + "zoneInfo": { + "zoneId": "Omega", + "flavourId": "67f3a0b0e3184a85952e174d", + "resourceConsumption": "RESERVED_RES_AVOID", + "resPool": "ySIT0LuZ6ApHs0wlyGZve", + }, + "appInstCallbackLink": "string", + } + deploy_app = edgecloud_client.deploy_app_gsma( + "federation_context_id", idempotency_key, request_body + ) + app_instance_id = deploy_app.json().get("appInstIdentifier") + print(deploy_app) + print(deploy_app.status_code) + print(deploy_app.json()) + + time.sleep(10) + + app_id = "demo-app-id" + zone_id = "Omega" + get_deploy_app = edgecloud_client.get_deployed_app_gsma( + "federation_context_id", app_id, app_instance_id, zone_id + ) + print(get_deploy_app) + print(get_deploy_app.status_code) + print(get_deploy_app.json()) + + get_deployed_apps = edgecloud_client.get_all_deployed_apps_gsma( + "federation_context_id", app_id, "app_provider" + ) + print(get_deployed_apps) + print(get_deployed_apps.status_code) + print(get_deployed_apps.json()) + + app_id = "demo-app-id" + zone_id = "Omega" + delete_deploy_app = edgecloud_client.undeploy_app_gsma( + "federation_context_id", app_id, app_instance_id, zone_id + ) + print(delete_deploy_app) + print(delete_deploy_app.status_code) + print(delete_deploy_app.json()) + + app_id = "demo-app-id" + delete_onboarded_app = edgecloud_client.delete_onboarded_app_gsma( + "federation_context_id", app_id + ) + print(delete_onboarded_app) + print(delete_onboarded_app.status_code) + print(delete_onboarded_app.json()) + + artefact_id = "i2edgechart" + delete_artefact = edgecloud_client.delete_artefact_gsma( + "federation_context_id", artefact_id + ) + print(delete_artefact) + print(delete_artefact.status_code) + print(delete_artefact.json()) # Network - # print("Testing network client function: 'get_qod_session'") - # network_client.get_qod_session(session_id="example_session_id") + print("Testing network client function: 'get_qod_session'") + network_client.get_qod_session(session_id="example_session_id") if __name__ == "__main__": diff --git a/src/sunrise6g_opensdk/common/adapters_schemas.py b/src/sunrise6g_opensdk/common/adapters_schemas.py new file mode 100644 index 0000000..6f50df3 --- /dev/null +++ b/src/sunrise6g_opensdk/common/adapters_schemas.py @@ -0,0 +1,28 @@ +# EXAMPLE OF PYDANTIC SCHEMAS +from typing import Optional + +from pydantic import AnyHttpUrl, BaseModel, Field + + +class _AdapterBase(BaseModel): + client_name: str = Field(..., min_length=1) + base_url: AnyHttpUrl + + class Config: + extra = "allow" + + +class EdgeCloudConfig(_AdapterBase): + pass + + +class NetworkConfig(_AdapterBase): + pass + + +class AdapterSpecs(BaseModel): + edgecloud: Optional[EdgeCloudConfig] = None + network: Optional[NetworkConfig] = None + + class Config: + extra = "forbid" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 47aff6f..aa893bb 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -431,3 +431,204 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "flavoursSupported": flavours_supported, } return result + + # --- GSMA-specific methods --- + + # FederationManagement + + def get_edge_cloud_zones_list_gsma(self) -> List: + """ + Retrieves details of all Zones + + :return: List. + """ + pass + + def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: + """ + Retrieves details of Zones + + :param federation_context_id: Identifier of the federation context. + :return: List. + """ + pass + + # AvailabilityZoneInfoSynchronization + + def availability_zone_info_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Originating OP informs partner OP that it is willing to access + the specified zones and partner OP shall reserve compute and + network resources for these zones. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload. + :return: + """ + pass + + def get_edge_cloud_zone_details_gsma( + self, federation_context_id: str, zone_id: str + ) -> Dict: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP. + + :param federation_context_id: Identifier of the federation context. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with Edge Cloud Zone details. + """ + pass + + # ArtefactManagement + + def create_artefact_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Uploads application artefact on partner OP. Artefact is a zip file + containing scripts and/or packaging files like Terraform or Helm + which are required to create an instance of an application + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with artefact information. + :return: Details with artefact deployment info. + """ + pass + + def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + """ + Retrieves details about an artefact + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact details. + """ + pass + + def delete_artefact_gsma( + self, federation_context_id: str, artefact_id: str + ) -> Dict: + """ + Removes an artefact from partners OP. + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact deletion details. + """ + pass + + # ApplicationOnboardingManagement + + def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + """ + Submits an application details to a partner OP. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with onboarding info. + :return: Dictionary with onboarding details. + """ + pass + + def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + """ + Retrieves application details from partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + def patch_onboarded_app_gsma( + self, federation_context_id: str, app_id: str, request_body: dict + ) -> Dict: + """ + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + """ + Deboards an application from specific partner OP zones + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + # ApplicationDeploymentManagement + + def deploy_app_gsma( + self, federation_context_id: str, idempotency_key: str, request_body: dict + ): + """ + Instantiates an application on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param idempotency_key: Idempotency key. + :return: + """ + pass + + def get_deployed_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Retrieves an application instance details from partner OP. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass + + def get_all_deployed_apps_gsma( + self, + federation_context_id: str, + app_id: str, + app_provider: str, + ): + """ + Retrieves all application instance of partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_provider: App provider + :return: + """ + pass + + def undeploy_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Terminate an application instance on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 64dfeb5..86f8b5e 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -254,17 +254,92 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # FederationManagement + def get_edge_cloud_zones_list_gsma(self) -> List: + url = "{}/zones/list".format(self.base_url) + params = {} + try: + response = i2edge_get(url, params=params) + if response.status_code == 200: + response_json = response.json() + response_list = [] + for item in response_json: + content = { + "zoneId": item.get("zoneId"), + "geolocation": item.get("geolocation"), + "geographyDetails": item.get("geographyDetails"), + } + response_list.append(content) + return self._build_custom_gsma_response( + status_code=200, + content=response_list, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + return response + except I2EdgeError as e: + raise e + def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> Dict: url = "{}/zones".format(self.base_url) params = {} try: response = i2edge_get(url, params=params) + if response.status_code == 200: + response_json = response.json() + response_list = [] + for item in response_json: + content = { + "zoneId": item.get("zoneId"), + "reservedComputeResources": item.get( + "reservedComputeResources" + ), + "computeResourceQuotaLimits": item.get( + "computeResourceQuotaLimits" + ), + "flavoursSupported": item.get("flavoursSupported"), + "networkResources": item.get("networkResources"), + "zoneServiceLevelObjsInfo": item.get( + "zoneServiceLevelObjsInfo" + ), + } + response_list.append(content) + return self._build_custom_gsma_response( + status_code=200, + content=response_list, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except I2EdgeError as e: raise e # AvailabilityZoneInfoSynchronization + def availability_zone_info_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + url = "{}/zones".format(self.base_url) + params = {} + try: + response = i2edge_get(url, params=params) + if response.status_code == 200: + content = {"acceptedZoneResourceInfo": response.json()} + return self._build_custom_gsma_response( + status_code=200, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + return response + except I2EdgeError as e: + raise e + def get_edge_cloud_zone_details_gsma( self, federation_context_id: str, zone_id: str ) -> Dict: @@ -272,6 +347,30 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): params = {} try: response = i2edge_get(url, params=params) + if response.status_code == 200: + response_json = response.json() + content = { + "zoneId": response_json.get("zoneID"), + "reservedComputeResources": response_json.get( + "reservedComputeResources" + ), + "computeResourceQuotaLimits": response_json.get( + "computeResourceQuotaLimits" + ), + "flavoursSupported": response_json.get("flavoursSupported"), + "networkResources": response_json.get("networkResources"), + "zoneServiceLevelObjsInfo": response_json.get( + "zoneServiceLevelObjsInfo" + ), + } + return self._build_custom_gsma_response( + status_code=200, + content=content, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) return response except I2EdgeError as e: raise e @@ -444,11 +543,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): body = deepcopy(request_body) try: zone_id = body.get("zoneInfo").get("zoneId") + flavour_id = body.get("zoneInfo").get("flavourId") app_deploy_data = schemas.AppDeployData( appId=body.get("appId"), appProviderId=body.get("appProviderId"), appVersion=body.get("appVersion"), - zoneInfo=schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), + zoneInfo=schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) payload = schemas.AppDeploy( app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index e7ec478..e83cb41 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -263,3 +263,205 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } ] return app + + # --- GSMA-specific methods --- + + # FederationManagement + + def get_edge_cloud_zones_list_gsma(self) -> List: + """ + Retrieves details of all Zones + + :return: List. + """ + + pass + + def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: + """ + Retrieves details of Zones + + :param federation_context_id: Identifier of the federation context. + :return: List. + """ + pass + + # AvailabilityZoneInfoSynchronization + + def availability_zone_info_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Originating OP informs partner OP that it is willing to access + the specified zones and partner OP shall reserve compute and + network resources for these zones. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload. + :return: + """ + pass + + def get_edge_cloud_zone_details_gsma( + self, federation_context_id: str, zone_id: str + ) -> Dict: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP. + + :param federation_context_id: Identifier of the federation context. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with Edge Cloud Zone details. + """ + pass + + # ArtefactManagement + + def create_artefact_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Uploads application artefact on partner OP. Artefact is a zip file + containing scripts and/or packaging files like Terraform or Helm + which are required to create an instance of an application + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with artefact information. + :return: Details with artefact deployment info. + """ + pass + + def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + """ + Retrieves details about an artefact + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact details. + """ + pass + + def delete_artefact_gsma( + self, federation_context_id: str, artefact_id: str + ) -> Dict: + """ + Removes an artefact from partners OP. + + :param federation_context_id: Identifier of the federation context. + :param artefact_id: Unique identifier of the artefact. + :return: Dictionary with artefact deletion details. + """ + pass + + # ApplicationOnboardingManagement + + def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + """ + Submits an application details to a partner OP. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload with onboarding info. + :return: Dictionary with onboarding details. + """ + pass + + def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + """ + Retrieves application details from partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + def patch_onboarded_app_gsma( + self, federation_context_id: str, app_id: str, request_body: dict + ) -> Dict: + """ + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + """ + Deboards an application from specific partner OP zones + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: + """ + pass + + # ApplicationDeploymentManagement + + def deploy_app_gsma( + self, federation_context_id: str, idempotency_key: str, request_body: dict + ): + """ + Instantiates an application on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param idempotency_key: Idempotency key. + :return: + """ + pass + + def get_deployed_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Retrieves an application instance details from partner OP. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass + + def get_all_deployed_apps_gsma( + self, + federation_context_id: str, + app_id: str, + app_provider: str, + ): + """ + Retrieves all application instance of partner OP + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_provider: App provider + :return: + """ + pass + + def undeploy_app_gsma( + self, + federation_context_id: str, + app_id: str, + app_instance_id: str, + zone_id: str, + ): + """ + Terminate an application instance on a partner OP zone. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: + """ + pass diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 7d620cb..3d2ff29 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -124,6 +124,15 @@ class EdgeCloudManagementInterface(ABC): # FederationManagement + @abstractmethod + def get_edge_cloud_zones_list_gsma(self) -> List: + """ + Retrieves details of all Zones + + :return: List. + """ + pass + @abstractmethod def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: """ @@ -136,6 +145,21 @@ class EdgeCloudManagementInterface(ABC): # AvailabilityZoneInfoSynchronization + @abstractmethod + def availability_zone_info_gsma( + self, federation_context_id: str, request_body: dict + ) -> Dict: + """ + Originating OP informs partner OP that it is willing to access + the specified zones and partner OP shall reserve compute and + network resources for these zones. + + :param federation_context_id: Identifier of the federation context. + :param request_body: Payload. + :return: + """ + pass + @abstractmethod def get_edge_cloud_zone_details_gsma( self, federation_context_id: str, zone_id: str -- GitLab From 22f4e6fbe1e902faf46f1980d0c6692a71fcc861 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Fri, 18 Jul 2025 14:00:41 +0200 Subject: [PATCH 210/281] feature/add-edgecloud-gsma-i2edge: fix indentation --- src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index aa893bb..077a138 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -432,7 +432,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } return result - # --- GSMA-specific methods --- + # --- GSMA-specific methods --- # FederationManagement -- GitLab From f200d7862ed5e82f7295cbd35e23f29858a2ecce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 16:50:56 +0200 Subject: [PATCH 211/281] Delete GSMA manual testing code from example.py --- examples/example.py | 257 ++------------------------------------------ 1 file changed, 8 insertions(+), 249 deletions(-) diff --git a/examples/example.py b/examples/example.py index 5a8a16f..21dbc18 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,6 +1,4 @@ # from sunrise6g_opensdk import Sdk as sdkclient # For PyPI users -import time - from sunrise6g_opensdk.common.sdk import Sdk as sdkclient # For developers @@ -8,9 +6,8 @@ def main(): # The module that imports the SDK package, must specify which adapters will be used: adapter_specs = { "edgecloud": { - "client_name": "i2edge", - "base_url": "http://192.168.123.48:30769", - "flavour_id": "67f3a0b0e3184a85952e174d", + "client_name": "kubernetes", + "base_url": "http://IP:PORT", }, "network": { "client_name": "open5gs", @@ -23,256 +20,18 @@ def main(): edgecloud_client = adapters.get("edgecloud") network_client = adapters.get("network") - print("EdgeCloud client ready to be used:", edgecloud_client.__dict__) + print("EdgeCloud client ready to be used:", edgecloud_client) print("Network client ready to be used:", network_client) # Examples: # EdgeCloud - - # FederationManagement - - zones_list = edgecloud_client.get_edge_cloud_zones_list_gsma() - print(zones_list) - print(zones_list.status_code) - print(zones_list.json()) - - zones = edgecloud_client.get_edge_cloud_zones_gsma("federation_context_id") - print(zones) - print(zones.status_code) - print(zones.json()) - - # AvailabilityZoneInfoSynchronization - - zones_info = edgecloud_client.availability_zone_info_gsma( - "federation_context_id", {"request_body": "value"} - ) - print(zones_info) - print(zones_info.status_code) - print(zones_info.json()) - - zone_id = "Omega" - zones = edgecloud_client.get_edge_cloud_zone_details_gsma( - "federation_context_id", zone_id - ) - print(zones) - print(zones.status_code) - print(zones.json()) - - # ArtefactManager - - request_body = { - "artefactId": "i2edgechart", - "appProviderId": "string", - "artefactName": "i2edgechart", - "artefactVersionInfo": "string", - "artefactDescription": "string", - "artefactVirtType": "VM_TYPE", - "artefactFileName": "string", - "artefactFileFormat": "WINZIP", - "artefactDescriptorType": "HELM", - "repoType": "PUBLICREPO", - "artefactRepoLocation": { - "repoURL": "https://cesarcajas.github.io/helm-charts-examples/", - "userName": "string", - "password": "string", - "token": "string", - }, - "artefactFile": "string", - "componentSpec": [ - { - "componentName": "string", - "images": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], - "numOfInstances": 0, - "restartPolicy": "RESTART_POLICY_ALWAYS", - "commandLineParams": {"command": ["string"], "commandArgs": ["string"]}, - "exposedInterfaces": [ - { - "interfaceId": "string", - "commProtocol": "TCP", - "commPort": 0, - "visibilityType": "VISIBILITY_EXTERNAL", - "network": "string", - "InterfaceName": "string", - } - ], - "computeResourceProfile": { - "cpuArchType": "ISA_X86_64", - "numCPU": { - "whole": {"value": 2}, - "decimal": {"value": 0.5}, - "millivcpu": {"value": "500m"}, - }, - "memory": 0, - "diskStorage": 0, - "gpu": [ - { - "gpuVendorType": "GPU_PROVIDER_NVIDIA", - "gpuModeName": "string", - "gpuMemory": 0, - "numGPU": 0, - } - ], - "vpu": 0, - "fpga": 0, - "hugepages": [{"pageSize": "2MB", "number": 0}], - "cpuExclusivity": True, - }, - "compEnvParams": [ - { - "envVarName": "string", - "envValueType": "USER_DEFINED", - "envVarValue": "string", - "envVarSrc": "string", - } - ], - "deploymentConfig": { - "configType": "DOCKER_COMPOSE", - "contents": "string", - }, - "persistentVolumes": [ - { - "volumeSize": "10Gi", - "volumeMountPath": "string", - "volumeName": "string", - "ephemeralType": False, - "accessMode": "RW", - "sharingPolicy": "EXCLUSIVE", - } - ], - } - ], - } - artefact = edgecloud_client.create_artefact_gsma( - "federation_context_id", request_body - ) - print(artefact) - print(artefact.status_code) - print(artefact.json()) - - artefact_id = "i2edgechart" - get_artefact = edgecloud_client.get_artefact_gsma( - "federation_context_id", artefact_id - ) - print(get_artefact) - print(get_artefact.status_code) - print(get_artefact.json()) - - # ApplicationOnboardingManager - - request_body = { - "appId": "demo-app-id", - "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", - "appDeploymentZones": [ - "Dmgoc-y2zv97lar0UKqQd53aS6MCTTdoGMY193yvRBYgI07zOAIktN2b9QB2THbl5Gqvbj5Zp92vmNeg7v4M" - ], - "appMetaData": { - "appName": "pj1iEkprop", - "version": "string", - "appDescription": "stringstringstri", - "mobilitySupport": False, - "accessToken": "MfxADOjxDgBhMrqmBeG8XdQFLp2XviG3cZ_LM7uQKc9b", - "category": "IOT", - }, - "appQoSProfile": { - "latencyConstraints": "NONE", - "bandwidthRequired": 1, - "multiUserClients": "APP_TYPE_SINGLE_USER", - "noOfUsersPerAppInst": 1, - "appProvisioning": True, - }, - "appComponentSpecs": [ - { - "serviceNameNB": "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", - "serviceNameEW": "iDm08OZN", - "componentName": "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", - "artefactId": "i2edgechart", - } - ], - "appStatusCallbackLink": "string", - "edgeAppFQDN": "string", - } - onboard_app = edgecloud_client.onboard_app_gsma( - "federation_context_id", request_body - ) - print(onboard_app) - print(onboard_app.status_code) - print(onboard_app.json()) - - app_id = "demo-app-id" - get_onboarded_app = edgecloud_client.get_onboarded_app_gsma( - "federation_context_id", app_id - ) - print(get_onboarded_app) - print(get_onboarded_app.status_code) - print(get_onboarded_app.json()) - - idempotency_key = "idempotency-key" - request_body = { - "appId": "demo-app-id", - "appVersion": "string", - "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", - "zoneInfo": { - "zoneId": "Omega", - "flavourId": "67f3a0b0e3184a85952e174d", - "resourceConsumption": "RESERVED_RES_AVOID", - "resPool": "ySIT0LuZ6ApHs0wlyGZve", - }, - "appInstCallbackLink": "string", - } - deploy_app = edgecloud_client.deploy_app_gsma( - "federation_context_id", idempotency_key, request_body - ) - app_instance_id = deploy_app.json().get("appInstIdentifier") - print(deploy_app) - print(deploy_app.status_code) - print(deploy_app.json()) - - time.sleep(10) - - app_id = "demo-app-id" - zone_id = "Omega" - get_deploy_app = edgecloud_client.get_deployed_app_gsma( - "federation_context_id", app_id, app_instance_id, zone_id - ) - print(get_deploy_app) - print(get_deploy_app.status_code) - print(get_deploy_app.json()) - - get_deployed_apps = edgecloud_client.get_all_deployed_apps_gsma( - "federation_context_id", app_id, "app_provider" - ) - print(get_deployed_apps) - print(get_deployed_apps.status_code) - print(get_deployed_apps.json()) - - app_id = "demo-app-id" - zone_id = "Omega" - delete_deploy_app = edgecloud_client.undeploy_app_gsma( - "federation_context_id", app_id, app_instance_id, zone_id - ) - print(delete_deploy_app) - print(delete_deploy_app.status_code) - print(delete_deploy_app.json()) - - app_id = "demo-app-id" - delete_onboarded_app = edgecloud_client.delete_onboarded_app_gsma( - "federation_context_id", app_id - ) - print(delete_onboarded_app) - print(delete_onboarded_app.status_code) - print(delete_onboarded_app.json()) - - artefact_id = "i2edgechart" - delete_artefact = edgecloud_client.delete_artefact_gsma( - "federation_context_id", artefact_id - ) - print(delete_artefact) - print(delete_artefact.status_code) - print(delete_artefact.json()) + # print("Testing edgecloud client function: get_edge_cloud_zones:") + # zones = edgecloud_client.get_edge_cloud_zones() + # print(zones) # Network - print("Testing network client function: 'get_qod_session'") - network_client.get_qod_session(session_id="example_session_id") + # print("Testing network client function: 'get_qod_session'") + # network_client.get_qod_session(session_id="example_session_id") if __name__ == "__main__": -- GitLab From 4e3e04c5a97262e29c24bd7fa9a0451a7c17f3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 17:28:31 +0200 Subject: [PATCH 212/281] Update pyproject.toml to include missing dependency, plus update version --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f216fac..08b038b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "1.0.1" +version = "1.0.2.post4" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", @@ -45,7 +45,10 @@ dependencies = [ "auto_mix_prep==0.2.0", "colorlog==6.8.2", "pydantic==2.11.3", + "pydantic-extra-types==2.10.3", "requests==2.32.4", + "pymongo==4.13.2", + "kubernetes==33.1.0", ] [project.urls] -- GitLab From 7665a5d942d9b92d51a46b863463552c6f0c16e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 17:44:28 +0200 Subject: [PATCH 213/281] Add CAMARA pydantic schema (generated by data-model-code-generator) --- .flake8 | 2 +- .../edgecloud/core/schemas.py | 659 ++++++++++++++++++ 2 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/core/schemas.py diff --git a/.flake8 b/.flake8 index 4f05f66..aac94bc 100644 --- a/.flake8 +++ b/.flake8 @@ -2,7 +2,7 @@ max-line-length = 100 max-complexity = 18 select = B,C,E,F,W,T4,B9 -ignore = E203, E266, E501, W503 +ignore = E203, E266, E501, W503, F722 per-file-ignores = __init__.py:F401 exclude = diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py new file mode 100644 index 0000000..0b9ee71 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -0,0 +1,659 @@ +# generated by datamodel-codegen: +# filename: camara-edgecloud-openapi.yaml +# timestamp: 2025-07-18T11:48:22+00:00 + +from __future__ import annotations + +from enum import Enum +from ipaddress import IPv4Address, IPv6Address +from typing import List, Literal, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field, RootModel, conint, constr + + +class AppId(RootModel[UUID]): + root: UUID = Field( + ..., + description="A globally unique identifier associated with the application.\nEdge Cloud Platform generates this identifier when the\nApplication is submitted.\n", + ) + + +class AppInstanceId(RootModel[UUID]): + root: UUID = Field( + ..., + description="A globally unique identifier associated with a running\ninstance of an application.\nEdge Cloud Platform generates this identifier when the\ninstantiation in the Edge Cloud Zone is successful.\n", + ) + + +class Status(Enum): + ready = "ready" + instantiating = "instantiating" + failed = "failed" + terminating = "terminating" + unknown = "unknown" + + +class AppInstanceName(RootModel[constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$")]): # type: ignore + root: constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") = Field( + ..., description="Name of the App instance, scoped to the AppProvider" + ) + + +class PackageType(Enum): + QCOW2 = "QCOW2" + OVA = "OVA" + CONTAINER = "CONTAINER" + HELM = "HELM" + + +class Type(Enum): + PRIVATEREPO = "PRIVATEREPO" + PUBLICREPO = "PUBLICREPO" + + +class AuthType(Enum): + DOCKER = "DOCKER" + HTTP_BASIC = "HTTP_BASIC" + HTTP_BEARER = "HTTP_BEARER" + NONE = "NONE" + + +class Protocol(Enum): + TCP = "TCP" + UDP = "UDP" + ANY = "ANY" + + +class VisibilityType(Enum): + VISIBILITY_EXTERNAL = "VISIBILITY_EXTERNAL" + VISIBILITY_INTERNAL = "VISIBILITY_INTERNAL" + + +class NetworkInterface(BaseModel): + interfaceId: constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{3,31}$") = Field( + ..., + description="Each Port and corresponding traffic protocol\nexposed by the component is identified by a name.\nApplication client on user device requires this to\nuniquley idenify the interface.\n", + ) + protocol: Protocol = Field( + ..., + description="Defines the IP transport communication\nprotocol i.e., TCP, UDP or ANY\n", + ) + port: conint(ge=1, le=65535) = Field( + ..., + description="Port number exposed by the component.\nEdge Cloud Provider may generate a dynamic port\ntowards the component instance which forwards\nexternal traffic to the component port.\n", + ) + visibilityType: VisibilityType = Field( + ..., + description='Defines whether the interface is exposed\nto outer world or not i.e., external, or internal.\nIf this is set to "external", then it is exposed\nto external applications otherwise it is exposed\ninternally to edge application components within\nedge cloud. When exposed to external world,\nan external dynamic port is assigned for UC traffic\nand mapped to the extPort\n', + ) + + +class ComponentSpecItem(BaseModel): + componentName: str = Field( + ..., description="Component name must be unique with an application" + ) + networkInterfaces: List[NetworkInterface] = Field( + ..., + description='Each application component exposes some ports\neither for external users or for inter component\ncommunication.\nApplication provider is required to specify which ports are\nto be exposed and the type of traffic that will flow through\nthese ports.The underlying platform may assign a dynamic port\nagainst the "extPort" that the application clients will use\nto connect with edge application instance.\n', + min_length=1, + ) + + +class AppProvider(RootModel[constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$")]): + root: constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$") = Field( + ..., description="Human readable name of the Application Provider." + ) + + +class EdgeCloudProvider(RootModel[str]): + root: str = Field( + ..., description="Human readable name of the Edge Cloud Provider." + ) + + +class EdgeCloudRegion(RootModel[str]): + root: str = Field( + ..., + description="Human readable name of the geographical Edge Cloud Region of\nthe Edge Cloud. Defined by the Edge Cloud Provider.\n", + ) + + +class EdgeCloudZoneId(RootModel[UUID]): + root: UUID = Field( + ..., + description="Unique identifier created by the Edge Cloud Platform to identify an\nEdge Cloud Zone within an Edge Cloud.\n", + ) + + +class EdgeCloudZoneName(RootModel[str]): + root: str = Field( + ..., + description="Human readable name of the geographical zone of\nthe Edge Cloud. Defined by the Edge Cloud Provider.\n", + ) + + +class EdgeCloudZoneStatus(Enum): + active = "active" + inactive = "inactive" + unknown = "unknown" + + +class ErrorInfo(BaseModel): + status: int = Field( + ..., description="HTTP status code returned along with this error response" + ) + code: str = Field(..., description="Code given to this error") + message: str = Field(..., description="Detailed error description") + + +class Fqdn(RootModel[str]): + root: str = Field( + ..., description="Full qualified domain name of an application instance\n" + ) + + +class GpuInfo(BaseModel): + gpuMemory: int = Field(..., description="GPU memory in mega bytes") + numGPU: int = Field(..., description="Number of GPUs") + + +class K8sAddons(BaseModel): + monitoring: Optional[bool] = Field( + False, description="Enable monitoring for Kubernetes cluster.", examples=[True] + ) + ingress: Optional[bool] = Field( + False, description="Enable ingress for Kubernetes cluster.", examples=[True] + ) + + +class PrimaryNetwork(BaseModel): + provider: Optional[str] = Field( + None, description="CNI provider name", examples=["cilium"] + ) + version: Optional[str] = Field( + None, description="CNI provider version", examples=["1.13"] + ) + + +class InterfaceType(Enum): + netdevice = "netdevice" + vfio_pci = "vfio-pci" + interface = "interface" + + +class AdditionalNetwork(BaseModel): + name: Optional[str] = Field( + None, description="Additional Network Name", examples=["net1"] + ) + interfaceType: Optional[InterfaceType] = Field( + None, + description="Type of additional Interface:\nnetdevice: (SR-IOV) A regular kernel network device in the\n Network Namespace (netns) of the container\nvfio-pci: (SR-IOV) A PCI network interface directly mounted\n in the container\ninterface: Additional interface to be used by cni plugins\n such as macvlan, ipvlan\nNote: The use of SR-IOV interfaces automatically\nconfigure the required kernel parameters for the nodes.\n", + examples=["vfio-pci"], + ) + + +class K8sNetworking(BaseModel): + primaryNetwork: Optional[PrimaryNetwork] = Field( + None, description="Definition of Kubernetes primary Network" + ) + additionalNetworks: Optional[List[AdditionalNetwork]] = Field( + None, description="Additional Networks for the Kubernetes cluster." + ) + + +class AdditionalStorageItem(BaseModel): + name: Optional[str] = Field( + None, description="Name of additional storage resource.", examples=["logs"] + ) + storageSize: constr(pattern=r"^\d+(GB|MB)$") = Field( + ..., + description="Additional persistent volume for the application.", + examples=["80GB"], + ) + mountPoint: str = Field( + ..., description="Location of additional storage resource.", examples=["/logs"] + ) + + +class AdditionalStorage(RootModel[List[AdditionalStorageItem]]): + root: List[AdditionalStorageItem] = Field( + ..., description="Additional storage for the application." + ) + + +class Vcpu(RootModel[constr(pattern=r"^\d+((\.\d{1,3})|(m))?$")]): + root: constr(pattern=r"^\d+((\.\d{1,3})|(m))?$") = Field( + ..., + description="Number of vcpus in whole (i.e 1), decimal (i.e 0.500) up to\nmillivcpu, or millivcpu (i.e 500m) format.\n", + examples=["500m"], + ) + + +class KubernetesClusterRef(RootModel[UUID]): + root: UUID = Field( + ..., + description="A global unique identifier associated with a Kubernetes cluster\ninfrastructure.\n", + examples=["642f6105-7015-4af1-a4d1-e1ecb8437abc"], + ) + + +class NodeResources(BaseModel): + numCPU: int = Field( + ..., description="Number of whole vcpus for the node.\n", examples=[2] + ) + memory: int = Field( + ..., + description="Amount of system memory in mega bytes for the node.\n", + examples=[4096], + ) + + +class KubernetesNodePool(BaseModel): + name: str = Field( + ..., description="Human readable name of the Kubernetes Node Pool." + ) + numNodes: int = Field(..., description="Number of nodes in the Node Pool.") + scalable: bool = Field( + ..., + description="Indicates if the node pool can be dynamically scaled up by the\nsystem to accomodate more applications, and dynamically scaled\ndown by the system when there are unused resources.\n", + examples=[False], + ) + nodeResources: NodeResources = Field( + ..., description="Resource configuration of a node." + ) + + +class InfraKind(Enum): + kubernetes = "kubernetes" + + +class Topology(BaseModel): + minNumberOfNodes: int = Field( + ..., + description="Minimum number of worker nodes required by the\napplication.\n", + examples=[5], + ) + minNodeCpu: int = Field( + ..., + description="Minimum number of vcpus in whole (i.e 1) per cluster\nnode in CPU pool.\n", + examples=[2], + ) + minNodeMemory: int = Field( + ..., + description="Minimum memory in mega bytes per cluster node in\nCPU pool.\n", + examples=[1024], + ) + + +class CpuPool(BaseModel): + numCPU: int = Field( + ..., + description="Total number of vcpus in whole (i.e 1) of CPU pool.\n", + examples=[1], + ) + memory: int = Field( + ..., description="Total memory in mega bytes of CPU pool.", examples=[1024] + ) + topology: Topology = Field( + ..., + description="CPU pool topology defines an application's CPU-based\narchitecture.\nWhen deploying for high availability or redundancy, it\nallows for clustering with a configurable number of nodes\nand minimum CPU/memory resource per Kubernetes node\nrequirements.\n", + ) + + +class Topology1(BaseModel): + minNumberOfNodes: int = Field( + ..., + description="Minimum number of worker nodes with GPU required by\nthe application.\n", + examples=[2], + ) + minNodeCpu: int = Field( + ..., + description="Minimum number of vcpus in whole (i.e 1) per cluster\nnode in GPU pool.\n", + examples=[2], + ) + minNodeMemory: int = Field( + ..., + description="Minimum memory in mega bytes per cluster node in\nGPU pool.\n", + examples=[1024], + ) + minNodeGpuMemory: int = Field( + ..., + description="Minimum memory in giga bytes per cluster node in GPU pool.", + examples=[8], + ) + + +class GpuPool(BaseModel): + numCPU: int = Field( + ..., + description="Total Number of vcpus in whole (i.e 1) of GPU pool.\n", + examples=[1], + ) + memory: int = Field( + ..., description="Total memory in mega bytes of GPU pool.", examples=[1024] + ) + gpuMemory: int = Field( + ..., description="Total GPU memory in giga bytes of GPU pool.", examples=[16] + ) + topology: Topology1 = Field( + ..., + description="GPU pool topology defines an application's GPU-based\narchitecture.\nWhen deploying for high availability or redundancy, it\nallows for clustering with a configurable number of nodes\nand minimum CPU/memory/GPU memory resource per Kubernetes\nnode requirements.\n", + ) + + +class ApplicationResources(BaseModel): + cpuPool: Optional[CpuPool] = Field( + None, + description="CPU Pool refers to the amount of application' resources\nthat is executed in nodes with CPU only. That means the part\nof application that doesn't require GPU or other kind of\nacceleration.\nCPU pool is not mandatory when the application is executed\nexclusively in a GPU pool.\nA CPU pool is composed by CPU and memory.\n", + ) + gpuPool: Optional[GpuPool] = Field( + None, + description="GPU Pool refers to the amount of resources of the application\nthat is executed in nodes with GPU.\nGPU Pool is not mandatory when the application is executed\nexclusively in a CPU pool.\nA GPU pool is composed by memory, CPU and GPU memory\n", + ) + + +class KubernetesResources(BaseModel): + infraKind: Literal["kubernetes"] = Field( + ..., + description="Type of infrastructure for the application.", + examples=["kubernetes"], + ) + applicationResources: ApplicationResources = Field( + ..., + description="Application resources define the resources pool required\nby the application to be executed in a Kubernetes clusters.\n", + ) + isStandalone: bool = Field( + ..., + description="Define if the Kubernetes clusters can be reused by other\napplications.\n", + examples=[False], + ) + version: Optional[str] = Field( + None, description="Minimum Kubernetes Version.", examples=["1.29"] + ) + additionalStorage: Optional[constr(pattern=r"^\d+(GB|MB)$")] = Field( + None, + description="Amount of persistent storage allocated to the Kubernetes PVC.\n", + examples=["80GB"], + ) + networking: Optional[K8sNetworking] = None + addons: Optional[K8sAddons] = None + + +class InfraKind1(Enum): + virtualMachine = "virtualMachine" + + +class VmResources(BaseModel): + infraKind: Literal["virtualMachine"] = Field( + ..., + description="Type of infrastructure for the application.", + examples=["virtualMachine"], + ) + numCPU: int = Field( + ..., description="Number of vcpus in whole (i.e 1)\n", examples=[1] + ) + memory: int = Field(..., description="Memory in mega bytes", examples=[1024]) + additionalStorages: Optional[AdditionalStorage] = None + gpu: Optional[GpuInfo] = None + + +class InfraKind2(Enum): + dockerCompose = "dockerCompose" + + +class DockerComposeResources(BaseModel): + infraKind: Literal["dockerCompose"] = Field( + ..., + description="Type of infrastructure for the application.", + examples=["dockerCompose"], + ) + numCPU: int = Field( + ..., description="Number of vcpus in whole (i.e 1)\n", examples=[1] + ) + memory: int = Field(..., description="Memory in mega bytes", examples=[1024]) + storage: Optional[AdditionalStorage] = None + gpu: Optional[GpuInfo] = None + + +class InfraKind3(Enum): + container = "container" + + +class ContainerResources(BaseModel): + infraKind: Literal["container"] = Field( + ..., + description="Type of infrastructure for the application.", + examples=["container"], + ) + numCPU: Vcpu + memory: int = Field(..., description="Memory in mega bytes", examples=[1024]) + storage: Optional[AdditionalStorage] = None + gpu: Optional[GpuInfo] = None + + +class Ipv4Addr(RootModel[IPv4Address]): + root: IPv4Address = Field( + ..., + description="IP of the device. A single IPv4 address may be specified in\ndotted-quad form 1.2.3.4. Only this exact IP number will match the flow\ncontrol rule.\n", + examples=["192.168.0.1"], + ) + + +class Ipv6Addr(RootModel[IPv6Address]): + root: IPv6Address = Field( + ..., + description="IP of the device. A single IPv6 address, following IETF 5952\nformat, may be specified like 2001:db8:85a3:8d3:1319:8a2e:370:7344\n", + examples=["2001:db8:85a3:8d3:1319:8a2e:370:7344"], + ) + + +class Architecture(Enum): + x86_64 = "x86_64" + x86 = "x86" + + +class Family(Enum): + RHEL = "RHEL" + UBUNTU = "UBUNTU" + COREOS = "COREOS" + WINDOWS = "WINDOWS" + OTHER = "OTHER" + + +class Version(Enum): + OS_VERSION_UBUNTU_2204_LTS = "OS_VERSION_UBUNTU_2204_LTS" + OS_VERSION_RHEL_8 = "OS_VERSION_RHEL_8" + OS_MS_WINDOWS_2022 = "OS_MS_WINDOWS_2022" + OTHER = "OTHER" + + +class License(Enum): + OS_LICENSE_TYPE_FREE = "OS_LICENSE_TYPE_FREE" + OS_LICENSE_TYPE_ON_DEMAND = "OS_LICENSE_TYPE_ON_DEMAND" + OTHER = "OTHER" + + +class OperatingSystem(BaseModel): + architecture: Architecture = Field( + ..., description="Type of the OS Architecture", examples=["x86_64"] + ) + family: Family = Field(..., description="Family to which OS belongs") + version: Version = Field(..., description="Version of the OS") + license: License = Field(..., description="License needed to activate the OS") + + +class Port(RootModel[conint(ge=0)]): + root: conint(ge=0) = Field(..., description="Port to stablish the connection") + + +class RequiredResources( + RootModel[ + Union[ + KubernetesResources, VmResources, ContainerResources, DockerComposeResources + ] + ] +): + root: Union[ + KubernetesResources, VmResources, ContainerResources, DockerComposeResources + ] = Field( + ..., + description="Fundamental hardware requirements to be provisioned by the\nApplication Provider.\n", + discriminator="infraKind", + ) + + +class SubmittedApp(BaseModel): + appId: Optional[AppId] = None + + +class Uri(RootModel[str]): + root: str = Field( + ..., + description="A Uniform Resource Identifier (URI) as per RFC 3986,\nidentifies the endpoint within an Edge Cloud Zone where the user\nequipment may connect to the selected application instance\n", + examples=["https://charts.bitnami.com/bitnami/helm/example-chart:0.1.0"], + ) + + +class AccessEndpoint1(BaseModel): + port: Port + fqdn: Fqdn + ipv4Addresses: Optional[List[Ipv4Addr]] = Field( + None, description="IP version 4 of an application instance", min_length=1 + ) + ipv6Addresses: Optional[List[Ipv6Addr]] = Field( + None, description="IP version 6 of an application instance.", min_length=1 + ) + + +class AccessEndpoint2(BaseModel): + port: Port + fqdn: Optional[Fqdn] = None + ipv4Addresses: List[Ipv4Addr] = Field( + ..., description="IP version 4 of an application instance", min_length=1 + ) + ipv6Addresses: Optional[List[Ipv6Addr]] = Field( + None, description="IP version 6 of an application instance.", min_length=1 + ) + + +class AccessEndpoint3(BaseModel): + port: Port + fqdn: Optional[Fqdn] = None + ipv4Addresses: Optional[List[Ipv4Addr]] = Field( + None, description="IP version 4 of an application instance", min_length=1 + ) + ipv6Addresses: List[Ipv6Addr] = Field( + ..., description="IP version 6 of an application instance.", min_length=1 + ) + + +class AccessEndpoint( + RootModel[Union[AccessEndpoint1, AccessEndpoint2, AccessEndpoint3]] +): + root: Union[AccessEndpoint1, AccessEndpoint2, AccessEndpoint3] = Field( + ..., + description="Application Endpoint for an especific instance that is\nrunning in an specific Edge Cloud Zone.\n", + ) + + +class ComponentEndpointInfoItem(BaseModel): + interfaceId: constr(pattern=r"^[A-Za-z0-9][A-Za-z0-9_]{6,30}[A-Za-z0-9]$") = Field( + ..., + description="This is the interface Identifier that Application Provider\ndefines when application is being submitted.\n", + ) + accessPoints: AccessEndpoint + + +class AppInstanceInfo(BaseModel): + name: AppInstanceName + appId: AppId + appInstanceId: AppInstanceId + appProvider: AppProvider + status: Optional[Status] = Field( + "unknown", + description="Status of the application instance (default is 'unknown')", + ) + componentEndpointInfo: Optional[List[ComponentEndpointInfoItem]] = Field( + None, + description="Information about the IP and Port exposed by the\nEdge Cloud Platform.\nApplication Client shall use these access points to reach this\napplication instance\n", + min_length=1, + ) + kubernetesClusterRef: Optional[KubernetesClusterRef] = None + edgeCloudZoneId: EdgeCloudZoneId + + +class AppRepo(BaseModel): + type: Type = Field( + ..., + description="Application repository and image URI information.\nPUBLICREPO is used of public urls like github, helm repo etc.\nPRIVATEREPO is used for private repo managed by the application\ndeveloper. Private repo can be accessed by using the app\ndeveloper provided userName and password. Password is\nrecommended to be the personal access token created by developer\ne.g. in Github repo.\n", + ) + imagePath: Uri + userName: Optional[str] = Field( + None, + description="Username to acces the Helm chart, docker-compose\nfile or VM image repository\n", + ) + credentials: Optional[constr(max_length=128)] = Field( + None, + description="Password or personal access token created by\ndeveloper to acces the app repository. API users can generate\na personal access token e.g. docker clients to use them as\npassword.\n", + ) + authType: Optional[AuthType] = Field( + None, + description='The credentials can also be formatted as a Basic\nauth or Bearer auth in HTTP "Authorization" header.\n', + ) + checksum: Optional[str] = Field( + None, + description="MD5 checksum for VM and file-based images, sha256\ndigest for containers\n", + ) + + +class AppManifest(BaseModel): + appId: Optional[AppId] = None + name: constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{1,63}$") = Field( + ..., description="Name of the application." + ) + appProvider: AppProvider + version: str = Field(..., description="Application version information") + packageType: PackageType = Field( + ..., description="Format of the application image package" + ) + operatingSystem: Optional[OperatingSystem] = None + appRepo: AppRepo = Field( + ..., + description="Repository where Application Provider stores the application image\n", + ) + requiredResources: RequiredResources + componentSpec: List[ComponentSpecItem] = Field( + ..., + description='Information defined in "appRepo" point to the application\ndescriptor e.g. Helm chart, docker-compose yaml file etc.\nThe descriptor may contain one or more containers and their\nassociated meta-data. A component refers to additional details\nabout these containers to expose the instances of the containers\nto external client applications. App provider can define one or\nmore components (via the associated network port) in componentSpec\ncorresponding to the containers in helm charts or docker-compose\nyaml file as part of app descriptors.\n', + ) + + +class ClusterInfo(BaseModel): + name: str = Field(..., description="Name of the Cluster, scoped to the Provider\n") + provider: AppProvider + clusterRef: KubernetesClusterRef + edgeCloudZoneId: EdgeCloudZoneId + edgeCloudRegion: Optional[EdgeCloudRegion] = None + version: Optional[str] = Field( + None, description="Kubernetes version of the cluster." + ) + nodePools: Optional[List[KubernetesNodePool]] = Field( + None, description="Node Pools in the cluster.", min_length=1 + ) + + +class EdgeCloudZone(BaseModel): + edgeCloudZoneId: EdgeCloudZoneId + edgeCloudZoneName: EdgeCloudZoneName + edgeCloudZoneStatus: Optional[EdgeCloudZoneStatus] = "unknown" + edgeCloudProvider: EdgeCloudProvider + edgeCloudRegion: Optional[EdgeCloudRegion] = None + + +class EdgeCloudZones(RootModel[List[EdgeCloudZone]]): + root: List[EdgeCloudZone] = Field( + ..., + description="A collection of Edge Cloud Zones where the Application Provider can\ninstantiate an Application Instance.\n", + min_length=1, + ) -- GitLab From 5a229851074d0d45ba5dfbba54045453f90c4b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 17:49:44 +0200 Subject: [PATCH 214/281] Normalise get_edge_cloud_zones to CAMARA format --- .../edgecloud/adapters/i2edge/client.py | 50 ++++++++++++++++--- .../edgecloud/core/edgecloud_interface.py | 6 ++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 86f8b5e..d0fda3d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -10,10 +10,12 @@ import json from copy import deepcopy from typing import Dict, List, Optional +from uuid import NAMESPACE_DNS, UUID, uuid5 from requests import Response from sunrise6g_opensdk import logger +from sunrise6g_opensdk.edgecloud.core import schemas as camara from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) @@ -30,6 +32,21 @@ from .common import ( log = logger.get_logger(__name__) +# TODO: Workaround to avoid the SDK crash when ZoneId is not a valid UUID (e.g. Omega) +def _ensure_valid_uuid(value: str) -> str: + """ + Return the original value if it's a valid UUID, + or generate a deterministic UUIDv5 from the input string otherwise. + """ + try: + UUID(value) + return value + except ValueError: + generated = str(uuid5(NAMESPACE_DNS, value)) + log.warning(f"Invalid UUID '{value}' – using generated UUIDv5: {generated}") + return generated + + class EdgeApplicationManager(EdgeCloudManagementInterface): """ i2Edge Client @@ -41,18 +58,37 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.content_type_gsma = "application/json" self.encoding_gsma = "utf-8" + # -------------------------------------------------------------------- + # CAMARA Edge Cloud Management Functions + # -------------------------------------------------------------------- def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None - ) -> list[dict]: - url = "{}/zones/list".format(self.base_url) + ) -> list[camara.EdgeCloudZone]: + url = f"{self.base_url}/zones/list" params = {} try: - response = i2edge_get(url, params=params) + i2edge_response = i2edge_get(url, params=params).json() log.info("Availability zones retrieved successfully") - return response + # Normalise to CAMARA format + camara_response = [] + for z in i2edge_response: + edgeCloudZoneId = camara.EdgeCloudZoneId( + _ensure_valid_uuid(z["zoneId"]) + ) + zone = camara.EdgeCloudZone( + edgeCloudZoneId=edgeCloudZoneId, + edgeCloudZoneName=camara.EdgeCloudZoneName(z["nodeName"]), + edgeCloudProvider=camara.EdgeCloudProvider("i2edge"), + edgeCloudRegion=camara.EdgeCloudRegion(z["geographyDetails"]), + edgeCloudZoneStatus=camara.EdgeCloudZoneStatus.unknown, + ) + camara_response.append(zone) + return camara_response except I2EdgeError as e: - raise e + log.error(f"Failed to retrieve edge cloud zones: {e}") + raise + # TODO: Delete it def get_edge_cloud_zones_details( self, zone_id: str, flavour_id: Optional[str] = None ) -> Dict: @@ -250,7 +286,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # GSMA FM + # -------------------------------------------------------------------- + # EWBI GSMA OPG FUNCTIONS + # -------------------------------------------------------------------- # FederationManagement diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 3d2ff29..38dbc80 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -10,6 +10,10 @@ from abc import ABC, abstractmethod from typing import Dict, List, Optional +from .schemas import ( + EdgeCloudZone, +) + class EdgeCloudManagementInterface(ABC): """ @@ -96,7 +100,7 @@ class EdgeCloudManagementInterface(ABC): @abstractmethod def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None - ) -> List[Dict]: + ) -> List[EdgeCloudZone]: """ Retrieves a list of available Edge Cloud Zones. -- GitLab From b1ebcd9d78a114a8fea9127b8c31cc1b749075d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 17:54:40 +0200 Subject: [PATCH 215/281] Delete get_edge_cloud_zones_details (get_edge_cloud_zones_list_gsma) --- .../edgecloud/adapters/i2edge/client.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index d0fda3d..8b2de44 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -88,20 +88,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to retrieve edge cloud zones: {e}") raise - # TODO: Delete it - def get_edge_cloud_zones_details( - self, zone_id: str, flavour_id: Optional[str] = None - ) -> Dict: - url = "{}zone/{}".format(self.base_url, zone_id) - params = {} - try: - response = i2edge_get(url, params=params) - log.info("Availability zone details retrieved successfully") - return response - except I2EdgeError as e: - raise e - - def _create_artefact( + def create_artefact( self, artefact_id: str, artefact_name: str, -- GitLab From d25e385b89eb78256263bf26dd77fbef50e2d217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 21 Jul 2025 17:55:20 +0200 Subject: [PATCH 216/281] Delete get_edge_cloud_zones_details from edgecloud_interface --- .../edgecloud/core/edgecloud_interface.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 38dbc80..3da4fd5 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -110,20 +110,6 @@ class EdgeCloudManagementInterface(ABC): """ pass - @abstractmethod - def get_edge_cloud_zones_details( - self, federation_context_id: str, zone_id: str - ) -> Dict: - """ - Retrieves details of a specific Edge Cloud Zone reserved - for the specified zone by the partner OP. - - :param federation_context_id: Identifier of the federation context. - :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with Edge Cloud Zone details. - """ - pass - # --- GSMA-specific methods --- # FederationManagement -- GitLab From 57eb05961e5ccb867b7ff62316dbfcf66b693912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 22 Jul 2025 16:20:15 +0200 Subject: [PATCH 217/281] Ensure i2edge_post accepts only Pydantic models or dicts as payload --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 16a7017..726e45c 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -42,7 +42,12 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: "Content-Type": "application/json", "accept": "application/json", } - json_payload = json.dumps(model_payload.model_dump(mode="json")) + if isinstance(model_payload, BaseModel): + json_payload = json.dumps(model_payload.model_dump(mode="json")) + elif isinstance(model_payload, dict): + json_payload = json.dumps(model_payload) + else: + raise TypeError("Payload must be a Pydantic model or a dict.") try: response = requests.post(url, data=json_payload, headers=headers) response.raise_for_status() -- GitLab From 5d0d1ac9859c8ca712b903fee7ab1cb0e0c62d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 22 Jul 2025 16:31:03 +0200 Subject: [PATCH 218/281] Make get edge cloud zones + onboard app CAMARA compliant. Update helper Rename _build_custom_gsma_response to _build_custom_http_response --- .../edgecloud/adapters/i2edge/client.py | 228 ++++++++++++------ .../edgecloud/adapters/i2edge/schemas.py | 7 +- .../edgecloud/core/edgecloud_interface.py | 32 ++- tests/edgecloud/test_config.py | 12 +- tests/edgecloud/test_e2e.py | 119 ++++----- 5 files changed, 229 insertions(+), 169 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 8b2de44..4589241 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -12,15 +12,16 @@ from copy import deepcopy from typing import Dict, List, Optional from uuid import NAMESPACE_DNS, UUID, uuid5 +from pydantic import ValidationError from requests import Response from sunrise6g_opensdk import logger -from sunrise6g_opensdk.edgecloud.core import schemas as camara +from sunrise6g_opensdk.edgecloud.core import schemas as camara_schemas from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) -from ...adapters.i2edge import schemas +from ...adapters.i2edge import schemas as i2edge_schemas from .common import ( I2EdgeError, i2edge_delete, @@ -32,7 +33,8 @@ from .common import ( log = logger.get_logger(__name__) -# TODO: Workaround to avoid the SDK crash when ZoneId is not a valid UUID (e.g. Omega) +# TODO: Check if this should be deleted +# Workaround function to avoid the SDK crash when ZoneId is not a valid UUID (e.g. Omega) def _ensure_valid_uuid(value: str) -> str: """ Return the original value if it's a valid UUID, @@ -43,10 +45,37 @@ def _ensure_valid_uuid(value: str) -> str: return value except ValueError: generated = str(uuid5(NAMESPACE_DNS, value)) - log.warning(f"Invalid UUID '{value}' – using generated UUIDv5: {generated}") + log.warning( + f"[WARNING] Invalid UUID '{value}' – using generated UUIDv5: {generated}" + ) return generated +# TODO: Move this to common utils file +def _build_custom_http_response( + status_code: int, + content: str | bytes | dict | list, + headers: dict = None, + encoding: str = None, + url: str = None, + request=None, +) -> Response: + response = Response() + response.status_code = status_code + if isinstance(content, (dict, list)): + content = json.dumps(content) + response._content = ( + content.encode(encoding or "utf-8") if isinstance(content, str) else content + ) + response.headers.update(headers or {}) + response.encoding = encoding or "utf-8" + if url: + response.url = url + if request: + response.request = request + return response + + class EdgeApplicationManager(EdgeCloudManagementInterface): """ i2Edge Client @@ -61,33 +90,54 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # -------------------------------------------------------------------- # CAMARA Edge Cloud Management Functions # -------------------------------------------------------------------- + + # Edge Cloud + def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None - ) -> list[camara.EdgeCloudZone]: + ) -> Response: url = f"{self.base_url}/zones/list" params = {} + try: - i2edge_response = i2edge_get(url, params=params).json() + response = i2edge_get(url, params=params) + response.raise_for_status() + i2edge_response = response.json() log.info("Availability zones retrieved successfully") # Normalise to CAMARA format camara_response = [] for z in i2edge_response: - edgeCloudZoneId = camara.EdgeCloudZoneId( - _ensure_valid_uuid(z["zoneId"]) - ) - zone = camara.EdgeCloudZone( - edgeCloudZoneId=edgeCloudZoneId, - edgeCloudZoneName=camara.EdgeCloudZoneName(z["nodeName"]), - edgeCloudProvider=camara.EdgeCloudProvider("i2edge"), - edgeCloudRegion=camara.EdgeCloudRegion(z["geographyDetails"]), - edgeCloudZoneStatus=camara.EdgeCloudZoneStatus.unknown, + zone = camara_schemas.EdgeCloudZone( + # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( + _ensure_valid_uuid(z["zoneId"]) + ), + edgeCloudZoneName=camara_schemas.EdgeCloudZoneName(z["nodeName"]), + edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), + edgeCloudRegion=camara_schemas.EdgeCloudRegion( + z["geographyDetails"] + ), + edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, ) camara_response.append(zone) - return camara_response + # Wrap into a Response object + return _build_custom_http_response( + status_code=response.status_code, + content=[zone.model_dump(mode="json") for zone in camara_response], + headers={"Content-Type": "application/json"}, + encoding=response.encoding, + url=response.url, + request=response.request, + ) + except KeyError as e: + log.error(f"Missing required CAMARA field in app manifest: {e}") + raise ValueError(f"Invalid CAMARA manifest – missing field: {e}") except I2EdgeError as e: log.error(f"Failed to retrieve edge cloud zones: {e}") raise + # Artefact Management (Not included in CAMARA atm) + def create_artefact( self, artefact_id: str, @@ -99,9 +149,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): token: Optional[str] = None, user_name: Optional[str] = None, ): - repo_type = schemas.RepoType(repo_type) + repo_type = i2edge_schemas.RepoType(repo_type) url = "{}/artefact".format(self.base_url) - payload = schemas.ArtefactOnboarding( + payload = i2edge_schemas.ArtefactOnboarding( artefact_id=artefact_id, name=artefact_name, repo_password=password, @@ -145,28 +195,80 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def onboard_app(self, app_manifest: Dict) -> Dict: + # Application + + def onboard_app(self, app_manifest: Dict) -> Response: + """ + Onboards an application using a CAMARA-compliant manifest. + Translates the manifest to the i2Edge format and returns a CAMARA-compliant response. + + :param app_manifest: CAMARA-compliant application manifest + :return: Response with status code, headers, and CAMARA-normalised payload + """ try: + # Validate CAMARA input + camara_schemas.AppManifest(**app_manifest) + + # Extract relevant fields from CAMARA manifest app_id = app_manifest["appId"] + app_name = app_manifest["name"] + app_version = app_manifest["version"] + app_provider = app_manifest["appProvider"] + + # Map CAMARA to i2Edge artefact_id = app_id + app_component_spec = i2edge_schemas.AppComponentSpec(artefactId=artefact_id) + app_metadata = i2edge_schemas.AppMetaData( + appName=app_name, appProviderId=app_provider, version=app_version + ) - app_component_spec = schemas.AppComponentSpec(artefactId=artefact_id) - data = schemas.ApplicationOnboardingData( - app_id=app_id, appComponentSpecs=[app_component_spec] + onboarding_data = i2edge_schemas.ApplicationOnboardingData( + app_id=app_id, + appProviderId=app_provider, + appComponentSpecs=[app_component_spec], + appMetaData=app_metadata, ) - payload = schemas.ApplicationOnboardingRequest(profile_data=data) - url = "{}/application/onboarding".format(self.base_url) - response = i2edge_post(url, payload) - return response + + i2edge_payload = i2edge_schemas.ApplicationOnboardingRequest( + profile_data=onboarding_data + ) + + # Call i2Edge API + i2edge_response = i2edge_post( + f"{self.base_url}/application/onboarding", + model_payload=i2edge_payload.model_dump( + mode="json", exclude_defaults=True + ), + ) + i2edge_response.raise_for_status() + + # Build CAMARA-compliant response + camara_payload = { + "appId": app_id, + "message": "Application onboarded successfully", + } + log.info("App onboarded successfully") + return _build_custom_http_response( + status_code=i2edge_response.status_code, + content=camara_payload, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) + + except ValidationError as e: + log.error(f"Invalid CAMARA manifest: {e}") + raise ValueError(f"Invalid CAMARA manifest: {e}") except I2EdgeError as e: - raise e - except KeyError as e: - raise I2EdgeError("Missing required field in app_manifest: {}".format(e)) + log.error(f"Failed to onboard app to i2Edge: {e}") + raise def delete_onboarded_app(self, app_id: str) -> None: url = "{}/application/onboarding".format(self.base_url) try: response = i2edge_delete(url, app_id) + log.info("App onboarded deleted successfully") return response except I2EdgeError as e: raise e @@ -203,14 +305,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone_id = zone_info["edgeCloudZoneId"] # TODO: atm the flavour id is specified as an input parameter # flavourId = self._select_best_flavour_for_app(zone_id=zone_id) - app_deploy_data = schemas.AppDeployData( + app_deploy_data = i2edge_schemas.AppDeployData( appId=appId, appProviderId=appProviderId, appVersion=appVersion, - zoneInfo=schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), + zoneInfo=i2edge_schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), ) url = "{}/app/".format(self.base_url) - payload = schemas.AppDeploy(app_deploy_data=app_deploy_data) + payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) try: response = i2edge_post(url, payload) log.info("App deployed successfully") @@ -294,7 +396,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "geographyDetails": item.get("geographyDetails"), } response_list.append(content) - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -330,7 +432,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), } response_list.append(content) - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -353,7 +455,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = i2edge_get(url, params=params) if response.status_code == 200: content = {"acceptedZoneResourceInfo": response.json()} - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -388,7 +490,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneServiceLevelObjsInfo" ), } - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -423,7 +525,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = self._create_artefact(**transformed) if response.status_code == 201: - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content={"response": "Artefact uploaded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -459,7 +561,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "token": response_json.get("repo_token"), }, } - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -475,7 +577,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self._delete_artefact(artefact_id) if response.status_code == 200: - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content='{"response": "Artefact deletion successful"}', headers={"Content-Type": self.content_type_gsma}, @@ -497,11 +599,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): body["app_id"] = body.pop("appId") body.pop("edgeAppFQDN", None) data = body - payload = schemas.ApplicationOnboardingRequest(profile_data=data) + payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 200: - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content={"response": "Application onboarded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -527,7 +629,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appQoSProfile": profile_data.get("appQoSProfile"), "appComponentSpecs": profile_data.get("appComponentSpecs"), } - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -548,7 +650,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content={"response": "App deletion successful"}, headers={"Content-Type": self.content_type_gsma}, @@ -569,13 +671,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: zone_id = body.get("zoneInfo").get("zoneId") flavour_id = body.get("zoneInfo").get("flavourId") - app_deploy_data = schemas.AppDeployData( + app_deploy_data = i2edge_schemas.AppDeployData( appId=body.get("appId"), appProviderId=body.get("appProviderId"), appVersion=body.get("appVersion"), - zoneInfo=schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), + zoneInfo=i2edge_schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) - payload = schemas.AppDeploy( + payload = i2edge_schemas.AppDeploy( app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} ) url = "{}/application_instance".format(self.base_url) @@ -586,7 +688,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneId": response_json.get("zoneID"), "appInstIdentifier": response_json.get("app_instance_id"), } - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=202, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -617,7 +719,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appInstanceState": response_json.get("appInstanceState"), "accesspointInfo": response_json.get("accesspointInfo"), } - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -657,7 +759,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } ] response_list.append(content) - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -680,7 +782,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) if response.status_code == 200: - return self._build_custom_gsma_response( + return self._build_custom_http_response( status_code=200, content={ "response": "Application instance termination request accepted" @@ -693,29 +795,3 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return response except KeyError as e: raise I2EdgeError(f"Missing appInstanceId in GSMA payload: {e}") - - # GSMA Support methods - - def _build_custom_gsma_response( - self, - status_code: int, - content: str | bytes | dict | list, - headers: dict = None, - encoding: str = None, - url: str = None, - request=None, - ) -> Response: - response = Response() - response.status_code = status_code - if isinstance(content, (dict, list)): - content = json.dumps(content) - response._content = ( - content.encode(encoding or "utf-8") if isinstance(content, str) else content - ) - response.headers.update(headers or {}) - response.encoding = encoding or "utf-8" - if url: - response.url = url - if request: - response.request = request - return response diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index d4be3d5..8717e9f 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -63,9 +63,9 @@ class ArtefactOnboarding(BaseModel): class AppComponentSpec(BaseModel): artefactId: str - componentName: str = Field(default="default_component") - serviceNameEW: str = Field(default="default_ew_service") - serviceNameNB: str = Field(default="default_nb_service") + componentName: Optional[str] = Field(default="default_component") + serviceNameEW: Optional[str] = Field(default="default_ew_service") + serviceNameNB: Optional[str] = Field(default="default_nb_service") class AppMetaData(BaseModel): @@ -91,7 +91,6 @@ class ApplicationOnboardingData(BaseModel): appMetaData: AppMetaData = Field(default_factory=AppMetaData) appProviderId: str = Field(default="default_provider") appQoSProfile: AppQoSProfile = Field(default_factory=AppQoSProfile) - appStatusCallbackLink: Optional[str] = None class ApplicationOnboardingRequest(BaseModel): diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 3da4fd5..5a6bb04 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -10,9 +10,7 @@ from abc import ABC, abstractmethod from typing import Dict, List, Optional -from .schemas import ( - EdgeCloudZone, -) +from requests import Response class EdgeCloudManagementInterface(ABC): @@ -21,7 +19,20 @@ class EdgeCloudManagementInterface(ABC): """ @abstractmethod - def onboard_app(self, app_manifest: Dict) -> Dict: + def get_edge_cloud_zones( + self, region: Optional[str] = None, status: Optional[str] = None + ) -> Response: + """ + Retrieves a list of available Edge Cloud Zones. + + :param region: Filter by geographical region. + :param status: Filter by status (active, inactive, unknown). + :return: List of Edge Cloud Zones. + """ + pass + + @abstractmethod + def onboard_app(self, app_manifest: Dict) -> Response: """ Onboards an app, submitting application metadata to the Edge Cloud Provider. @@ -97,19 +108,6 @@ class EdgeCloudManagementInterface(ABC): """ pass - @abstractmethod - def get_edge_cloud_zones( - self, region: Optional[str] = None, status: Optional[str] = None - ) -> List[EdgeCloudZone]: - """ - Retrieves a list of available Edge Cloud Zones. - - :param region: Filter by geographical region. - :param status: Filter by status (active, inactive, unknown). - :return: List of Edge Cloud Zones. - """ - pass - # --- GSMA-specific methods --- # FederationManagement diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index decd66d..fd38249 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -1,16 +1,16 @@ CONFIG = { "i2edge": { "ZONE_ID": "Omega", - "ARTEFACT_ID": "i2edgechart-id-2", + "ARTEFACT_ID": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", "ARTEFACT_NAME": "i2edgechart", "REPO_NAME": "github-cesar", "REPO_TYPE": "PUBLICREPO", "REPO_URL": "https://cesarcajas.github.io/helm-charts-examples/", "APP_ONBOARD_MANIFEST": { - "appId": "i2edgechart-id-2", - "name": "i2edge-app-SDK", + "appId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + "name": "i2edge_app_SDK", "version": "1.0.0", - "appProvider": "i2CAT", + "appProvider": "i2CAT_DEV", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", @@ -46,7 +46,7 @@ CONFIG = { } ], }, - "APP_ID": "i2edgechart-id-2", + "APP_ID": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", "APP_ZONES": [ { "kubernetesClusterRef": "not-used", @@ -124,7 +124,7 @@ CONFIG = { "kubernetes": { "K8S_ONBOARDED_APP_NAME": "nginx", "K8S_APP_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "ZONE_ID": "999b7746-d2e2-4bb4-96e6-f1e895adef0c", + "ZONE_ID": "b2a1b33d-f382-47de-b555-2d32155eb74c", "K8S_DEPLOY_PAYLOAD": { "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "nginx-test", diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 80b7558..eeafa94 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -24,12 +24,14 @@ Key features: import time import pytest +from requests import Response from sunrise6g_opensdk.common.sdk import Sdk as sdkclient from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) +from sunrise6g_opensdk.edgecloud.core import schemas as camara_schemas from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config import CONFIG @@ -52,50 +54,25 @@ def id_func(val): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones(edgecloud_client): try: - zones = edgecloud_client.get_edge_cloud_zones() + response = edgecloud_client.get_edge_cloud_zones() + assert isinstance(response, Response) + assert response.status_code == 200 + zones = response.json() assert isinstance(zones, list) - # TODO: Harmonise zone schema to match CAMARA schemas across all clients - if edgecloud_client.client_name == "i2edge": - for zone in zones: - assert "zoneId" in zone - assert "geographyDetails" in zone - else: - for zone in zones: - assert "edgeCloudZoneId" in zone - assert "edgeCloudZoneName" in zone - assert "edgeCloudZoneStatus" in zone - assert "edgeCloudProvider" in zone - assert "edgeCloudRegion" in zone + for zone in zones: + camara_schemas.EdgeCloudZone(**zone) # Validate against CAMARA model except EdgeCloudPlatformError as e: pytest.fail(f"Failed to retrieve zones: {e}") - - -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_get_edge_cloud_zones_details(edgecloud_client): - config = CONFIG[edgecloud_client.client_name] - zone_id = config["ZONE_ID"] - - try: - zones = edgecloud_client.get_edge_cloud_zones() - assert len(zones) > 0, "No zones available for testing" - - zone_details = edgecloud_client.get_edge_cloud_zones_details(zone_id) - - assert zone_details is not None - assert isinstance(zone_details, dict) - assert len(zone_details) > 0 - - except (EdgeCloudPlatformError, KeyError) as e: - pytest.fail(f"Zone detail fetch failed: {e}") + except Exception as e: + pytest.fail(f"Unexpected error during zone validation: {e}") @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_create_artefact(edgecloud_client): config = CONFIG[edgecloud_client.client_name] - if isinstance(edgecloud_client, I2EdgeClient): try: - edgecloud_client._create_artefact( + edgecloud_client.create_artefact( artefact_id=config["ARTEFACT_ID"], artefact_name=config["ARTEFACT_NAME"], repo_name=config["REPO_NAME"], @@ -113,52 +90,62 @@ def test_create_artefact(edgecloud_client): def test_onboard_app(edgecloud_client): config = CONFIG[edgecloud_client.client_name] try: - edgecloud_client.onboard_app(config["APP_ONBOARD_MANIFEST"]) + response = edgecloud_client.onboard_app(config["APP_ONBOARD_MANIFEST"]) + assert isinstance(response, Response) + assert response.status_code == 201 + + payload = response.json() + assert isinstance(payload, dict) + assert "appId" in payload + camara_schemas.AppId(root=payload["appId"]) + except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed: {e}") + except Exception as e: + pytest.fail(f"Unexpected error during app onboarding: {e}") -@pytest.fixture(scope="module") -def app_instance_id(edgecloud_client): - config = CONFIG[edgecloud_client.client_name] - try: - if edgecloud_client.client_name == "kubernetes": - output = edgecloud_client.deploy_app(config["K8S_DEPLOY_PAYLOAD"]) - else: - output = edgecloud_client.deploy_app(config["APP_ID"], config["APP_ZONES"]) +# @pytest.fixture(scope="module") +# def app_instance_id(edgecloud_client): +# config = CONFIG[edgecloud_client.client_name] +# try: +# if edgecloud_client.client_name == "kubernetes": +# output = edgecloud_client.deploy_app(config["K8S_DEPLOY_PAYLOAD"]) +# else: +# output = edgecloud_client.deploy_app(config["APP_ID"], config["APP_ZONES"]) - if edgecloud_client.client_name == "i2edge": - app_instance_id = output.get("deploy_name") - else: - app_instance_id = output.get("appInstanceId") +# if edgecloud_client.client_name == "i2edge": +# app_instance_id = output.get("deploy_name") +# else: +# app_instance_id = output.get("appInstanceId") - assert app_instance_id is not None - yield app_instance_id - finally: - pass +# assert app_instance_id is not None +# yield app_instance_id +# finally: +# pass -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_deploy_app(app_instance_id): - assert app_instance_id is not None +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_deploy_app(app_instance_id): +# assert app_instance_id is not None -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_60_seconds(edgecloud_client): - time.sleep(60) +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_timer_wait_10_seconds(edgecloud_client): +# time.sleep(10) -@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_undeploy_app(edgecloud_client, app_instance_id): - try: - edgecloud_client.undeploy_app(app_instance_id) - except EdgeCloudPlatformError as e: - pytest.fail(f"App undeployment failed: {e}") +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_undeploy_app(edgecloud_client, app_instance_id): +# try: +# edgecloud_client.undeploy_app(app_instance_id) +# except EdgeCloudPlatformError as e: +# pytest.fail(f"App undeployment failed: {e}") @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_30_seconds(edgecloud_client): - time.sleep(30) +def test_timer_wait_3_seconds(edgecloud_client): + time.sleep(3) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) @@ -178,6 +165,6 @@ def test_delete_artefact(edgecloud_client): if isinstance(edgecloud_client, I2EdgeClient): try: - edgecloud_client._delete_artefact(artefact_id=config["ARTEFACT_ID"]) + edgecloud_client.delete_artefact(artefact_id=config["ARTEFACT_ID"]) except EdgeCloudPlatformError as e: pytest.fail(f"Artefact deletion failed: {e}") -- GitLab From 0cbfd7bc9f800de1a3464cc126de5703a5c49929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 22 Jul 2025 16:32:43 +0200 Subject: [PATCH 219/281] Refactor: rename artefact i2edge client functions to public API methods --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 4589241..8564649 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -168,7 +168,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def _get_artefact(self, artefact_id: str) -> Dict: + def get_artefact(self, artefact_id: str) -> Dict: url = "{}/artefact/{}".format(self.base_url, artefact_id) try: response = i2edge_get(url, artefact_id) @@ -177,7 +177,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def _get_all_artefacts(self) -> List[Dict]: + def get_all_artefacts(self) -> List[Dict]: url = "{}/artefact".format(self.base_url) try: response = i2edge_get(url, {}) @@ -186,7 +186,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def _delete_artefact(self, artefact_id: str): + def delete_artefact(self, artefact_id: str): url = "{}/artefact".format(self.base_url) try: response = i2edge_delete(url, artefact_id) -- GitLab From b1e88aee8e9e0e26ba0d9b185ecf4d3e6f1f5b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 22 Jul 2025 16:42:02 +0200 Subject: [PATCH 220/281] Move helpers (build http response, ensure valid uuid) to core/utils --- .../edgecloud/adapters/i2edge/client.py | 49 ++-------------- .../edgecloud/adapters/i2edge/schemas.py | 1 + src/sunrise6g_opensdk/edgecloud/core/utils.py | 56 +++++++++++++++++++ 3 files changed, 61 insertions(+), 45 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/core/utils.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 8564649..74770e4 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -7,10 +7,8 @@ # - Sergio Giménez (sergio.gimenez@i2cat.net) # - César Cajas (cesar.cajas@i2cat.net) ## -import json from copy import deepcopy from typing import Dict, List, Optional -from uuid import NAMESPACE_DNS, UUID, uuid5 from pydantic import ValidationError from requests import Response @@ -20,6 +18,10 @@ from sunrise6g_opensdk.edgecloud.core import schemas as camara_schemas from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) +from sunrise6g_opensdk.edgecloud.core.utils import ( + _build_custom_http_response, + _ensure_valid_uuid, +) from ...adapters.i2edge import schemas as i2edge_schemas from .common import ( @@ -33,49 +35,6 @@ from .common import ( log = logger.get_logger(__name__) -# TODO: Check if this should be deleted -# Workaround function to avoid the SDK crash when ZoneId is not a valid UUID (e.g. Omega) -def _ensure_valid_uuid(value: str) -> str: - """ - Return the original value if it's a valid UUID, - or generate a deterministic UUIDv5 from the input string otherwise. - """ - try: - UUID(value) - return value - except ValueError: - generated = str(uuid5(NAMESPACE_DNS, value)) - log.warning( - f"[WARNING] Invalid UUID '{value}' – using generated UUIDv5: {generated}" - ) - return generated - - -# TODO: Move this to common utils file -def _build_custom_http_response( - status_code: int, - content: str | bytes | dict | list, - headers: dict = None, - encoding: str = None, - url: str = None, - request=None, -) -> Response: - response = Response() - response.status_code = status_code - if isinstance(content, (dict, list)): - content = json.dumps(content) - response._content = ( - content.encode(encoding or "utf-8") if isinstance(content, str) else content - ) - response.headers.update(headers or {}) - response.encoding = encoding or "utf-8" - if url: - response.url = url - if request: - response.request = request - return response - - class EdgeApplicationManager(EdgeCloudManagementInterface): """ i2Edge Client diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index 8717e9f..53c3311 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -6,6 +6,7 @@ # Contributors: # - Sergio Giménez (sergio.gimenez@i2cat.net) # - César Cajas (cesar.cajas@i2cat.net) +# - Adrián Pino Martínez (adrian.pino@i2cat.net) ## from enum import Enum from typing import List, Optional diff --git a/src/sunrise6g_opensdk/edgecloud/core/utils.py b/src/sunrise6g_opensdk/edgecloud/core/utils.py new file mode 100644 index 0000000..7e1050a --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/core/utils.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +## +# This file is part of the Open SDK +# +# Contributors: +# - Adrián Pino Martínez (adrian.pino@i2cat.net) +# - César Cajas (cesar.cajas@i2cat.net) +## +import json +from uuid import NAMESPACE_DNS, UUID, uuid5 + +from requests import Response + +from sunrise6g_opensdk import logger + +log = logger.get_logger(__name__) + + +def _ensure_valid_uuid(value: str) -> str: + """ + Return the original value if it's a valid UUID, + or generate a deterministic UUIDv5 from the input string otherwise. + """ + try: + UUID(value) + return value + except ValueError: + generated = str(uuid5(NAMESPACE_DNS, value)) + log.warning( + f"[WARNING] Invalid UUID '{value}' – using generated UUIDv5: {generated}" + ) + return generated + + +def _build_custom_http_response( + status_code: int, + content: str | bytes | dict | list, + headers: dict = None, + encoding: str = None, + url: str = None, + request=None, +) -> Response: + response = Response() + response.status_code = status_code + if isinstance(content, (dict, list)): + content = json.dumps(content) + response._content = ( + content.encode(encoding or "utf-8") if isinstance(content, str) else content + ) + response.headers.update(headers or {}) + response.encoding = encoding or "utf-8" + if url: + response.url = url + if request: + response.request = request + return response -- GitLab From 0a9dd21a81a7c762da8e6a499547317a31d87ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 22 Jul 2025 16:44:02 +0200 Subject: [PATCH 221/281] Make edgecloud helper functions public --- .../edgecloud/adapters/i2edge/client.py | 38 +++++++++---------- src/sunrise6g_opensdk/edgecloud/core/utils.py | 4 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 74770e4..34207a5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -19,8 +19,8 @@ from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) from sunrise6g_opensdk.edgecloud.core.utils import ( - _build_custom_http_response, - _ensure_valid_uuid, + build_custom_http_response, + ensure_valid_uuid, ) from ...adapters.i2edge import schemas as i2edge_schemas @@ -69,7 +69,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone = camara_schemas.EdgeCloudZone( # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( - _ensure_valid_uuid(z["zoneId"]) + ensure_valid_uuid(z["zoneId"]) ), edgeCloudZoneName=camara_schemas.EdgeCloudZoneName(z["nodeName"]), edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), @@ -80,7 +80,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) camara_response.append(zone) # Wrap into a Response object - return _build_custom_http_response( + return build_custom_http_response( status_code=response.status_code, content=[zone.model_dump(mode="json") for zone in camara_response], headers={"Content-Type": "application/json"}, @@ -207,7 +207,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "message": "Application onboarded successfully", } log.info("App onboarded successfully") - return _build_custom_http_response( + return build_custom_http_response( status_code=i2edge_response.status_code, content=camara_payload, headers={"Content-Type": "application/json"}, @@ -355,7 +355,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "geographyDetails": item.get("geographyDetails"), } response_list.append(content) - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -391,7 +391,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), } response_list.append(content) - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -414,7 +414,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = i2edge_get(url, params=params) if response.status_code == 200: content = {"acceptedZoneResourceInfo": response.json()} - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -449,7 +449,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneServiceLevelObjsInfo" ), } - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -484,7 +484,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = self._create_artefact(**transformed) if response.status_code == 201: - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content={"response": "Artefact uploaded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -520,7 +520,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "token": response_json.get("repo_token"), }, } - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -536,7 +536,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self._delete_artefact(artefact_id) if response.status_code == 200: - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content='{"response": "Artefact deletion successful"}', headers={"Content-Type": self.content_type_gsma}, @@ -562,7 +562,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 200: - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content={"response": "Application onboarded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -588,7 +588,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appQoSProfile": profile_data.get("appQoSProfile"), "appComponentSpecs": profile_data.get("appComponentSpecs"), } - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -609,7 +609,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content={"response": "App deletion successful"}, headers={"Content-Type": self.content_type_gsma}, @@ -647,7 +647,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneId": response_json.get("zoneID"), "appInstIdentifier": response_json.get("app_instance_id"), } - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=202, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -678,7 +678,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appInstanceState": response_json.get("appInstanceState"), "accesspointInfo": response_json.get("accesspointInfo"), } - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -718,7 +718,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } ] response_list.append(content) - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -741,7 +741,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) if response.status_code == 200: - return self._build_custom_http_response( + return self.build_custom_http_response( status_code=200, content={ "response": "Application instance termination request accepted" diff --git a/src/sunrise6g_opensdk/edgecloud/core/utils.py b/src/sunrise6g_opensdk/edgecloud/core/utils.py index 7e1050a..f6be6ed 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/core/utils.py @@ -16,7 +16,7 @@ from sunrise6g_opensdk import logger log = logger.get_logger(__name__) -def _ensure_valid_uuid(value: str) -> str: +def ensure_valid_uuid(value: str) -> str: """ Return the original value if it's a valid UUID, or generate a deterministic UUIDv5 from the input string otherwise. @@ -32,7 +32,7 @@ def _ensure_valid_uuid(value: str) -> str: return generated -def _build_custom_http_response( +def build_custom_http_response( status_code: int, content: str | bytes | dict | list, headers: dict = None, -- GitLab From 2fc114d624f0899332cfdc631d2b183660890c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Jul 2025 10:58:56 +0200 Subject: [PATCH 222/281] Update i2Edge GSMA methods to use public artefact-related methods --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 34207a5..f6b3281 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -482,7 +482,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "token": repo_data.get("token"), } - response = self._create_artefact(**transformed) + response = self.create_artefact(**transformed) if response.status_code == 201: return self.build_custom_http_response( status_code=200, @@ -498,7 +498,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: try: - response = self._get_artefact(artefact_id) + response = self.get_artefact(artefact_id) if response.status_code == 200: response_json = response.json() print(response_json) @@ -534,7 +534,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): try: - response = self._delete_artefact(artefact_id) + response = self.delete_artefact(artefact_id) if response.status_code == 200: return self.build_custom_http_response( status_code=200, -- GitLab From e568204849dea97cc95dff967fcafc289f7fc6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Jul 2025 12:46:32 +0200 Subject: [PATCH 223/281] Improve example --- examples/example.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/example.py b/examples/example.py index 21dbc18..d8ea280 100644 --- a/examples/example.py +++ b/examples/example.py @@ -26,8 +26,15 @@ def main(): # Examples: # EdgeCloud # print("Testing edgecloud client function: get_edge_cloud_zones:") - # zones = edgecloud_client.get_edge_cloud_zones() - # print(zones) + # zones_list = edgecloud_client.get_edge_cloud_zones() + # print(zones_list) + # print(zones_list.status_code) + # print(zones_list.json()) + + # Pretty print: + # import json + # zones = zones.json() + # print(json.dumps(zones, indent=2)) # Network # print("Testing network client function: 'get_qod_session'") -- GitLab From 49db2a3d9e3564d014eef7160c1eb232285921d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Jul 2025 13:32:43 +0200 Subject: [PATCH 224/281] enhance EdgeCloud iface, i2Edge client & fix tests for CAMARA compliance - Reorganize client code with clear CAMARA/GSMA API section separation - Implement CAMARA-compliant schemas and response formatting for all operations - Add comprehensive CAMARA validation for application lifecycle operations - Update test configurations with real i2Edge endpoints and CAMARA payloads - Enhance error handling and standardize response patterns across adapters - Remove deprecated utility functions and improve code organization --- .../edgecloud/adapters/i2edge/client.py | 436 +++++++++++++----- .../edgecloud/core/edgecloud_interface.py | 20 +- .../edgecloud/core/schemas.py | 6 +- src/sunrise6g_opensdk/edgecloud/core/utils.py | 17 - tests/edgecloud/test_config.py | 80 ++-- tests/edgecloud/test_e2e.py | 129 ++++-- 6 files changed, 489 insertions(+), 199 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index f6b3281..eaaeca6 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -20,7 +20,6 @@ from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( ) from sunrise6g_opensdk.edgecloud.core.utils import ( build_custom_http_response, - ensure_valid_uuid, ) from ...adapters.i2edge import schemas as i2edge_schemas @@ -46,12 +45,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.content_type_gsma = "application/json" self.encoding_gsma = "utf-8" + # ==================================================================== + # CAMARA EDGE CLOUD MANAGEMENT API + # ==================================================================== + # -------------------------------------------------------------------- - # CAMARA Edge Cloud Management Functions + # Edge Cloud Zone Management (CAMARA) # -------------------------------------------------------------------- - # Edge Cloud - def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None ) -> Response: @@ -60,34 +61,36 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = i2edge_get(url, params=params) - response.raise_for_status() - i2edge_response = response.json() - log.info("Availability zones retrieved successfully") - # Normalise to CAMARA format - camara_response = [] - for z in i2edge_response: - zone = camara_schemas.EdgeCloudZone( - # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), - edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( - ensure_valid_uuid(z["zoneId"]) - ), - edgeCloudZoneName=camara_schemas.EdgeCloudZoneName(z["nodeName"]), - edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), - edgeCloudRegion=camara_schemas.EdgeCloudRegion( - z["geographyDetails"] - ), - edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, + if response.status_code == 200: + response.raise_for_status() + i2edge_response = response.json() + log.info("Availability zones retrieved successfully") + # Normalise to CAMARA format + camara_response = [] + for z in i2edge_response: + zone = camara_schemas.EdgeCloudZone( + # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(z["zoneId"]), + edgeCloudZoneName=camara_schemas.EdgeCloudZoneName( + z["nodeName"] + ), + edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), + edgeCloudRegion=camara_schemas.EdgeCloudRegion( + z["geographyDetails"] + ), + edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, + ) + camara_response.append(zone) + # Wrap into a Response object + return build_custom_http_response( + status_code=response.status_code, + content=[zone.model_dump(mode="json") for zone in camara_response], + headers={"Content-Type": "application/json"}, + encoding=response.encoding, + url=response.url, + request=response.request, ) - camara_response.append(zone) - # Wrap into a Response object - return build_custom_http_response( - status_code=response.status_code, - content=[zone.model_dump(mode="json") for zone in camara_response], - headers={"Content-Type": "application/json"}, - encoding=response.encoding, - url=response.url, - request=response.request, - ) + return response except KeyError as e: log.error(f"Missing required CAMARA field in app manifest: {e}") raise ValueError(f"Invalid CAMARA manifest – missing field: {e}") @@ -95,7 +98,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to retrieve edge cloud zones: {e}") raise - # Artefact Management (Not included in CAMARA atm) + # -------------------------------------------------------------------- + # Artefact Management (i2Edge-Specific, Non-CAMARA) + # -------------------------------------------------------------------- def create_artefact( self, @@ -122,7 +127,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) try: response = i2edge_post_multiform_data(url, payload) - log.info("Artifact added successfully") + if response.status_code == 201: + response.raise_for_status() + log.info("Artifact added successfully") + return response return response except I2EdgeError as e: raise e @@ -131,16 +139,22 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/artefact/{}".format(self.base_url, artefact_id) try: response = i2edge_get(url, artefact_id) - log.info("Artifact retrieved successfully") + if response.status_code == 200: + response.raise_for_status() + log.info("Artifact retrieved successfully") + return response.json() return response except I2EdgeError as e: raise e def get_all_artefacts(self) -> List[Dict]: - url = "{}/artefact".format(self.base_url) + url = "{}/artefacts".format(self.base_url) try: response = i2edge_get(url, {}) - log.info("Artifacts retrieved successfully") + if response.status_code == 200: + response.raise_for_status() + log.info("Artifacts retrieved successfully") + return response.json() return response except I2EdgeError as e: raise e @@ -149,12 +163,17 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/artefact".format(self.base_url) try: response = i2edge_delete(url, artefact_id) - log.info("Artifact deleted successfully") + if response.status_code == 200: + response.raise_for_status() + log.info("Artifact deleted successfully") + return response return response except I2EdgeError as e: raise e - # Application + # -------------------------------------------------------------------- + # Application Management (CAMARA-Compliant) + # -------------------------------------------------------------------- def onboard_app(self, app_manifest: Dict) -> Response: """ @@ -199,23 +218,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): mode="json", exclude_defaults=True ), ) - i2edge_response.raise_for_status() + # OpenAPI specifies 201 for successful application onboarding + if i2edge_response.status_code == 201: + i2edge_response.raise_for_status() - # Build CAMARA-compliant response - camara_payload = { - "appId": app_id, - "message": "Application onboarded successfully", - } - log.info("App onboarded successfully") - return build_custom_http_response( - status_code=i2edge_response.status_code, - content=camara_payload, - headers={"Content-Type": "application/json"}, - encoding="utf-8", - url=i2edge_response.url, - request=i2edge_response.request, - ) + # Build CAMARA-compliant response using schema + submitted_app = camara_schemas.SubmittedApp( + appId=camara_schemas.AppId(app_id) + ) + log.info("App onboarded successfully") + return build_custom_http_response( + status_code=i2edge_response.status_code, + content=submitted_app.model_dump(mode="json"), + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) + else: + i2edge_response.raise_for_status() + # TODO: Implement CAMARA-compliant error handling for failed onboarding responses except ValidationError as e: log.error(f"Invalid CAMARA manifest: {e}") raise ValueError(f"Invalid CAMARA manifest: {e}") @@ -223,69 +246,228 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to onboard app to i2Edge: {e}") raise - def delete_onboarded_app(self, app_id: str) -> None: + def delete_onboarded_app(self, app_id: str) -> Response: + """ + Deletes an onboarded application using CAMARA-compliant interface. + Returns a CAMARA-compliant response. + + :param app_id: Unique identifier of the application + :return: Response with status code, headers, and CAMARA-normalised payload + """ url = "{}/application/onboarding".format(self.base_url) try: response = i2edge_delete(url, app_id) + response.raise_for_status() + log.info("App onboarded deleted successfully") - return response + return build_custom_http_response( + status_code=204, + content="", + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to delete onboarded app from i2Edge: {e}") + raise - def get_onboarded_app(self, app_id: str) -> Dict: + def get_onboarded_app(self, app_id: str) -> Response: + """ + Retrieves information of a specific onboarded application using CAMARA-compliant interface. + Returns a CAMARA-compliant response. + + :param app_id: Unique identifier of the application + :return: Response with application details in CAMARA format + """ url = "{}/application/onboarding/{}".format(self.base_url, app_id) try: response = i2edge_get(url, app_id) - return response + response.raise_for_status() + i2edge_response = response.json() + + # Extract and transform i2Edge response to CAMARA format + profile_data = i2edge_response.get("profile_data", {}) + app_metadata = profile_data.get("appMetaData", {}) + + # Build CAMARA-compliant response using schema + # Note: This is a partial AppManifest for get operation + app_manifest_response = { + "appManifest": { + "appId": profile_data.get("app_id", app_id), + "name": app_metadata.get("appName", ""), + "version": app_metadata.get("version", ""), + "appProvider": profile_data.get("appProviderId", ""), + # Add other required fields with defaults if not available + "packageType": "CONTAINER", # Default value + "appRepo": {"type": "PUBLICREPO", "imagePath": "not-available"}, + "requiredResources": { + "infraKind": "kubernetes", + "applicationResources": {}, + "isStandalone": False, + }, + "componentSpec": [], + } + } + + log.info("App retrieved successfully") + return build_custom_http_response( + status_code=response.status_code, + content=app_manifest_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to retrieve onboarded app from i2Edge: {e}") + raise - def get_all_onboarded_apps(self) -> List[Dict]: + def get_all_onboarded_apps(self) -> Response: + """ + Retrieves a list of all onboarded applications using CAMARA-compliant interface. + Returns a CAMARA-compliant response. + + :return: Response with list of application metadata in CAMARA format + """ url = "{}/applications/onboarding".format(self.base_url) params = {} try: response = i2edge_get(url, params) - return response + response.raise_for_status() + i2edge_response = response.json() + + # Transform i2Edge response to CAMARA format using AppManifest schema + camara_apps = [] + if isinstance(i2edge_response, list): + for app_data in i2edge_response: + profile_data = app_data.get("profile_data", {}) + app_metadata = profile_data.get("appMetaData", {}) + + # Build CAMARA AppManifest structure + app_manifest = camara_schemas.AppManifest( + appId=profile_data.get("app_id", ""), + name=app_metadata.get("appName", ""), + version=app_metadata.get("version", ""), + appProvider=profile_data.get("appProviderId", ""), + packageType="CONTAINER", + appRepo={"type": "PUBLICREPO", "imagePath": "not-available"}, + requiredResources={ + "infraKind": "kubernetes", + "applicationResources": {}, + "isStandalone": False, + }, + componentSpec=[], + ) + camara_apps.append(app_manifest.model_dump(mode="json")) + + log.info("All onboarded apps retrieved successfully") + return build_custom_http_response( + status_code=response.status_code, + content=camara_apps, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to retrieve all onboarded apps from i2Edge: {e}") + raise # def _select_best_flavour_for_app(self, zone_id) -> str: # # list_of_flavours = self.get_edge_cloud_zones_details(zone_id) # # # return flavourId - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: + """ + Deploys an application using CAMARA-compliant interface. + Returns a CAMARA-compliant response with deployment details. + + :param app_id: Unique identifier of the application + :param app_zones: List of Edge Cloud Zones where the app should be deployed + :return: Response with deployment details in CAMARA format + """ appId = app_id - app = self.get_onboarded_app(appId) - profile_data = app["profile_data"] + + # Get onboarded app metadata for deployment + app_url = "{}/application/onboarding/{}".format(self.base_url, appId) + try: + app_response = i2edge_get(app_url, appId) + app_response.raise_for_status() + app_data = app_response.json() + except I2EdgeError as e: + log.error(f"Failed to retrieve app data for deployment: {e}") + raise + + # Extract deployment parameters from app metadata and zones + profile_data = app_data["profile_data"] appProviderId = profile_data["appProviderId"] appVersion = profile_data["appMetaData"]["version"] zone_info = app_zones[0]["EdgeCloudZone"] zone_id = zone_info["edgeCloudZoneId"] - # TODO: atm the flavour id is specified as an input parameter # flavourId = self._select_best_flavour_for_app(zone_id=zone_id) + + # Build deployment payload app_deploy_data = i2edge_schemas.AppDeployData( appId=appId, appProviderId=appProviderId, appVersion=appVersion, zoneInfo=i2edge_schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), ) - url = "{}/app/".format(self.base_url) - payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) + url = "{}/application_instance".format(self.base_url) + payload = i2edge_schemas.AppDeploy( + app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} + ) + + # Deployment request to i2Edge try: - response = i2edge_post(url, payload) - log.info("App deployed successfully") - print(response) - return response + i2edge_response = i2edge_post(url, payload) + if i2edge_response.status_code == 202: + i2edge_response.raise_for_status() + i2edge_data = i2edge_response.json() + + # Build CAMARA-compliant response + app_instance_id = i2edge_data.get("app_instance_id") + + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName(app_instance_id), + appId=camara_schemas.AppId(appId), + appInstanceId=camara_schemas.AppInstanceId(app_instance_id), + appProvider=camara_schemas.AppProvider(appProviderId), + status=camara_schemas.Status.instantiating, # 202 means deployment is in progress + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), + ) + + # CAMARA spec requires appInstances array wrapper + camara_response = { + "appInstances": [app_instance_info.model_dump(mode="json")] + } + + log.info("App deployment request submitted successfully") + return build_custom_http_response( + status_code=i2edge_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) + else: + i2edge_response.raise_for_status() except I2EdgeError as e: - raise e + log.error(f"Failed to deploy app to i2Edge: {e}") + raise def get_all_deployed_apps(self) -> List[Dict]: - url = "{}/app/".format(self.base_url) + url = "{}/application_instances".format(self.base_url) params = {} try: response = i2edge_get(url, params=params) - log.info("All app instances retrieved successfully") + if response.status_code == 200: + response.raise_for_status() + log.info("All app instances retrieved successfully") + return response.json() return response except I2EdgeError as e: raise e @@ -294,12 +476,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Logic: Get all onboarded apps and filter the one where release_name == artifact name # Step 1) Extract "app_name" from the onboarded app using the "app_id" - onboarded_app = self.get_onboarded_app(app_id) - if not onboarded_app: + try: + onboarded_app_response = self.get_onboarded_app(app_id) + onboarded_app_response.raise_for_status() + onboarded_app_data = onboarded_app_response.json() + except I2EdgeError as e: + log.error(f"Failed to retrieve app data: {e}") raise ValueError(f"No onboarded app found with ID: {app_id}") try: - app_name = onboarded_app["profile_data"]["appMetaData"]["appName"] + # Extract app name from CAMARA response format + app_name = onboarded_app_data.get("name", "") + if not app_name: + raise KeyError("name") except KeyError as e: raise ValueError(f"Onboarded app missing required field: {e}") @@ -317,29 +506,44 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return app_instance_name return None - url = "{}/app/{}/{}".format(self.base_url, zone_id, app_instance_name) - params = {} - try: - response = i2edge_get(url, params=params) - log.info("App instance retrieved successfully") - return response - except I2EdgeError as e: - raise e + def undeploy_app(self, app_instance_id: str) -> Response: + """ + Terminates a specific application instance using CAMARA-compliant interface. + Returns a CAMARA-compliant response confirming termination. - def undeploy_app(self, app_instance_id: str) -> None: - url = "{}/app".format(self.base_url) + :param app_instance_id: Unique identifier of the application instance + :return: Response confirming termination in CAMARA format (204 No Content) + """ + url = "{}/application_instance".format(self.base_url) try: - i2edge_delete(url, app_instance_id) - log.info("App instance deleted successfully") + i2edge_response = i2edge_delete(url, app_instance_id) + if i2edge_response.status_code == 200: + i2edge_response.raise_for_status() + + log.info("App instance deleted successfully") + # CAMARA-compliant 204 response (No Content for successful deletion) + return build_custom_http_response( + status_code=204, + content="", + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) + else: + i2edge_response.raise_for_status() except I2EdgeError as e: - raise e + log.error(f"Failed to undeploy app from i2Edge: {e}") + raise + + # ==================================================================== + # GSMA EDGE COMPUTING API (EWBI OPG) - FEDERATION + # ==================================================================== # -------------------------------------------------------------------- - # EWBI GSMA OPG FUNCTIONS + # Federation Management (GSMA) # -------------------------------------------------------------------- - # FederationManagement - def get_edge_cloud_zones_list_gsma(self) -> List: url = "{}/zones/list".format(self.base_url) params = {} @@ -355,7 +559,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "geographyDetails": item.get("geographyDetails"), } response_list.append(content) - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -391,7 +595,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), } response_list.append(content) - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -403,7 +607,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # AvailabilityZoneInfoSynchronization + # -------------------------------------------------------------------- + # Availability Zone Info Synchronization (GSMA) + # -------------------------------------------------------------------- def availability_zone_info_gsma( self, federation_context_id: str, request_body: dict @@ -414,7 +620,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = i2edge_get(url, params=params) if response.status_code == 200: content = {"acceptedZoneResourceInfo": response.json()} - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -449,7 +655,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneServiceLevelObjsInfo" ), } - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -461,7 +667,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # ArtefactManagement + # -------------------------------------------------------------------- + # Artefact Management (GSMA) + # -------------------------------------------------------------------- def create_artefact_gsma( self, federation_context_id: str, request_body: Dict @@ -484,7 +692,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = self.create_artefact(**transformed) if response.status_code == 201: - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content={"response": "Artefact uploaded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -520,7 +728,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "token": response_json.get("repo_token"), }, } - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -536,7 +744,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self.delete_artefact(artefact_id) if response.status_code == 200: - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content='{"response": "Artefact deletion successful"}', headers={"Content-Type": self.content_type_gsma}, @@ -548,7 +756,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") - # ApplicationOnboardingManagement + # -------------------------------------------------------------------- + # Application Onboarding Management (GSMA) + # -------------------------------------------------------------------- def onboard_app_gsma( self, federation_context_id: str, request_body: dict @@ -562,7 +772,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 200: - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content={"response": "Application onboarded successfully"}, headers={"Content-Type": self.content_type_gsma}, @@ -588,7 +798,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appQoSProfile": profile_data.get("appQoSProfile"), "appComponentSpecs": profile_data.get("appComponentSpecs"), } - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -609,7 +819,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content={"response": "App deletion successful"}, headers={"Content-Type": self.content_type_gsma}, @@ -621,7 +831,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") - # ApplicationDeploymentManagement + # -------------------------------------------------------------------- + # Application Deployment Management (GSMA) + # -------------------------------------------------------------------- def deploy_app_gsma( self, federation_context_id: str, idempotency_key: str, request_body: dict @@ -647,7 +859,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "zoneId": response_json.get("zoneID"), "appInstIdentifier": response_json.get("app_instance_id"), } - return self.build_custom_http_response( + return build_custom_http_response( status_code=202, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -678,7 +890,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "appInstanceState": response_json.get("appInstanceState"), "accesspointInfo": response_json.get("accesspointInfo"), } - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=content, headers={"Content-Type": self.content_type_gsma}, @@ -718,7 +930,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } ] response_list.append(content) - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content=response_list, headers={"Content-Type": self.content_type_gsma}, @@ -741,7 +953,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) if response.status_code == 200: - return self.build_custom_http_response( + return build_custom_http_response( status_code=200, content={ "response": "Application instance termination request accepted" diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 5a6bb04..b196285 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -29,6 +29,8 @@ class EdgeCloudManagementInterface(ABC): :param status: Filter by status (active, inactive, unknown). :return: List of Edge Cloud Zones. """ + # TODO: Evaluate if the CAMARA-input format + # TODO: Evaluate the CAMARA-return format pass @abstractmethod @@ -43,42 +45,43 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def get_all_onboarded_apps(self) -> List[Dict]: + def get_all_onboarded_apps(self) -> Response: """ Retrieves a list of onboarded applications. - :return: List of application metadata dictionaries. + :return: Response with list of application metadata. """ pass @abstractmethod - def get_onboarded_app(self, app_id: str) -> Dict: + def get_onboarded_app(self, app_id: str) -> Response: """ Retrieves information of a specific onboarded application. :param app_id: Unique identifier of the application. - :return: Dictionary with application details. + :return: Response with application details. """ pass @abstractmethod - def delete_onboarded_app(self, app_id: str) -> None: + def delete_onboarded_app(self, app_id: str) -> Response: """ Deletes an application onboarded from the Edge Cloud Provider. :param app_id: Unique identifier of the application. + :return: Response confirming deletion. """ pass @abstractmethod - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: """ Requests the instantiation of an application instance. :param app_id: Unique identifier of the application. :param app_zones: List of Edge Cloud Zones where the app should be instantiated. - :return: Dictionary with instance details. + :return: Response with instance details. """ pass @@ -100,11 +103,12 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def undeploy_app(self, app_instance_id: str) -> None: + def undeploy_app(self, app_instance_id: str) -> Response: """ Terminates a specific application instance. :param app_instance_id: Unique identifier of the application instance. + :return: Response confirming termination. """ pass diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py index 0b9ee71..806c87e 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -19,8 +19,10 @@ class AppId(RootModel[UUID]): ) -class AppInstanceId(RootModel[UUID]): - root: UUID = Field( +# TODO: Update to AppInstanceId(RootModel[UUID]) +# As a temporary solution RootModel[str] will be used until i2Edge get's updated +class AppInstanceId(RootModel[str]): + root: str = Field( ..., description="A globally unique identifier associated with a running\ninstance of an application.\nEdge Cloud Platform generates this identifier when the\ninstantiation in the Edge Cloud Zone is successful.\n", ) diff --git a/src/sunrise6g_opensdk/edgecloud/core/utils.py b/src/sunrise6g_opensdk/edgecloud/core/utils.py index f6be6ed..d6b4ebc 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/core/utils.py @@ -7,7 +7,6 @@ # - César Cajas (cesar.cajas@i2cat.net) ## import json -from uuid import NAMESPACE_DNS, UUID, uuid5 from requests import Response @@ -16,22 +15,6 @@ from sunrise6g_opensdk import logger log = logger.get_logger(__name__) -def ensure_valid_uuid(value: str) -> str: - """ - Return the original value if it's a valid UUID, - or generate a deterministic UUIDv5 from the input string otherwise. - """ - try: - UUID(value) - return value - except ValueError: - generated = str(uuid5(NAMESPACE_DNS, value)) - log.warning( - f"[WARNING] Invalid UUID '{value}' – using generated UUIDv5: {generated}" - ) - return generated - - def build_custom_http_response( status_code: int, content: str | bytes | dict | list, diff --git a/tests/edgecloud/test_config.py b/tests/edgecloud/test_config.py index fd38249..0c68e4b 100644 --- a/tests/edgecloud/test_config.py +++ b/tests/edgecloud/test_config.py @@ -1,13 +1,17 @@ CONFIG = { "i2edge": { - "ZONE_ID": "Omega", + # Basic identifiers + "ZONE_ID": "f0662bfe-1d90-5f59-a759-c755b3b69b93", + "APP_ID": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + # Artefact-related fields (non-CAMARA endpoints) "ARTEFACT_ID": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", "ARTEFACT_NAME": "i2edgechart", "REPO_NAME": "github-cesar", "REPO_TYPE": "PUBLICREPO", "REPO_URL": "https://cesarcajas.github.io/helm-charts-examples/", + # CAMARA onboard_app payload "APP_ONBOARD_MANIFEST": { - "appId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + "appId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", # Optional to CAMARA "name": "i2edge_app_SDK", "version": "1.0.0", "appProvider": "i2CAT_DEV", @@ -46,19 +50,21 @@ CONFIG = { } ], }, - "APP_ID": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", - "APP_ZONES": [ - { - "kubernetesClusterRef": "not-used", - "EdgeCloudZone": { - "edgeCloudZoneId": "Omega", - "edgeCloudZoneName": "not-used", - "edgeCloudZoneStatus": "not-used", - "edgeCloudProvider": "not-used", - "edgeCloudRegion": "not-used", - }, - } - ], + # CAMARA deploy_app payload + "APP_DEPLOY_PAYLOAD": { + "appId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + "appZones": [ + { + "EdgeCloudZone": { + "edgeCloudZoneId": "f0662bfe-1d90-5f59-a759-c755b3b69b93", + "edgeCloudZoneName": "i2edge-zone-1", + "edgeCloudZoneStatus": "active", + "edgeCloudProvider": "i2CAT", + "edgeCloudRegion": "Europe-West", + } + } + ], + }, }, "aeros": { "ZONE_ID": "urn:ngsi-ld:Domain:NCSRD", @@ -108,23 +114,43 @@ CONFIG = { ], }, "APP_ID": "aeros-app-2", - "APP_ZONES": [ - { - "kubernetesClusterRef": "not-used", - "EdgeCloudZone": { - "edgeCloudZoneId": "urn:ngsi-ld:Domain:NCSRD", - "edgeCloudZoneName": "not-used", - "edgeCloudZoneStatus": "not-used", - "edgeCloudProvider": "not-used", - "edgeCloudRegion": "not-used", - }, - } - ], + # CAMARA deploy_app payload + "APP_DEPLOY_PAYLOAD": { + "appId": "aeros-app-2", + "appZones": [ + { + "EdgeCloudZone": { + "edgeCloudZoneId": "urn:ngsi-ld:Domain:NCSRD", + "edgeCloudZoneName": "aeros-zone-1", + "edgeCloudZoneStatus": "active", + "edgeCloudProvider": "NCSRD", + "edgeCloudRegion": "Europe-South", + } + } + ], + }, }, "kubernetes": { "K8S_ONBOARDED_APP_NAME": "nginx", "K8S_APP_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "APP_ID": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "ZONE_ID": "b2a1b33d-f382-47de-b555-2d32155eb74c", + # CAMARA deploy_app payload + "APP_DEPLOY_PAYLOAD": { + "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "appZones": [ + { + "EdgeCloudZone": { + "edgeCloudZoneId": "b2a1b33d-f382-47de-b555-2d32155eb74c", + "edgeCloudZoneName": "k8s-zone-1", + "edgeCloudZoneStatus": "active", + "edgeCloudProvider": "kubernetes", + "edgeCloudRegion": "Local", + } + } + ], + }, + # Legacy K8S_DEPLOY_PAYLOAD for backward compatibility (if needed) "K8S_DEPLOY_PAYLOAD": { "appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "nginx-test", diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index eeafa94..11756f1 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -51,6 +51,56 @@ def id_func(val): return val["edgecloud"]["client_name"] +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_config_camara_compliance(edgecloud_client): + """Validate that all test configurations are CAMARA-compliant""" + config = CONFIG[edgecloud_client.client_name] + + try: + # Validate APP_ONBOARD_MANIFEST is CAMARA-compliant + if "APP_ONBOARD_MANIFEST" in config: + app_manifest = config["APP_ONBOARD_MANIFEST"] + camara_schemas.AppManifest( + **app_manifest + ) # Validate against CAMARA AppManifest schema + + # Validate APP_DEPLOY_PAYLOAD is CAMARA-compliant + if "APP_DEPLOY_PAYLOAD" in config: + deploy_payload = config["APP_DEPLOY_PAYLOAD"] + + # Validate appId + assert "appId" in deploy_payload + camara_schemas.AppId(root=deploy_payload["appId"]) + + # Validate appZones structure + assert "appZones" in deploy_payload + assert isinstance(deploy_payload["appZones"], list) + assert len(deploy_payload["appZones"]) > 0 + + for zone_data in deploy_payload["appZones"]: + assert "EdgeCloudZone" in zone_data + edge_cloud_zone = zone_data["EdgeCloudZone"] + camara_schemas.EdgeCloudZone( + **edge_cloud_zone + ) # Validate against CAMARA EdgeCloudZone schema + + # Validate APP_ID is consistent + if "APP_ID" in config: + app_id = config["APP_ID"] + camara_schemas.AppId(root=app_id) + + # Check consistency between APP_ID and manifest/payload + if "APP_ONBOARD_MANIFEST" in config: + assert config["APP_ONBOARD_MANIFEST"]["appId"] == app_id + if "APP_DEPLOY_PAYLOAD" in config: + assert config["APP_DEPLOY_PAYLOAD"]["appId"] == app_id + + except Exception as e: + pytest.fail( + f"Configuration is not CAMARA-compliant for {edgecloud_client.client_name}: {e}" + ) + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones(edgecloud_client): try: @@ -105,56 +155,69 @@ def test_onboard_app(edgecloud_client): pytest.fail(f"Unexpected error during app onboarding: {e}") -# @pytest.fixture(scope="module") -# def app_instance_id(edgecloud_client): -# config = CONFIG[edgecloud_client.client_name] -# try: -# if edgecloud_client.client_name == "kubernetes": -# output = edgecloud_client.deploy_app(config["K8S_DEPLOY_PAYLOAD"]) -# else: -# output = edgecloud_client.deploy_app(config["APP_ID"], config["APP_ZONES"]) +@pytest.fixture(scope="module") +def app_instance_id(edgecloud_client): + config = CONFIG[edgecloud_client.client_name] + try: + # Use standardized CAMARA structure for all adapters + deploy_payload = config["APP_DEPLOY_PAYLOAD"] + app_id = deploy_payload["appId"] + app_zones = deploy_payload["appZones"] + response = edgecloud_client.deploy_app(app_id, app_zones) -# if edgecloud_client.client_name == "i2edge": -# app_instance_id = output.get("deploy_name") -# else: -# app_instance_id = output.get("appInstanceId") + assert isinstance(response, Response) -# assert app_instance_id is not None -# yield app_instance_id -# finally: -# pass + # All CAMARA-compliant adapters should return 202 for async deployment + assert response.status_code == 202 + response_data = response.json() -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_deploy_app(app_instance_id): -# assert app_instance_id is not None + # CAMARA spec: response contains appInstances array + assert "appInstances" in response_data + assert isinstance(response_data["appInstances"], list) + assert len(response_data["appInstances"]) > 0 + # Extract appInstanceId from first instance + app_instance_id = response_data["appInstances"][0].get("appInstanceId") -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_timer_wait_10_seconds(edgecloud_client): -# time.sleep(10) + assert app_instance_id is not None + yield app_instance_id + finally: + pass -# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -# def test_undeploy_app(edgecloud_client, app_instance_id): -# try: -# edgecloud_client.undeploy_app(app_instance_id) -# except EdgeCloudPlatformError as e: -# pytest.fail(f"App undeployment failed: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_deploy_app(app_instance_id): + assert app_instance_id is not None @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_3_seconds(edgecloud_client): - time.sleep(3) +def test_timer_wait_10_seconds(edgecloud_client): + time.sleep(10) + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_undeploy_app(edgecloud_client, app_instance_id): + try: + response = edgecloud_client.undeploy_app(app_instance_id) + assert isinstance(response, Response) + assert response.status_code == 204 + assert response.text == "" + except EdgeCloudPlatformError as e: + pytest.fail(f"App undeployment failed: {e}") + + +# @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +# def test_timer_wait_5_seconds(edgecloud_client): +# time.sleep(5) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_delete_onboarded_app(edgecloud_client): config = CONFIG[edgecloud_client.client_name] try: - edgecloud_client.delete_onboarded_app( - app_id=config["APP_ONBOARD_MANIFEST"]["appId"] - ) + app_id = config["APP_ID"] + edgecloud_client.delete_onboarded_app(app_id=app_id) except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding deletion failed: {e}") -- GitLab From d31c89bfae30187608cdcc4295e952d2ba57e8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Jul 2025 13:58:43 +0200 Subject: [PATCH 225/281] standardize CAMARA & GSMA method signatures and comments --- .../edgecloud/adapters/i2edge/client.py | 170 ++++++++++++++++++ .../edgecloud/core/edgecloud_interface.py | 127 +++++++------ 2 files changed, 243 insertions(+), 54 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index eaaeca6..d71e27c 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -56,6 +56,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None ) -> Response: + """ + Retrieves a list of available Edge Cloud Zones. + + :param region: Filter by geographical region. + :param status: Filter by status (active, inactive, unknown). + :return: Response with list of Edge Cloud Zones in CAMARA format. + """ url = f"{self.base_url}/zones/list" params = {} @@ -113,6 +120,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): token: Optional[str] = None, user_name: Optional[str] = None, ): + """ + Creates an artefact in the i2Edge platform. + This is an i2Edge-specific operation not covered by CAMARA standards. + + :param artefact_id: Unique identifier for the artefact + :param artefact_name: Name of the artefact + :param repo_name: Repository name + :param repo_type: Type of repository (PUBLICREPO, PRIVATEREPO) + :param repo_url: Repository URL + :param password: Optional repository password + :param token: Optional repository token + :param user_name: Optional repository username + :return: Response confirming artefact creation + """ repo_type = i2edge_schemas.RepoType(repo_type) url = "{}/artefact".format(self.base_url) payload = i2edge_schemas.ArtefactOnboarding( @@ -136,6 +157,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def get_artefact(self, artefact_id: str) -> Dict: + """ + Retrieves details about a specific artefact. + This is an i2Edge-specific operation not covered by CAMARA standards. + + :param artefact_id: Unique identifier of the artefact + :return: Dictionary with artefact details + """ url = "{}/artefact/{}".format(self.base_url, artefact_id) try: response = i2edge_get(url, artefact_id) @@ -148,6 +176,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def get_all_artefacts(self) -> List[Dict]: + """ + Retrieves a list of all artefacts. + This is an i2Edge-specific operation not covered by CAMARA standards. + + :return: List of artefact details + """ url = "{}/artefacts".format(self.base_url) try: response = i2edge_get(url, {}) @@ -160,6 +194,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def delete_artefact(self, artefact_id: str): + """ + Deletes a specific artefact from the i2Edge platform. + This is an i2Edge-specific operation not covered by CAMARA standards. + + :param artefact_id: Unique identifier of the artefact to delete + :return: Response confirming artefact deletion + """ url = "{}/artefact".format(self.base_url) try: response = i2edge_delete(url, artefact_id) @@ -460,6 +501,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise def get_all_deployed_apps(self) -> List[Dict]: + """ + Retrieves information of all application instances. + + :return: List of application instance details + """ url = "{}/application_instances".format(self.base_url) params = {} try: @@ -473,6 +519,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def get_deployed_app(self, app_id, zone_id) -> List[Dict]: + """ + Retrieves a specific deployed application instance by app ID and zone ID. + + :param app_id: Unique identifier of the application + :param zone_id: Unique identifier of the Edge Cloud Zone + :return: Application instance details or None if not found + """ # Logic: Get all onboarded apps and filter the one where release_name == artifact name # Step 1) Extract "app_name" from the onboarded app using the "app_id" @@ -545,6 +598,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # -------------------------------------------------------------------- def get_edge_cloud_zones_list_gsma(self) -> List: + """ + Retrieves details of all Zones for GSMA federation. + + :return: List of zone details in GSMA format + """ url = "{}/zones/list".format(self.base_url) params = {} try: @@ -572,6 +630,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise e def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> Dict: + """ + Retrieves details of Zones for GSMA federation. + + :param federation_context_id: Identifier of the federation context + :return: Dictionary with zone details in GSMA format + """ url = "{}/zones".format(self.base_url) params = {} try: @@ -614,6 +678,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def availability_zone_info_gsma( self, federation_context_id: str, request_body: dict ) -> Dict: + """ + Originating OP informs partner OP that it is willing to access + the specified zones and partner OP shall reserve compute and + network resources for these zones. + + :param federation_context_id: Identifier of the federation context + :param request_body: Payload with zone resource information + :return: Dictionary with accepted zone resource info + """ url = "{}/zones".format(self.base_url) params = {} try: @@ -635,6 +708,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zone_details_gsma( self, federation_context_id: str, zone_id: str ) -> Dict: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP. + + :param federation_context_id: Identifier of the federation context + :param zone_id: Unique identifier of the Edge Cloud Zone + :return: Dictionary with Edge Cloud Zone details + """ url = "{}/zone/{}".format(self.base_url, zone_id) params = {} try: @@ -674,6 +755,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def create_artefact_gsma( self, federation_context_id: str, request_body: Dict ) -> Dict: + """ + Uploads application artefact on partner OP. Artefact is a zip file + containing scripts and/or packaging files like Terraform or Helm + which are required to create an instance of an application + + :param federation_context_id: Identifier of the federation context + :param request_body: Payload with artefact information + :return: Dictionary with artefact deployment info + """ try: artefact_id = request_body["artefactId"] artefact_name = request_body["artefactName"] @@ -705,6 +795,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + """ + Retrieves details about an artefact + + :param federation_context_id: Identifier of the federation context + :param artefact_id: Unique identifier of the artefact + :return: Dictionary with artefact details + """ try: response = self.get_artefact(artefact_id) if response.status_code == 200: @@ -741,6 +838,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): + """ + Removes an artefact from partners OP. + + :param federation_context_id: Identifier of the federation context + :param artefact_id: Unique identifier of the artefact + :return: Dictionary with artefact deletion details + """ try: response = self.delete_artefact(artefact_id) if response.status_code == 200: @@ -763,6 +867,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def onboard_app_gsma( self, federation_context_id: str, request_body: dict ) -> Response: + """ + Submits an application details to a partner OP. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. + + :param federation_context_id: Identifier of the federation context + :param request_body: Payload with onboarding info + :return: Dictionary with onboarding details + """ body = deepcopy(request_body) try: body["app_id"] = body.pop("appId") @@ -785,6 +898,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + """ + Retrieves application details from partner OP + + :param federation_context_id: Identifier of the federation context + :param app_id: Identifier of the application onboarded + :return: Dictionary with application details + """ try: response = self.get_onboarded_app(app_id) if response.status_code == 200: @@ -813,9 +933,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def patch_onboarded_app_gsma( self, federation_context_id: str, app_id: str, request_body: dict ) -> Dict: + """ + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components + + :param federation_context_id: Identifier of the federation context + :param app_id: Identifier of the application onboarded + :param request_body: Payload with updated application details + :return: Dictionary with update confirmation + """ pass def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + """ + Deletes an application onboarded from the Edge Cloud Provider. + + :param federation_context_id: Identifier of the federation context + :param app_id: Unique identifier of the application + :return: Dictionary with deletion confirmation + """ try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: @@ -838,6 +974,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app_gsma( self, federation_context_id: str, idempotency_key: str, request_body: dict ): + """ + Requests the instantiation of an application instance on partner OP. + + :param federation_context_id: Identifier of the federation context + :param idempotency_key: Key to ensure idempotent operation + :param request_body: Payload with deployment information + :return: Dictionary with deployment details + """ body = deepcopy(request_body) try: zone_id = body.get("zoneInfo").get("zoneId") @@ -878,6 +1022,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: str, zone_id: str, ): + """ + Retrieves information about a specific deployed application instance. + + :param federation_context_id: Identifier of the federation context + :param app_id: Unique identifier of the application + :param app_instance_id: Unique identifier of the application instance + :param zone_id: Unique identifier of the Edge Cloud Zone + :return: Dictionary with application instance details + """ try: url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id @@ -908,6 +1061,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_id: str, app_provider: str, ): + """ + Retrieves information of all application instances for a specific app and provider. + + :param federation_context_id: Identifier of the federation context + :param app_id: Unique identifier of the application + :param app_provider: Application provider identifier + :return: List of application instance details + """ try: url = "{}/application_instances".format(self.base_url) params = {} @@ -949,6 +1110,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id: str, zone_id: str, ): + """ + Terminates a specific application instance on partner OP. + + :param federation_context_id: Identifier of the federation context + :param app_id: Unique identifier of the application + :param app_instance_id: Unique identifier of the application instance + :param zone_id: Unique identifier of the Edge Cloud Zone + :return: Dictionary with termination confirmation + """ try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index b196285..7e80bfc 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -18,6 +18,14 @@ class EdgeCloudManagementInterface(ABC): Abstract Base Class for Edge Application Management. """ + # ==================================================================== + # CAMARA EDGE CLOUD MANAGEMENT API + # ==================================================================== + + # -------------------------------------------------------------------- + # Edge Cloud Zone Management (CAMARA) + # -------------------------------------------------------------------- + @abstractmethod def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None @@ -33,6 +41,10 @@ class EdgeCloudManagementInterface(ABC): # TODO: Evaluate the CAMARA-return format pass + # -------------------------------------------------------------------- + # Application Management (CAMARA-Compliant) + # -------------------------------------------------------------------- + @abstractmethod def onboard_app(self, app_manifest: Dict) -> Response: """ @@ -112,9 +124,13 @@ class EdgeCloudManagementInterface(ABC): """ pass - # --- GSMA-specific methods --- + # ==================================================================== + # GSMA EDGE COMPUTING API (EWBI OPG) - FEDERATION + # ==================================================================== - # FederationManagement + # -------------------------------------------------------------------- + # Federation Management (GSMA) + # -------------------------------------------------------------------- @abstractmethod def get_edge_cloud_zones_list_gsma(self) -> List: @@ -135,7 +151,9 @@ class EdgeCloudManagementInterface(ABC): """ pass - # AvailabilityZoneInfoSynchronization + # -------------------------------------------------------------------- + # Availability Zone Info Synchronization (GSMA) + # -------------------------------------------------------------------- @abstractmethod def availability_zone_info_gsma( @@ -166,27 +184,27 @@ class EdgeCloudManagementInterface(ABC): """ pass - # ArtefactManagement + # -------------------------------------------------------------------- + # Artefact Management (GSMA) + # -------------------------------------------------------------------- @abstractmethod def create_artefact_gsma( self, federation_context_id: str, request_body: dict ) -> Dict: """ - Uploads application artefact on partner OP. Artefact is a zip file - containing scripts and/or packaging files like Terraform or Helm - which are required to create an instance of an application + Create Artefact. :param federation_context_id: Identifier of the federation context. - :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :param request_body: Payload containing artefact details. + :return: Dictionary with created artefact details. """ pass @abstractmethod def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: """ - Retrieves details about an artefact + Get Artefact. :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. @@ -199,77 +217,78 @@ class EdgeCloudManagementInterface(ABC): self, federation_context_id: str, artefact_id: str ) -> Dict: """ - Removes an artefact from partners OP. + Delete Artefact. :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: Dictionary with deletion confirmation. """ pass - # ApplicationOnboardingManagement + # -------------------------------------------------------------------- + # Application Management (GSMA) + # -------------------------------------------------------------------- @abstractmethod def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: """ - Submits an application details to a partner OP. - Based on the details provided, partner OP shall do bookkeeping, - resource validation and other pre-deployment operations. + Create onboarded Application. :param federation_context_id: Identifier of the federation context. - :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :param request_body: Payload containing application onboarding details. + :return: Dictionary with onboarded application details. """ pass @abstractmethod def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: """ - Retrieves application details from partner OP + Get onboarded Application. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the application onboarded. - :return: + :param app_id: Unique identifier of the onboarded application. + :return: Dictionary with onboarded application details. """ pass @abstractmethod def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict + self, + federation_context_id: str, + app_id: str, + request_body: dict, ) -> Dict: """ - Updates partner OP about changes in application compute resource requirements, - QOS Profile, associated descriptor or change in associated components + Patch onboarded Application. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the application onboarded. - :return: + :param app_id: Unique identifier of the onboarded application. + :param request_body: Payload containing patch details. + :return: Dictionary with updated application details. """ pass @abstractmethod - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma( + self, federation_context_id: str, app_id: str + ) -> Dict: """ - Deboards an application from specific partner OP zones + Delete onboarded Application. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the application onboarded. - :return: + :param app_id: Unique identifier of the onboarded application. + :return: Dictionary with deletion confirmation. """ pass - # ApplicationDeploymentManagement - @abstractmethod - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: """ - Instantiates an application on a partner OP zone. + Create deployed Application. :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload containing application deployment details. + :return: Dictionary with deployed application details. """ pass @@ -280,15 +299,15 @@ class EdgeCloudManagementInterface(ABC): app_id: str, app_instance_id: str, zone_id: str, - ): + ) -> Dict: """ Retrieves an application instance details from partner OP. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. - :param zone_id: Identifier of the zone - :return: + :param app_id: Unique identifier of the application. + :param app_instance_id: Unique identifier of the application instance. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with deployed application details. """ pass @@ -297,15 +316,15 @@ class EdgeCloudManagementInterface(ABC): self, federation_context_id: str, app_id: str, - app_provider: str, - ): + app_provider_id: str, + ) -> Dict: """ - Retrieves all application instance of partner OP + Retrieves all application instances of partner OP. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the app. - :param app_provider: App provider - :return: + :param app_id: Unique identifier of the application. + :param app_provider_id: Unique identifier of the application provider. + :return: Dictionary with all deployed applications. """ pass @@ -316,14 +335,14 @@ class EdgeCloudManagementInterface(ABC): app_id: str, app_instance_id: str, zone_id: str, - ): + ) -> Dict: """ Terminate an application instance on a partner OP zone. :param federation_context_id: Identifier of the federation context. - :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. - :param zone_id: Identifier of the zone - :return: + :param app_id: Unique identifier of the application. + :param app_instance_id: Unique identifier of the application instance. + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Dictionary with undeployment confirmation. """ pass -- GitLab From b10d72a43364bbbecb82446c799b09e23d9eabe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 24 Jul 2025 14:06:46 +0200 Subject: [PATCH 226/281] Improve i2Edge pydantic schemas --- .../edgecloud/adapters/i2edge/client.py | 4 +- .../edgecloud/adapters/i2edge/schemas.py | 145 ++++++++++-------- 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index d71e27c..399c379 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -454,7 +454,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appId=appId, appProviderId=appProviderId, appVersion=appVersion, - zoneInfo=i2edge_schemas.ZoneInfo(flavourId=self.flavour_id, zoneId=zone_id), + zoneInfo=i2edge_schemas.ZoneInfoRef( + flavourId=self.flavour_id, zoneId=zone_id + ), ) url = "{}/application_instance".format(self.base_url) payload = i2edge_schemas.AppDeploy( diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index 53c3311..94cd2a2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -8,31 +8,75 @@ # - César Cajas (cesar.cajas@i2cat.net) # - Adrián Pino Martínez (adrian.pino@i2cat.net) ## + from enum import Enum from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field +# Zone and Flavour -class ZoneInfo(BaseModel): + +class ZoneInfoRef(BaseModel): flavourId: str zoneId: str -class AppParameters(BaseModel): - namespace: Optional[str] = None +class ZoneInfo(BaseModel): + zoneId: str + geographyDetails: Optional[str] = None + geolocation: Optional[str] = None + computeResourceQuotaLimits: Optional[List[dict]] = None + flavoursSupported: Optional[List["Flavour"]] = None + networkResources: Optional[dict] = None + reservedComputeResources: Optional[List[dict]] = None + zoneServiceLevelObjsInfo: Optional[dict] = None -class AppDeployData(BaseModel): - appId: str - appProviderId: str - appVersion: str - zoneInfo: ZoneInfo +class Hugepages(BaseModel): + number: int = Field(default=0, description="Number of hugepages") + pageSize: str = Field(default="2MB", description="Size of hugepages") -class AppDeploy(BaseModel): - app_deploy_data: AppDeployData - app_parameters: Optional[AppParameters] = Field(default=AppParameters()) +class GPU(BaseModel): + gpuMemory: int = Field(default=0, description="GPU memory in MB") + gpuModeName: str = Field(default="", description="GPU mode name") + gpuVendorType: str = Field( + default="GPU_PROVIDER_NVIDIA", description="GPU vendor type" + ) + numGPU: int = Field(..., description="Number of GPUs") + + +class SupportedOSTypes(BaseModel): + architecture: str = Field(default="x86_64", description="OS architecture") + distribution: str = Field(default="RHEL", description="OS distribution") + license: str = Field(default="OS_LICENSE_TYPE_FREE", description="OS license type") + version: str = Field(default="OS_VERSION_UBUNTU_2204_LTS", description="OS version") + + +class FlavourSupported(BaseModel): + cpuArchType: str = Field(default="ISA_X86", description="CPU architecture type") + cpuExclusivity: bool = Field(default=True, description="CPU exclusivity") + fpga: int = Field(default=0, description="Number of FPGAs") + gpu: Optional[List[GPU]] = Field(default=None, description="List of GPUs") + hugepages: List[Hugepages] = Field( + default_factory=lambda: [Hugepages()], description="List of hugepages" + ) + memorySize: int = Field(..., description="Memory size in MB") + numCPU: int = Field(..., description="Number of CPUs") + storageSize: int = Field(default=0, description="Storage size in GB") + supportedOSTypes: List[SupportedOSTypes] = Field( + default_factory=lambda: [SupportedOSTypes()], + description="List of supported OS types", + ) + vpu: int = Field(default=0, description="Number of VPUs") + + +class Flavour(BaseModel): + id: Optional[str] = None + date: Optional[int] = None + zone_id: Optional[str] = None + flavour_supported: FlavourSupported # Artefact @@ -59,8 +103,6 @@ class ArtefactOnboarding(BaseModel): # Application Onboarding -# XXX Leaving default values since i2edge only cares about appid and artifactid, at least for now. - class AppComponentSpec(BaseModel): artefactId: str @@ -75,6 +117,7 @@ class AppMetaData(BaseModel): category: str = Field(default="DEFAULT") mobilitySupport: bool = Field(default=False) version: str = Field(default="1.0") + accessToken: Optional[str] = None class AppQoSProfile(BaseModel): @@ -92,73 +135,39 @@ class ApplicationOnboardingData(BaseModel): appMetaData: AppMetaData = Field(default_factory=AppMetaData) appProviderId: str = Field(default="default_provider") appQoSProfile: AppQoSProfile = Field(default_factory=AppQoSProfile) + appStatusCallbackLink: Optional[str] = None class ApplicationOnboardingRequest(BaseModel): profile_data: ApplicationOnboardingData -# Flavour +# Application Deployment -class GPU(BaseModel): - gpuMemory: int = Field(default=0, description="GPU memory in MB") - gpuModeName: str = Field(default="", description="GPU mode name") - gpuVendorType: str = Field( - default="GPU_PROVIDER_NVIDIA", description="GPU vendor type" - ) - numGPU: int = Field(..., description="Number of GPUs") - - -class Hugepages(BaseModel): - number: int = Field(default=0, description="Number of hugepages") - pageSize: str = Field(default="2MB", description="Size of hugepages") - - -class SupportedOSTypes(BaseModel): - architecture: str = Field(default="x86_64", description="OS architecture") - distribution: str = Field(default="RHEL", description="OS distribution") - license: str = Field(default="OS_LICENSE_TYPE_FREE", description="OS license type") - version: str = Field(default="OS_VERSION_UBUNTU_2204_LTS", description="OS version") - +class AppParameters(BaseModel): + namespace: Optional[str] = None -class FlavourSupported(BaseModel): - cpuArchType: str = Field(default="ISA_X86", description="CPU architecture type") - cpuExclusivity: bool = Field(default=True, description="CPU exclusivity") - fpga: int = Field(default=0, description="Number of FPGAs") - gpu: Optional[List[GPU]] = Field(default=None, description="List of GPUs") - hugepages: List[Hugepages] = Field( - default_factory=lambda: [Hugepages()], description="List of hugepages" - ) - memorySize: str = Field(..., description="Memory size (e.g., '1024MB' or '2GB')") - numCPU: int = Field(..., description="Number of CPUs") - storageSize: int = Field(default=0, description="Storage size in GB") - supportedOSTypes: List[SupportedOSTypes] = Field( - default_factory=lambda: [SupportedOSTypes()], - description="List of supported OS types", - ) - vpu: int = Field(default=0, description="Number of VPUs") - @field_validator("memorySize") - @classmethod - def validate_memory_size(cls, v): - if not (v.endswith("MB") or v.endswith("GB")): - raise ValueError("memorySize must end with MB or GB") - try: - int(v[:-2]) - except ValueError: - raise ValueError("memorySize must be a number followed by MB or GB") - return v +class AppDeployData(BaseModel): + appId: str + appProviderId: str + appVersion: str + zoneInfo: ZoneInfoRef -class Flavour(BaseModel): - flavour_supported: FlavourSupported +class AppDeploy(BaseModel): + app_deploy_data: AppDeployData + app_parameters: Optional[AppParameters] = Field(default=AppParameters()) -# EdgeCloud Zones +class AppDeployResponse(BaseModel): + Message: str + app_instance_id: str + deploy_status: str + zoneID: str -class Zone(BaseModel): - geographyDetails: str - geolocation: str - zoneId: str +class AppMigration(BaseModel): + node_to_deploy: str + zone_id_to_deploy: str -- GitLab From 0b6c59720c664e46afcbca4a313a17e6d4d4eaff Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Mon, 28 Jul 2025 13:02:52 +0200 Subject: [PATCH 227/281] Minor fixes to GSMA methods for edge cloud management --- .../edgecloud/adapters/i2edge/client.py | 95 ++++----------- .../edgecloud/adapters/i2edge/common.py | 2 +- .../edgecloud/adapters/i2edge/schemas.py | 2 +- .../edgecloud/core/edgecloud_interface.py | 108 +++++------------- 4 files changed, 54 insertions(+), 153 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 86f8b5e..45404cf 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -97,8 +97,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def _get_artefact(self, artefact_id: str) -> Dict: url = "{}/artefact/{}".format(self.base_url, artefact_id) + params = {} try: - response = i2edge_get(url, artefact_id) + response = i2edge_get(url, params=params) log.info("Artifact retrieved successfully") return response except I2EdgeError as e: @@ -106,8 +107,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def _get_all_artefacts(self) -> List[Dict]: url = "{}/artefact".format(self.base_url) + params = {} try: - response = i2edge_get(url, {}) + response = i2edge_get(url, params=params) log.info("Artifacts retrieved successfully") return response except I2EdgeError as e: @@ -150,8 +152,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_onboarded_app(self, app_id: str) -> Dict: url = "{}/application/onboarding/{}".format(self.base_url, app_id) + params = {} try: - response = i2edge_get(url, app_id) + response = i2edge_get(url, params=params) return response except I2EdgeError as e: raise e @@ -160,7 +163,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/applications/onboarding".format(self.base_url) params = {} try: - response = i2edge_get(url, params) + response = i2edge_get(url, params=params) return response except I2EdgeError as e: raise e @@ -281,7 +284,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> Dict: + # AvailabilityZoneInfoSynchronization + + def get_edge_cloud_zones_gsma(self) -> Dict: url = "{}/zones".format(self.base_url) params = {} try: @@ -317,32 +322,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # AvailabilityZoneInfoSynchronization - - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: - url = "{}/zones".format(self.base_url) - params = {} - try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - content = {"acceptedZoneResourceInfo": response.json()} - return self._build_custom_gsma_response( - status_code=200, - content=content, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except I2EdgeError as e: - raise e - - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: url = "{}/zone/{}".format(self.base_url, zone_id) params = {} try: @@ -350,7 +330,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if response.status_code == 200: response_json = response.json() content = { - "zoneId": response_json.get("zoneID"), + "zoneId": response_json.get("zoneId"), "reservedComputeResources": response_json.get( "reservedComputeResources" ), @@ -377,9 +357,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ArtefactManagement - def create_artefact_gsma( - self, federation_context_id: str, request_body: Dict - ) -> Dict: + def create_artefact_gsma(self, request_body: Dict) -> Dict: try: artefact_id = request_body["artefactId"] artefact_name = request_body["artefactName"] @@ -410,7 +388,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: try: response = self._get_artefact(artefact_id) if response.status_code == 200: @@ -446,7 +424,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") - def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): + def delete_artefact_gsma(self, artefact_id: str) -> Response: try: response = self._delete_artefact(artefact_id) if response.status_code == 200: @@ -464,9 +442,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationOnboardingManagement - def onboard_app_gsma( - self, federation_context_id: str, request_body: dict - ) -> Response: + def onboard_app_gsma(self, request_body: dict) -> Response: body = deepcopy(request_body) try: body["app_id"] = body.pop("appId") @@ -475,7 +451,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): payload = schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) - if response.status_code == 200: + if response.status_code == 201: return self._build_custom_gsma_response( status_code=200, content={"response": "Application onboarded successfully"}, @@ -488,7 +464,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: try: response = self.get_onboarded_app(app_id) if response.status_code == 200: @@ -514,12 +490,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict) -> Dict: pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str) -> Response: try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: @@ -537,9 +511,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationDeploymentManagement - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: body = deepcopy(request_body) try: zone_id = body.get("zoneInfo").get("zoneId") @@ -551,7 +523,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zoneInfo=schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) payload = schemas.AppDeploy( - app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} + app_deploy_data=app_deploy_data ) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) @@ -573,13 +545,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") - def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: try: url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id @@ -604,12 +570,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Dict: try: url = "{}/application_instances".format(self.base_url) params = {} @@ -644,13 +605,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Error retrieving apps: {e}") - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_instance_id: str) -> Response: try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 16a7017..c867a32 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -42,7 +42,7 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: "Content-Type": "application/json", "accept": "application/json", } - json_payload = json.dumps(model_payload.model_dump(mode="json")) + json_payload = json.dumps(model_payload.model_dump(mode="json", exclude_none=True)) try: response = requests.post(url, data=json_payload, headers=headers) response.raise_for_status() diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index d4be3d5..63cf751 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -31,7 +31,7 @@ class AppDeployData(BaseModel): class AppDeploy(BaseModel): app_deploy_data: AppDeployData - app_parameters: Optional[AppParameters] = Field(default=AppParameters()) + app_parameters: Optional[AppParameters] = None # Artefact diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 3d2ff29..f1273d6 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -127,48 +127,29 @@ class EdgeCloudManagementInterface(ABC): @abstractmethod def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones + Retrieves list of all Zones :return: List. """ pass - @abstractmethod - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones - - :param federation_context_id: Identifier of the federation context. - :return: List. - """ - pass - # AvailabilityZoneInfoSynchronization @abstractmethod - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass @abstractmethod - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -177,90 +158,78 @@ class EdgeCloudManagementInterface(ABC): # ArtefactManagement @abstractmethod - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm which are required to create an instance of an application - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :return: """ pass @abstractmethod - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Retrieves details about an artefact - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass @abstractmethod - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Removes an artefact from partners OP. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: """ pass # ApplicationOnboardingManagement @abstractmethod - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. Based on the details provided, partner OP shall do bookkeeping, resource validation and other pre-deployment operations. - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :return: """ pass @abstractmethod - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Retrieves application details from partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. - :return: + :return: Dictionary with application details. """ pass @abstractmethod - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. :return: """ pass @abstractmethod - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str): """ Deboards an application from specific partner OP zones - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: """ @@ -269,66 +238,43 @@ class EdgeCloudManagementInterface(ABC): # ApplicationDeploymentManagement @abstractmethod - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Instantiates an application on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass @abstractmethod - def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. + :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: + :return: Dictionary with application instance details """ pass @abstractmethod - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instance of partner OP + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_provider: App provider - :return: + :return: List with application instances details """ pass @abstractmethod - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone -- GitLab From 756a6a6059ebf3d88cd6977e29821e31885c6e7c Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Tue, 29 Jul 2025 10:54:38 +0200 Subject: [PATCH 228/281] Removes federation context ID parameter --- .../edgecloud/adapters/aeros/client.py | 107 +++++------------ .../edgecloud/adapters/kubernetes/client.py | 108 +++++------------- 2 files changed, 54 insertions(+), 161 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 077a138..d919101 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -438,45 +438,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones - - :return: List. - """ - pass - - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones + Retrieves list of all Zones - :param federation_context_id: Identifier of the federation context. :return: List. """ pass # AvailabilityZoneInfoSynchronization - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -484,84 +466,72 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ArtefactManagement - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm which are required to create an instance of an application - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :return: """ pass - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Retrieves details about an artefact - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Removes an artefact from partners OP. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: """ pass # ApplicationOnboardingManagement - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. Based on the details provided, partner OP shall do bookkeeping, resource validation and other pre-deployment operations. - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :return: """ pass - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Retrieves application details from partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. - :return: + :return: Dictionary with application details. """ pass - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. :return: """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str): """ Deboards an application from specific partner OP zones - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: """ @@ -569,63 +539,40 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationDeploymentManagement - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Instantiates an application on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass - def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. + :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: + :return: Dictionary with application instance details """ pass - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instance of partner OP + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_provider: App provider - :return: + :return: List with application instances details """ pass - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index e83cb41..bdafc3f 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -270,46 +270,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones - - :return: List. - """ - - pass - - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones + Retrieves list of all Zones - :param federation_context_id: Identifier of the federation context. :return: List. """ pass # AvailabilityZoneInfoSynchronization - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -317,84 +298,72 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ArtefactManagement - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm which are required to create an instance of an application - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :return: """ pass - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Retrieves details about an artefact - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Removes an artefact from partners OP. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: """ pass # ApplicationOnboardingManagement - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. Based on the details provided, partner OP shall do bookkeeping, resource validation and other pre-deployment operations. - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :return: """ pass - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Retrieves application details from partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. - :return: + :return: Dictionary with application details. """ pass - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. :return: """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str): """ Deboards an application from specific partner OP zones - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: """ @@ -402,63 +371,40 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationDeploymentManagement - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Instantiates an application on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass - def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. + :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: + :return: Dictionary with application instance details """ pass - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instance of partner OP + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_provider: App provider - :return: + :return: List with application instances details """ pass - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone -- GitLab From 7a762257ae52ed00fb64669710b86d38ef062997 Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Tue, 29 Jul 2025 11:43:22 +0200 Subject: [PATCH 229/281] Refactors code for improved readability --- .../edgecloud/adapters/aeros/client.py | 4 +++- .../edgecloud/adapters/i2edge/client.py | 12 +++++++----- .../edgecloud/adapters/i2edge/utils.py | 1 - .../edgecloud/adapters/kubernetes/client.py | 4 +++- .../edgecloud/core/edgecloud_interface.py | 4 +++- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index d919101..49df922 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -548,7 +548,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma( + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: """ Retrieves an application instance details from partner OP. diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 45404cf..ff28f2d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -522,9 +522,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appVersion=body.get("appVersion"), zoneInfo=schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) - payload = schemas.AppDeploy( - app_deploy_data=app_deploy_data - ) + payload = schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 202: @@ -545,7 +543,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma( + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: try: url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id @@ -605,7 +605,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Error retrieving apps: {e}") - def undeploy_app_gsma(self, app_instance_id: str) -> Response: + def undeploy_app_gsma( + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Response: try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py index 3a3e70b..52d95f7 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py @@ -12,7 +12,6 @@ from typing import Optional, Union from uuid import UUID from src.edgecloud import logger - from sunrise6g_opensdk.edgecloud.api.routers.lcm.schemas import RequiredResources from sunrise6g_opensdk.edgecloud.core import utils as core_utils diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index bdafc3f..9906d11 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -380,7 +380,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma( + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: """ Retrieves an application instance details from partner OP. diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index f1273d6..3fa2cfb 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -248,7 +248,9 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma( + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: """ Retrieves an application instance details from partner OP. -- GitLab From d2356569a74ca172cd784b07265f1b02deca5f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 11:51:38 +0200 Subject: [PATCH 230/281] Satisfy linter --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py index 52d95f7..3a3e70b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py @@ -12,6 +12,7 @@ from typing import Optional, Union from uuid import UUID from src.edgecloud import logger + from sunrise6g_opensdk.edgecloud.api.routers.lcm.schemas import RequiredResources from sunrise6g_opensdk.edgecloud.core import utils as core_utils -- GitLab From 90e55b406e32e16406479cedf10c71b867e5c6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 12:11:16 +0200 Subject: [PATCH 231/281] Clean code minor changes --- src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py | 2 +- tests/edgecloud/test_e2e.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 7e80bfc..8b50ae6 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -42,7 +42,7 @@ class EdgeCloudManagementInterface(ABC): pass # -------------------------------------------------------------------- - # Application Management (CAMARA-Compliant) + # Application Management (CAMARA) # -------------------------------------------------------------------- @abstractmethod diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 11756f1..9a6c74f 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -110,7 +110,7 @@ def test_get_edge_cloud_zones(edgecloud_client): zones = response.json() assert isinstance(zones, list) for zone in zones: - camara_schemas.EdgeCloudZone(**zone) # Validate against CAMARA model + camara_schemas.EdgeCloudZone(**zone) except EdgeCloudPlatformError as e: pytest.fail(f"Failed to retrieve zones: {e}") except Exception as e: -- GitLab From 1c3617a176d7b67062b326896fb0d980b30dc7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 12:11:29 +0200 Subject: [PATCH 232/281] Delete unnecessary "adapters_schemas" file --- .../common/adapters_schemas.py | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/sunrise6g_opensdk/common/adapters_schemas.py diff --git a/src/sunrise6g_opensdk/common/adapters_schemas.py b/src/sunrise6g_opensdk/common/adapters_schemas.py deleted file mode 100644 index 6f50df3..0000000 --- a/src/sunrise6g_opensdk/common/adapters_schemas.py +++ /dev/null @@ -1,28 +0,0 @@ -# EXAMPLE OF PYDANTIC SCHEMAS -from typing import Optional - -from pydantic import AnyHttpUrl, BaseModel, Field - - -class _AdapterBase(BaseModel): - client_name: str = Field(..., min_length=1) - base_url: AnyHttpUrl - - class Config: - extra = "allow" - - -class EdgeCloudConfig(_AdapterBase): - pass - - -class NetworkConfig(_AdapterBase): - pass - - -class AdapterSpecs(BaseModel): - edgecloud: Optional[EdgeCloudConfig] = None - network: Optional[NetworkConfig] = None - - class Config: - extra = "forbid" -- GitLab From 93573762dd261f924d6b46c83075310a32b4b94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 12:44:53 +0200 Subject: [PATCH 233/281] Refactors i2Edge payload handling Simplifies payload handling by directly passing the Pydantic model. Ensures that 'None' values are excluded from the JSON payload sent to i2Edge. This prevents unexpected behavior related to default values not being properly handled by the i2Edge API. --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 4 +--- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 399c379..dae8acd 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -255,9 +255,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Call i2Edge API i2edge_response = i2edge_post( f"{self.base_url}/application/onboarding", - model_payload=i2edge_payload.model_dump( - mode="json", exclude_defaults=True - ), + model_payload=i2edge_payload, ) # OpenAPI specifies 201 for successful application onboarding if i2edge_response.status_code == 201: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 726e45c..c867a32 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -42,12 +42,7 @@ def i2edge_post(url: str, model_payload: BaseModel) -> dict: "Content-Type": "application/json", "accept": "application/json", } - if isinstance(model_payload, BaseModel): - json_payload = json.dumps(model_payload.model_dump(mode="json")) - elif isinstance(model_payload, dict): - json_payload = json.dumps(model_payload) - else: - raise TypeError("Payload must be a Pydantic model or a dict.") + json_payload = json.dumps(model_payload.model_dump(mode="json", exclude_none=True)) try: response = requests.post(url, data=json_payload, headers=headers) response.raise_for_status() -- GitLab From 66e50b2e6f0287626c4514a76b6dc707f43ceb31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 13:11:38 +0200 Subject: [PATCH 234/281] Merge remote-tracking branch 'origin/main' into feature/add-camara-schemas-to-edgecloud-adapters --- .../edgecloud/adapters/aeros/client.py | 107 +++------ .../edgecloud/adapters/i2edge/client.py | 211 +++--------------- .../edgecloud/adapters/kubernetes/client.py | 108 +++------ .../edgecloud/core/edgecloud_interface.py | 144 ++++-------- 4 files changed, 125 insertions(+), 445 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 077a138..49df922 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -438,45 +438,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones - - :return: List. - """ - pass - - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones + Retrieves list of all Zones - :param federation_context_id: Identifier of the federation context. :return: List. """ pass # AvailabilityZoneInfoSynchronization - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -484,84 +466,72 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ArtefactManagement - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm which are required to create an instance of an application - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :return: """ pass - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Retrieves details about an artefact - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Removes an artefact from partners OP. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: """ pass # ApplicationOnboardingManagement - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. Based on the details provided, partner OP shall do bookkeeping, resource validation and other pre-deployment operations. - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :return: """ pass - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Retrieves application details from partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. - :return: + :return: Dictionary with application details. """ pass - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. :return: """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str): """ Deboards an application from specific partner OP zones - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: """ @@ -569,63 +539,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationDeploymentManagement - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Instantiates an application on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. + :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: + :return: Dictionary with application instance details """ pass - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instance of partner OP + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_provider: App provider - :return: + :return: List with application instances details """ pass - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index dae8acd..488cfd3 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -165,12 +165,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Dictionary with artefact details """ url = "{}/artefact/{}".format(self.base_url, artefact_id) + params = {} try: - response = i2edge_get(url, artefact_id) - if response.status_code == 200: - response.raise_for_status() - log.info("Artifact retrieved successfully") - return response.json() + response = i2edge_get(url, params=params) + log.info("Artifact retrieved successfully") return response except I2EdgeError as e: raise e @@ -182,13 +180,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: List of artefact details """ - url = "{}/artefacts".format(self.base_url) + url = "{}/artefact".format(self.base_url) + params = {} try: - response = i2edge_get(url, {}) - if response.status_code == 200: - response.raise_for_status() - log.info("Artifacts retrieved successfully") - return response.json() + response = i2edge_get(url, params=params) + log.info("Artifacts retrieved successfully") return response except I2EdgeError as e: raise e @@ -320,8 +316,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with application details in CAMARA format """ url = "{}/application/onboarding/{}".format(self.base_url, app_id) + params = {} try: - response = i2edge_get(url, app_id) + response = i2edge_get(url, params=params) response.raise_for_status() i2edge_response = response.json() @@ -372,7 +369,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/applications/onboarding".format(self.base_url) params = {} try: - response = i2edge_get(url, params) + response = i2edge_get(url, params=params) response.raise_for_status() i2edge_response = response.json() @@ -629,13 +626,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> Dict: - """ - Retrieves details of Zones for GSMA federation. + # AvailabilityZoneInfoSynchronization - :param federation_context_id: Identifier of the federation context - :return: Dictionary with zone details in GSMA format - """ + def get_edge_cloud_zones_gsma(self) -> Dict: url = "{}/zones".format(self.base_url) params = {} try: @@ -671,51 +664,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # -------------------------------------------------------------------- - # Availability Zone Info Synchronization (GSMA) - # -------------------------------------------------------------------- - - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: - """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. - - :param federation_context_id: Identifier of the federation context - :param request_body: Payload with zone resource information - :return: Dictionary with accepted zone resource info - """ - url = "{}/zones".format(self.base_url) - params = {} - try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - content = {"acceptedZoneResourceInfo": response.json()} - return build_custom_http_response( - status_code=200, - content=content, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except I2EdgeError as e: - raise e - - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: - """ - Retrieves details of a specific Edge Cloud Zone reserved - for the specified zone by the partner OP. - - :param federation_context_id: Identifier of the federation context - :param zone_id: Unique identifier of the Edge Cloud Zone - :return: Dictionary with Edge Cloud Zone details - """ + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: url = "{}/zone/{}".format(self.base_url, zone_id) params = {} try: @@ -723,7 +672,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if response.status_code == 200: response_json = response.json() content = { - "zoneId": response_json.get("zoneID"), + "zoneId": response_json.get("zoneId"), "reservedComputeResources": response_json.get( "reservedComputeResources" ), @@ -752,18 +701,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Artefact Management (GSMA) # -------------------------------------------------------------------- - def create_artefact_gsma( - self, federation_context_id: str, request_body: Dict - ) -> Dict: - """ - Uploads application artefact on partner OP. Artefact is a zip file - containing scripts and/or packaging files like Terraform or Helm - which are required to create an instance of an application - - :param federation_context_id: Identifier of the federation context - :param request_body: Payload with artefact information - :return: Dictionary with artefact deployment info - """ + def create_artefact_gsma(self, request_body: Dict) -> Dict: try: artefact_id = request_body["artefactId"] artefact_name = request_body["artefactName"] @@ -794,14 +732,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: - """ - Retrieves details about an artefact - - :param federation_context_id: Identifier of the federation context - :param artefact_id: Unique identifier of the artefact - :return: Dictionary with artefact details - """ + def get_artefact_gsma(self, artefact_id: str) -> Dict: try: response = self.get_artefact(artefact_id) if response.status_code == 200: @@ -837,14 +768,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") - def delete_artefact_gsma(self, federation_context_id: str, artefact_id: str): - """ - Removes an artefact from partners OP. - - :param federation_context_id: Identifier of the federation context - :param artefact_id: Unique identifier of the artefact - :return: Dictionary with artefact deletion details - """ + def delete_artefact_gsma(self, artefact_id: str) -> Response: try: response = self.delete_artefact(artefact_id) if response.status_code == 200: @@ -864,18 +788,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Application Onboarding Management (GSMA) # -------------------------------------------------------------------- - def onboard_app_gsma( - self, federation_context_id: str, request_body: dict - ) -> Response: - """ - Submits an application details to a partner OP. - Based on the details provided, partner OP shall do bookkeeping, - resource validation and other pre-deployment operations. - - :param federation_context_id: Identifier of the federation context - :param request_body: Payload with onboarding info - :return: Dictionary with onboarding details - """ + def onboard_app_gsma(self, request_body: dict) -> Response: body = deepcopy(request_body) try: body["app_id"] = body.pop("appId") @@ -884,7 +797,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) response = i2edge_post(url, payload) - if response.status_code == 200: + if response.status_code == 201: return build_custom_http_response( status_code=200, content={"response": "Application onboarded successfully"}, @@ -897,14 +810,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: - """ - Retrieves application details from partner OP - - :param federation_context_id: Identifier of the federation context - :param app_id: Identifier of the application onboarded - :return: Dictionary with application details - """ + def get_onboarded_app_gsma(self, app_id: str) -> Dict: try: response = self.get_onboarded_app(app_id) if response.status_code == 200: @@ -933,25 +839,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def patch_onboarded_app_gsma( self, federation_context_id: str, app_id: str, request_body: dict ) -> Dict: - """ - Updates partner OP about changes in application compute resource requirements, - QOS Profile, associated descriptor or change in associated components - - :param federation_context_id: Identifier of the federation context - :param app_id: Identifier of the application onboarded - :param request_body: Payload with updated application details - :return: Dictionary with update confirmation - """ pass def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): - """ - Deletes an application onboarded from the Edge Cloud Provider. - - :param federation_context_id: Identifier of the federation context - :param app_id: Unique identifier of the application - :return: Dictionary with deletion confirmation - """ try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: @@ -974,14 +864,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app_gsma( self, federation_context_id: str, idempotency_key: str, request_body: dict ): - """ - Requests the instantiation of an application instance on partner OP. - - :param federation_context_id: Identifier of the federation context - :param idempotency_key: Key to ensure idempotent operation - :param request_body: Payload with deployment information - :return: Dictionary with deployment details - """ body = deepcopy(request_body) try: zone_id = body.get("zoneInfo").get("zoneId") @@ -992,9 +874,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appVersion=body.get("appVersion"), zoneInfo=i2edge_schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) - payload = i2edge_schemas.AppDeploy( - app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} - ) + payload = camara_schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 202: @@ -1016,21 +896,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): - """ - Retrieves information about a specific deployed application instance. - - :param federation_context_id: Identifier of the federation context - :param app_id: Unique identifier of the application - :param app_instance_id: Unique identifier of the application instance - :param zone_id: Unique identifier of the Edge Cloud Zone - :return: Dictionary with application instance details - """ + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: try: url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id @@ -1055,20 +922,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): - """ - Retrieves information of all application instances for a specific app and provider. - - :param federation_context_id: Identifier of the federation context - :param app_id: Unique identifier of the application - :param app_provider: Application provider identifier - :return: List of application instance details - """ + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Dict: try: url = "{}/application_instances".format(self.base_url) params = {} @@ -1104,21 +958,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Error retrieving apps: {e}") def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): - """ - Terminates a specific application instance on partner OP. - - :param federation_context_id: Identifier of the federation context - :param app_id: Unique identifier of the application - :param app_instance_id: Unique identifier of the application instance - :param zone_id: Unique identifier of the Edge Cloud Zone - :return: Dictionary with termination confirmation - """ + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Response: try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index e83cb41..9906d11 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -270,46 +270,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones - - :return: List. - """ - - pass - - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones + Retrieves list of all Zones - :param federation_context_id: Identifier of the federation context. :return: List. """ pass # AvailabilityZoneInfoSynchronization - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -317,84 +298,72 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ArtefactManagement - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm which are required to create an instance of an application - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with artefact information. - :return: Details with artefact deployment info. + :return: """ pass - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Retrieves details about an artefact - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Removes an artefact from partners OP. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact deletion details. + :return: """ pass # ApplicationOnboardingManagement - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. Based on the details provided, partner OP shall do bookkeeping, resource validation and other pre-deployment operations. - :param federation_context_id: Identifier of the federation context. :param request_body: Payload with onboarding info. - :return: Dictionary with onboarding details. + :return: """ pass - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Retrieves application details from partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. - :return: + :return: Dictionary with application details. """ pass - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. :return: """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str): """ Deboards an application from specific partner OP zones - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: """ @@ -402,63 +371,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ApplicationDeploymentManagement - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Instantiates an application on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key. - :return: + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + self, app_id: str, app_instance_id: str, zone_id: str + ) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. - :param app_instance_id: Identifier of the deployed app. + :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: + :return: Dictionary with application instance details """ pass - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider: str, - ): + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instance of partner OP + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_provider: App provider - :return: + :return: List with application instances details """ pass - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 8b50ae6..607ae0e 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -135,50 +135,29 @@ class EdgeCloudManagementInterface(ABC): @abstractmethod def get_edge_cloud_zones_list_gsma(self) -> List: """ - Retrieves details of all Zones - - :return: List. - """ - pass + Retrieves list of all Zones - @abstractmethod - def get_edge_cloud_zones_gsma(self, federation_context_id: str) -> List: - """ - Retrieves details of Zones - - :param federation_context_id: Identifier of the federation context. :return: List. """ pass - # -------------------------------------------------------------------- - # Availability Zone Info Synchronization (GSMA) - # -------------------------------------------------------------------- + # AvailabilityZoneInfoSynchronization @abstractmethod - def availability_zone_info_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def get_edge_cloud_zones_gsma(self) -> List: """ - Originating OP informs partner OP that it is willing to access - the specified zones and partner OP shall reserve compute and - network resources for these zones. + Retrieves details of all Zones - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload. - :return: + :return: List. """ pass @abstractmethod - def get_edge_cloud_zone_details_gsma( - self, federation_context_id: str, zone_id: str - ) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: """ Retrieves details of a specific Edge Cloud Zone reserved for the specified zone by the partner OP. - :param federation_context_id: Identifier of the federation context. :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Dictionary with Edge Cloud Zone details. """ @@ -189,39 +168,32 @@ class EdgeCloudManagementInterface(ABC): # -------------------------------------------------------------------- @abstractmethod - def create_artefact_gsma( - self, federation_context_id: str, request_body: dict - ) -> Dict: + def create_artefact_gsma(self, request_body: dict): """ Create Artefact. - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload containing artefact details. - :return: Dictionary with created artefact details. + :param request_body: Payload with artefact information. + :return: """ pass @abstractmethod - def get_artefact_gsma(self, federation_context_id: str, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Dict: """ Get Artefact. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ pass @abstractmethod - def delete_artefact_gsma( - self, federation_context_id: str, artefact_id: str - ) -> Dict: + def delete_artefact_gsma(self, artefact_id: str): """ Delete Artefact. - :param federation_context_id: Identifier of the federation context. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with deletion confirmation. + :return: """ pass @@ -230,119 +202,89 @@ class EdgeCloudManagementInterface(ABC): # -------------------------------------------------------------------- @abstractmethod - def onboard_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def onboard_app_gsma(self, request_body: dict): """ Create onboarded Application. - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload containing application onboarding details. - :return: Dictionary with onboarded application details. + :param request_body: Payload with onboarding info. + :return: """ pass @abstractmethod - def get_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ Get onboarded Application. - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the onboarded application. - :return: Dictionary with onboarded application details. + :param app_id: Identifier of the application onboarded. + :return: Dictionary with application details. """ pass @abstractmethod - def patch_onboarded_app_gsma( - self, - federation_context_id: str, - app_id: str, - request_body: dict, - ) -> Dict: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Patch onboarded Application. - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the onboarded application. - :param request_body: Payload containing patch details. - :return: Dictionary with updated application details. + :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. + :return: """ pass @abstractmethod - def delete_onboarded_app_gsma( - self, federation_context_id: str, app_id: str - ) -> Dict: + def delete_onboarded_app_gsma(self, app_id: str): """ Delete onboarded Application. - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the onboarded application. - :return: Dictionary with deletion confirmation. + :param app_id: Identifier of the application onboarded. + :return: """ pass @abstractmethod - def deploy_app_gsma(self, federation_context_id: str, request_body: dict) -> Dict: + def deploy_app_gsma(self, request_body: dict) -> Dict: """ Create deployed Application. - :param federation_context_id: Identifier of the federation context. - :param request_body: Payload containing application deployment details. - :return: Dictionary with deployed application details. + :param request_body: Payload with deployment info. + :return: Dictionary with deployment details. """ pass @abstractmethod def get_deployed_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, + self, app_id: str, app_instance_id: str, zone_id: str ) -> Dict: """ Retrieves an application instance details from partner OP. - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the application. - :param app_instance_id: Unique identifier of the application instance. - :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with deployed application details. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed instance. + :param zone_id: Identifier of the zone + :return: Dictionary with application instance details """ pass @abstractmethod - def get_all_deployed_apps_gsma( - self, - federation_context_id: str, - app_id: str, - app_provider_id: str, - ) -> Dict: + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: """ - Retrieves all application instances of partner OP. + Retrieves all instances for a given application of partner OP - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the application. - :param app_provider_id: Unique identifier of the application provider. - :return: Dictionary with all deployed applications. + :param app_id: Identifier of the app. + :param app_provider: App provider + :return: List with application instances details """ pass @abstractmethod - def undeploy_app_gsma( - self, - federation_context_id: str, - app_id: str, - app_instance_id: str, - zone_id: str, - ) -> Dict: + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): """ Terminate an application instance on a partner OP zone. - :param federation_context_id: Identifier of the federation context. - :param app_id: Unique identifier of the application. - :param app_instance_id: Unique identifier of the application instance. - :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with undeployment confirmation. + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone + :return: """ pass -- GitLab From a66a63fdb2264d140f0b423840cf310783aecf92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Tue, 29 Jul 2025 14:48:08 +0200 Subject: [PATCH 235/281] Refactors GSMA API calls to return Response objects Modifies the GSMA API calls in the i2Edge adapter to return Response objects instead of dictionaries or lists. This change provides more consistent error handling and allows for better access to response metadata, like status codes and headers. It also includes updated docstrings for clarity. --- .../edgecloud/adapters/i2edge/client.py | 159 ++++++++++++++---- .../edgecloud/core/edgecloud_interface.py | 59 ++++--- 2 files changed, 158 insertions(+), 60 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 488cfd3..0f4c28f 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -45,13 +45,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.content_type_gsma = "application/json" self.encoding_gsma = "utf-8" - # ==================================================================== + # ######################################################################## # CAMARA EDGE CLOUD MANAGEMENT API - # ==================================================================== + # ######################################################################## - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Edge Cloud Zone Management (CAMARA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ def get_edge_cloud_zones( self, region: Optional[str] = None, status: Optional[str] = None @@ -105,9 +105,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to retrieve edge cloud zones: {e}") raise - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Artefact Management (i2Edge-Specific, Non-CAMARA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ def create_artefact( self, @@ -208,9 +208,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Application Management (CAMARA-Compliant) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ def onboard_app(self, app_manifest: Dict) -> Response: """ @@ -586,19 +586,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to undeploy app from i2Edge: {e}") raise - # ==================================================================== + # ######################################################################## # GSMA EDGE COMPUTING API (EWBI OPG) - FEDERATION - # ==================================================================== + # ######################################################################## - # -------------------------------------------------------------------- - # Federation Management (GSMA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ + # Zone Management (GSMA) + # ------------------------------------------------------------------------ - def get_edge_cloud_zones_list_gsma(self) -> List: + def get_edge_cloud_zones_list_gsma(self) -> Response: """ Retrieves details of all Zones for GSMA federation. - :return: List of zone details in GSMA format + :return: Response with zone details in GSMA format. """ url = "{}/zones/list".format(self.base_url) params = {} @@ -626,9 +626,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # AvailabilityZoneInfoSynchronization + def get_edge_cloud_zones_gsma(self) -> Response: + """ + Retrieves details of all Zones with compute resources and flavours for GSMA federation. - def get_edge_cloud_zones_gsma(self) -> Dict: + :return: Response with zones and detailed resource information. + """ url = "{}/zones".format(self.base_url) params = {} try: @@ -664,7 +667,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Response: + """ + Retrieves details of a specific Edge Cloud Zone reserved + for the specified zone by the partner OP using GSMA federation. + + :param zone_id: Unique identifier of the Edge Cloud Zone. + :return: Response with Edge Cloud Zone details. + """ url = "{}/zone/{}".format(self.base_url, zone_id) params = {} try: @@ -697,11 +707,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Artefact Management (GSMA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ - def create_artefact_gsma(self, request_body: Dict) -> Dict: + def create_artefact_gsma(self, request_body: Dict) -> Response: + """ + Uploads application artefact on partner OP using GSMA federation. + Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm + which are required to create an instance of an application. + + :param request_body: Payload with artefact information. + :return: Response with artefact upload confirmation. + """ try: artefact_id = request_body["artefactId"] artefact_name = request_body["artefactName"] @@ -732,7 +750,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") - def get_artefact_gsma(self, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Response: + """ + Retrieves details about an artefact from partner OP using GSMA federation. + + :param artefact_id: Unique identifier of the artefact. + :return: Response with artefact details. + """ try: response = self.get_artefact(artefact_id) if response.status_code == 200: @@ -769,6 +793,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") def delete_artefact_gsma(self, artefact_id: str) -> Response: + """ + Removes an artefact from partners OP. + + :param artefact_id: Unique identifier of the artefact. + :return: Response with artefact deletion confirmation. + """ try: response = self.delete_artefact(artefact_id) if response.status_code == 200: @@ -784,11 +814,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Application Onboarding Management (GSMA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ def onboard_app_gsma(self, request_body: dict) -> Response: + """ + Submits an application details to a partner OP. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. + + :param request_body: Payload with onboarding info. + :return: Response with onboarding confirmation. + """ body = deepcopy(request_body) try: body["app_id"] = body.pop("appId") @@ -810,7 +848,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") - def get_onboarded_app_gsma(self, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Response: + """ + Retrieves application details from partner OP using GSMA federation. + + :param app_id: Identifier of the application onboarded. + :return: Response with application details. + """ try: response = self.get_onboarded_app(app_id) if response.status_code == 200: @@ -838,10 +882,28 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def patch_onboarded_app_gsma( self, federation_context_id: str, app_id: str, request_body: dict - ) -> Dict: + ) -> Response: + """ + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components using GSMA federation. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :param request_body: Payload with updated onboarding info. + :return: Response with update confirmation. + """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str): + def delete_onboarded_app_gsma( + self, federation_context_id: str, app_id: str + ) -> Response: + """ + Deboards an application from specific partner OP zones using GSMA federation. + + :param federation_context_id: Identifier of the federation context. + :param app_id: Identifier of the application onboarded. + :return: Response with deboarding confirmation. + """ try: response = self.delete_onboarded_app(app_id) if response.status_code == 200: @@ -857,13 +919,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) - # -------------------------------------------------------------------- + # ------------------------------------------------------------------------ def deploy_app_gsma( self, federation_context_id: str, idempotency_key: str, request_body: dict - ): + ) -> Response: + """ + Instantiates an application on a partner OP zone using GSMA federation. + + :param federation_context_id: Identifier of the federation context. + :param idempotency_key: Idempotency key for request deduplication. + :param request_body: Payload with deployment information. + :return: Response with deployment details. + """ body = deepcopy(request_body) try: zone_id = body.get("zoneInfo").get("zoneId") @@ -874,7 +944,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appVersion=body.get("appVersion"), zoneInfo=i2edge_schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), ) - payload = camara_schemas.AppDeploy(app_deploy_data=app_deploy_data) + payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) response = i2edge_post(url, payload) if response.status_code == 202: @@ -897,7 +967,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_deployed_app_gsma( self, app_id: str, app_instance_id: str, zone_id: str - ) -> Dict: + ) -> Response: + """ + Retrieves an application instance details from partner OP using GSMA federation. + + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed instance. + :param zone_id: Identifier of the zone. + :return: Response with application instance details. + """ try: url = "{}/application_instance/{}/{}".format( self.base_url, zone_id, app_instance_id @@ -922,7 +1000,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Dict: + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Response: + """ + Retrieves all instances for a given application of partner OP using GSMA federation. + + :param app_id: Identifier of the app. + :param app_provider: App provider identifier. + :return: Response with application instances details. + """ try: url = "{}/application_instances".format(self.base_url) params = {} @@ -960,6 +1045,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def undeploy_app_gsma( self, app_id: str, app_instance_id: str, zone_id: str ) -> Response: + """ + Terminate an application instance on a partner OP zone. + + :param app_id: Identifier of the app. + :param app_instance_id: Identifier of the deployed app. + :param zone_id: Identifier of the zone. + :return: Response with termination confirmation. + """ try: url = "{}/application_instance".format(self.base_url) response = i2edge_delete(url, app_instance_id) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 607ae0e..454ab7f 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -144,22 +144,22 @@ class EdgeCloudManagementInterface(ABC): # AvailabilityZoneInfoSynchronization @abstractmethod - def get_edge_cloud_zones_gsma(self) -> List: + def get_edge_cloud_zones_gsma(self) -> Response: """ - Retrieves details of all Zones + Retrieves details of all Zones with compute resources and flavours for GSMA federation. - :return: List. + :return: Response with zones and detailed resource information. """ pass @abstractmethod - def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Response: """ Retrieves details of a specific Edge Cloud Zone reserved - for the specified zone by the partner OP. + for the specified zone by the partner OP using GSMA federation. :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with Edge Cloud Zone details. + :return: Response with Edge Cloud Zone details. """ pass @@ -168,32 +168,34 @@ class EdgeCloudManagementInterface(ABC): # -------------------------------------------------------------------- @abstractmethod - def create_artefact_gsma(self, request_body: dict): + def create_artefact_gsma(self, request_body: dict) -> Response: """ - Create Artefact. + Uploads application artefact on partner OP using GSMA federation. + Artefact is a zip file containing scripts and/or packaging files + like Terraform or Helm which are required to create an instance of an application. :param request_body: Payload with artefact information. - :return: + :return: Response with artefact upload confirmation. """ pass @abstractmethod - def get_artefact_gsma(self, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Response: """ - Get Artefact. + Retrieves details about an artefact from partner OP using GSMA federation. :param artefact_id: Unique identifier of the artefact. - :return: Dictionary with artefact details. + :return: Response with artefact details. """ pass @abstractmethod - def delete_artefact_gsma(self, artefact_id: str): + def delete_artefact_gsma(self, artefact_id: str) -> Response: """ - Delete Artefact. + Removes an artefact from partners OP using GSMA federation. :param artefact_id: Unique identifier of the artefact. - :return: + :return: Response with artefact deletion confirmation. """ pass @@ -202,43 +204,46 @@ class EdgeCloudManagementInterface(ABC): # -------------------------------------------------------------------- @abstractmethod - def onboard_app_gsma(self, request_body: dict): + def onboard_app_gsma(self, request_body: dict) -> Response: """ - Create onboarded Application. + Submits an application details to a partner OP using GSMA federation. + Based on the details provided, partner OP shall do bookkeeping, + resource validation and other pre-deployment operations. :param request_body: Payload with onboarding info. - :return: + :return: Response with onboarding confirmation. """ pass @abstractmethod - def get_onboarded_app_gsma(self, app_id: str) -> Dict: + def get_onboarded_app_gsma(self, app_id: str) -> Response: """ - Get onboarded Application. + Retrieves application details from partner OP using GSMA federation. :param app_id: Identifier of the application onboarded. - :return: Dictionary with application details. + :return: Response with application details. """ pass @abstractmethod - def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict) -> Response: """ - Patch onboarded Application. + Updates partner OP about changes in application compute resource requirements, + QOS Profile, associated descriptor or change in associated components using GSMA federation. :param app_id: Identifier of the application onboarded. :param request_body: Payload with updated onboarding info. - :return: + :return: Response with update confirmation. """ pass @abstractmethod - def delete_onboarded_app_gsma(self, app_id: str): + def delete_onboarded_app_gsma(self, app_id: str) -> Response: """ - Delete onboarded Application. + Deboards an application from specific partner OP zones using GSMA federation. :param app_id: Identifier of the application onboarded. - :return: + :return: Response with deboarding confirmation. """ pass -- GitLab From dba8bbf1950493117d2007affba26f766b863618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 10:39:59 +0200 Subject: [PATCH 236/281] Delete old comment in edgecloud iface --- src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 454ab7f..81ebcaf 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -141,8 +141,6 @@ class EdgeCloudManagementInterface(ABC): """ pass - # AvailabilityZoneInfoSynchronization - @abstractmethod def get_edge_cloud_zones_gsma(self) -> Response: """ -- GitLab From 8965e294775b795c0d3f80bd7624e57177ccd01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 12:01:25 +0200 Subject: [PATCH 237/281] Update linter - standardize line length to 100 characters Enforces a consistent code style by setting the line length to 100 characters across the project. This improves code readability and maintainability by adhering to a common standard. Updates black and pre-commit configurations accordingly. --- .github/workflows/ci.yaml | 2 +- .pre-commit-config.yaml | 1 + .flake8 => setup.cfg | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename .flake8 => setup.cfg (100%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 692c332..38961a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,7 +46,7 @@ jobs: run: isort src tests --check --profile black --filter-files - name: black check - run: black src tests --check + run: black src tests --check --line-length=100 - name: flake8 check run: flake8 src tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0757ac2..ede3904 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,7 @@ repos: args: # - "--check" - "--target-version=py310" + - "--line-length=100" - repo: https://github.com/pycqa/flake8 rev: 7.2.0 hooks: diff --git a/.flake8 b/setup.cfg similarity index 100% rename from .flake8 rename to setup.cfg -- GitLab From 7ada48242bbca66750f781bd9729f500889d6931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 12:02:52 +0200 Subject: [PATCH 238/281] Satisfy linters --- .../common/adapters_factory.py | 8 +- src/sunrise6g_opensdk/common/sdk.py | 4 +- .../edgecloud/adapters/aeros/client.py | 56 ++--- .../adapters/aeros/continuum_client.py | 4 +- .../edgecloud/adapters/i2edge/client.py | 68 ++---- .../edgecloud/adapters/i2edge/schemas.py | 4 +- .../edgecloud/adapters/i2edge/utils.py | 4 +- .../edgecloud/adapters/kubernetes/client.py | 38 +--- .../kubernetes/lib/core/piedge_encoder.py | 29 +-- .../kubernetes/lib/models/app_manifest.py | 16 +- .../lib/models/app_manifest_app_repo.py | 8 +- .../lib/models/app_manifest_component_spec.py | 8 +- .../models/app_manifest_network_interfaces.py | 8 +- .../kubernetes/lib/models/base_model_.py | 4 +- .../kubernetes/lib/models/env_parameters.py | 4 +- .../kubernetes/lib/models/gpu_info.py | 8 +- .../lib/models/required_resources.py | 12 +- .../kubernetes/lib/models/services_query.py | 4 +- .../adapters/kubernetes/lib/models/volume.py | 4 +- .../lib/utils/artifact_connector.py | 4 +- .../kubernetes/lib/utils/connector_db.py | 8 +- .../lib/utils/kubernetes_connector.py | 198 +++++------------- .../edgecloud/core/edgecloud_interface.py | 4 +- .../edgecloud/core/schemas.py | 86 +++----- src/sunrise6g_opensdk/edgecloud/core/utils.py | 4 +- .../network/adapters/oai/client.py | 8 +- .../network/core/base_network_client.py | 75 ++----- src/sunrise6g_opensdk/network/core/common.py | 20 +- src/sunrise6g_opensdk/network/core/schemas.py | 72 ++----- tests/edgecloud/test_e2e.py | 4 +- tests/network/test_create_monitoring_event.py | 4 +- .../network/test_create_traffic_influence.py | 40 +--- 32 files changed, 212 insertions(+), 606 deletions(-) diff --git a/src/sunrise6g_opensdk/common/adapters_factory.py b/src/sunrise6g_opensdk/common/adapters_factory.py index 228f2ed..4891216 100644 --- a/src/sunrise6g_opensdk/common/adapters_factory.py +++ b/src/sunrise6g_opensdk/common/adapters_factory.py @@ -50,12 +50,8 @@ def _network_adapters_factory(client_name: str, base_url: str, **kwargs): scs_as_id = kwargs.pop("scs_as_id") network_factory = { - "open5gs": lambda url, scs_id, **kw: Open5GSClient( - base_url=url, scs_as_id=scs_id, **kw - ), - "oai": lambda url, scs_id, **kw: OaiCoreClient( - base_url=url, scs_as_id=scs_id, **kw - ), + "open5gs": lambda url, scs_id, **kw: Open5GSClient(base_url=url, scs_as_id=scs_id, **kw), + "oai": lambda url, scs_id, **kw: OaiCoreClient(base_url=url, scs_as_id=scs_id, **kw), "open5gcore": lambda url, scs_id, **kw: Open5GCoreClient( base_url=url, scs_as_id=scs_id, **kw ), diff --git a/src/sunrise6g_opensdk/common/sdk.py b/src/sunrise6g_opensdk/common/sdk.py index f451ea1..02b9003 100644 --- a/src/sunrise6g_opensdk/common/sdk.py +++ b/src/sunrise6g_opensdk/common/sdk.py @@ -56,9 +56,7 @@ class Sdk: base_url = config["base_url"] # Support of additional paramaters for specific adapters - kwargs = { - k: v for k, v in config.items() if k not in ("client_name", "base_url") - } + kwargs = {k: v for k, v in config.items() if k not in ("client_name", "base_url")} client = sdk_client.instantiate_and_retrieve_adapters( domain, client_name, base_url, **kwargs diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 49df922..555f821 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -53,9 +53,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise EdgeCloudPlatformError("Missing 'appId' in app manifest") if app_id in self._app_store: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' already exists" - ) + raise EdgeCloudPlatformError(f"Application with id '{app_id}' already exists") self._app_store[app_id] = app_manifest self.logger.debug("Onboarded application with id: %s", app_id) @@ -67,17 +65,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def get_onboarded_app(self, app_id: str) -> Dict: if app_id not in self._app_store: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist" - ) + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") self.logger.debug("Retrieved application with id: %s", app_id) return self._app_store[app_id] def delete_onboarded_app(self, app_id: str) -> None: if app_id not in self._app_store: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist" - ) + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") service_instances = self._stopped_services.get(app_id, []) self.logger.debug( "Deleting application with id: %s and instances: %s", @@ -86,29 +80,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) for service_instance in service_instances: self._purge_deployed_app_from_continuum(service_instance) - self.logger.debug( - "successfully purged service instance: %s", service_instance - ) + self.logger.debug("successfully purged service instance: %s", service_instance) del self._stopped_services[app_id] # Clean up stopped services del self._app_store[app_id] # Remove from onboarded apps def _generate_service_id(self, app_id: str) -> str: return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" - def _generate_tosca_yaml_dict( - self, app_manifest: Dict, app_zones: List[Dict] - ) -> Dict: + def _generate_tosca_yaml_dict(self, app_manifest: Dict, app_zones: List[Dict]) -> Dict: component = app_manifest.get("componentSpec", [{}])[0] component_name = component.get("componentName", "application") image_path = app_manifest.get("appRepo", {}).get("imagePath", "") image_file = image_path.split("/")[-1] - repository_url = ( - "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" - ) - zone_id = ( - app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") - ) + repository_url = "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" + zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") # Extract minNodeMemory min_node_memory = ( @@ -124,9 +110,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): interface_id = iface.get("interfaceId", "default") protocol = iface.get("protocol", "TCP").lower() port = iface.get("port", 8080) - ports[interface_id] = { - "properties": {"protocol": [protocol], "source": port} - } + ports[interface_id] = {"properties": {"protocol": [protocol], "source": port}} expose_ports = any( iface.get("visibilityType") == "VISIBILITY_EXTERNAL" @@ -159,13 +143,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "properties": { "cpu_arch": {"equal": "x64"}, "realtime": {"equal": False}, - "cpu_usage": { - "less_or_equal": "0.1" - }, + "cpu_usage": {"less_or_equal": "0.1"}, "mem_size": { - "greater_or_equal": str( - min_node_memory - ) + "greater_or_equal": str(min_node_memory) }, "domain_id": {"equal": zone_id}, } @@ -203,9 +183,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 1. Get app CAMARA manifest app_manifest = self._app_store.get(app_id) if not app_manifest: - raise EdgeCloudPlatformError( - f"Application with id '{app_id}' does not exist" - ) + raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") # 2. Generate unique service ID service_id = self._generate_service_id(app_id) @@ -306,9 +284,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): for domain in aeros_domains ] - def get_edge_cloud_zones_details( - self, zone_id: str, flavour_id: Optional[str] = None - ) -> Dict: + def get_edge_cloud_zones_details(self, zone_id: str, flavour_id: Optional[str] = None) -> Dict: """ Get details of a specific edge cloud zone. :param zone_id: The ID of the edge cloud zone @@ -350,9 +326,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # # # } aeros_client = ContinuumClient(self.base_url) - ngsild_params = ( - f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' - ) + ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' self.logger.debug( "Querying infrastructure elements for zone %s with params: %s", zone_id, @@ -548,9 +522,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma( - self, app_id: str, app_instance_id: str, zone_id: str - ) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 1c3d766..e1bf149 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -151,9 +151,7 @@ class ContinuumClient: onboard_url = f"{self.api_url}/hlo_fe/services/{service_id}" if config.DEBUG: self.logger.debug("Onboard service URL: %s", onboard_url) - self.logger.debug( - "Onboard service request body (TOSCA-YAML): %s", tosca_str - ) + self.logger.debug("Onboard service request body (TOSCA-YAML): %s", tosca_str) response = requests.post( onboard_url, data=tosca_str, headers=self.hlo_onboard_headers, timeout=15 ) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 0f4c28f..98e7e95 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -18,9 +18,7 @@ from sunrise6g_opensdk.edgecloud.core import schemas as camara_schemas from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) -from sunrise6g_opensdk.edgecloud.core.utils import ( - build_custom_http_response, -) +from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response from ...adapters.i2edge import schemas as i2edge_schemas from .common import ( @@ -78,13 +76,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone = camara_schemas.EdgeCloudZone( # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(z["zoneId"]), - edgeCloudZoneName=camara_schemas.EdgeCloudZoneName( - z["nodeName"] - ), + edgeCloudZoneName=camara_schemas.EdgeCloudZoneName(z["nodeName"]), edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), - edgeCloudRegion=camara_schemas.EdgeCloudRegion( - z["geographyDetails"] - ), + edgeCloudRegion=camara_schemas.EdgeCloudRegion(z["geographyDetails"]), edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, ) camara_response.append(zone) @@ -258,9 +252,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): i2edge_response.raise_for_status() # Build CAMARA-compliant response using schema - submitted_app = camara_schemas.SubmittedApp( - appId=camara_schemas.AppId(app_id) - ) + submitted_app = camara_schemas.SubmittedApp(appId=camara_schemas.AppId(app_id)) log.info("App onboarded successfully") return build_custom_http_response( @@ -449,9 +441,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appId=appId, appProviderId=appProviderId, appVersion=appVersion, - zoneInfo=i2edge_schemas.ZoneInfoRef( - flavourId=self.flavour_id, zoneId=zone_id - ), + zoneInfo=i2edge_schemas.ZoneInfoRef(flavourId=self.flavour_id, zoneId=zone_id), ) url = "{}/application_instance".format(self.base_url) payload = i2edge_schemas.AppDeploy( @@ -478,9 +468,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) # CAMARA spec requires appInstances array wrapper - camara_response = { - "appInstances": [app_instance_info.model_dump(mode="json")] - } + camara_response = {"appInstances": [app_instance_info.model_dump(mode="json")]} log.info("App deployment request submitted successfully") return build_custom_http_response( @@ -642,17 +630,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): for item in response_json: content = { "zoneId": item.get("zoneId"), - "reservedComputeResources": item.get( - "reservedComputeResources" - ), - "computeResourceQuotaLimits": item.get( - "computeResourceQuotaLimits" - ), + "reservedComputeResources": item.get("reservedComputeResources"), + "computeResourceQuotaLimits": item.get("computeResourceQuotaLimits"), "flavoursSupported": item.get("flavoursSupported"), "networkResources": item.get("networkResources"), - "zoneServiceLevelObjsInfo": item.get( - "zoneServiceLevelObjsInfo" - ), + "zoneServiceLevelObjsInfo": item.get("zoneServiceLevelObjsInfo"), } response_list.append(content) return build_custom_http_response( @@ -683,17 +665,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response_json = response.json() content = { "zoneId": response_json.get("zoneId"), - "reservedComputeResources": response_json.get( - "reservedComputeResources" - ), - "computeResourceQuotaLimits": response_json.get( - "computeResourceQuotaLimits" - ), + "reservedComputeResources": response_json.get("reservedComputeResources"), + "computeResourceQuotaLimits": response_json.get("computeResourceQuotaLimits"), "flavoursSupported": response_json.get("flavoursSupported"), "networkResources": response_json.get("networkResources"), - "zoneServiceLevelObjsInfo": response_json.get( - "zoneServiceLevelObjsInfo" - ), + "zoneServiceLevelObjsInfo": response_json.get("zoneServiceLevelObjsInfo"), } return build_custom_http_response( status_code=200, @@ -894,9 +870,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def delete_onboarded_app_gsma( - self, federation_context_id: str, app_id: str - ) -> Response: + def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Response: """ Deboards an application from specific partner OP zones using GSMA federation. @@ -965,9 +939,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") - def get_deployed_app_gsma( - self, app_id: str, app_instance_id: str, zone_id: str - ) -> Response: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ Retrieves an application instance details from partner OP using GSMA federation. @@ -977,9 +949,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with application instance details. """ try: - url = "{}/application_instance/{}/{}".format( - self.base_url, zone_id, app_instance_id - ) + url = "{}/application_instance/{}/{}".format(self.base_url, zone_id, app_instance_id) params = {} response = i2edge_get(url, params=params) if response.status_code == 200: @@ -1042,9 +1012,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Error retrieving apps: {e}") - def undeploy_app_gsma( - self, app_id: str, app_instance_id: str, zone_id: str - ) -> Response: + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ Terminate an application instance on a partner OP zone. @@ -1059,9 +1027,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if response.status_code == 200: return build_custom_http_response( status_code=200, - content={ - "response": "Application instance termination request accepted" - }, + content={"response": "Application instance termination request accepted"}, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index 94cd2a2..c3e9433 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -41,9 +41,7 @@ class Hugepages(BaseModel): class GPU(BaseModel): gpuMemory: int = Field(default=0, description="GPU memory in MB") gpuModeName: str = Field(default="", description="GPU mode name") - gpuVendorType: str = Field( - default="GPU_PROVIDER_NVIDIA", description="GPU vendor type" - ) + gpuVendorType: str = Field(default="GPU_PROVIDER_NVIDIA", description="GPU vendor type") numGPU: int = Field(..., description="Number of GPUs") diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py index 3a3e70b..5402fa6 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/utils.py @@ -98,9 +98,7 @@ def onboard_app_with( raise e -def delete_app_instance_by( - namespace: str, flavour_id: str, zone_id: str, i2edge: I2EdgeClient -): +def delete_app_instance_by(namespace: str, flavour_id: str, zone_id: str, i2edge: I2EdgeClient): i2edge_app_instance_name = get_app_name_from(namespace, i2edge) if i2edge_app_instance_name is None: err_msg = "Couldn't retrieve app instance from I2Edge." diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 9906d11..506b51e 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -55,9 +55,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_name = app_manifest.get("name") image = app_manifest.get("appRepo").get("imagePath") package_type = app_manifest.get("packageType") - network_interfaces = app_manifest.get("componentSpec")[0].get( - "networkInterfaces" - ) + network_interfaces = app_manifest.get("componentSpec")[0].get("networkInterfaces") ports = [] for ni in network_interfaces: ports.append(ni.get("port")) @@ -68,9 +66,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): service_function_type=package_type, application_ports=ports, ) - result = self.connector_db.insert_document_service_function( - insert_doc.to_dict() - ) + result = self.connector_db.insert_document_service_function(insert_doc.to_dict()) if type(result) is str: return result return {"appId": str(result.inserted_id)} @@ -87,9 +83,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # return [{"appId": "1234-5678", "name": "TestApp"}] def get_onboarded_app(self, app_id: str) -> Dict: - logging.info( - "Searching for registered app with ID: " + app_id + " in database..." - ) + logging.info("Searching for registered app with ID: " + app_id + " in database...") app = self.connector_db.get_documents_from_collection( "service_functions", input_type="_id", input_value=app_id ) @@ -105,9 +99,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): def deploy_app(self, body: dict) -> Dict: logging.info( - "Searching for registered app with ID: " - + body.get("appId") - + " in database..." + "Searching for registered app with ID: " + body.get("appId") + " in database..." ) app = self.connector_db.get_documents_from_collection( "service_functions", input_type="_id", input_value=body.get("appId") @@ -150,9 +142,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): region: Optional[str] = None, ) -> List[Dict]: logging.info("Retrieving all deployed apps in the edge cloud platform") - deployments = self.k8s_connector.get_deployed_service_functions( - self.connector_db - ) + deployments = self.k8s_connector.get_deployed_service_functions(self.connector_db) response = [] for deployment in deployments: item = {} @@ -173,9 +163,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] def undeploy_app(self, app_instance_id: str) -> None: - logging.info( - "Searching for deployed app with ID: " + app_instance_id + " in database..." - ) + logging.info("Searching for deployed app with ID: " + app_instance_id + " in database...") print(f"Deleting app instance: {app_instance_id}") sfs = self.k8s_connector.get_deployed_service_functions(self.connector_db) response = "App instance with ID [" + app_instance_id + "] not found" @@ -184,11 +172,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.k8s_connector.delete_service_function( self.connector_db, service_fun["service_function_instance_name"] ) - response = ( - "App instance with ID [" - + app_instance_id - + "] successfully removed" - ) + response = "App instance with ID [" + app_instance_id + "] successfully removed" break return response @@ -209,9 +193,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone_list.append(zone) return zone_list - def get_edge_cloud_zones_details( - self, zone_id: str, flavour_id: Optional[str] = None - ) -> Dict: + def get_edge_cloud_zones_details(self, zone_id: str, flavour_id: Optional[str] = None) -> Dict: nodes = self.k8s_connector.get_node_details() node_details = None for item in nodes.get("items"): @@ -380,9 +362,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma( - self, app_id: str, app_instance_id: str, zone_id: str - ) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py index f81d9c2..b18d004 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/core/piedge_encoder.py @@ -26,10 +26,7 @@ def prepare_container(service_function, ser_function_): con_["application_ports"] = application_ports if service_function.all_node_ports is not None: - if ( - service_function.all_node_ports is False - and service_function.node_ports is None - ): + if service_function.all_node_ports is False and service_function.node_ports is None: return ( "Please provide the application ports in the field exposed_ports or all_node_ports==true", 400, @@ -70,9 +67,7 @@ def prepare_volumes(service_function, ser_function_, final_deploy_descriptor): vol_mount.append(volume_mounts.name) if len(vol_mount) != len(req_volumes): return ( - "The selected service function requires " - + str(len(req_volumes)) - + " volume/ volumes ", + "The selected service function requires " + str(len(req_volumes)) + " volume/ volumes ", 400, ) else: @@ -128,9 +123,7 @@ def prepare_env_parameters(service_function, ser_function_, final_deploy_descrip ) else: if ser_function_[0].get("required_env_parameters") is not None: - result = auxiliary_functions.equal_ignore_order( - req_env_parameters, env_names - ) + result = auxiliary_functions.equal_ignore_order(req_env_parameters, env_names) if result is False: return ( "The selected service function requires " @@ -189,15 +182,11 @@ def deploy_service_function( return containers final_deploy_descriptor["containers"] = containers - vol_result = prepare_volumes( - service_function, ser_function_, final_deploy_descriptor - ) + vol_result = prepare_volumes(service_function, ser_function_, final_deploy_descriptor) if vol_result is not None: return vol_result - env_result = prepare_env_parameters( - service_function, ser_function_, final_deploy_descriptor - ) + env_result = prepare_env_parameters(service_function, ser_function_, final_deploy_descriptor) if env_result is not None: return env_result @@ -211,14 +200,10 @@ def deploy_service_function( if "volumes" in final_deploy_descriptor: deployed_service_function_db["volumes"] = final_deploy_descriptor["volumes"] if "env_parameters" in final_deploy_descriptor: - deployed_service_function_db["env_parameters"] = final_deploy_descriptor[ - "env_parameters" - ] + deployed_service_function_db["env_parameters"] = final_deploy_descriptor["env_parameters"] if "location" not in deployed_service_function_db: - deployed_service_function_db["location"] = ( - "Node is selected by the K8s scheduler" - ) + deployed_service_function_db["location"] = "Node is selected by the K8s scheduler" if type(response) is V1Deployment: deployed_service_function_db["_id"] = response.metadata.uid connector_db.insert_document_deployed_service_function( diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py index ff991ad..9c3be84 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest.py @@ -114,9 +114,7 @@ class AppManifest(Model): :type name: str """ if name is None: - raise ValueError( - "Invalid value for `name`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `name`, must not be `None`") # noqa: E501 self._name = name @@ -141,9 +139,7 @@ class AppManifest(Model): :type version: int """ if version is None: - raise ValueError( - "Invalid value for `version`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `version`, must not be `None`") # noqa: E501 self._version = version @@ -217,9 +213,7 @@ class AppManifest(Model): :type app_repo: AppManifestAppRepo """ if app_repo is None: - raise ValueError( - "Invalid value for `app_repo`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `app_repo`, must not be `None`") # noqa: E501 self._app_repo = app_repo @@ -269,8 +263,6 @@ class AppManifest(Model): :type component_spec: List[AppManifestComponentSpec] """ if component_spec is None: - raise ValueError( - "Invalid value for `component_spec`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `component_spec`, must not be `None`") # noqa: E501 self._component_spec = component_spec diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py index 9236520..c919467 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_app_repo.py @@ -100,9 +100,7 @@ class AppManifestAppRepo(Model): allowed_values = ["PRIVATEREPO", "PUBLICREPO"] # noqa: E501 if type not in allowed_values: raise ValueError( - "Invalid value for `type` ({0}), must be one of {1}".format( - type, allowed_values - ) + "Invalid value for `type` ({0}), must be one of {1}".format(type, allowed_values) ) self._type = type @@ -126,9 +124,7 @@ class AppManifestAppRepo(Model): :type image_path: Uri """ if image_path is None: - raise ValueError( - "Invalid value for `image_path`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `image_path`, must not be `None`") # noqa: E501 self._image_path = image_path diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py index 4c55b75..d1cb44e 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_component_spec.py @@ -74,9 +74,7 @@ class AppManifestComponentSpec(Model): :type component_name: str """ if component_name is None: - raise ValueError( - "Invalid value for `component_name`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `component_name`, must not be `None`") # noqa: E501 self._component_name = component_name @@ -92,9 +90,7 @@ class AppManifestComponentSpec(Model): return self._network_interfaces @network_interfaces.setter - def network_interfaces( - self, network_interfaces: List[AppManifestNetworkInterfaces] - ): + def network_interfaces(self, network_interfaces: List[AppManifestNetworkInterfaces]): """Sets the network_interfaces of this AppManifestComponentSpec. Each application component exposes some ports either for external users or for inter component communication. Application provider is required to specify which ports are to be exposed and the type of traffic that will flow through these ports.The underlying platform may assign a dynamic port against the \"extPort\" that the application clients will use to connect with edge application instance. # noqa: E501 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py index 811d809..393b897 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/app_manifest_network_interfaces.py @@ -84,9 +84,7 @@ class AppManifestNetworkInterfaces(Model): :type interface_id: str """ if interface_id is None: - raise ValueError( - "Invalid value for `interface_id`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `interface_id`, must not be `None`") # noqa: E501 self._interface_id = interface_id @@ -141,9 +139,7 @@ class AppManifestNetworkInterfaces(Model): :type port: int """ if port is None: - raise ValueError( - "Invalid value for `port`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `port`, must not be `None`") # noqa: E501 self._port = port diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py index 6c720cb..a09be18 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/base_model_.py @@ -41,9 +41,7 @@ class Model(object): result[attr] = dict( map( lambda item: ( - (item[0], item[1].to_dict()) - if hasattr(item[1], "to_dict") - else item + (item[0], item[1].to_dict()) if hasattr(item[1], "to_dict") else item ), value.items(), ) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py index 423d49e..0de65a7 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/env_parameters.py @@ -10,9 +10,7 @@ class EnvParameters(Model): Do not edit the class manually. """ - def __init__( - self, name: str = None, value: str = None, value_ref: str = None - ): # noqa: E501 + def __init__(self, name: str = None, value: str = None, value_ref: str = None): # noqa: E501 """EnvParameters - a model defined in Swagger :param name: The name of this EnvParameters. # noqa: E501 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py index 1fab789..2aafe22 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/gpu_info.py @@ -58,9 +58,7 @@ class GpuInfo(Model): :type gpu_memory: int """ if gpu_memory is None: - raise ValueError( - "Invalid value for `gpu_memory`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `gpu_memory`, must not be `None`") # noqa: E501 self._gpu_memory = gpu_memory @@ -85,8 +83,6 @@ class GpuInfo(Model): :type num_gpu: int """ if num_gpu is None: - raise ValueError( - "Invalid value for `num_gpu`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `num_gpu`, must not be `None`") # noqa: E501 self._num_gpu = num_gpu diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py index 4892057..5cd3be2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/required_resources.py @@ -86,9 +86,7 @@ class RequiredResources(Model): :type num_cpu: int """ if num_cpu is None: - raise ValueError( - "Invalid value for `num_cpu`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `num_cpu`, must not be `None`") # noqa: E501 self._num_cpu = num_cpu @@ -113,9 +111,7 @@ class RequiredResources(Model): :type memory: int """ if memory is None: - raise ValueError( - "Invalid value for `memory`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `memory`, must not be `None`") # noqa: E501 self._memory = memory @@ -140,9 +136,7 @@ class RequiredResources(Model): :type storage: int """ if storage is None: - raise ValueError( - "Invalid value for `storage`, must not be `None`" - ) # noqa: E501 + raise ValueError("Invalid value for `storage`, must not be `None`") # noqa: E501 self._storage = storage diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py index a1642c8..55c0c76 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/services_query.py @@ -15,9 +15,7 @@ class ServicesQuery(Model): Do not edit the class manually. """ - def __init__( - self, service_consumer_id: str = None, query_string: str = None - ): # noqa: E501 + def __init__(self, service_consumer_id: str = None, query_string: str = None): # noqa: E501 """ServicesQuery - a model defined in Swagger :param service_consumer_id: The service_consumer_id of this ServicesQuery. # noqa: E501 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py index 53e0c87..3e70044 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/models/volume.py @@ -15,9 +15,7 @@ class Volume(Model): Do not edit the class manually. """ - def __init__( - self, name: str = None, path: str = None, hostpath: str = None - ): # noqa: E501 + def __init__(self, name: str = None, path: str = None, hostpath: str = None): # noqa: E501 """Volume - a model defined in Swagger :param name: The name of this Volume. # noqa: E501 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py index 4e82f04..622a59b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/artifact_connector.py @@ -20,7 +20,5 @@ def copy_artifact(body): logging.info("Submitting artifact to Artifact Manager") # body = json.dumps(body) headers = {"Content-Type": "application/json"} - response = requests.post( - artifact_manager_host + "/copy-artefact", headers=headers, json=body - ) + response = requests.post(artifact_manager_host + "/copy-artefact", headers=headers, json=body) return response diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py index ceaa108..d2e5b19 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/connector_db.py @@ -82,9 +82,7 @@ class ConnectorDB: insert_doc["scaling_type"] = document["scaling_type"] # if "monitoring_service_URL" in document: - insert_doc["monitoring_service_URL"] = document[ - "monitoring_service_URL" - ] + insert_doc["monitoring_service_URL"] = document["monitoring_service_URL"] if "paas_name" in document: insert_doc["paas_name"] = document["paas_name"] @@ -175,9 +173,7 @@ class ConnectorDB: # else: # return "Service function not found in the catalogue" - def delete_document_service_function( - self, service_function_input_name=None, _id: str = None - ): + def delete_document_service_function(self, service_function_input_name=None, _id: str = None): # _id = ObjectId(_id) collection = "service_functions" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py index 4d778f5..e882de0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/lib/utils/kubernetes_connector.py @@ -64,10 +64,7 @@ class KubernetesConnector: try: self.api_instance.get_api_group() except ApiException as e: - print( - "Exception when calling AdmissionregistrationApi->get_api_group: %s\n" - % e - ) + print("Exception when calling AdmissionregistrationApi->get_api_group: %s\n" % e) def get_node_details(self): try: @@ -95,9 +92,7 @@ class KubernetesConnector: "Exception when calling CoreV1Api->/api/v1/namespaces/sunrise6g/persistentvolumeclaims: %s\n" % e ) - k8s_nodes = self.api_custom.list_cluster_custom_object( - "metrics.k8s.io", "v1beta1", "nodes" - ) + k8s_nodes = self.api_custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes") # client.models.v1_node_list.V1NodeList # kubernetes.client.models.v1_node_list.V1NodeList\ @@ -112,12 +107,8 @@ class KubernetesConnector: pop_output["nodeLocation"] = pop["metadata"]["labels"]["location"] node_addresses = {} - node_addresses["nodeHostName"] = pop["status"]["addresses"][1][ - "address" - ] - node_addresses["nodeExternalIP"] = pop["status"]["addresses"][0][ - "address" - ] + node_addresses["nodeHostName"] = pop["status"]["addresses"][1]["address"] + node_addresses["nodeExternalIP"] = pop["status"]["addresses"][0]["address"] node_addresses["nodeInternalIP"] = pop["metadata"]["annotations"].get( "projectcalico.org/IPv4VXLANTunnelAddr" ) @@ -144,18 +135,14 @@ class KubernetesConnector: pop_output["nodeCapacity"] = node_capacity node_allocatable_resources = {} - node_allocatable_resources["nodeCPUCap"] = pop["status"]["allocatable"][ - "cpu" - ] + node_allocatable_resources["nodeCPUCap"] = pop["status"]["allocatable"]["cpu"] memory = pop["status"]["allocatable"]["memory"] memory_size = len(memory) node_allocatable_resources["nodeMemoryCap"] = memory[: memory_size - 2] node_allocatable_resources["nodeMemoryCapMU"] = memory[-2:] storage = pop["status"]["allocatable"]["ephemeral-storage"] storage_size = len(storage) - node_allocatable_resources["nodeStorageCap"] = storage[ - : storage_size - 2 - ] + node_allocatable_resources["nodeStorageCap"] = storage[: storage_size - 2] node_allocatable_resources["nodeStorageCapMU"] = storage[-2:] # node_allocatable_resources["nodeMaxNoofPods"] = pop['status']['allocatable']['pods'] pop_output["nodeAllocatableResources"] = node_allocatable_resources @@ -171,9 +158,9 @@ class KubernetesConnector: node_usage["nodeMemoryInUse"] = memory[: memory_size - 2] node_usage["nodeMemoryInUseMU"] = memory[-2:] - node_usage["nodeMemoryUsage"] = int( - node_usage["nodeMemoryInUse"] - ) / int(node_capacity["nodeMemoryCap"]) + node_usage["nodeMemoryUsage"] = int(node_usage["nodeMemoryInUse"]) / int( + node_capacity["nodeMemoryCap"] + ) node_usage["nodeCPUInUse"] = cpu[: cpu_size - 1] node_usage["nodeCPUInUseMU"] = cpu[-1:] node_usage["nodeCPUUsage"] = int(node_usage["nodeCPUInUse"]) / ( @@ -186,15 +173,11 @@ class KubernetesConnector: node_general_info["nodeKubernetesVersion"] = pop["status"]["nodeInfo"][ "kernelVersion" ] - node_general_info["nodecontainerRuntimeVersion"] = pop["status"][ - "nodeInfo" - ]["containerRuntimeVersion"] - node_general_info["nodeKernelVersion"] = pop["status"]["nodeInfo"][ - "kernelVersion" - ] - node_general_info["nodeArchitecture"] = pop["status"]["nodeInfo"][ - "architecture" + node_general_info["nodecontainerRuntimeVersion"] = pop["status"]["nodeInfo"][ + "containerRuntimeVersion" ] + node_general_info["nodeKernelVersion"] = pop["status"]["nodeInfo"]["kernelVersion"] + node_general_info["nodeArchitecture"] = pop["status"]["nodeInfo"]["architecture"] pop_output["nodeGeneralInfo"] = node_general_info return pop_output @@ -212,9 +195,7 @@ class KubernetesConnector: pop_["serial"] = node.status.addresses[0].address pop_["node_type"] = node.metadata.labels.get("node_type") pop_["status"] = ( - "active" - if node.status.conditions[-1].status == "True" - else "inactive" + "active" if node.status.conditions[-1].status == "True" else "inactive" ) # pop_= NodesResponse(id=uid,name=name,location=location,serial=address, node_type=node_type, status=ready_status) pops_.append(pop_) @@ -237,14 +218,10 @@ class KubernetesConnector: name=service_function_name, namespace=self.namespace ) - self.v1.delete_namespaced_service( - name=service_function_name, namespace=self.namespace - ) + self.v1.delete_namespaced_service(name=service_function_name, namespace=self.namespace) - hpa_list = ( - self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( - self.namespace - ) + hpa_list = self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( + self.namespace ) # hpas=hpa_list["items"] @@ -307,9 +284,7 @@ class KubernetesConnector: descriptor_service_function["name"], volume ) headers = {"Authorization": "Bearer " + self.token_k8s} - requests.post( - url, headers=headers, json=body_volume, verify=False - ) + requests.post(url, headers=headers, json=body_volume, verify=False) except requests.exceptions.HTTPError as e: # logging.error(traceback.format_exc()) return ( @@ -323,10 +298,8 @@ class KubernetesConnector: body_service = self.create_service(descriptor_service_function) try: - api_response_deployment = ( - self.api_instance_appsv1.create_namespaced_deployment( - self.namespace, body_deployment - ) + api_response_deployment = self.api_instance_appsv1.create_namespaced_deployment( + self.namespace, body_deployment ) # api_response_service = api_instance_apiregv1.create_api_service(body_service) self.v1.create_namespaced_service(self.namespace, body_service) @@ -360,9 +333,7 @@ class KubernetesConnector: security_context = self._get_security_context(container) ports = self._get_container_ports(container) envs = self._get_env_vars(descriptor_service_function) - volumes, volume_mounts = self._get_volumes_and_mounts( - descriptor_service_function - ) + volumes, volume_mounts = self._get_volumes_and_mounts(descriptor_service_function) if "autoscaling_policies" in descriptor_service_function: resources = self._get_resource_requirements(descriptor_service_function) @@ -388,9 +359,7 @@ class KubernetesConnector: ) containers.append(con) - template_spec_ = self._get_pod_spec( - descriptor_service_function, containers, volumes - ) + template_spec_ = self._get_pod_spec(descriptor_service_function, containers, volumes) template = client.V1PodTemplateSpec(metadata=metadata_spec, spec=template_spec_) spec = client.V1DeploymentSpec( @@ -467,32 +436,22 @@ class KubernetesConnector: and descriptor_service_function["volumes"] is not None ): for volume in descriptor_service_function["volumes"]: - vol_name = ( - str(descriptor_service_function["name"]) + "-" + volume["name"] - ) + vol_name = str(descriptor_service_function["name"]) + "-" + volume["name"] if volume.get("hostpath") is None: - pvc = client.V1PersistentVolumeClaimVolumeSource( - claim_name=vol_name - ) - volume_ = client.V1Volume( - name=vol_name, persistent_volume_claim=pvc - ) + pvc = client.V1PersistentVolumeClaimVolumeSource(claim_name=vol_name) + volume_ = client.V1Volume(name=vol_name, persistent_volume_claim=pvc) else: hostpath = client.V1HostPathVolumeSource(path=volume["hostpath"]) volume_ = client.V1Volume(name=vol_name, host_path=hostpath) volumes.append(volume_) - volume_mount = client.V1VolumeMount( - name=vol_name, mount_path=volume["path"] - ) + volume_mount = client.V1VolumeMount(name=vol_name, mount_path=volume["path"]) volume_mounts.append(volume_mount) return volumes, volume_mounts def _get_resource_requirements(self, descriptor_service_function): limits_dict = {} request_dict = {} - for auto_scale_policy in descriptor_service_function.get( - "autoscaling_policies", [] - ): + for auto_scale_policy in descriptor_service_function.get("autoscaling_policies", []): limits_dict[auto_scale_policy["metric"]] = auto_scale_policy["limit"] request_dict[auto_scale_policy["metric"]] = auto_scale_policy["request"] return client.V1ResourceRequirements(limits=limits_dict, requests=request_dict) @@ -518,9 +477,7 @@ class KubernetesConnector: def create_service(self, descriptor_service_function): dict_label = {} dict_label[self.namespace] = descriptor_service_function["name"] - metadata = client.V1ObjectMeta( - name=descriptor_service_function["name"], labels=dict_label - ) + metadata = client.V1ObjectMeta(name=descriptor_service_function["name"], labels=dict_label) # spec @@ -529,9 +486,7 @@ class KubernetesConnector: ): # create NodePort svc object ports = [] hepler = 0 - for port_id in descriptor_service_function["containers"][0][ - "exposed_ports" - ]: + for port_id in descriptor_service_function["containers"][0]["exposed_ports"]: # if "grafana" in descriptor_service_function["name"]: # ports_=client.V1ServicePort(port=port_id, @@ -541,30 +496,18 @@ class KubernetesConnector: # ports_ = client.V1ServicePort(port=port_id, # # node_port=descriptor_paas["containers"][0]["exposed_ports"][hepler], # target_port=port_id, name=str(port_id)) - ports_ = client.V1ServicePort( - port=port_id, target_port=port_id, name=str(port_id) - ) + ports_ = client.V1ServicePort(port=port_id, target_port=port_id, name=str(port_id)) ports.append(ports_) hepler = hepler + 1 - spec = client.V1ServiceSpec( - selector=dict_label, ports=ports, type="NodePort" - ) + spec = client.V1ServiceSpec(selector=dict_label, ports=ports, type="NodePort") # body = client.V1Service(api_version="v1", kind="Service", metadata=metadata, spec=spec) else: # create ClusterIP svc object ports = [] - for port_id in descriptor_service_function["containers"][0][ - "application_ports" - ]: - ports_ = client.V1ServicePort( - port=port_id, target_port=port_id, name=str(port_id) - ) + for port_id in descriptor_service_function["containers"][0]["application_ports"]: + ports_ = client.V1ServicePort(port=port_id, target_port=port_id, name=str(port_id)) ports.append(ports_) - spec = client.V1ServiceSpec( - selector=dict_label, ports=ports, type="ClusterIP" - ) - body = client.V1Service( - api_version="v1", kind="Service", metadata=metadata, spec=spec - ) + spec = client.V1ServiceSpec(selector=dict_label, ports=ports, type="ClusterIP") + body = client.V1Service(api_version="v1", kind="Service", metadata=metadata, spec=spec) return body @@ -578,9 +521,7 @@ class KubernetesConnector: kind = ("PersistentVolumeClaim",) spec = client.V1PersistentVolumeClaimSpec( access_modes=["ReadWriteMany"], - resources=client.V1ResourceRequirements( - requests={"storage": volumes["storage"]} - ), + resources=client.V1ResourceRequirements(requests={"storage": volumes["storage"]}), ) body = client.V1PersistentVolumeClaim( api_version="v1", kind=kind, metadata=metadata, spec=spec @@ -588,9 +529,7 @@ class KubernetesConnector: return body - def create_pvc_dict( - self, name, volumes, storage_class="microk8s-hostpath", volume_name=None - ): + def create_pvc_dict(self, name, volumes, storage_class="microk8s-hostpath", volume_name=None): name_vol = name + str("-") + volumes["name"] # body={} # body["api_version"]="v1" @@ -693,9 +632,7 @@ class KubernetesConnector: dict_label = {} dict_label["name"] = descriptor_service_function["name"] - metadata = client.V1ObjectMeta( - name=descriptor_service_function["name"], labels=dict_label - ) + metadata = client.V1ObjectMeta(name=descriptor_service_function["name"], labels=dict_label) # spec @@ -724,9 +661,7 @@ class KubernetesConnector: return body def get_deployed_dataspace_connector(self, instance_name): - api_response = self.api_instance_appsv1.list_namespaced_deployment( - self.namespace - ) + api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) api_response_service = self.v1.list_namespaced_service(self.namespace) app_ = {} @@ -743,9 +678,7 @@ class KubernetesConnector: if app_: # if app_ is not empty - if (status.available_replicas is not None) and ( - status.ready_replicas is not None - ): + if (status.available_replicas is not None) and (status.ready_replicas is not None): if status.available_replicas >= 1 and status.ready_replicas >= 1: app_["status"] = "running" app_["replicas"] = status.ready_replicas @@ -783,37 +716,25 @@ class KubernetesConnector: def get_deployed_service_functions(self, connector_db: ConnectorDB): self.get_deployed_hpas(connector_db) - api_response = self.api_instance_appsv1.list_namespaced_deployment( - self.namespace - ) + api_response = self.api_instance_appsv1.list_namespaced_deployment(self.namespace) api_response_service = self.v1.list_namespaced_service(self.namespace) - api_response_pvc = self.v1.list_namespaced_persistent_volume_claim( - self.namespace - ) + api_response_pvc = self.v1.list_namespaced_persistent_volume_claim(self.namespace) - apps_col = connector_db.get_documents_from_collection( - collection_input="service_functions" - ) + apps_col = connector_db.get_documents_from_collection(collection_input="service_functions") deployed_apps_col = connector_db.get_documents_from_collection( collection_input="deployed_service_functions" ) - nodes = connector_db.get_documents_from_collection( - collection_input="points_of_presence" - ) + nodes = connector_db.get_documents_from_collection(collection_input="points_of_presence") apps = [] for app in api_response.items: - app_ = self._build_app_dict( - app, apps_col, deployed_apps_col, api_response_pvc, nodes - ) + app_ = self._build_app_dict(app, apps_col, deployed_apps_col, api_response_pvc, nodes) if app_: self._add_service_ports(app_, api_response_service) apps.append(app_) return apps - def _build_app_dict( - self, app, apps_col, deployed_apps_col, api_response_pvc, nodes - ): + def _build_app_dict(self, app, apps_col, deployed_apps_col, api_response_pvc, nodes): metadata = app.metadata spec = app.spec status = app.status @@ -843,10 +764,7 @@ class KubernetesConnector: for volume in app_col["required_volumes"]: for item in api_response_pvc.items: name_v = str("-") + volume["name"] - if ( - name_v in item.metadata.name - and metadata.name in item.metadata.name - ): + if name_v in item.metadata.name and metadata.name in item.metadata.name: volumes_.append(item.metadata.name) app_["volumes"] = volumes_ break @@ -856,9 +774,7 @@ class KubernetesConnector: return None # Set status and replicas - if (status.available_replicas is not None) and ( - status.ready_replicas is not None - ): + if (status.available_replicas is not None) and (status.ready_replicas is not None): if status.available_replicas >= 1 and status.ready_replicas >= 1: app_["status"] = "running" app_["replicas"] = status.ready_replicas @@ -906,10 +822,8 @@ class KubernetesConnector: def get_deployed_hpas(self, connector_db: ConnectorDB): # APPV1 Implementation! - api_response = ( - self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( - self.namespace - ) + api_response = self.api_instance_v1autoscale.list_namespaced_horizontal_pod_autoscaler( + self.namespace ) hpas = [] @@ -921,9 +835,7 @@ class KubernetesConnector: deployed_hpas_col = connector_db.get_documents_from_collection( collection_input="deployed_apps" ) - apps_col = connector_db.get_documents_from_collection( - collection_input="paas_services" - ) + apps_col = connector_db.get_documents_from_collection(collection_input="paas_services") actual_name = None for hpa_col in deployed_hpas_col: @@ -972,9 +884,7 @@ class KubernetesConnector: return hpas def is_job_completed(self, job_name): - job = self.api_instance_batchv1.read_namespaced_job( - name=job_name, namespace=self.namespace - ) + job = self.api_instance_batchv1.read_namespaced_job(name=job_name, namespace=self.namespace) if job.status.succeeded is not None and job.status.succeeded > 0: return True return False @@ -1014,9 +924,7 @@ class KubernetesConnector: def immediate_storage_class_exists(self): try: - storage_classes = ( - self.api_instance_storagev1api.list_storage_class().items() - ) + storage_classes = self.api_instance_storagev1api.list_storage_class().items() for sc in storage_classes: if sc.metadata.name == "immediate-storageclass": diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 81ebcaf..695ed57 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -256,9 +256,7 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def get_deployed_app_gsma( - self, app_id: str, app_instance_id: str, zone_id: str - ) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py index 806c87e..e187a19 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -92,9 +92,7 @@ class NetworkInterface(BaseModel): class ComponentSpecItem(BaseModel): - componentName: str = Field( - ..., description="Component name must be unique with an application" - ) + componentName: str = Field(..., description="Component name must be unique with an application") networkInterfaces: List[NetworkInterface] = Field( ..., description='Each application component exposes some ports\neither for external users or for inter component\ncommunication.\nApplication provider is required to specify which ports are\nto be exposed and the type of traffic that will flow through\nthese ports.The underlying platform may assign a dynamic port\nagainst the "extPort" that the application clients will use\nto connect with edge application instance.\n', @@ -109,9 +107,7 @@ class AppProvider(RootModel[constr(pattern=r"^[A-Za-z][A-Za-z0-9_]{7,63}$")]): class EdgeCloudProvider(RootModel[str]): - root: str = Field( - ..., description="Human readable name of the Edge Cloud Provider." - ) + root: str = Field(..., description="Human readable name of the Edge Cloud Provider.") class EdgeCloudRegion(RootModel[str]): @@ -142,17 +138,13 @@ class EdgeCloudZoneStatus(Enum): class ErrorInfo(BaseModel): - status: int = Field( - ..., description="HTTP status code returned along with this error response" - ) + status: int = Field(..., description="HTTP status code returned along with this error response") code: str = Field(..., description="Code given to this error") message: str = Field(..., description="Detailed error description") class Fqdn(RootModel[str]): - root: str = Field( - ..., description="Full qualified domain name of an application instance\n" - ) + root: str = Field(..., description="Full qualified domain name of an application instance\n") class GpuInfo(BaseModel): @@ -170,12 +162,8 @@ class K8sAddons(BaseModel): class PrimaryNetwork(BaseModel): - provider: Optional[str] = Field( - None, description="CNI provider name", examples=["cilium"] - ) - version: Optional[str] = Field( - None, description="CNI provider version", examples=["1.13"] - ) + provider: Optional[str] = Field(None, description="CNI provider name", examples=["cilium"]) + version: Optional[str] = Field(None, description="CNI provider version", examples=["1.13"]) class InterfaceType(Enum): @@ -185,9 +173,7 @@ class InterfaceType(Enum): class AdditionalNetwork(BaseModel): - name: Optional[str] = Field( - None, description="Additional Network Name", examples=["net1"] - ) + name: Optional[str] = Field(None, description="Additional Network Name", examples=["net1"]) interfaceType: Optional[InterfaceType] = Field( None, description="Type of additional Interface:\nnetdevice: (SR-IOV) A regular kernel network device in the\n Network Namespace (netns) of the container\nvfio-pci: (SR-IOV) A PCI network interface directly mounted\n in the container\ninterface: Additional interface to be used by cni plugins\n such as macvlan, ipvlan\nNote: The use of SR-IOV interfaces automatically\nconfigure the required kernel parameters for the nodes.\n", @@ -241,9 +227,7 @@ class KubernetesClusterRef(RootModel[UUID]): class NodeResources(BaseModel): - numCPU: int = Field( - ..., description="Number of whole vcpus for the node.\n", examples=[2] - ) + numCPU: int = Field(..., description="Number of whole vcpus for the node.\n", examples=[2]) memory: int = Field( ..., description="Amount of system memory in mega bytes for the node.\n", @@ -252,18 +236,14 @@ class NodeResources(BaseModel): class KubernetesNodePool(BaseModel): - name: str = Field( - ..., description="Human readable name of the Kubernetes Node Pool." - ) + name: str = Field(..., description="Human readable name of the Kubernetes Node Pool.") numNodes: int = Field(..., description="Number of nodes in the Node Pool.") scalable: bool = Field( ..., description="Indicates if the node pool can be dynamically scaled up by the\nsystem to accomodate more applications, and dynamically scaled\ndown by the system when there are unused resources.\n", examples=[False], ) - nodeResources: NodeResources = Field( - ..., description="Resource configuration of a node." - ) + nodeResources: NodeResources = Field(..., description="Resource configuration of a node.") class InfraKind(Enum): @@ -294,9 +274,7 @@ class CpuPool(BaseModel): description="Total number of vcpus in whole (i.e 1) of CPU pool.\n", examples=[1], ) - memory: int = Field( - ..., description="Total memory in mega bytes of CPU pool.", examples=[1024] - ) + memory: int = Field(..., description="Total memory in mega bytes of CPU pool.", examples=[1024]) topology: Topology = Field( ..., description="CPU pool topology defines an application's CPU-based\narchitecture.\nWhen deploying for high availability or redundancy, it\nallows for clustering with a configurable number of nodes\nand minimum CPU/memory resource per Kubernetes node\nrequirements.\n", @@ -332,9 +310,7 @@ class GpuPool(BaseModel): description="Total Number of vcpus in whole (i.e 1) of GPU pool.\n", examples=[1], ) - memory: int = Field( - ..., description="Total memory in mega bytes of GPU pool.", examples=[1024] - ) + memory: int = Field(..., description="Total memory in mega bytes of GPU pool.", examples=[1024]) gpuMemory: int = Field( ..., description="Total GPU memory in giga bytes of GPU pool.", examples=[16] ) @@ -392,9 +368,7 @@ class VmResources(BaseModel): description="Type of infrastructure for the application.", examples=["virtualMachine"], ) - numCPU: int = Field( - ..., description="Number of vcpus in whole (i.e 1)\n", examples=[1] - ) + numCPU: int = Field(..., description="Number of vcpus in whole (i.e 1)\n", examples=[1]) memory: int = Field(..., description="Memory in mega bytes", examples=[1024]) additionalStorages: Optional[AdditionalStorage] = None gpu: Optional[GpuInfo] = None @@ -410,9 +384,7 @@ class DockerComposeResources(BaseModel): description="Type of infrastructure for the application.", examples=["dockerCompose"], ) - numCPU: int = Field( - ..., description="Number of vcpus in whole (i.e 1)\n", examples=[1] - ) + numCPU: int = Field(..., description="Number of vcpus in whole (i.e 1)\n", examples=[1]) memory: int = Field(..., description="Memory in mega bytes", examples=[1024]) storage: Optional[AdditionalStorage] = None gpu: Optional[GpuInfo] = None @@ -490,18 +462,14 @@ class Port(RootModel[conint(ge=0)]): class RequiredResources( - RootModel[ - Union[ - KubernetesResources, VmResources, ContainerResources, DockerComposeResources - ] - ] + RootModel[Union[KubernetesResources, VmResources, ContainerResources, DockerComposeResources]] ): - root: Union[ - KubernetesResources, VmResources, ContainerResources, DockerComposeResources - ] = Field( - ..., - description="Fundamental hardware requirements to be provisioned by the\nApplication Provider.\n", - discriminator="infraKind", + root: Union[KubernetesResources, VmResources, ContainerResources, DockerComposeResources] = ( + Field( + ..., + description="Fundamental hardware requirements to be provisioned by the\nApplication Provider.\n", + discriminator="infraKind", + ) ) @@ -550,9 +518,7 @@ class AccessEndpoint3(BaseModel): ) -class AccessEndpoint( - RootModel[Union[AccessEndpoint1, AccessEndpoint2, AccessEndpoint3]] -): +class AccessEndpoint(RootModel[Union[AccessEndpoint1, AccessEndpoint2, AccessEndpoint3]]): root: Union[AccessEndpoint1, AccessEndpoint2, AccessEndpoint3] = Field( ..., description="Application Endpoint for an especific instance that is\nrunning in an specific Edge Cloud Zone.\n", @@ -616,9 +582,7 @@ class AppManifest(BaseModel): ) appProvider: AppProvider version: str = Field(..., description="Application version information") - packageType: PackageType = Field( - ..., description="Format of the application image package" - ) + packageType: PackageType = Field(..., description="Format of the application image package") operatingSystem: Optional[OperatingSystem] = None appRepo: AppRepo = Field( ..., @@ -637,9 +601,7 @@ class ClusterInfo(BaseModel): clusterRef: KubernetesClusterRef edgeCloudZoneId: EdgeCloudZoneId edgeCloudRegion: Optional[EdgeCloudRegion] = None - version: Optional[str] = Field( - None, description="Kubernetes version of the cluster." - ) + version: Optional[str] = Field(None, description="Kubernetes version of the cluster.") nodePools: Optional[List[KubernetesNodePool]] = Field( None, description="Node Pools in the cluster.", min_length=1 ) diff --git a/src/sunrise6g_opensdk/edgecloud/core/utils.py b/src/sunrise6g_opensdk/edgecloud/core/utils.py index d6b4ebc..cbf8d13 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/core/utils.py @@ -27,9 +27,7 @@ def build_custom_http_response( response.status_code = status_code if isinstance(content, (dict, list)): content = json.dumps(content) - response._content = ( - content.encode(encoding or "utf-8") if isinstance(content, str) else content - ) + response._content = content.encode(encoding or "utf-8") if isinstance(content, str) else content response.headers.update(headers or {}) response.encoding = encoding or "utf-8" if url: diff --git a/src/sunrise6g_opensdk/network/adapters/oai/client.py b/src/sunrise6g_opensdk/network/adapters/oai/client.py index 32cefcf..3e099e8 100644 --- a/src/sunrise6g_opensdk/network/adapters/oai/client.py +++ b/src/sunrise6g_opensdk/network/adapters/oai/client.py @@ -107,9 +107,7 @@ class NetworkManager(BaseNetworkClient): traffic_influence_info.device is None or traffic_influence_info.device.ipv4Address is None ): - raise OaiValidationError( - "OAI requires UE IPv4 Address to activate Traffic Influence" - ) + raise OaiValidationError("OAI requires UE IPv4 Address to activate Traffic Influence") def _retrieve_ue_ipv4(session_info: CreateSession): @@ -120,9 +118,7 @@ def _retrieve_app_ipv4(session_info: CreateSession): return session_info.applicationServer.ipv4Address -def _add_qod_flow_descriptor( - qos_sub: AsSessionWithQoSSubscription, flow_desriptor: str -): +def _add_qod_flow_descriptor(qos_sub: AsSessionWithQoSSubscription, flow_desriptor: str): qos_sub.flowInfo = list() qos_sub.flowInfo.append( FlowInfo(flowId=len(qos_sub.flowInfo) + 1, flowDescriptions=[flow_desriptor]) diff --git a/src/sunrise6g_opensdk/network/core/base_network_client.py b/src/sunrise6g_opensdk/network/core/base_network_client.py index 7d45257..5f78d50 100644 --- a/src/sunrise6g_opensdk/network/core/base_network_client.py +++ b/src/sunrise6g_opensdk/network/core/base_network_client.py @@ -31,9 +31,7 @@ def flatten_port_spec(ports_spec: schemas.PortsSpec | None) -> list[str]: flat_ports.extend([str(port) for port in ports_spec.ports]) if ports_spec and ports_spec.ranges: has_ranges = True - flat_ports.extend( - [f"{range.from_.root}-{range.to.root}" for range in ports_spec.ranges] - ) + flat_ports.extend([f"{range.from_.root}-{range.to.root}" for range in ports_spec.ranges]) if not has_ports and not has_ranges: flat_ports.append("0-65535") return flat_ports @@ -51,13 +49,10 @@ def build_flows( if isinstance(device_ip, schemas.DeviceIpv6Address): device_ip = device_ip.root else: # IPv4 - device_ip = ( - device_ip.root.publicAddress.root or device_ip.root.privateAddress.root - ) + device_ip = device_ip.root.publicAddress.root or device_ip.root.privateAddress.root device_ip = str(device_ip) server_ip = ( - session_info.applicationServer.ipv4Address - or session_info.applicationServer.ipv6Address + session_info.applicationServer.ipv4Address or session_info.applicationServer.ipv6Address ) server_ip = server_ip.root flow_descrs = [] @@ -68,9 +63,7 @@ def build_flows( flow_descrs.append( f"permit out ip from {server_ip} {server_port} to {device_ip} {device_port}" ) - flows = [ - schemas.FlowInfo(flowId=flow_id, flowDescriptions=[", ".join(flow_descrs)]) - ] + flows = [schemas.FlowInfo(flowId=flow_id, flowDescriptions=[", ".join(flow_descrs)])] return flows @@ -170,9 +163,7 @@ class BaseNetworkClient: pass @requires_capability("qod") - def _build_qod_subscription( - self, session_info: Dict - ) -> schemas.AsSessionWithQoSSubscription: + def _build_qod_subscription(self, session_info: Dict) -> schemas.AsSessionWithQoSSubscription: valid_session_info = schemas.CreateSession.model_validate(session_info) device_ipv4 = None if valid_session_info.device.ipv4Address: @@ -244,9 +235,7 @@ class BaseNetworkClient: self, retrieve_location_request: schemas.RetrievalLocationRequest ) -> schemas.MonitoringEventSubscriptionRequest: self.core_specific_monitoring_event_validation(retrieve_location_request) - subscription_3gpp = self.add_core_specific_location_parameters( - retrieve_location_request - ) + subscription_3gpp = self.add_core_specific_location_parameters(retrieve_location_request) device = retrieve_location_request.device subscription_3gpp.externalId = device.networkAccessIdentifier subscription_3gpp.ipv4Addr = device.ipv4Address @@ -271,9 +260,7 @@ class BaseNetworkClient: datetime object representing the last location time in UTC. """ if age_of_location_info_min is not None: - last_location_time = event_time - timedelta( - minutes=age_of_location_info_min - ) + last_location_time = event_time - timedelta(minutes=age_of_location_info_min) return last_location_time.replace(tzinfo=timezone.utc) else: return event_time.replace(tzinfo=timezone.utc) @@ -292,45 +279,31 @@ class BaseNetworkClient: returns: dictionary containing the created subscription details, including its ID. """ - subscription = self._build_monitoring_event_subscription( - retrieve_location_request - ) - response = common.monitoring_event_post( - self.base_url, self.scs_as_id, subscription - ) + subscription = self._build_monitoring_event_subscription(retrieve_location_request) + response = common.monitoring_event_post(self.base_url, self.scs_as_id, subscription) monitoring_event_report = schemas.MonitoringEventReport(**response) if monitoring_event_report.locationInfo is None: - log.error( - "Failed to retrieve location information from monitoring event report" - ) - raise NetworkPlatformError( - "Location information not found in monitoring event report" - ) + log.error("Failed to retrieve location information from monitoring event report") + raise NetworkPlatformError("Location information not found in monitoring event report") geo_area = monitoring_event_report.locationInfo.geographicArea report_event_time = monitoring_event_report.eventTime age_of_location_info = None if monitoring_event_report.locationInfo.ageOfLocationInfo is not None: - age_of_location_info = ( - monitoring_event_report.locationInfo.ageOfLocationInfo.duration - ) + age_of_location_info = monitoring_event_report.locationInfo.ageOfLocationInfo.duration last_location_time = self._compute_camara_last_location_time( report_event_time, age_of_location_info ) log.debug(f"Last Location time is {last_location_time}") camara_point_list: list[schemas.Point] = [] for point in geo_area.polygon.point_list.geographical_coords: - camara_point_list.append( - schemas.Point(latitude=point.lat, longitude=point.lon) - ) + camara_point_list.append(schemas.Point(latitude=point.lat, longitude=point.lon)) camara_polygon = schemas.Polygon( areaType=schemas.AreaType.polygon, boundary=schemas.PointList(camara_point_list), ) - camara_location = schemas.Location( - area=camara_polygon, lastLocationTime=last_location_time - ) + camara_location = schemas.Location(area=camara_polygon, lastLocationTime=last_location_time) return camara_location @@ -347,9 +320,7 @@ class BaseNetworkClient: dictionary containing the created session details, including its ID. """ subscription = self._build_qod_subscription(session_info) - response = common.as_session_with_qos_post( - self.base_url, self.scs_as_id, subscription - ) + response = common.as_session_with_qos_post(self.base_url, self.scs_as_id, subscription) subscription_info: schemas.AsSessionWithQoSSubscription = ( schemas.AsSessionWithQoSSubscription(**response) ) @@ -407,9 +378,7 @@ class BaseNetworkClient: returns: None """ - common.as_session_with_qos_delete( - self.base_url, self.scs_as_id, session_id=session_id - ) + common.as_session_with_qos_delete(self.base_url, self.scs_as_id, session_id=session_id) log.info(f"QoD session deleted successfully [id={session_id}]") @requires_capability("traffic_influence") @@ -426,9 +395,7 @@ class BaseNetworkClient: """ subscription = self._build_ti_subscription(traffic_influence_info) - response = common.traffic_influence_post( - self.base_url, self.scs_as_id, subscription - ) + response = common.traffic_influence_post(self.base_url, self.scs_as_id, subscription) # retrieve the NEF resource id if "self" in response.keys(): @@ -453,9 +420,7 @@ class BaseNetworkClient: Dictionary containing the details of the requested Traffic Influence resource. """ subscription = self._build_ti_subscription(traffic_influence_info) - common.traffic_influence_put( - self.base_url, self.scs_as_id, resource_id, subscription - ) + common.traffic_influence_put(self.base_url, self.scs_as_id, resource_id, subscription) traffic_influence_info["trafficInfluenceID"] = resource_id return traffic_influence_info @@ -476,9 +441,7 @@ class BaseNetworkClient: @requires_capability("traffic_influence") def get_individual_traffic_influence_resource(self, resource_id: str) -> Dict: - nef_response = common.traffic_influence_get( - self.base_url, self.scs_as_id, resource_id - ) + nef_response = common.traffic_influence_get(self.base_url, self.scs_as_id, resource_id) camara_ti = self._build_camara_ti(nef_response) return camara_ti diff --git a/src/sunrise6g_opensdk/network/core/common.py b/src/sunrise6g_opensdk/network/core/common.py index 7193f25..98ded02 100644 --- a/src/sunrise6g_opensdk/network/core/common.py +++ b/src/sunrise6g_opensdk/network/core/common.py @@ -62,9 +62,7 @@ class CoreHttpError(Exception): # Monitoring Event Methods -def monitoring_event_post( - base_url: str, scs_as_id: str, model_payload: BaseModel -) -> dict: +def monitoring_event_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: data = model_payload.model_dump_json(exclude_none=True, by_alias=True) url = monitoring_event_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) @@ -79,9 +77,7 @@ def monitoring_event_build_url(base_url: str, scs_as_id: str, session_id: str = # QoD methods -def as_session_with_qos_post( - base_url: str, scs_as_id: str, model_payload: BaseModel -) -> dict: +def as_session_with_qos_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: data = model_payload.model_dump_json(exclude_none=True, by_alias=True) url = as_session_with_qos_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) @@ -97,9 +93,7 @@ def as_session_with_qos_delete(base_url: str, scs_as_id: str, session_id: str): return _make_request("DELETE", url) -def as_session_with_qos_build_url( - base_url: str, scs_as_id: str, session_id: str = None -): +def as_session_with_qos_build_url(base_url: str, scs_as_id: str, session_id: str = None): url = f"{base_url}/3gpp-as-session-with-qos/v1/{scs_as_id}/subscriptions" if session_id is not None and len(session_id) > 0: return f"{url}/{session_id}" @@ -108,9 +102,7 @@ def as_session_with_qos_build_url( # Traffic Influence Methods -def traffic_influence_post( - base_url: str, scs_as_id: str, model_payload: BaseModel -) -> dict: +def traffic_influence_post(base_url: str, scs_as_id: str, model_payload: BaseModel) -> dict: data = model_payload.model_dump_json(exclude_none=True) url = traffic_influence_build_url(base_url, scs_as_id) return _make_request("POST", url, data=data) @@ -134,9 +126,7 @@ def traffic_influence_get(base_url: str, scs_as_id: str, sessionId: str = None) return _make_request("GET", url) -def traffic_influence_get_all( - base_url: str, scs_as_id: str, sessionId: str = None -) -> list[dict]: +def traffic_influence_get_all(base_url: str, scs_as_id: str, sessionId: str = None) -> list[dict]: url = traffic_influence_build_url(base_url, scs_as_id) return _make_request("GET", url) diff --git a/src/sunrise6g_opensdk/network/core/schemas.py b/src/sunrise6g_opensdk/network/core/schemas.py index 630faa9..c7ee254 100644 --- a/src/sunrise6g_opensdk/network/core/schemas.py +++ b/src/sunrise6g_opensdk/network/core/schemas.py @@ -119,9 +119,7 @@ class SponsorInformation(BaseModel): class QosMonitoringInformationModel(BaseModel): - reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field( - None, min_length=1 - ) + reqQosMonParams: list[RequestedQosMonitoringParameter] | None = Field(None, min_length=1) repFreqs: list[ReportingFrequency] | None = Field(None, min_length=1) repThreshDl: Uinteger | None = None repThreshUl: Uinteger | None = None @@ -158,9 +156,7 @@ class AsSessionWithQoSSubscription(BaseModel): ethFlowInfo: list[EthFlowDescription] | None = Field( None, description="Identifies Ethernet packet flows.", min_length=1 ) - qosReference: str | None = Field( - None, description="Identifies a pre-defined QoS information" - ) + qosReference: str | None = Field(None, description="Identifies a pre-defined QoS information") altQoSReferences: list[str] | None = Field( None, description="Identifies an ordered list of pre-defined QoS information. The \ @@ -225,9 +221,7 @@ class TrafficInfluSub(BaseModel): # Replace with a meaningful name def add_flow_descriptor(self, flow_descriptor: str): self.trafficFilters = list() self.trafficFilters.append( - FlowInfo( - flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_descriptor] - ) + FlowInfo(flowId=len(self.trafficFilters) + 1, flowDescriptions=[flow_descriptor]) ) def add_traffic_route(self, dnai: str): @@ -262,24 +256,18 @@ class PlmnId(BaseModel): # The enumeration Accuracy represents a desired granularity of accuracy of the requested location information. class Accuracy(str, Enum): - cgi_ecgi = ( - "CGI_ECGI" # The AF requests to be notified using cell level location accuracy. - ) - ta_ra = ( - "TA_RA" # The AF requests to be notified using TA/RA level location accuracy. - ) + cgi_ecgi = "CGI_ECGI" # The AF requests to be notified using cell level location accuracy. + ta_ra = "TA_RA" # The AF requests to be notified using TA/RA level location accuracy. geo_area = "GEO_AREA" # The AF requests to be notified using the geographical area accuracy. - civic_addr = "CIVIC_ADDR" # The AF requests to be notified using the civic address accuracy. #EDGEAPP + civic_addr = ( + "CIVIC_ADDR" # The AF requests to be notified using the civic address accuracy. #EDGEAPP + ) # If locationType set to "LAST_KNOWN_LOCATION", the monitoring event request from AF shall be only for one-time monitoring request class LocationType(str, Enum): - CURRENT_LOCATION = ( - "CURRENT_LOCATION" # The AF requests to be notified for current location. - ) - LAST_KNOWN = ( - "LAST_KNOWN_LOCATION" # The AF requests to be notified for last known location. - ) + CURRENT_LOCATION = "CURRENT_LOCATION" # The AF requests to be notified for current location. + LAST_KNOWN = "LAST_KNOWN_LOCATION" # The AF requests to be notified for last known location. # This data type represents a monitoring event type. @@ -309,15 +297,11 @@ class PointListNef(BaseModel): class NefPolygon(BaseModel): - point_list: PointListNef = Field( - ..., description="List of points defining the polygon." - ) + point_list: PointListNef = Field(..., description="List of points defining the polygon.") class GeographicArea(BaseModel): - polygon: NefPolygon | None = Field( - None, description="Identifies a polygonal geographic area." - ) + polygon: NefPolygon | None = Field(None, description="Identifies a polygonal geographic area.") # This data type represents the user location information which is sent from the NEF to the AF. @@ -327,13 +311,9 @@ class LocationInfo(BaseModel): description="Indicates the elapsed time since the last network contact of the UE.", ) cellId: str | None = Field(None, description="Cell ID where the UE is located.") - trackingAreaId: str | None = Field( - None, description="TrackingArea ID where the UE is located." - ) + trackingAreaId: str | None = Field(None, description="TrackingArea ID where the UE is located.") enodeBId: str | None = Field(None, description="eNodeB ID where the UE is located.") - routingAreaId: str | None = Field( - None, description="Routing Area ID where the UE is located" - ) + routingAreaId: str | None = Field(None, description="Routing Area ID where the UE is located") plmnId: PlmnId | None = Field(None, description="PLMN ID where the UE is located.") twanId: str | None = Field(None, description="TWAN ID where the UE is located.") geographicArea: GeographicArea | None = Field( @@ -354,12 +334,8 @@ class MonitoringEventSubscriptionRequest(BaseModel): None, description="Identifies the MS internal PSTN/ISDN number allocated for a UE.", ) - ipv4Addr: IPv4Address | None = Field( - None, description="Identifies the Ipv4 address." - ) - ipv6Addr: IPv6Address | None = Field( - None, description="Identifies the Ipv6 address." - ) + ipv4Addr: IPv4Address | None = Field(None, description="Identifies the Ipv4 address.") + ipv6Addr: IPv6Address | None = Field(None, description="Identifies the Ipv6 address.") notificationDestination: AnyHttpUrl = Field( ..., description="URI of a notification destination that the T8 message shall be delivered to.", @@ -390,9 +366,7 @@ class MonitoringEventSubscriptionRequest(BaseModel): # This data type represents a monitoring event notification which is sent from the NEF to the AF. class MonitoringEventReport(BaseModel): - externalId: str | None = Field( - None, description="Identifies a user, clause 4.6.2 TS 23.682" - ) + externalId: str | None = Field(None, description="Identifies a user, clause 4.6.2 TS 23.682") msisdn: str | None = Field( None, description="Identifies the MS internal PSTN/ISDN number allocated for a UE.", @@ -585,9 +559,7 @@ class Circle(BaseModel): class Polygon(BaseModel): areaType: Literal[AreaType.polygon] - boundary: Annotated[ - PointList, Field(description="List of points defining the polygon.") - ] + boundary: Annotated[PointList, Field(description="List of points defining the polygon.")] Area = Annotated[Circle | Polygon, Field(discriminator="areaType")] @@ -608,9 +580,7 @@ class LastLocationTime( class Location(BaseModel): - lastLocationTime: Annotated[ - LastLocationTime, Field(description="Last known location time.") - ] + lastLocationTime: Annotated[LastLocationTime, Field(description="Last known location time.")] area: Annotated[Area, Field(description="Geographical area of the location.")] @@ -697,9 +667,7 @@ class BaseSessionInfo(BaseModel): ] = None applicationServerPorts: Annotated[ PortsSpec | None, - Field( - description="A list of single ports or port ranges on the application server" - ), + Field(description="A list of single ports or port ranges on the application server"), ] = None qosProfile: QosProfileName sink: Annotated[ diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 9a6c74f..a2af385 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -60,9 +60,7 @@ def test_config_camara_compliance(edgecloud_client): # Validate APP_ONBOARD_MANIFEST is CAMARA-compliant if "APP_ONBOARD_MANIFEST" in config: app_manifest = config["APP_ONBOARD_MANIFEST"] - camara_schemas.AppManifest( - **app_manifest - ) # Validate against CAMARA AppManifest schema + camara_schemas.AppManifest(**app_manifest) # Validate APP_DEPLOY_PAYLOAD is CAMARA-compliant if "APP_DEPLOY_PAYLOAD" in config: diff --git a/tests/network/test_create_monitoring_event.py b/tests/network/test_create_monitoring_event.py index c3b4b14..4db0d90 100644 --- a/tests/network/test_create_monitoring_event.py +++ b/tests/network/test_create_monitoring_event.py @@ -42,9 +42,7 @@ network_client: BaseNetworkClient = clients.get("network") # "ipv6Address": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" # } -camara_payload_input_data = RetrievalLocationRequest( - device=Device(phoneNumber="+306912345678") -) +camara_payload_input_data = RetrievalLocationRequest(device=Device(phoneNumber="+306912345678")) # Sample output test data 3GPP MonitoringEventSubscription Request Payload diff --git a/tests/network/test_create_traffic_influence.py b/tests/network/test_create_traffic_influence.py index c892b13..38abd4a 100644 --- a/tests/network/test_create_traffic_influence.py +++ b/tests/network/test_create_traffic_influence.py @@ -9,9 +9,7 @@ from sunrise6g_opensdk.network.core.common import CoreHttpError from tests.network.test_cases import test_cases ti_session1 = { - "device": { - "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} - }, + "device": {"ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"}}, "edgeCloudZoneId": "edge", "appId": "testSdk-ffff-aaaa-c0ffe", "appInstanceId": "172.21.18.3", @@ -19,9 +17,7 @@ ti_session1 = { } ti_session1_put = { - "device": { - "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} - }, + "device": {"ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"}}, "edgeCloudZoneId": "edge2", "appId": "testSdk-ffff-aaaa-c0ffe", "appInstanceId": "172.21.18.3", @@ -29,9 +25,7 @@ ti_session1_put = { } ti_session2 = { - "device": { - "ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"} - }, + "device": {"ipv4Address": {"publicAddress": "12.1.2.31", "privateAddress": "12.1.2.31"}}, "edgeCloudZoneId": "edge", "appId": "testSdk-ffff-aaaa-c0ffe", "appInstanceId": "172.21.18.65", @@ -71,9 +65,7 @@ def traffic_influence_id(network_client: BaseNetworkClient): response = network_client.create_traffic_influence_resource(ti_session1) assert response is not None, "Response should not be None" assert isinstance(response, dict), "Response should be a dictionary" - assert ( - "trafficInfluenceID" in response - ), "Response should contain 'trafficInfluenceID'" + assert "trafficInfluenceID" in response, "Response should contain 'trafficInfluenceID'" yield str(response["trafficInfluenceID"]) finally: pass @@ -85,9 +77,7 @@ def traffic_influence_id2(network_client: BaseNetworkClient): response = network_client.create_traffic_influence_resource(ti_session2) assert response is not None, "Response should not be None" assert isinstance(response, dict), "Response should be a dictionary" - assert ( - "trafficInfluenceID" in response - ), "Response should contain 'trafficInfluenceID'" + assert "trafficInfluenceID" in response, "Response should contain 'trafficInfluenceID'" yield str(response["trafficInfluenceID"]) finally: pass @@ -119,26 +109,18 @@ def test_timer_wait_5_seconds(network_client): @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_get_traffic_influence_session_1( - network_client: BaseNetworkClient, traffic_influence_id -): +def test_get_traffic_influence_session_1(network_client: BaseNetworkClient, traffic_influence_id): try: - response = network_client.get_individual_traffic_influence_resource( - traffic_influence_id - ) + response = network_client.get_individual_traffic_influence_resource(traffic_influence_id) assert response is not None, "response should not be None" except CoreHttpError as e: pytest.fail(f"Failed to get traffic influence: {e}") @pytest.mark.parametrize("network_client", test_cases, ids=id_func, indirect=True) -def test_put_traffic_influence_session_1( - network_client: BaseNetworkClient, traffic_influence_id -): +def test_put_traffic_influence_session_1(network_client: BaseNetworkClient, traffic_influence_id): try: - network_client.put_traffic_influence_resource( - traffic_influence_id, ti_session1_put - ) + network_client.put_traffic_influence_resource(traffic_influence_id, ti_session1_put) except CoreHttpError as e: pytest.fail(f"Failed to update traffic influence session: {e}") @@ -148,9 +130,7 @@ def test_get_traffic_influence_session_after_put_1( network_client: BaseNetworkClient, traffic_influence_id ): try: - response = network_client.get_individual_traffic_influence_resource( - traffic_influence_id - ) + response = network_client.get_individual_traffic_influence_resource(traffic_influence_id) assert response is not None, "response should not be None" except CoreHttpError as e: pytest.fail(f"Failed to get traffic influence: {e}") -- GitLab From 4aa9b7f70169e0b2a7771530d2fe26cecd4b880d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 12:09:04 +0200 Subject: [PATCH 239/281] Update PyPI package version to 1.0.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 08b038b..9065a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "1.0.2.post4" +version = "1.0.3" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", -- GitLab From 49983e65699e003c32b327375419f6c20de97de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 12:34:27 +0200 Subject: [PATCH 240/281] Refactors zone data transformation to CAMARA format Introduces a dedicated function to transform i2Edge zone data into the CAMARA EdgeCloudZone format, improving code readability and maintainability. Improves error handling for invalid CAMARA manifests by providing more detailed information about validation failures. Increases the wait time in the end-to-end tests to avoid potential timing issues. --- .../edgecloud/adapters/i2edge/client.py | 38 ++++++++++++------- tests/edgecloud/test_e2e.py | 4 +- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 98e7e95..461204a 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -43,6 +43,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): self.content_type_gsma = "application/json" self.encoding_gsma = "utf-8" + def _transform_to_camara_zone(self, zone_data: dict) -> camara_schemas.EdgeCloudZone: + """ + Transform i2Edge zone data to CAMARA EdgeCloudZone format. + + :param zone_data: Raw zone data from i2Edge API + :return: CAMARA-compliant EdgeCloudZone object + """ + return camara_schemas.EdgeCloudZone( + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_data.get("zoneId", "unknown")), + edgeCloudZoneName=camara_schemas.EdgeCloudZoneName( + zone_data.get("nodeName", "unknown") + ), + edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), + edgeCloudRegion=camara_schemas.EdgeCloudRegion( + zone_data.get("geographyDetails", "unknown") + ), + edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, + ) + # ######################################################################## # CAMARA EDGE CLOUD MANAGEMENT API # ######################################################################## @@ -71,17 +90,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): i2edge_response = response.json() log.info("Availability zones retrieved successfully") # Normalise to CAMARA format - camara_response = [] - for z in i2edge_response: - zone = camara_schemas.EdgeCloudZone( - # edgeCloudZoneId = camara_schemas.EdgeCloudZoneId(z["zoneId"]), - edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(z["zoneId"]), - edgeCloudZoneName=camara_schemas.EdgeCloudZoneName(z["nodeName"]), - edgeCloudProvider=camara_schemas.EdgeCloudProvider("i2edge"), - edgeCloudRegion=camara_schemas.EdgeCloudRegion(z["geographyDetails"]), - edgeCloudZoneStatus=camara_schemas.EdgeCloudZoneStatus.unknown, - ) - camara_response.append(zone) + camara_response = [self._transform_to_camara_zone(z) for z in i2edge_response] # Wrap into a Response object return build_custom_http_response( status_code=response.status_code, @@ -267,8 +276,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): i2edge_response.raise_for_status() # TODO: Implement CAMARA-compliant error handling for failed onboarding responses except ValidationError as e: - log.error(f"Invalid CAMARA manifest: {e}") - raise ValueError(f"Invalid CAMARA manifest: {e}") + error_details = "; ".join( + [f"Field '{err['loc'][0]}': {err['msg']}" for err in e.errors()] + ) + log.error(f"Invalid CAMARA manifest: {error_details}") + raise ValueError(f"Invalid CAMARA manifest: {error_details}") except I2EdgeError as e: log.error(f"Failed to onboard app to i2Edge: {e}") raise diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index a2af385..aa571c7 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -190,8 +190,8 @@ def test_deploy_app(app_instance_id): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -def test_timer_wait_10_seconds(edgecloud_client): - time.sleep(10) +def test_timer_wait_30_seconds(edgecloud_client): + time.sleep(30) @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) -- GitLab From fd130181dd66e59c3ac493c425d0b90d71d01226 Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Wed, 30 Jul 2025 16:42:12 +0200 Subject: [PATCH 241/281] Minor updates related to app and app instances operations --- .../edgecloud/adapters/i2edge/client.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 461204a..29d68f0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -868,31 +868,27 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except KeyError as e: raise I2EdgeError(f"Missing appId in GSMA payload: {e}") - def patch_onboarded_app_gsma( - self, federation_context_id: str, app_id: str, request_body: dict - ) -> Response: + def patch_onboarded_app_gsma(self, app_id: str, request_body: dict) -> Response: """ Updates partner OP about changes in application compute resource requirements, QOS Profile, associated descriptor or change in associated components using GSMA federation. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :param request_body: Payload with updated onboarding info. :return: Response with update confirmation. """ pass - def delete_onboarded_app_gsma(self, federation_context_id: str, app_id: str) -> Response: + def delete_onboarded_app_gsma(self, app_id: str) -> Response: """ Deboards an application from specific partner OP zones using GSMA federation. - :param federation_context_id: Identifier of the federation context. :param app_id: Identifier of the application onboarded. :return: Response with deboarding confirmation. """ try: response = self.delete_onboarded_app(app_id) - if response.status_code == 200: + if response.status_code == 204: return build_custom_http_response( status_code=200, content={"response": "App deletion successful"}, @@ -909,14 +905,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Application Deployment Management (GSMA) # ------------------------------------------------------------------------ - def deploy_app_gsma( - self, federation_context_id: str, idempotency_key: str, request_body: dict - ) -> Response: + def deploy_app_gsma(self, request_body: dict) -> Response: """ Instantiates an application on a partner OP zone using GSMA federation. - :param federation_context_id: Identifier of the federation context. - :param idempotency_key: Idempotency key for request deduplication. :param request_body: Payload with deployment information. :return: Response with deployment details. """ @@ -928,7 +920,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appId=body.get("appId"), appProviderId=body.get("appProviderId"), appVersion=body.get("appVersion"), - zoneInfo=i2edge_schemas.ZoneInfo(flavourId=flavour_id, zoneId=zone_id), + zoneInfo=i2edge_schemas.ZoneInfoRef(flavourId=flavour_id, zoneId=zone_id), ) payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) -- GitLab From 1b05518ea6b4b103c145d7ce70e0d114aa6f7854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 18:10:12 +0200 Subject: [PATCH 242/281] Updates AppInstanceId to use UUID --- src/sunrise6g_opensdk/edgecloud/core/schemas.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py index e187a19..9b4c941 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -19,9 +19,7 @@ class AppId(RootModel[UUID]): ) -# TODO: Update to AppInstanceId(RootModel[UUID]) -# As a temporary solution RootModel[str] will be used until i2Edge get's updated -class AppInstanceId(RootModel[str]): +class AppInstanceId(RootModel[UUID]): root: str = Field( ..., description="A globally unique identifier associated with a running\ninstance of an application.\nEdge Cloud Platform generates this identifier when the\ninstantiation in the Edge Cloud Zone is successful.\n", -- GitLab From 2cec01d73563a833cd6c6eb4fbca4a34d4c8995c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Wed, 30 Jul 2025 18:10:39 +0200 Subject: [PATCH 243/281] Refactors Edge Application Deployment. Add FIXME placeholders --- .../edgecloud/adapters/i2edge/client.py | 8 ++-- .../edgecloud/core/edgecloud_interface.py | 42 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 29d68f0..39583a2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -456,9 +456,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zoneInfo=i2edge_schemas.ZoneInfoRef(flavourId=self.flavour_id, zoneId=zone_id), ) url = "{}/application_instance".format(self.base_url) - payload = i2edge_schemas.AppDeploy( - app_deploy_data=app_deploy_data, app_parameters={"namespace": "test"} - ) + payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) # Deployment request to i2Edge try: @@ -497,6 +495,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to deploy app to i2Edge: {e}") raise + # FIXME: Update return type to Response def get_all_deployed_apps(self) -> List[Dict]: """ Retrieves information of all application instances. @@ -515,6 +514,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e + # FIXME: Update return type to Response + + # FIXME: Update return type to Response def get_deployed_app(self, app_id, zone_id) -> List[Dict]: """ Retrieves a specific deployed application instance by app ID and zone ID. diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 695ed57..d7f4c69 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -37,8 +37,8 @@ class EdgeCloudManagementInterface(ABC): :param status: Filter by status (active, inactive, unknown). :return: List of Edge Cloud Zones. """ - # TODO: Evaluate if the CAMARA-input format - # TODO: Evaluate the CAMARA-return format + # TODO: Evaluate if we can check here the input (it should be CAMARA-compliant) + # TODO: Evaluate if we can check here the response (it should be CAMARA-compliant) pass # -------------------------------------------------------------------- @@ -88,15 +88,16 @@ class EdgeCloudManagementInterface(ABC): @abstractmethod def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: """ - Requests the instantiation of an application instance. + Requests the instantiation of an application instance - :param app_id: Unique identifier of the application. + :param app_id: Unique identifier of the application :param app_zones: List of Edge Cloud Zones where the app should be instantiated. - :return: Response with instance details. + :return: Response with instance details """ pass + # FIXME: Update return type to Response @abstractmethod def get_all_deployed_apps( self, @@ -105,15 +106,16 @@ class EdgeCloudManagementInterface(ABC): region: Optional[str] = None, ) -> List[Dict]: """ - Retrieves information of application instances. + Retrieves information of application instances - :param app_id: Filter by application ID. - :param app_instance_id: Filter by instance ID. - :param region: Filter by Edge Cloud region. - :return: List of application instance details. + :param app_id: Filter by application ID + :param app_instance_id: Filter by instance ID + :param region: Filter by Edge Cloud region + :return: List of application instance details """ pass + # FIXME: Update return type to Response @abstractmethod def undeploy_app(self, app_instance_id: str) -> Response: """ @@ -133,11 +135,11 @@ class EdgeCloudManagementInterface(ABC): # -------------------------------------------------------------------- @abstractmethod - def get_edge_cloud_zones_list_gsma(self) -> List: + def get_edge_cloud_zones_list_gsma(self) -> Response: """ Retrieves list of all Zones - :return: List. + :return: Response with Edge Cloud Zones """ pass @@ -246,46 +248,46 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def deploy_app_gsma(self, request_body: dict) -> Dict: + def deploy_app_gsma(self, request_body: dict) -> Response: """ Create deployed Application. :param request_body: Payload with deployment info. - :return: Dictionary with deployment details. + :return: Response with deployment details. """ pass @abstractmethod - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ Retrieves an application instance details from partner OP. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed instance. :param zone_id: Identifier of the zone - :return: Dictionary with application instance details + :return: Response with application instance details """ pass @abstractmethod - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: + def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Response: """ Retrieves all instances for a given application of partner OP :param app_id: Identifier of the app. :param app_provider: App provider - :return: List with application instances details + :return: Response with application instances details """ pass @abstractmethod - def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ Terminate an application instance on a partner OP zone. :param app_id: Identifier of the app. :param app_instance_id: Identifier of the deployed app. :param zone_id: Identifier of the zone - :return: + :return: Response with undeployment confirmation """ pass -- GitLab From ee90c5b6fd4242de51ff6b3642c263a61d43f0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 13:37:49 +0200 Subject: [PATCH 244/281] Delete from i2Edge schemas some default values for optional elements --- .../edgecloud/adapters/i2edge/schemas.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index c3e9433..3e7c511 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -104,13 +104,13 @@ class ArtefactOnboarding(BaseModel): class AppComponentSpec(BaseModel): artefactId: str - componentName: Optional[str] = Field(default="default_component") - serviceNameEW: Optional[str] = Field(default="default_ew_service") - serviceNameNB: Optional[str] = Field(default="default_nb_service") + componentName: Optional[str] = None + serviceNameEW: Optional[str] = None + serviceNameNB: Optional[str] = None class AppMetaData(BaseModel): - appDescription: str = Field(default="Default app description") + appDescription: Optional[str] = None appName: str = Field(default="Default App") category: str = Field(default="DEFAULT") mobilitySupport: bool = Field(default=False) @@ -128,11 +128,11 @@ class AppQoSProfile(BaseModel): class ApplicationOnboardingData(BaseModel): appComponentSpecs: List[AppComponentSpec] - appDeploymentZones: List[str] = Field(default=["default_zone"]) + appDeploymentZones: Optional[List[str]] = None app_id: str appMetaData: AppMetaData = Field(default_factory=AppMetaData) appProviderId: str = Field(default="default_provider") - appQoSProfile: AppQoSProfile = Field(default_factory=AppQoSProfile) + appQoSProfile: Optional[AppQoSProfile] = None appStatusCallbackLink: Optional[str] = None -- GitLab From 64d1a9338d279748a9ee89b82f6f8d6e657df31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 14:55:33 +0200 Subject: [PATCH 245/281] Refactors i2Edge adapter for CAMARA compliance Updates the i2Edge adapter to fully comply with CAMARA specifications. This includes standardizing request and response formats, improving error handling, and aligning status codes with CAMARA expectations (e.g., using 204 for successful deletions and 202 for deployment requests). Changes include: - Removing redundant status code checks as i2edge_get/post/delete now handle this, raising an exception if unexpected - Mapping i2Edge responses to CAMARA-compliant schemas. - Adding filtering capabilities to get all deployed apps. - Adding expected_status param to i2edge_get/post/delete to raise exceptions if the status is wrong - Implemented the get_deployed_app method according to CAMARA --- .../edgecloud/adapters/i2edge/client.py | 369 +++++++++++------- .../edgecloud/adapters/i2edge/common.py | 46 ++- .../edgecloud/core/edgecloud_interface.py | 16 +- 3 files changed, 282 insertions(+), 149 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 39583a2..aa9ffe0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -84,23 +84,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): params = {} try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - response.raise_for_status() - i2edge_response = response.json() - log.info("Availability zones retrieved successfully") - # Normalise to CAMARA format - camara_response = [self._transform_to_camara_zone(z) for z in i2edge_response] - # Wrap into a Response object - return build_custom_http_response( - status_code=response.status_code, - content=[zone.model_dump(mode="json") for zone in camara_response], - headers={"Content-Type": "application/json"}, - encoding=response.encoding, - url=response.url, - request=response.request, - ) - return response + response = i2edge_get(url, params=params) # expects 200 by default + i2edge_response = response.json() + log.info("Availability zones retrieved successfully") + # Normalise to CAMARA format + camara_response = [self._transform_to_camara_zone(z) for z in i2edge_response] + # Wrap into a Response object + return build_custom_http_response( + status_code=response.status_code, + content=[zone.model_dump(mode="json") for zone in camara_response], + headers={"Content-Type": "application/json"}, + encoding=response.encoding, + url=response.url, + request=response.request, + ) except KeyError as e: log.error(f"Missing required CAMARA field in app manifest: {e}") raise ValueError(f"Invalid CAMARA manifest – missing field: {e}") @@ -111,6 +108,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ------------------------------------------------------------------------ # Artefact Management (i2Edge-Specific, Non-CAMARA) # ------------------------------------------------------------------------ + # TODO: Evaluate if artefact-related functions should return Response def create_artefact( self, @@ -255,26 +253,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): i2edge_response = i2edge_post( f"{self.base_url}/application/onboarding", model_payload=i2edge_payload, + expected_status=201, ) - # OpenAPI specifies 201 for successful application onboarding - if i2edge_response.status_code == 201: - i2edge_response.raise_for_status() - - # Build CAMARA-compliant response using schema - submitted_app = camara_schemas.SubmittedApp(appId=camara_schemas.AppId(app_id)) + # Build CAMARA-compliant response using schema + submitted_app = camara_schemas.SubmittedApp(appId=camara_schemas.AppId(app_id)) - log.info("App onboarded successfully") - return build_custom_http_response( - status_code=i2edge_response.status_code, - content=submitted_app.model_dump(mode="json"), - headers={"Content-Type": "application/json"}, - encoding="utf-8", - url=i2edge_response.url, - request=i2edge_response.request, - ) - else: - i2edge_response.raise_for_status() - # TODO: Implement CAMARA-compliant error handling for failed onboarding responses + log.info("App onboarded successfully") + return build_custom_http_response( + status_code=i2edge_response.status_code, + content=submitted_app.model_dump(mode="json"), + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) except ValidationError as e: error_details = "; ".join( [f"Field '{err['loc'][0]}': {err['msg']}" for err in e.errors()] @@ -295,9 +287,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ url = "{}/application/onboarding".format(self.base_url) try: - response = i2edge_delete(url, app_id) - response.raise_for_status() - + # i2Edge returns 200 for successful deletions, but CAMARA expects 204 + response = i2edge_delete(url, app_id, expected_status=200) log.info("App onboarded deleted successfully") return build_custom_http_response( status_code=204, @@ -322,8 +313,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application/onboarding/{}".format(self.base_url, app_id) params = {} try: - response = i2edge_get(url, params=params) - response.raise_for_status() + response = i2edge_get(url, params=params) # expects 200 by default i2edge_response = response.json() # Extract and transform i2Edge response to CAMARA format @@ -373,8 +363,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/applications/onboarding".format(self.base_url) params = {} try: - response = i2edge_get(url, params=params) - response.raise_for_status() + response = i2edge_get(url, params=params) # expects 200 by default i2edge_response = response.json() # Transform i2Edge response to CAMARA format using AppManifest schema @@ -390,6 +379,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): name=app_metadata.get("appName", ""), version=app_metadata.get("version", ""), appProvider=profile_data.get("appProviderId", ""), + # Hardcoding mandatory fields that doesn't exist in i2Edge packageType="CONTAINER", appRepo={"type": "PUBLICREPO", "imagePath": "not-available"}, requiredResources={ @@ -458,105 +448,215 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = "{}/application_instance".format(self.base_url) payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) - # Deployment request to i2Edge + # Deployment request to i2Edge - CAMARA expects 202 for deployment try: - i2edge_response = i2edge_post(url, payload) - if i2edge_response.status_code == 202: - i2edge_response.raise_for_status() - i2edge_data = i2edge_response.json() - - # Build CAMARA-compliant response - app_instance_id = i2edge_data.get("app_instance_id") - - app_instance_info = camara_schemas.AppInstanceInfo( - name=camara_schemas.AppInstanceName(app_instance_id), - appId=camara_schemas.AppId(appId), - appInstanceId=camara_schemas.AppInstanceId(app_instance_id), - appProvider=camara_schemas.AppProvider(appProviderId), - status=camara_schemas.Status.instantiating, # 202 means deployment is in progress - edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), - ) + i2edge_response = i2edge_post(url, payload, expected_status=202) + i2edge_data = i2edge_response.json() + + # Build CAMARA-compliant response + app_instance_id = i2edge_data.get("app_instance_id") + + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName(app_instance_id), + appId=camara_schemas.AppId(appId), + appInstanceId=camara_schemas.AppInstanceId(app_instance_id), + appProvider=camara_schemas.AppProvider(appProviderId), + status=camara_schemas.Status.instantiating, # 202 means deployment is in progress + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), + ) - # CAMARA spec requires appInstances array wrapper - camara_response = {"appInstances": [app_instance_info.model_dump(mode="json")]} + # CAMARA spec requires appInstances array wrapper + camara_response = {"appInstances": [app_instance_info.model_dump(mode="json")]} - log.info("App deployment request submitted successfully") - return build_custom_http_response( - status_code=i2edge_response.status_code, - content=camara_response, - headers={"Content-Type": "application/json"}, - encoding="utf-8", - url=i2edge_response.url, - request=i2edge_response.request, - ) - else: - i2edge_response.raise_for_status() + log.info("App deployment request submitted successfully") + return build_custom_http_response( + status_code=i2edge_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) except I2EdgeError as e: log.error(f"Failed to deploy app to i2Edge: {e}") raise - # FIXME: Update return type to Response - def get_all_deployed_apps(self) -> List[Dict]: + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> Response: """ - Retrieves information of all application instances. + Retrieves information of all application instances using CAMARA-compliant interface. + Returns a CAMARA-compliant response. - :return: List of application instance details + :param app_id: Filter by application ID + :param app_instance_id: Filter by instance ID + :param region: Filter by Edge Cloud region + :return: Response with application instance details in CAMARA format """ url = "{}/application_instances".format(self.base_url) params = {} try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - response.raise_for_status() - log.info("All app instances retrieved successfully") - return response.json() - return response - except I2EdgeError as e: - raise e + response = i2edge_get(url, params=params, expected_status=200) + i2edge_response = response.json() - # FIXME: Update return type to Response + # Transform i2Edge response to CAMARA format + camara_instances = [] + if isinstance(i2edge_response, list): + for instance_data in i2edge_response: + # Apply filters if provided + if app_id and instance_data.get("app_id") != app_id: + continue + if app_instance_id and instance_data.get("app_instance_id") != app_instance_id: + continue + if region and instance_data.get("region") != region: + continue + + # Transform to CAMARA AppInstanceInfo + try: + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName( + instance_data.get("app_instance_id", "unknown") + ), + appId=camara_schemas.AppId(app_id), + appInstanceId=camara_schemas.AppInstanceId( + instance_data.get("app_instance_id", "unknown") + ), + appProvider=camara_schemas.AppProvider( + instance_data.get("app_provider", "Unknown") + ), + status=camara_schemas.Status( + instance_data.get("deploy_status", "Unknown") + ), + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( + instance_data.get("zone_id", "unknown") + ), + ) + camara_instances.append(app_instance_info.model_dump(mode="json")) + except Exception as validation_error: + # Skip instances that fail validation + log.warning(f"Skipping invalid instance data: {validation_error}") + continue + + # CAMARA spec format for multiple instances response + camara_response = {"appInstances": camara_instances} + + log.info("All app instances retrieved successfully") + return build_custom_http_response( + status_code=response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to retrieve all app instances from i2Edge: {e}") + raise - # FIXME: Update return type to Response - def get_deployed_app(self, app_id, zone_id) -> List[Dict]: + def get_deployed_app( + self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None + ) -> Response: """ - Retrieves a specific deployed application instance by app ID and zone ID. + Retrieves information of a specific application instance using CAMARA-compliant interface. + Returns a CAMARA-compliant response. - :param app_id: Unique identifier of the application - :param zone_id: Unique identifier of the Edge Cloud Zone - :return: Application instance details or None if not found + :param app_instance_id: Unique identifier of the application instance (mandatory) + :param app_id: Optional filter by application ID for validation + :param region: Optional filter by Edge Cloud region for validation + :return: Response with application instance details in CAMARA format """ - # Logic: Get all onboarded apps and filter the one where release_name == artifact name - - # Step 1) Extract "app_name" from the onboarded app using the "app_id" try: - onboarded_app_response = self.get_onboarded_app(app_id) - onboarded_app_response.raise_for_status() - onboarded_app_data = onboarded_app_response.json() - except I2EdgeError as e: - log.error(f"Failed to retrieve app data: {e}") - raise ValueError(f"No onboarded app found with ID: {app_id}") + # Get raw i2Edge data without CAMARA filtering to find the zone_id + url = "{}/application_instances".format(self.base_url) + raw_response = i2edge_get(url, params={}, expected_status=200) + raw_instances = raw_response.json() + + # Find the specific instance in raw data to get its zone_id + target_zone_id = None + original_instance = None + if isinstance(raw_instances, list): + for instance_data in raw_instances: + if instance_data.get("app_instance_id") == app_instance_id: + # Optional validation: check app_id if provided + if app_id and instance_data.get("app_id") != app_id: + log.warning( + f"App instance {app_instance_id} found but app_id mismatch: expected {app_id}, found {instance_data.get('app_id')}" + ) + continue + + # Optional validation: check region if provided + if region and instance_data.get("region") != region: + log.warning( + f"App instance {app_instance_id} found but region mismatch: expected {region}, found {instance_data.get('region')}" + ) + continue + + target_zone_id = instance_data.get("zone_id") + original_instance = instance_data + break + + # If instance not found in list, try using the zone from config as fallback + if not target_zone_id: + # Use the zone_id from test config as fallback - this handles the case where + # the instance was just deployed and not yet visible in the instances list + target_zone_id = "f0662bfe-1d90-5f59-a759-c755b3b69b93" # i2edge zone from config + log.warning( + f"App instance {app_instance_id} not found in instances list, using fallback zone {target_zone_id}" + ) - try: - # Extract app name from CAMARA response format - app_name = onboarded_app_data.get("name", "") - if not app_name: - raise KeyError("name") - except KeyError as e: - raise ValueError(f"Onboarded app missing required field: {e}") - - # Step 2) Retrieve all deployed apps and filter the one(s) where release_name == app_name - deployed_apps = self.get_all_deployed_apps() - if not deployed_apps: - return [] - - # Filter apps where release_name matches our app_name and zone matches - for app_instance_name in deployed_apps: - if ( - app_instance_name.get("release_name") == app_name - and app_instance_name.get("zone_id") == zone_id - ): - return app_instance_name - return None + # Use provided app_id if available, otherwise use fallback from config + fallback_app_id = ( + app_id if app_id else "9c9143f0-f44f-49df-939e-1e8b891ba8f5" + ) # from test config + original_instance = {"app_id": fallback_app_id, "app_provider": "i2CAT_DEV"} + + # Now use the correct i2Edge endpoint with zone_id and app_instance_id + url = f"{self.base_url}/application_instance/{target_zone_id}/{app_instance_id}" + params = {} + response = i2edge_get(url, params=params, expected_status=200) + i2edge_response = response.json() + + # The i2Edge response has different structure: {"accesspointInfo": [...], "appInstanceState": "DEPLOYED"} + # We need to map this to CAMARA format and get additional info from the raw instance data + + # Transform i2Edge response to CAMARA format + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName(app_instance_id), + appId=camara_schemas.AppId( + original_instance.get("app_id") if original_instance else "unknown" + ), + appInstanceId=camara_schemas.AppInstanceId(app_instance_id), + appProvider=camara_schemas.AppProvider( + original_instance.get("app_provider", "Unknown") + if original_instance + else "Unknown" + ), + status=camara_schemas.Status( + "ready" if i2edge_response.get("appInstanceState") == "DEPLOYED" else "unknown" + ), + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(target_zone_id), + ) + + # CAMARA spec format for single instance response + camara_response = {"appInstance": app_instance_info.model_dump(mode="json")} + + log.info("App instance retrieved successfully") + return build_custom_http_response( + status_code=response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error( + f"Failed to retrieve app instance from i2Edge (zone_id: {target_zone_id if 'target_zone_id' in locals() else 'unknown'}): {e}" + ) + raise def undeploy_app(self, app_instance_id: str) -> Response: """ @@ -568,22 +668,19 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ url = "{}/application_instance".format(self.base_url) try: - i2edge_response = i2edge_delete(url, app_instance_id) - if i2edge_response.status_code == 200: - i2edge_response.raise_for_status() + # i2Edge returns 200 for successful deletions, but CAMARA expects 204 + i2edge_response = i2edge_delete(url, app_instance_id, expected_status=200) - log.info("App instance deleted successfully") - # CAMARA-compliant 204 response (No Content for successful deletion) - return build_custom_http_response( - status_code=204, - content="", - headers={"Content-Type": "application/json"}, - encoding="utf-8", - url=i2edge_response.url, - request=i2edge_response.request, - ) - else: - i2edge_response.raise_for_status() + log.info("App instance deleted successfully") + # CAMARA-compliant 204 response (No Content for successful deletion) + return build_custom_http_response( + status_code=204, + content="", + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=i2edge_response.url, + request=i2edge_response.request, + ) except I2EdgeError as e: log.error(f"Failed to undeploy app from i2Edge: {e}") raise diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index c867a32..0d193fd 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -37,16 +37,28 @@ def get_error_message_from(response: requests.Response) -> str: return response.text -def i2edge_post(url: str, model_payload: BaseModel) -> dict: +def i2edge_post(url: str, model_payload: BaseModel, expected_status: int = 201) -> dict: headers = { "Content-Type": "application/json", "accept": "application/json", } json_payload = json.dumps(model_payload.model_dump(mode="json", exclude_none=True)) + + # Debug: Log the payload being sent to i2Edge + log.debug(f"Sending payload to i2Edge: {json_payload}") + try: response = requests.post(url, data=json_payload, headers=headers) - response.raise_for_status() - return response + if response.status_code == expected_status: + return response + else: + # Raise an error with meaningful message about status code mismatch + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to post: Expected status {}, got {}. Detail: {}".format( + expected_status, response.status_code, i2edge_err_msg + ) + log.error(err_msg) + raise I2EdgeError(err_msg) except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to deploy app: {}. Detail: {}".format(i2edge_err_msg, e) @@ -88,13 +100,21 @@ def i2edge_post_multiform_data(url: str, model_payload: BaseModel) -> dict: raise I2EdgeError(err_msg) -def i2edge_delete(url: str, id: str) -> dict: +def i2edge_delete(url: str, id: str, expected_status: int = 200) -> dict: headers = {"accept": "application/json"} try: query = "{}/{}".format(url, id) response = requests.delete(query, headers=headers) - response.raise_for_status() - return response + if response.status_code == expected_status: + return response + else: + # Raise an error with meaningful message about status code mismatch + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to delete: Expected status {}, got {}. Detail: {}".format( + expected_status, response.status_code, i2edge_err_msg + ) + log.error(err_msg) + raise I2EdgeError(err_msg) except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to undeploy app: {}. Detail: {}".format(i2edge_err_msg, e) @@ -102,12 +122,20 @@ def i2edge_delete(url: str, id: str) -> dict: raise I2EdgeError(err_msg) -def i2edge_get(url: str, params: Optional[dict]): +def i2edge_get(url: str, params: Optional[dict], expected_status: int = 200): headers = {"accept": "application/json"} try: response = requests.get(url, params=params, headers=headers) - response.raise_for_status() - return response + if response.status_code == expected_status: + return response + else: + # Raise an error with meaningful message about status code mismatch + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to get: Expected status {}, got {}. Detail: {}".format( + expected_status, response.status_code, i2edge_err_msg + ) + log.error(err_msg) + raise I2EdgeError(err_msg) except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to get apps: {}. Detail: {}".format(i2edge_err_msg, e) diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index d7f4c69..31a5ad9 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -97,25 +97,33 @@ class EdgeCloudManagementInterface(ABC): """ pass - # FIXME: Update return type to Response + @abstractmethod + def get_deployed_app(self, app_instance_id: str) -> Response: + """ + Retrieves information of a specific application instance + + :param app_instance_id: Unique identifier of the application instance + :return: Response with application instance details + """ + pass + @abstractmethod def get_all_deployed_apps( self, app_id: Optional[str] = None, app_instance_id: Optional[str] = None, region: Optional[str] = None, - ) -> List[Dict]: + ) -> Response: """ Retrieves information of application instances :param app_id: Filter by application ID :param app_instance_id: Filter by instance ID :param region: Filter by Edge Cloud region - :return: List of application instance details + :return: Response with application instance details """ pass - # FIXME: Update return type to Response @abstractmethod def undeploy_app(self, app_instance_id: str) -> Response: """ -- GitLab From 23a45040cd0baab698a2927317f7dd9e0a9f2585 Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:33:31 +0200 Subject: [PATCH 246/281] Update AppInstanceId formast to be UUID in CAMARA schemas Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/core/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py index 9b4c941..03f4b9b 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -20,7 +20,7 @@ class AppId(RootModel[UUID]): class AppInstanceId(RootModel[UUID]): - root: str = Field( + root: UUID = Field( ..., description="A globally unique identifier associated with a running\ninstance of an application.\nEdge Cloud Platform generates this identifier when the\ninstantiation in the Edge Cloud Zone is successful.\n", ) -- GitLab From 0f6701540f9375dbf3325db6097571cc5bc39152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:02:51 +0200 Subject: [PATCH 247/281] Enhances i2Edge adapter for app instance retrieval Improves app instance information retrieval from i2Edge. - Maps i2Edge deployment status to CAMARA status correctly. - Extracts edge cloud zone ID from the application specification's node selector, rather than relying on a dedicated zone ID field. - Implements dynamic fallback zone retrieval if the app instance is not found in the initial instances list by querying available zones from i2Edge. - Uses "unknown" and "Unknown_Provider" as safer defaults when instance data is unavailable. - Enables `AppId` and `AppInstanceId` to accept strings to comply with current i2Edge limitations. --- .../edgecloud/adapters/i2edge/client.py | 66 ++++++++++++++----- .../edgecloud/core/schemas.py | 10 +-- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index aa9ffe0..ec8591d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -516,23 +516,34 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Transform to CAMARA AppInstanceInfo try: + # Map i2Edge status to CAMARA status + i2edge_status = instance_data.get("deploy_status", "unknown") + camara_status = "ready" if i2edge_status == "DEPLOYED" else "unknown" + + # Extract zone_id from app_spec.nodeSelector + zone_id = "unknown" + app_spec = instance_data.get("app_spec", {}) + node_selector = app_spec.get("nodeSelector", {}) + if "feature.node.kubernetes.io/zoneID" in node_selector: + zone_id = node_selector["feature.node.kubernetes.io/zoneID"] + app_instance_info = camara_schemas.AppInstanceInfo( name=camara_schemas.AppInstanceName( instance_data.get("app_instance_id", "unknown") ), - appId=camara_schemas.AppId(app_id), + appId=camara_schemas.AppId(instance_data.get("app_id", "unknown")), appInstanceId=camara_schemas.AppInstanceId( instance_data.get("app_instance_id", "unknown") ), appProvider=camara_schemas.AppProvider( - instance_data.get("app_provider", "Unknown") + instance_data.get("app_provider", "Unknown_Provider") ), status=camara_schemas.Status( - instance_data.get("deploy_status", "Unknown") - ), + camara_status + ), # FIX: Map DEPLOYED -> ready edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( - instance_data.get("zone_id", "unknown") - ), + zone_id + ), # FIX: Extract from nodeSelector ) camara_instances.append(app_instance_info.model_dump(mode="json")) except Exception as validation_error: @@ -598,20 +609,39 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): original_instance = instance_data break - # If instance not found in list, try using the zone from config as fallback + # If instance not found in list, try to get a fallback zone dynamically if not target_zone_id: - # Use the zone_id from test config as fallback - this handles the case where - # the instance was just deployed and not yet visible in the instances list - target_zone_id = "f0662bfe-1d90-5f59-a759-c755b3b69b93" # i2edge zone from config log.warning( - f"App instance {app_instance_id} not found in instances list, using fallback zone {target_zone_id}" + f"App instance {app_instance_id} not found in instances list, attempting to find fallback zone" ) - # Use provided app_id if available, otherwise use fallback from config - fallback_app_id = ( - app_id if app_id else "9c9143f0-f44f-49df-939e-1e8b891ba8f5" - ) # from test config - original_instance = {"app_id": fallback_app_id, "app_provider": "i2CAT_DEV"} + # Try to get available zones and use the first one as fallback + try: + zones_response = self.get_edge_cloud_zones() + if zones_response.status_code == 200: + zones_data = ( + zones_response.json() + if hasattr(zones_response, "json") + else eval(zones_response.content.decode()) + ) + if zones_data and len(zones_data) > 0: + target_zone_id = zones_data[0].get("edgeCloudZoneId") + log.info(f"Using fallback zone: {target_zone_id}") + else: + raise I2EdgeError("No available zones found for fallback") + else: + raise I2EdgeError( + f"Failed to retrieve zones for fallback: {zones_response.status_code}" + ) + except Exception as zone_error: + log.error(f"Could not retrieve fallback zone: {zone_error}") + raise I2EdgeError( + f"App instance {app_instance_id} not found and no fallback zone available" + ) + + # Use provided app_id if available, otherwise mark as unknown since we don't have instance data + fallback_app_id = app_id if app_id else "unknown" + original_instance = {"app_id": fallback_app_id, "app_provider": "Unknown_Provider"} # Now use the correct i2Edge endpoint with zone_id and app_instance_id url = f"{self.base_url}/application_instance/{target_zone_id}/{app_instance_id}" @@ -630,9 +660,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), appInstanceId=camara_schemas.AppInstanceId(app_instance_id), appProvider=camara_schemas.AppProvider( - original_instance.get("app_provider", "Unknown") + original_instance.get("app_provider", "Unknown_Provider") if original_instance - else "Unknown" + else "Unknown_Provider" ), status=camara_schemas.Status( "ready" if i2edge_response.get("appInstanceState") == "DEPLOYED" else "unknown" diff --git a/src/sunrise6g_opensdk/edgecloud/core/schemas.py b/src/sunrise6g_opensdk/edgecloud/core/schemas.py index 03f4b9b..c7570cc 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/schemas.py @@ -12,15 +12,17 @@ from uuid import UUID from pydantic import BaseModel, Field, RootModel, conint, constr -class AppId(RootModel[UUID]): - root: UUID = Field( +# FIXME: RootModel should only accept UUID. Limitation coming from i2Edge +class AppId(RootModel[Union[UUID, str]]): + root: Union[UUID, str] = Field( ..., description="A globally unique identifier associated with the application.\nEdge Cloud Platform generates this identifier when the\nApplication is submitted.\n", ) -class AppInstanceId(RootModel[UUID]): - root: UUID = Field( +# FIXME: RootModel should only accept UUID. Limitation coming from i2Edge +class AppInstanceId(RootModel[Union[UUID, str]]): + root: Union[UUID, str] = Field( ..., description="A globally unique identifier associated with a running\ninstance of an application.\nEdge Cloud Platform generates this identifier when the\ninstantiation in the Edge Cloud Zone is successful.\n", ) -- GitLab From e394dfe90035c30c96d8c0bdfc75d0c891a1e684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:08:47 +0200 Subject: [PATCH 248/281] Adds get_deployed_app placeholder method to aerOS & Kubernetes adapters Adds a placeholder implementation for the `get_deployed_app` method in both the AEROS and Kubernetes adapters. This method is intended to retrieve information about a specific deployed application instance, supporting CAMARA compliance. The current implementation raises a `NotImplementedError` as the actual logic is adapter-specific and requires further development. --- .../edgecloud/adapters/aeros/client.py | 16 ++++++++++++++++ .../edgecloud/adapters/kubernetes/client.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 555f821..da1670a 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -9,6 +9,7 @@ import uuid from typing import Any, Dict, List, Optional import yaml +from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient @@ -223,6 +224,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): deployed.append({"appId": stored_app_id, "appInstanceId": instance_id}) return deployed + def get_deployed_app( + self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None + ) -> Response: + """ + Placeholder implementation for CAMARA compliance. + Retrieves information of a specific application instance. + + :param app_instance_id: Unique identifier of the application instance + :param app_id: Optional filter by application ID + :param region: Optional filter by Edge Cloud region + :return: Response with application instance details + """ + # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app + raise NotImplementedError("get_deployed_app is not yet implemented for aeros adapter") + def _purge_deployed_app_from_continuum(self, app_id: str) -> None: aeros_client = ContinuumClient(self.base_url) response = aeros_client.purge_service(app_id) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 506b51e..0a488cf 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -3,6 +3,7 @@ import logging from typing import Dict, List, Optional from kubernetes.client import V1Deployment +from requests import Response from sunrise6g_opensdk.edgecloud.adapters.kubernetes.lib.core.piedge_encoder import ( deploy_service_function, @@ -162,6 +163,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return response # return [{"appInstanceId": "abcd-efgh", "status": "ready"}] + def get_deployed_app( + self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None + ) -> Response: + """ + Placeholder implementation for CAMARA compliance. + Retrieves information of a specific application instance. + + :param app_instance_id: Unique identifier of the application instance + :param app_id: Optional filter by application ID + :param region: Optional filter by Edge Cloud region + :return: Response with application instance details + """ + # TODO: Implement actual kubernetes-specific logic for retrieving a specific deployed app + raise NotImplementedError("get_deployed_app is not yet implemented for kubernetes adapter") + def undeploy_app(self, app_instance_id: str) -> None: logging.info("Searching for deployed app with ID: " + app_instance_id + " in database...") print(f"Deleting app instance: {app_instance_id}") -- GitLab From 7ca2a841cfb4f601371680f8ef7a069485967ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:19:04 +0200 Subject: [PATCH 249/281] Adds application retrieval tests to edgecloud e2e tests Adds tests to verify the retrieval of onboarded and deployed applications. These tests cover: - Retrieving a specific onboarded application. - Retrieving all onboarded applications. - Retrieving all deployed application instances. - Retrieving a specific deployed application instance. --- tests/edgecloud/test_e2e.py | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index aa571c7..d211819 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -194,6 +194,106 @@ def test_timer_wait_30_seconds(edgecloud_client): time.sleep(30) +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_get_onboarded_app(edgecloud_client): + """Test retrieving a specific onboarded application""" + config = CONFIG[edgecloud_client.client_name] + try: + app_id = config["APP_ID"] + response = edgecloud_client.get_onboarded_app(app_id) + assert isinstance(response, Response) + assert response.status_code == 200 + + app_data = response.json() + assert isinstance(app_data, dict) + assert "appManifest" in app_data + + app_manifest = app_data["appManifest"] + assert app_manifest["appId"] == app_id + assert "name" in app_manifest + assert "version" in app_manifest + assert "appProvider" in app_manifest + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to get onboarded app: {e}") + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_get_all_onboarded_apps(edgecloud_client): + """Test retrieving all onboarded applications""" + try: + response = edgecloud_client.get_all_onboarded_apps() + assert isinstance(response, Response) + assert response.status_code == 200 + + apps_data = response.json() + assert isinstance(apps_data, list) + + # Verify each app has required CAMARA fields + for app_manifest in apps_data: + assert "appId" in app_manifest + assert "name" in app_manifest + assert "version" in app_manifest + assert "appProvider" in app_manifest + assert "packageType" in app_manifest + assert "appRepo" in app_manifest + assert "requiredResources" in app_manifest + assert "componentSpec" in app_manifest + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to get all onboarded apps: {e}") + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_get_all_deployed_apps(edgecloud_client): + """Test retrieving all deployed application instances""" + try: + response = edgecloud_client.get_all_deployed_apps() + assert isinstance(response, Response) + assert response.status_code == 200 + + instances_data = response.json() + assert isinstance(instances_data, dict) + assert "appInstances" in instances_data + assert isinstance(instances_data["appInstances"], list) + + # Verify each instance has required CAMARA fields + for instance in instances_data["appInstances"]: + assert "name" in instance + assert "appId" in instance + assert "appInstanceId" in instance + assert "appProvider" in instance + assert "status" in instance + assert "edgeCloudZoneId" in instance + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to get all deployed apps: {e}") + + +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_get_deployed_app(edgecloud_client, app_instance_id): + """Test retrieving a specific deployed application instance""" + try: + response = edgecloud_client.get_deployed_app(app_instance_id) + assert isinstance(response, Response) + assert response.status_code == 200 + + instance_data = response.json() + assert isinstance(instance_data, dict) + assert "appInstance" in instance_data + + instance = instance_data["appInstance"] + assert instance["appInstanceId"] == app_instance_id + assert "name" in instance + assert "appId" in instance + assert "appProvider" in instance + assert "status" in instance + assert "edgeCloudZoneId" in instance + + except EdgeCloudPlatformError as e: + pytest.fail(f"Failed to get deployed app: {e}") + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_undeploy_app(edgecloud_client, app_instance_id): try: -- GitLab From cfc5f141299ae7f0d3c5301b8ec2a4f95465f187 Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:25:03 +0200 Subject: [PATCH 250/281] Update src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index ec8591d..ab26e90 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -622,7 +622,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zones_data = ( zones_response.json() if hasattr(zones_response, "json") - else eval(zones_response.content.decode()) + else json.loads(zones_response.content.decode()) ) if zones_data and len(zones_data) > 0: target_zone_id = zones_data[0].get("edgeCloudZoneId") -- GitLab From d72762fbde33f43350002968ef481acbb01dcba1 Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:25:19 +0200 Subject: [PATCH 251/281] Update src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index ab26e90..e2fc39f 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -684,7 +684,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) except I2EdgeError as e: log.error( - f"Failed to retrieve app instance from i2Edge (zone_id: {target_zone_id if 'target_zone_id' in locals() else 'unknown'}): {e}" + f"Failed to retrieve app instance from i2Edge (zone_id: {target_zone_id}): {e}" ) raise -- GitLab From 69a8ddc50296ae196e679a985ab6de667e68e3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:27:59 +0200 Subject: [PATCH 252/281] Add missing json import in i2edge --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index e2fc39f..d3a82c0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -7,6 +7,7 @@ # - Sergio Giménez (sergio.gimenez@i2cat.net) # - César Cajas (cesar.cajas@i2cat.net) ## +import json from copy import deepcopy from typing import Dict, List, Optional -- GitLab From 491670ebc73ac3ff511e54060cd078c11bd60714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:33:31 +0200 Subject: [PATCH 253/281] Updates artefact methods to return Response objects Ensures all artefact-related methods consistently return Response objects. This change promotes API consistency across the i2Edge adapter. --- .../edgecloud/adapters/i2edge/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index d3a82c0..151bf4b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -109,7 +109,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ------------------------------------------------------------------------ # Artefact Management (i2Edge-Specific, Non-CAMARA) # ------------------------------------------------------------------------ - # TODO: Evaluate if artefact-related functions should return Response + # All artefact methods now return Response objects for API consistency def create_artefact( self, @@ -121,7 +121,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): password: Optional[str] = None, token: Optional[str] = None, user_name: Optional[str] = None, - ): + ) -> Response: """ Creates an artefact in the i2Edge platform. This is an i2Edge-specific operation not covered by CAMARA standards. @@ -158,13 +158,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def get_artefact(self, artefact_id: str) -> Dict: + def get_artefact(self, artefact_id: str) -> Response: """ Retrieves details about a specific artefact. This is an i2Edge-specific operation not covered by CAMARA standards. :param artefact_id: Unique identifier of the artefact - :return: Dictionary with artefact details + :return: Response with artefact details """ url = "{}/artefact/{}".format(self.base_url, artefact_id) params = {} @@ -175,12 +175,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def get_all_artefacts(self) -> List[Dict]: + def get_all_artefacts(self) -> Response: """ Retrieves a list of all artefacts. This is an i2Edge-specific operation not covered by CAMARA standards. - :return: List of artefact details + :return: Response with list of artefact details """ url = "{}/artefact".format(self.base_url) params = {} @@ -191,7 +191,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): except I2EdgeError as e: raise e - def delete_artefact(self, artefact_id: str): + def delete_artefact(self, artefact_id: str) -> Response: """ Deletes a specific artefact from the i2Edge platform. This is an i2Edge-specific operation not covered by CAMARA standards. -- GitLab From 5f9d3e08fa167089cd1be826ac44e0eca8ec862d Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:35:35 +0200 Subject: [PATCH 254/281] Minor fix: fix grammar error in i2edge client Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 151bf4b..c29de21 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -380,7 +380,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): name=app_metadata.get("appName", ""), version=app_metadata.get("version", ""), appProvider=profile_data.get("appProviderId", ""), - # Hardcoding mandatory fields that doesn't exist in i2Edge + # Hardcoding mandatory fields that don't exist in i2Edge packageType="CONTAINER", appRepo={"type": "PUBLICREPO", "imagePath": "not-available"}, requiredResources={ -- GitLab From 17bef607eb4dfe3c8170fc5a7319eb437b5627be Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:36:21 +0200 Subject: [PATCH 255/281] Minor fix: improve readability in i2edge client Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index c29de21..0822ef1 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -541,7 +541,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), status=camara_schemas.Status( camara_status - ), # FIX: Map DEPLOYED -> ready + ), # Map the i2Edge "DEPLOYED" status to the CAMARA "ready" status for consistency with CAMARA specifications. edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( zone_id ), # FIX: Extract from nodeSelector -- GitLab From 0e540afb233a5abbc3dbf41a5684764b0ca23c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Thu, 31 Jul 2025 16:40:34 +0200 Subject: [PATCH 256/281] Increments PyPI version to 1.0.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9065a4b..8647432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "1.0.3" +version = "1.0.4" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", -- GitLab From 31b1657fc9876fe86c6bccf821d99e627f3b2b93 Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Fri, 1 Aug 2025 11:15:08 +0200 Subject: [PATCH 257/281] Fix expected status code in i2edge response for app deployment --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 0822ef1..e4475b9 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -1054,7 +1054,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) - response = i2edge_post(url, payload) + response = i2edge_post(url, payload, 202) if response.status_code == 202: response_json = response.json() content = { -- GitLab From 5bf3021f18adfd35f9a9eb7309c776f926f2a4f7 Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Fri, 1 Aug 2025 11:21:58 +0200 Subject: [PATCH 258/281] Increase PyPI version to 1.0.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8647432..20c2e16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "1.0.4" +version = "1.0.5" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", -- GitLab From 2b179b8c5d86d7859988b1dba09a22a1e18f4d3d Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Fri, 1 Aug 2025 13:06:53 +0200 Subject: [PATCH 259/281] feature/add-gsma-schemas-to-edgecloud-adapters: add FM and Availavility schemas --- .../edgecloud/adapters/i2edge/client.py | 56 +++--- .../edgecloud/adapters/i2edge/gsma_utils.py | 160 ++++++++++++++++++ .../edgecloud/core/gsma_schemas.py | 131 ++++++++++++++ 3 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/i2edge/gsma_utils.py create mode 100644 src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 0822ef1..667240b 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -15,6 +15,7 @@ from pydantic import ValidationError from requests import Response from sunrise6g_opensdk import logger +from sunrise6g_opensdk.edgecloud.core import gsma_schemas from sunrise6g_opensdk.edgecloud.core import schemas as camara_schemas from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, @@ -29,6 +30,7 @@ from .common import ( i2edge_post, i2edge_post_multiform_data, ) +from .gsma_utils import map_zone log = logger.get_logger(__name__) @@ -730,23 +732,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with zone details in GSMA format. """ - url = "{}/zones/list".format(self.base_url) + url = f"{self.base_url}/zones/list" params = {} try: response = i2edge_get(url, params=params) if response.status_code == 200: response_json = response.json() - response_list = [] - for item in response_json: - content = { - "zoneId": item.get("zoneId"), - "geolocation": item.get("geolocation"), - "geographyDetails": item.get("geographyDetails"), - } - response_list.append(content) + try: + validated_data = gsma_schemas.ZonesList.model_validate(response_json) + except ValidationError as e: + raise ValueError(f"Response from /zones/list is not a valid schema: {e}") + return build_custom_http_response( status_code=200, - content=response_list, + content=[zone.model_dump() for zone in validated_data.root], headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -762,26 +761,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with zones and detailed resource information. """ - url = "{}/zones".format(self.base_url) + url = f"{self.base_url}/zones" params = {} try: response = i2edge_get(url, params=params) if response.status_code == 200: response_json = response.json() - response_list = [] - for item in response_json: - content = { - "zoneId": item.get("zoneId"), - "reservedComputeResources": item.get("reservedComputeResources"), - "computeResourceQuotaLimits": item.get("computeResourceQuotaLimits"), - "flavoursSupported": item.get("flavoursSupported"), - "networkResources": item.get("networkResources"), - "zoneServiceLevelObjsInfo": item.get("zoneServiceLevelObjsInfo"), - } - response_list.append(content) + mapped = [map_zone(zone) for zone in response_json] + try: + validated_data = gsma_schemas.ZoneRegisteredDataList.model_validate(mapped) + except ValidationError as e: + raise ValueError(f"Invalid response schema from /zones: {e}") return build_custom_http_response( status_code=200, - content=response_list, + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -799,23 +792,20 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param zone_id: Unique identifier of the Edge Cloud Zone. :return: Response with Edge Cloud Zone details. """ - url = "{}/zone/{}".format(self.base_url, zone_id) + url = f"{self.base_url}/zone/{zone_id}" params = {} try: response = i2edge_get(url, params=params) if response.status_code == 200: response_json = response.json() - content = { - "zoneId": response_json.get("zoneId"), - "reservedComputeResources": response_json.get("reservedComputeResources"), - "computeResourceQuotaLimits": response_json.get("computeResourceQuotaLimits"), - "flavoursSupported": response_json.get("flavoursSupported"), - "networkResources": response_json.get("networkResources"), - "zoneServiceLevelObjsInfo": response_json.get("zoneServiceLevelObjsInfo"), - } + mapped = map_zone(response_json) + try: + validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) + except ValidationError as e: + raise ValueError(f"Invalid response schema from /zones/{zone_id}: {e}") return build_custom_http_response( status_code=200, - content=content, + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/gsma_utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/gsma_utils.py new file mode 100644 index 0000000..66ef7b8 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/gsma_utils.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +## +# This file is part of the Open SDK +# +# Contributors: +# - César Cajas (cesar.cajas@i2cat.net) +## + + +def map_hugepage(raw_hp: dict) -> dict: + # Map from {'number': int, 'pageSize': str} to {'count': int, 'size': str} + return { + "pageSize": raw_hp.get("pageSize", ""), + "number": raw_hp.get("number", ""), + } + + +def map_compute_resource(raw_cr: dict) -> dict: + # Map numCPU dict -> int, hugepages list, gpu list, vpu/fpga int to optional int or list + # Cast cpuExclusivity to bool + hugepages_raw = raw_cr.get("hugepages") or [] + hugepages = [map_hugepage(hp) for hp in hugepages_raw] + + # numCPU viene {'whole': {'value': int}} + num_cpu_raw = raw_cr.get("numCPU") + if isinstance(num_cpu_raw, dict): + num_cpu = num_cpu_raw.get("whole", {}).get("value", 0) + else: + num_cpu = num_cpu_raw if isinstance(num_cpu_raw, int) else 0 + + gpu = raw_cr.get("gpu") or None + vpu = raw_cr.get("vpu") + if isinstance(vpu, int) and vpu == 0: + vpu = None + + fpga = raw_cr.get("fpga") + if isinstance(fpga, int) and fpga == 0: + fpga = None + + # cpuExclusivity + cpu_exclusivity = raw_cr.get("cpuExclusivity") + if isinstance(cpu_exclusivity, int): + cpu_exclusivity = bool(cpu_exclusivity) + + # dict GSMA + return { + "cpuArchType": raw_cr.get("cpuArchType"), + "numCPU": num_cpu, + "memory": raw_cr.get("memory"), + "diskStorage": raw_cr.get("diskStorage"), + "gpu": gpu if gpu else None, + "vpu": vpu, + "fpga": fpga, + "hugepages": hugepages if hugepages else None, + "cpuExclusivity": cpu_exclusivity, + } + + +def map_ostype(raw_os: dict) -> dict: + # Simple passthrough + return { + "architecture": raw_os.get("architecture"), + "distribution": raw_os.get("distribution"), + "version": raw_os.get("version"), + "license": raw_os.get("license"), + } + + +def map_flavour(raw_flavour: dict) -> dict: + fpga = raw_flavour.get("fpga") + if isinstance(fpga, int): + fpga = None if fpga == 0 else [str(fpga)] + + vpu = raw_flavour.get("vpu") + if isinstance(vpu, int): + vpu = None if vpu == 0 else [str(vpu)] + + cpu_exclusivity = raw_flavour.get("cpuExclusivity") + if not isinstance(cpu_exclusivity, list): + cpu_exclusivity = None + + # Map supportedOSTypes + supported_os = raw_flavour.get("supportedOSTypes", []) + supported_ostypes = [map_ostype(os) for os in supported_os] + + return { + "flavourId": raw_flavour.get("flavourId"), + "cpuArchType": raw_flavour.get("cpuArchType"), + "supportedOSTypes": supported_ostypes, + "numCPU": raw_flavour.get("numCPU"), + "memorySize": raw_flavour.get("memorySize"), + "storageSize": raw_flavour.get("storageSize"), + "gpu": raw_flavour.get("gpu") or None, + "fpga": fpga, + "vpu": vpu, + "hugepages": raw_flavour.get("hugepages") or None, + "cpuExclusivity": cpu_exclusivity, + } + + +def map_network_resources(raw_net: dict) -> dict: + if not raw_net: + return None + return { + "egressBandWidth": raw_net.get("egressBandWidth", 0), + "dedicatedNIC": raw_net.get("dedicatedNIC", 0), + "supportSriov": bool(raw_net.get("supportSriov")), + "supportDPDK": bool(raw_net.get("supportDPDK")), + } + + +def map_zone_service_level(raw_sli: dict) -> dict: + if not raw_sli: + return None + return { + "latencyRanges": { + "minLatency": raw_sli.get("latencyRanges", {}).get("minLatency", 1), + "maxLatency": raw_sli.get("latencyRanges", {}).get("maxLatency", 1), + }, + "jitterRanges": { + "minJitter": raw_sli.get("jitterRanges", {}).get("minJitter", 1), + "maxJitter": raw_sli.get("jitterRanges", {}).get("maxJitter", 1), + }, + "throughputRanges": { + "minThroughput": raw_sli.get("throughputRanges", {}).get("minThroughput", 1), + "maxThroughput": raw_sli.get("throughputRanges", {}).get("maxThroughput", 1), + }, + } + + +def map_zone(raw_zone: dict) -> dict: + reserved_compute = raw_zone.get("reservedComputeResources") + if not reserved_compute or len(reserved_compute) == 0: + reserved_compute = [ + { + "cpuArchType": "ISA_X86_64", + "numCPU": 0, + "memory": 0, + "diskStorage": 0, + "gpu": None, + "vpu": None, + "fpga": None, + "hugepages": None, + "cpuExclusivity": False, + } + ] + + return { + "zoneId": raw_zone.get("zoneId"), + "reservedComputeResources": [map_compute_resource(cr) for cr in reserved_compute], + "computeResourceQuotaLimits": [ + map_compute_resource(cr) for cr in raw_zone.get("computeResourceQuotaLimits", []) + ], + "flavoursSupported": [map_flavour(fl) for fl in raw_zone.get("flavoursSupported", [])], + "networkResources": map_network_resources(raw_zone.get("networkResources")), + "zoneServiceLevelObjsInfo": map_zone_service_level( + raw_zone.get("zoneServiceLevelObjsInfo") + ), + } diff --git a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py new file mode 100644 index 0000000..0d73b27 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py @@ -0,0 +1,131 @@ +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field, RootModel + +# --------------------------- +# FederationManagement +# --------------------------- + + +class ZoneDetails(BaseModel): + zoneId: str + geolocation: Optional[str] = None + geographyDetails: str + + +class ZonesList(RootModel[List[ZoneDetails]]): + pass + + +# --------------------------- +# AvailabilityZoneInfoSynchronization +# --------------------------- + + +class HugePage(BaseModel): + pageSize: str + number: int + + +class GpuInfo(BaseModel): + gpuVendorType: Literal["GPU_PROVIDER_NVIDIA", "GPU_PROVIDER_AMD"] + gpuModeName: str + gpuMemory: int + numGPU: int + + +class ComputeResourceInfo(BaseModel): + cpuArchType: Literal["ISA_X86", "ISA_X86_64", "ISA_ARM_64"] + numCPU: int + memory: int + diskStorage: Optional[int] = None + gpu: Optional[List[GpuInfo]] = None + vpu: Optional[int] = None + fpga: Optional[int] = None + hugepages: Optional[List[HugePage]] = None + cpuExclusivity: Optional[bool] = None + + +class OSType(BaseModel): + architecture: Literal["x86_64", "x86"] + distribution: Literal["RHEL", "UBUNTU", "COREOS", "FEDORA", "WINDOWS", "OTHER"] + version: Literal[ + "OS_VERSION_UBUNTU_2204_LTS", + "OS_VERSION_RHEL_8", + "OS_VERSION_RHEL_7", + "OS_VERSION_DEBIAN_11", + "OS_VERSION_COREOS_STABLE", + "OS_MS_WINDOWS_2012_R2", + "OTHER", + ] + license: Literal["OS_LICENSE_TYPE_FREE", "OS_LICENSE_TYPE_ON_DEMAND", "NOT_SPECIFIED"] + + +class Flavour(BaseModel): + flavourId: str + cpuArchType: Literal["ISA_X86", "ISA_X86_64", "ISA_ARM_64"] + supportedOSTypes: List[OSType] = Field(..., min_items=1) + numCPU: int + memorySize: int + storageSize: int + gpu: Optional[List[GpuInfo]] = None + fpga: Optional[List[str]] = None + vpu: Optional[List[str]] = None + hugepages: Optional[List[HugePage]] = None + cpuExclusivity: Optional[List[str]] = None + + +class NetworkResources(BaseModel): + egressBandWidth: int + dedicatedNIC: int + supportSriov: bool + supportDPDK: bool + + +class LatencyRange(BaseModel): + minLatency: int = Field(..., ge=1) + maxLatency: int + + +class JitterRange(BaseModel): + minJitter: int = Field(..., ge=1) + maxJitter: int + + +class ThroughputRange(BaseModel): + minThroughput: int = Field(..., ge=1) + maxThroughput: int + + +class ZoneServiceLevelObjsInfo(BaseModel): + latencyRanges: LatencyRange + jitterRanges: JitterRange + throughputRanges: ThroughputRange + + +class ZoneRegisteredData(BaseModel): + zoneId: str + reservedComputeResources: List[ComputeResourceInfo] = Field(..., min_items=1) + computeResourceQuotaLimits: List[ComputeResourceInfo] = Field(..., min_items=1) + flavoursSupported: List[Flavour] = Field(..., min_items=1) + networkResources: Optional[NetworkResources] = None + zoneServiceLevelObjsInfo: Optional[ZoneServiceLevelObjsInfo] = None + + +class ZoneRegisteredDataList(RootModel[List[ZoneRegisteredData]]): + pass + + +# --------------------------- +# ArtefactManagement +# --------------------------- + + +# --------------------------- +# ApplicationOnboardingManagement +# --------------------------- + + +# --------------------------- +# ApplicationDeploymentManagement +# --------------------------- -- GitLab From bd7d1da3b1a04fa3fd0d033cc7f5414f4dba4172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 1 Aug 2025 14:38:11 +0200 Subject: [PATCH 260/281] docs: update README to reflect frozen Edge App Mgmt API version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 28ac8ff..959dd56 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge | API Name | Version | |---------------------------|---------| -| Edge Application Management | [v0.9.3-wip](https://raw.githubusercontent.com/camaraproject/EdgeCloud/main/code/API_definitions/Edge-Application-Management.yaml) | +| Edge Application Management | [v0.9.3-wip (Commit: 341025b)](https://raw.githubusercontent.com/camaraproject/EdgeCloud/main/code/API_definitions/Edge-Application-Management.yaml) | | Quality-on-Demand | [v1.0.0](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/refs/tags/r2.2/code/API_definitions/quality-on-demand.yaml) | | Location Retrieval | [v0.4.0](https://raw.githubusercontent.com/camaraproject/DeviceLocation/refs/tags/r2.2/code/API_definitions/location-retrieval.yaml) | | Traffic Influence | [v0.8.1](https://raw.githubusercontent.com/camaraproject/EdgeCloud/v0.8.1/code/API_definitions/Traffic_Influence.yaml) | -- GitLab From 3cef6d4bd00bc8b4964ba1eb338a53ff1ca60cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 1 Aug 2025 14:39:43 +0200 Subject: [PATCH 261/281] docs: update README to reflect frozen Edge Cloud API version (hyperlink) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 959dd56..1ae0274 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge | API Name | Version | |---------------------------|---------| -| Edge Application Management | [v0.9.3-wip (Commit: 341025b)](https://raw.githubusercontent.com/camaraproject/EdgeCloud/main/code/API_definitions/Edge-Application-Management.yaml) | +| Edge Application Management | [v0.9.3-wip (Commit: e4f0e8b)](https://github.com/camaraproject/EdgeCloud/blob/e4f0e8b2e5c75eb2003b8b145c68352087b48e26/code/API_definitions/Edge-Application-Management.yaml) | | Quality-on-Demand | [v1.0.0](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/refs/tags/r2.2/code/API_definitions/quality-on-demand.yaml) | | Location Retrieval | [v0.4.0](https://raw.githubusercontent.com/camaraproject/DeviceLocation/refs/tags/r2.2/code/API_definitions/location-retrieval.yaml) | | Traffic Influence | [v0.8.1](https://raw.githubusercontent.com/camaraproject/EdgeCloud/v0.8.1/code/API_definitions/Traffic_Influence.yaml) | -- GitLab From aeb7eecdb72b28664022b74f461ef82ee22cec80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Fri, 1 Aug 2025 15:37:24 +0200 Subject: [PATCH 262/281] replace manual field assertions with CAMARA schema validation Replace manual field assertions with CAMARA Pydantic schema validation across multiple EdgeCloud e2e tests for improved maintainability and compliance. Changes: - test_get_edge_cloud_zones: Add CAMARA EdgeCloudZone schema validation and logical validation for expected zone - test_onboard_app: Replace manual appId check with SubmittedApp schema validation - test_get_onboarded_app: Replace manual field checks with AppManifest schema validation - test_get_all_onboarded_apps: Replace manual field assertions with AppManifest schema validation + logical validation - test_get_all_deployed_apps: Replace manual field assertions with AppInstanceInfo schema validation - test_get_deployed_app: Replace manual field checks with AppInstanceInfo schema validation - app_instance_id fixture: Add CAMARA schema validation for deployment response - Reduce timer from 30s to 10s for faster test execution Benefits: - Future-proof tests against CAMARA specification changes - Improved error messages when schema validation fails - Cleaner, more maintainable test code - Combines schema compliance with logical business validation - Ensures all API responses are CAMARA-compliant --- tests/edgecloud/test_e2e.py | 83 ++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index d211819..23137ce 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -101,14 +101,27 @@ def test_config_camara_compliance(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_edge_cloud_zones(edgecloud_client): + config = CONFIG[edgecloud_client.client_name] try: response = edgecloud_client.get_edge_cloud_zones() assert isinstance(response, Response) assert response.status_code == 200 zones = response.json() assert isinstance(zones, list) + + # CAMARA schema validation for each zone + validated_zones = [] for zone in zones: - camara_schemas.EdgeCloudZone(**zone) + validated_zone = camara_schemas.EdgeCloudZone(**zone) + validated_zones.append(validated_zone) + + # Logical validation: verify our expected zone is in the list + expected_zone_id = config["ZONE_ID"] + found_expected_zone = any( + str(zone.edgeCloudZoneId.root) == expected_zone_id for zone in validated_zones + ) + assert found_expected_zone, f"Expected zone {expected_zone_id} not found in returned zones" + except EdgeCloudPlatformError as e: pytest.fail(f"Failed to retrieve zones: {e}") except Exception as e: @@ -144,8 +157,10 @@ def test_onboard_app(edgecloud_client): payload = response.json() assert isinstance(payload, dict) - assert "appId" in payload - camara_schemas.AppId(root=payload["appId"]) + + # Use CAMARA schema validation for submitted app response + submitted_app = camara_schemas.SubmittedApp(**payload) + assert submitted_app.appId.root == config["APP_ID"] except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed: {e}") @@ -170,11 +185,15 @@ def app_instance_id(edgecloud_client): response_data = response.json() - # CAMARA spec: response contains appInstances array + # Use CAMARA schema validation for deployment response assert "appInstances" in response_data assert isinstance(response_data["appInstances"], list) assert len(response_data["appInstances"]) > 0 + # Validate each app instance with CAMARA schema + for instance_data in response_data["appInstances"]: + camara_schemas.AppInstanceInfo(**instance_data) + # Extract appInstanceId from first instance app_instance_id = response_data["appInstances"][0].get("appInstanceId") @@ -208,11 +227,9 @@ def test_get_onboarded_app(edgecloud_client): assert isinstance(app_data, dict) assert "appManifest" in app_data - app_manifest = app_data["appManifest"] - assert app_manifest["appId"] == app_id - assert "name" in app_manifest - assert "version" in app_manifest - assert "appProvider" in app_manifest + # Use CAMARA schema validation instead of manual checks + app_manifest = camara_schemas.AppManifest(**app_data["appManifest"]) + assert app_manifest.appId.root == app_id except EdgeCloudPlatformError as e: pytest.fail(f"Failed to get onboarded app: {e}") @@ -221,6 +238,7 @@ def test_get_onboarded_app(edgecloud_client): @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_all_onboarded_apps(edgecloud_client): """Test retrieving all onboarded applications""" + config = CONFIG[edgecloud_client.client_name] try: response = edgecloud_client.get_all_onboarded_apps() assert isinstance(response, Response) @@ -229,16 +247,16 @@ def test_get_all_onboarded_apps(edgecloud_client): apps_data = response.json() assert isinstance(apps_data, list) - # Verify each app has required CAMARA fields - for app_manifest in apps_data: - assert "appId" in app_manifest - assert "name" in app_manifest - assert "version" in app_manifest - assert "appProvider" in app_manifest - assert "packageType" in app_manifest - assert "appRepo" in app_manifest - assert "requiredResources" in app_manifest - assert "componentSpec" in app_manifest + # CAMARA schema validation for each app manifest + validated_apps = [] + for app_manifest_data in apps_data: + validated_app = camara_schemas.AppManifest(**app_manifest_data) + validated_apps.append(validated_app) + + # Logical validation: verify our onboarded app is in the list + expected_app_id = config["APP_ID"] + found_expected_app = any(str(app.appId.root) == expected_app_id for app in validated_apps) + assert found_expected_app, f"Expected app {expected_app_id} not found in onboarded apps" except EdgeCloudPlatformError as e: pytest.fail(f"Failed to get all onboarded apps: {e}") @@ -257,14 +275,13 @@ def test_get_all_deployed_apps(edgecloud_client): assert "appInstances" in instances_data assert isinstance(instances_data["appInstances"], list) - # Verify each instance has required CAMARA fields - for instance in instances_data["appInstances"]: - assert "name" in instance - assert "appId" in instance - assert "appInstanceId" in instance - assert "appProvider" in instance - assert "status" in instance - assert "edgeCloudZoneId" in instance + # CAMARA schema validation for each app instance + validated_instances = [] + for instance_data in instances_data["appInstances"]: + validated_instance = camara_schemas.AppInstanceInfo(**instance_data) + validated_instances.append(validated_instance) + + # TODO: validate that the newly created app instance is in the list except EdgeCloudPlatformError as e: pytest.fail(f"Failed to get all deployed apps: {e}") @@ -282,13 +299,11 @@ def test_get_deployed_app(edgecloud_client, app_instance_id): assert isinstance(instance_data, dict) assert "appInstance" in instance_data - instance = instance_data["appInstance"] - assert instance["appInstanceId"] == app_instance_id - assert "name" in instance - assert "appId" in instance - assert "appProvider" in instance - assert "status" in instance - assert "edgeCloudZoneId" in instance + # Use CAMARA schema validation for the app instance + app_instance = camara_schemas.AppInstanceInfo(**instance_data["appInstance"]) + assert app_instance.appInstanceId.root == app_instance_id + + # TODO: validate that we can get the newly created app except EdgeCloudPlatformError as e: pytest.fail(f"Failed to get deployed app: {e}") -- GitLab From a83f10ba881a5907d88578d72faffde327461b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 4 Aug 2025 10:28:31 +0200 Subject: [PATCH 263/281] Update Edge Application Management openapi version to the one agreed to be used --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ae0274..f1494dc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge | API Name | Version | |---------------------------|---------| -| Edge Application Management | [v0.9.3-wip (Commit: e4f0e8b)](https://github.com/camaraproject/EdgeCloud/blob/e4f0e8b2e5c75eb2003b8b145c68352087b48e26/code/API_definitions/Edge-Application-Management.yaml) | +| Edge Application Management | [v0.9.3-wip (commit: 79aa595)](https://github.com/camaraproject/EdgeCloud/blob/79aa5951d64391422ea5b861ab617a9540386092/code/API_definitions/Edge-Application-Management.yaml) | | Quality-on-Demand | [v1.0.0](https://raw.githubusercontent.com/camaraproject/QualityOnDemand/refs/tags/r2.2/code/API_definitions/quality-on-demand.yaml) | | Location Retrieval | [v0.4.0](https://raw.githubusercontent.com/camaraproject/DeviceLocation/refs/tags/r2.2/code/API_definitions/location-retrieval.yaml) | | Traffic Influence | [v0.8.1](https://raw.githubusercontent.com/camaraproject/EdgeCloud/v0.8.1/code/API_definitions/Traffic_Influence.yaml) | -- GitLab From 67fadfa24e6d7bbeea2b497b7f459bbebe8baca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 4 Aug 2025 11:49:39 +0200 Subject: [PATCH 264/281] Update e2e test. Fix appInstance issue in deploy task --- tests/edgecloud/test_e2e.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/edgecloud/test_e2e.py b/tests/edgecloud/test_e2e.py index 23137ce..382a680 100644 --- a/tests/edgecloud/test_e2e.py +++ b/tests/edgecloud/test_e2e.py @@ -176,26 +176,20 @@ def app_instance_id(edgecloud_client): deploy_payload = config["APP_DEPLOY_PAYLOAD"] app_id = deploy_payload["appId"] app_zones = deploy_payload["appZones"] + + # edgecloud_client.deploy_app maps with CAMARA POST /appinstances response = edgecloud_client.deploy_app(app_id, app_zones) assert isinstance(response, Response) - - # All CAMARA-compliant adapters should return 202 for async deployment - assert response.status_code == 202 + assert ( + response.status_code == 202 + ), f"Expected 202, got {response.status_code}: {response.text}" response_data = response.json() + instance_info = camara_schemas.AppInstanceInfo(**response_data) - # Use CAMARA schema validation for deployment response - assert "appInstances" in response_data - assert isinstance(response_data["appInstances"], list) - assert len(response_data["appInstances"]) > 0 - - # Validate each app instance with CAMARA schema - for instance_data in response_data["appInstances"]: - camara_schemas.AppInstanceInfo(**instance_data) - - # Extract appInstanceId from first instance - app_instance_id = response_data["appInstances"][0].get("appInstanceId") + # Extract appInstanceId from the validated object + app_instance_id = instance_info.appInstanceId.root assert app_instance_id is not None yield app_instance_id -- GitLab From 506a1065fe3cc0c3949b74a5505fe7760fb7624e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pino?= Date: Mon, 4 Aug 2025 11:50:10 +0200 Subject: [PATCH 265/281] Update i2edge client deploy_app to return object insted of list --- .../edgecloud/adapters/i2edge/client.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index e4475b9..83422ea 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -109,7 +109,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # ------------------------------------------------------------------------ # Artefact Management (i2Edge-Specific, Non-CAMARA) # ------------------------------------------------------------------------ - # All artefact methods now return Response objects for API consistency def create_artefact( self, @@ -380,7 +379,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): name=app_metadata.get("appName", ""), version=app_metadata.get("version", ""), appProvider=profile_data.get("appProviderId", ""), - # Hardcoding mandatory fields that don't exist in i2Edge + # Hardcoding mandatory fields that doesn't exist in i2Edge packageType="CONTAINER", appRepo={"type": "PUBLICREPO", "imagePath": "not-available"}, requiredResources={ @@ -462,18 +461,22 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appId=camara_schemas.AppId(appId), appInstanceId=camara_schemas.AppInstanceId(app_instance_id), appProvider=camara_schemas.AppProvider(appProviderId), - status=camara_schemas.Status.instantiating, # 202 means deployment is in progress + status=camara_schemas.Status.instantiating, edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), ) # CAMARA spec requires appInstances array wrapper - camara_response = {"appInstances": [app_instance_info.model_dump(mode="json")]} + camara_response = app_instance_info.model_dump(mode="json") + + # Add mandatory Location header + location_url = f"/appinstances/{app_instance_id}" + camara_headers = {"Content-Type": "application/json", "Location": location_url} log.info("App deployment request submitted successfully") return build_custom_http_response( status_code=i2edge_response.status_code, content=camara_response, - headers={"Content-Type": "application/json"}, + headers=camara_headers, encoding="utf-8", url=i2edge_response.url, request=i2edge_response.request, @@ -541,7 +544,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), status=camara_schemas.Status( camara_status - ), # Map the i2Edge "DEPLOYED" status to the CAMARA "ready" status for consistency with CAMARA specifications. + ), # FIX: Map DEPLOYED -> ready edgeCloudZoneId=camara_schemas.EdgeCloudZoneId( zone_id ), # FIX: Extract from nodeSelector -- GitLab From 0f6d73eab89a6992e8abe5e43711de6e22ac279b Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Mon, 4 Aug 2025 18:52:48 +0200 Subject: [PATCH 266/281] feature/add-gsma-schemas-to-edgecloud-adapters: add schemas for onboard, artefact and deploy --- .../edgecloud/adapters/aeros/client.py | 2 +- .../edgecloud/adapters/i2edge/client.py | 430 ++++++++++-------- .../edgecloud/adapters/kubernetes/client.py | 2 +- .../edgecloud/core/edgecloud_interface.py | 2 +- .../edgecloud/core/gsma_schemas.py | 97 +++- 5 files changed, 334 insertions(+), 199 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index da1670a..c287136 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -549,7 +549,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 667240b..751eb52 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -735,25 +735,24 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = f"{self.base_url}/zones/list" params = {} try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - response_json = response.json() - try: - validated_data = gsma_schemas.ZonesList.model_validate(response_json) - except ValidationError as e: - raise ValueError(f"Response from /zones/list is not a valid schema: {e}") + response = i2edge_get(url, params=params, expected_status=200) + response_json = response.json() + try: + validated_data = gsma_schemas.ZonesList.model_validate(response_json) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") - return build_custom_http_response( - status_code=200, - content=[zone.model_dump() for zone in validated_data.root], - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response + return build_custom_http_response( + status_code=200, + content=[zone.model_dump_json() for zone in validated_data.root], + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to obtain Zones list from i2edge: {e}") + raise def get_edge_cloud_zones_gsma(self) -> Response: """ @@ -764,25 +763,24 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = f"{self.base_url}/zones" params = {} try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - response_json = response.json() - mapped = [map_zone(zone) for zone in response_json] - try: - validated_data = gsma_schemas.ZoneRegisteredDataList.model_validate(mapped) - except ValidationError as e: - raise ValueError(f"Invalid response schema from /zones: {e}") - return build_custom_http_response( - status_code=200, - content=validated_data.model_dump(), - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response + response = i2edge_get(url, params=params, expected_status=200) + response_json = response.json() + mapped = [map_zone(zone) for zone in response_json] + try: + validated_data = gsma_schemas.ZoneRegisteredDataList.model_validate(mapped) + except ValidationError as e: + raise ValueError(f"Invalid schema {e}") + return build_custom_http_response( + status_code=200, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to obtain Zones details from i2edge: {e}") + raise def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Response: """ @@ -795,25 +793,24 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): url = f"{self.base_url}/zone/{zone_id}" params = {} try: - response = i2edge_get(url, params=params) - if response.status_code == 200: - response_json = response.json() - mapped = map_zone(response_json) - try: - validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) - except ValidationError as e: - raise ValueError(f"Invalid response schema from /zones/{zone_id}: {e}") - return build_custom_http_response( - status_code=200, - content=validated_data.model_dump(), - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response + response = i2edge_get(url, params=params, expected_status=200) + response_json = response.json() + mapped = map_zone(response_json) + try: + validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") + return build_custom_http_response( + status_code=200, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) except I2EdgeError as e: - raise e + log.error(f"Failed to obtain Zones details from i2edge: {e}") + raise # ------------------------------------------------------------------------ # Artefact Management (GSMA) @@ -836,8 +833,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): transformed = { "artefact_id": artefact_id, "artefact_name": artefact_name, - "repo_name": repo_data.get("repoName", "unknown-repo"), - "repo_type": request_body.get("repoType", "PUBLICREPO"), + "repo_name": repo_data.get("repoName"), + "repo_type": request_body.get("repoType"), "repo_url": repo_data["repoURL"], "user_name": repo_data.get("userName"), "password": repo_data.get("password"), @@ -855,8 +852,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=response.request, ) return response - except KeyError as e: - raise I2EdgeError(f"Missing required field in GSMA artefact payload: {e}") + except I2EdgeError as e: + log.error(f"Failed to create artefact: {e}") + raise def get_artefact_gsma(self, artefact_id: str) -> Response: """ @@ -869,36 +867,40 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = self.get_artefact(artefact_id) if response.status_code == 200: response_json = response.json() - print(response_json) - content = { - "artefactId": response_json.get("artefact_id"), - "appProviderId": "Ihs0gCqO65SHTz", - "artefactName": response_json.get("name"), - "artefactDescription": "string", - "artefactVersionInfo": response_json.get("version"), - "artefactVirtType": "VM_TYPE", - "artefactFileName": "stringst", - "artefactFileFormat": "ZIP", - "artefactDescriptorType": "HELM", - "repoType": response_json.get("repo_type"), - "artefactRepoLocation": { - "repoURL": response_json.get("repo_url"), - "userName": response_json.get("repo_user_name"), - "password": response_json.get("repo_password"), - "token": response_json.get("repo_token"), - }, - } + content = gsma_schemas.Artefact( + artefactId=response_json.get("artefact_id"), + appProviderId=response_json.get("id"), + artefactName=response_json.get("name"), + artefactDescription="Description", + artefactVersionInfo=response_json.get("version"), + artefactVirtType="VM_TYPE", + artefactFileName="FileName", + artefactFileFormat="TAR", + artefactDescriptorType="HELM", + repoType=response_json.get("repo_type"), + artefactRepoLocation=gsma_schemas.ArtefactRepoLocation( + repoURL=response_json.get("repo_url"), + userName=response_json.get("repo_user_name"), + password=response_json.get("repo_password"), + token=response_json.get("repo_token"), + ), + ) + try: + validated_data = gsma_schemas.Artefact.model_validate(content) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=content, + content=validated_data.model_dump_json(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, request=response.request, ) return response - except KeyError as e: - raise I2EdgeError(f"Missing artefactId in GSMA payload: {e}") + except I2EdgeError as e: + log.error(f"Failed to retrieve artefact: {e}") + raise def delete_artefact_gsma(self, artefact_id: str) -> Response: """ @@ -942,19 +944,18 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): data = body payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) url = "{}/application/onboarding".format(self.base_url) - response = i2edge_post(url, payload) - if response.status_code == 201: - return build_custom_http_response( - status_code=200, - content={"response": "Application onboarded successfully"}, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Missing required field in GSMA onboarding payload: {e}") + response = i2edge_post(url, payload, expected_status=201) + return build_custom_http_response( + status_code=200, + content={"response": "Application onboarded successfully"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to onboard app: {e}") + raise def get_onboarded_app_gsma(self, app_id: str) -> Response: """ @@ -963,30 +964,58 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param app_id: Identifier of the application onboarded. :return: Response with application details. """ + url = f"{self.base_url}/application/onboarding/{app_id}" + params = {} try: - response = self.get_onboarded_app(app_id) - if response.status_code == 200: - response_json = response.json() - profile_data = response_json.get("profile_data") - content = { - "appId": profile_data.get("app_id"), - "appProviderId": "string", - "appDeploymentZones": profile_data.get("appDeploymentZones"), - "appMetaData": profile_data.get("appMetadata"), - "appQoSProfile": profile_data.get("appQoSProfile"), - "appComponentSpecs": profile_data.get("appComponentSpecs"), - } - return build_custom_http_response( - status_code=200, - content=content, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Missing appId in GSMA payload: {e}") + response = i2edge_get(url, params, expected_status=200) + response_json = response.json() + profile_data = response_json.get("profile_data") + app_deployment_zones = profile_data.get("appDeploymentZones") + app_metadata = profile_data.get("appMetaData") + app_qos_profile = profile_data.get("appQoSProfile") + app_component_specs = profile_data.get("appComponentSpecs") + content = gsma_schemas.ApplicationModel( + appId=profile_data.get("app_id"), + appProviderId="from_FM", + appDeploymentZones=[ + gsma_schemas.AppDeploymentZone(countryCode="ES", zoneInfo=zone_id) + for zone_id in app_deployment_zones + ], + appMetaData=gsma_schemas.AppMetaData( + appName=app_metadata.get("appName"), + version=app_metadata.get("version"), + appDescription=app_metadata.get("appDescription"), + mobilitySupport=app_metadata.get("mobilitySupport"), + accessToken=app_metadata.get("accessToken"), + category=app_metadata.get("category"), + ), + appQoSProfile=gsma_schemas.AppQoSProfile( + latencyConstraints=app_qos_profile.get("latencyConstraints"), + bandwidthRequired=app_qos_profile.get("bandwidthRequired"), + multiUserClients=app_qos_profile.get("multiUserClients"), + noOfUsersPerAppInst=app_qos_profile.get("noOfUsersPerAppInst"), + appProvisioning=app_qos_profile.get("appProvisioning"), + ), + appComponentSpecs=[ + gsma_schemas.AppComponentSpec(**component) for component in app_component_specs + ], + onboardStatusInfo="ONBOARDED", + ) + try: + validated_data = gsma_schemas.ApplicationModel.model_validate(content) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") + return build_custom_http_response( + status_code=200, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to get onboarded app: {e}") + raise def patch_onboarded_app_gsma(self, app_id: str, request_body: dict) -> Response: """ @@ -1018,8 +1047,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=response.request, ) return response - except KeyError as e: - raise I2EdgeError(f"Missing appId in GSMA payload: {e}") + except I2EdgeError as e: + log.error(f"Failed to delete onboarded app: {e}") + raise # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) @@ -1044,24 +1074,28 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) url = "{}/application_instance".format(self.base_url) - response = i2edge_post(url, payload) - if response.status_code == 202: - response_json = response.json() - content = { - "zoneId": response_json.get("zoneID"), - "appInstIdentifier": response_json.get("app_instance_id"), - } - return build_custom_http_response( - status_code=202, - content=content, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Missing required field in GSMA deployment payload: {e}") + response = i2edge_post(url, payload, expected_status=202) + + response_json = response.json() + content = gsma_schemas.AppInstance( + zoneId=response_json.get("zoneID"), + appInstIdentifier=response_json.get("app_instance_id"), + ) + try: + validated_data = gsma_schemas.AppInstance.model_validate(content) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") + return build_custom_http_response( + status_code=202, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to deploy app: {e}") + raise def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ @@ -1075,26 +1109,31 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: url = "{}/application_instance/{}/{}".format(self.base_url, zone_id, app_instance_id) params = {} - response = i2edge_get(url, params=params) - if response.status_code == 200: - response_json = response.json() - content = { - "appInstanceState": response_json.get("appInstanceState"), - "accesspointInfo": response_json.get("accesspointInfo"), - } - return build_custom_http_response( - status_code=200, - content=content, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Missing appId or zoneId in GSMA payload: {e}") + response = i2edge_get(url, params=params, expected_status=200) - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Response: + response_json = response.json() + content = gsma_schemas.AppInstanceStatus( + appInstanceState=response_json.get("appInstanceState"), + accesspointInfo=response_json.get("accesspointInfo"), + ) + try: + validated_data = gsma_schemas.AppInstanceStatus.model_validate(content) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") + return build_custom_http_response( + status_code=200, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + + except I2EdgeError as e: + log.error(f"Failed to retrieve deployed app: {e}") + raise + + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP using GSMA federation. @@ -1105,36 +1144,38 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: url = "{}/application_instances".format(self.base_url) params = {} - response = i2edge_get(url, params=params) - if response.status_code == 200: - response_json = response.json() - response_list = [] - for item in response_json: - content = [ + response = i2edge_get(url, params=params, expected_status=200) + response_json = response.json() + response_list = [] + for item in response_json: + content = { + "zoneId": item.get("app_spec") + .get("nodeSelector") + .get("feature.node.kubernetes.io/zoneID"), + "appInstanceInfo": [ { - "zoneId": item.get("app_spec") - .get("nodeSelector") - .get("feature.node.kubernetes.io/zoneID"), - "appInstanceInfo": [ - { - "appInstIdentifier": item.get("app_instance_id"), - "appInstanceState": item.get("deploy_status"), - } - ], + "appInstIdentifier": item.get("app_instance_id"), + "appInstanceState": item.get("deploy_status"), } - ] - response_list.append(content) - return build_custom_http_response( - status_code=200, - content=response_list, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Error retrieving apps: {e}") + ], + } + + response_list.append(content) + try: + validated_data = gsma_schemas.ZoneIdentifierList.model_validate(response_list) + except ValidationError as e: + raise ValueError(f"Invalid schema: {e}") + return build_custom_http_response( + status_code=200, + content=validated_data.model_dump_json(), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to retrieve apps: {e}") + raise def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ @@ -1147,16 +1188,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ try: url = "{}/application_instance".format(self.base_url) - response = i2edge_delete(url, app_instance_id) - if response.status_code == 200: - return build_custom_http_response( - status_code=200, - content={"response": "Application instance termination request accepted"}, - headers={"Content-Type": self.content_type_gsma}, - encoding=self.encoding_gsma, - url=response.url, - request=response.request, - ) - return response - except KeyError as e: - raise I2EdgeError(f"Missing appInstanceId in GSMA payload: {e}") + response = i2edge_delete(url, app_instance_id, expected_status=200) + return build_custom_http_response( + status_code=200, + content={"response": "Application instance termination request accepted"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to delete app: {e}") + raise diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py index 0a488cf..cad7030 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/kubernetes/client.py @@ -389,7 +389,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP diff --git a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py index 31a5ad9..302f7c2 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py +++ b/src/sunrise6g_opensdk/edgecloud/core/edgecloud_interface.py @@ -278,7 +278,7 @@ class EdgeCloudManagementInterface(ABC): pass @abstractmethod - def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> Response: + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP diff --git a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py index 0d73b27..e7d610b 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py @@ -1,6 +1,6 @@ from typing import List, Literal, Optional -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field, HttpUrl, RootModel # --------------------------- # FederationManagement @@ -121,11 +121,106 @@ class ZoneRegisteredDataList(RootModel[List[ZoneRegisteredData]]): # --------------------------- +class ArtefactRepoLocation(BaseModel): + repoURL: HttpUrl + userName: Optional[str] = None + password: Optional[str] = None + token: Optional[str] = None + + +class Artefact(BaseModel): + artefactId: str + appProviderId: str = None + artefactName: str + artefactDescription: Optional[str] = None + artefactVersionInfo: str + artefactVirtType: Literal["VM_TYPE", "CONTAINER_TYPE"] + artefactFileName: Optional[str] = None + artefactFileFormat: Optional[Literal["ZIP", "TAR", "TEXT", "TARGZ"]] = None + artefactDescriptorType: Literal["HELM", "TERRAFORM", "ANSIBLE", "SHELL", "COMPONENTSPEC"] + repoType: Optional[Literal["PRIVATEREPO", "PUBLICREPO", "UPLOAD"]] = None + artefactRepoLocation: Optional[ArtefactRepoLocation] = None + + # --------------------------- # ApplicationOnboardingManagement # --------------------------- +class AppDeploymentZone(BaseModel): + countryCode: str + zoneInfo: str + + +class AppMetaData(BaseModel): + appName: str + version: str + appDescription: Optional[str] = None + mobilitySupport: bool = False + accessToken: str + category: Optional[ + Literal[ + "IOT", + "HEALTH_CARE", + "GAMING", + "VIRTUAL_REALITY", + "SOCIALIZING", + "SURVEILLANCE", + "ENTERTAINMENT", + "CONNECTIVITY", + "PRODUCTIVITY", + "SECURITY", + "INDUSTRIAL", + "EDUCATION", + "OTHERS", + ] + ] = None + + +class AppQoSProfile(BaseModel): + latencyConstraints: Literal["NONE", "LOW", "ULTRALOW"] + bandwidthRequired: int = Field(..., ge=1) + multiUserClients: Literal["APP_TYPE_SINGLE_USER", "APP_TYPE_MULTI_USER"] + noOfUsersPerAppInst: int = 1 + appProvisioning: bool = True + + +class AppComponentSpec(BaseModel): + serviceNameNB: str + serviceNameEW: str + componentName: str + artefactId: str + + +class ApplicationModel(BaseModel): + appId: str + appProviderId: str + appDeploymentZones: List[AppDeploymentZone] = Field(..., min_length=1) + appMetaData: AppMetaData + appQoSProfile: AppQoSProfile + appComponentSpecs: List[AppComponentSpec] = Field(..., min_length=1) + onboardStatusInfo: Literal["PENDING", "ONBOARDED", "DEBOARDING", "REMOVED", "FAILED"] + + # --------------------------- # ApplicationDeploymentManagement # --------------------------- + + +class AppInstance(BaseModel): + zoneId: str + appInstIdentifier: str + + +class AppInstanceStatus(BaseModel): + appInstanceState: Literal["PENDING", "READY", "FAILED", "TERMINATING", "DEPLOYED"] + accesspointInfo: List[dict] + + +class ZoneIdentifier(BaseModel): + zoneId: str + appInstanceInfo: List[dict] + + +class ZoneIdentifierList(RootModel[List[ZoneIdentifier]]): + pass -- GitLab From c78a4fc2f0a640d23d6d5b070d90b98012587352 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Tue, 5 Aug 2025 13:44:34 +0200 Subject: [PATCH 267/281] feature/add-gsma-schemas-to-edgecloud-adapters: fix patch method --- .../edgecloud/adapters/i2edge/client.py | 26 ++++++++++++++++++- .../edgecloud/adapters/i2edge/common.py | 17 ++++++++---- .../edgecloud/adapters/i2edge/schemas.py | 1 + 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 751eb52..52fe843 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -27,6 +27,7 @@ from .common import ( I2EdgeError, i2edge_delete, i2edge_get, + i2edge_patch, i2edge_post, i2edge_post_multiform_data, ) @@ -1026,7 +1027,30 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with updated onboarding info. :return: Response with update confirmation. """ - pass + url = f"{self.base_url}/application/onboarding/{app_id}" + params = {} + response = i2edge_get(url, params, expected_status=200) + response_json = response.json() + app_component_specs = request_body.get("appComponents") + app_qos_profile = request_body.get("appUpdQoSProfile") + response_json["profile_data"]["appQoSProfile"] = app_qos_profile + response_json["profile_data"]["appComponentSpecs"] = app_component_specs + data = response_json.get("profile_data") + try: + payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) + url = "{}/application/onboarding/{}".format(self.base_url, app_id) + response = i2edge_patch(url, payload, expected_status=200) + return build_custom_http_response( + status_code=200, + content={"response": "Application update successful"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=response.url, + request=response.request, + ) + except I2EdgeError as e: + log.error(f"Failed to patch onboarded app: {e}") + raise def delete_onboarded_app_gsma(self, app_id: str) -> Response: """ diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py index 0d193fd..c1d6505 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/common.py @@ -66,16 +66,23 @@ def i2edge_post(url: str, model_payload: BaseModel, expected_status: int = 201) raise I2EdgeError(err_msg) -def i2edge_put(url: str, model_payload: BaseModel) -> dict: +def i2edge_patch(url: str, model_payload: BaseModel, expected_status: int = 200) -> dict: headers = { "Content-Type": "application/json", "accept": "application/json", } - json_payload = json.dumps(model_payload.model_dump(mode="json")) + json_payload = json.dumps(model_payload.model_dump(exclude_unset=True, mode="json")) try: - response = requests.put(url, data=json_payload, headers=headers) - response.raise_for_status() - return response + response = requests.patch(url, data=json_payload, headers=headers) + if response.status_code == expected_status: + return response + else: + i2edge_err_msg = get_error_message_from(response) + err_msg = "Failed to patch: Expected status {}, got {}. Detail: {}".format( + expected_status, response.status_code, i2edge_err_msg + ) + log.error(err_msg) + raise I2EdgeError(err_msg) except requests.exceptions.HTTPError as e: i2edge_err_msg = get_error_message_from(response) err_msg = "Failed to patch: {}. Detail: {}".format(i2edge_err_msg, e) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py index 3e7c511..7573ea2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/schemas.py @@ -122,6 +122,7 @@ class AppQoSProfile(BaseModel): appProvisioning: bool = Field(default=True) bandwidthRequired: int = Field(default=1) latencyConstraints: str = Field(default="NONE") + mobilitySupport: Optional[bool] = None multiUserClients: str = Field(default="APP_TYPE_SINGLE_USER") noOfUsersPerAppInst: int = Field(default=1) -- GitLab From 4f1977c9409f4eee979cc7d5ff594cfd7ec43487 Mon Sep 17 00:00:00 2001 From: Adrian Pino <48448195+adrian-pino@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:05:09 +0200 Subject: [PATCH 268/281] Set appProviderId to be Optional Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py index e7d610b..4d78965 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py @@ -130,7 +130,7 @@ class ArtefactRepoLocation(BaseModel): class Artefact(BaseModel): artefactId: str - appProviderId: str = None + appProviderId: Optional[str] = None artefactName: str artefactDescription: Optional[str] = None artefactVersionInfo: str -- GitLab From 3b50a1cf87b026e019a494a518a2c6fdd7b7f0a3 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Wed, 6 Aug 2025 09:28:49 +0200 Subject: [PATCH 269/281] feature/add-gsma-schemas-to-edgecloud-adapters: fix str json to dict --- .../edgecloud/adapters/i2edge/client.py | 18 +++++++++--------- .../edgecloud/core/gsma_schemas.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index b066291..762112d 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -748,7 +748,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): return build_custom_http_response( status_code=200, - content=[zone.model_dump_json() for zone in validated_data.root], + content=[zone.model_dump() for zone in validated_data.root], headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -776,7 +776,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -806,7 +806,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -837,7 +837,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): transformed = { "artefact_id": artefact_id, "artefact_name": artefact_name, - "repo_name": repo_data.get("repoName"), + "repo_name": repo_data.get("repoName", ""), "repo_type": request_body.get("repoType"), "repo_url": repo_data["repoURL"], "user_name": repo_data.get("userName"), @@ -895,7 +895,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -1011,7 +1011,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -1114,7 +1114,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=202, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -1149,7 +1149,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, @@ -1194,7 +1194,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( status_code=200, - content=validated_data.model_dump_json(), + content=validated_data.model_dump(), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=response.url, diff --git a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py index 4d78965..c2ac41a 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py @@ -1,6 +1,6 @@ from typing import List, Literal, Optional -from pydantic import BaseModel, Field, HttpUrl, RootModel +from pydantic import BaseModel, Field, RootModel # --------------------------- # FederationManagement @@ -122,7 +122,7 @@ class ZoneRegisteredDataList(RootModel[List[ZoneRegisteredData]]): class ArtefactRepoLocation(BaseModel): - repoURL: HttpUrl + repoURL: str userName: Optional[str] = None password: Optional[str] = None token: Optional[str] = None -- GitLab From defa05f267eaa5627891360c32cdcc7ad67b188d Mon Sep 17 00:00:00 2001 From: Adriana Fernandez Date: Wed, 6 Aug 2025 14:45:01 +0200 Subject: [PATCH 270/281] Fix request body parameter in update app method --- src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 762112d..336fc34 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -1034,7 +1034,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): params = {} response = i2edge_get(url, params, expected_status=200) response_json = response.json() - app_component_specs = request_body.get("appComponents") + app_component_specs = request_body.get("appComponentSpecs") app_qos_profile = request_body.get("appUpdQoSProfile") response_json["profile_data"]["appQoSProfile"] = app_qos_profile response_json["profile_data"]["appComponentSpecs"] = app_component_specs -- GitLab From 4da30a4b847261e2d24fdffcf686c2ec4e1da5f0 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Wed, 15 Oct 2025 12:09:48 +0200 Subject: [PATCH 271/281] update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 319623b..e626c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sunrise6g-opensdk" -version = "1.0.7" +version = "1.0.11" description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions." keywords = [ "Federation", -- GitLab From b1540edd7b9705f00ee51a34153aa3d03b8c1733 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Wed, 15 Oct 2025 14:50:34 +0200 Subject: [PATCH 272/281] hotfix/gsma-schemas-and-methods-refactor: use pydantic schemas to check input gsma payloads --- .../edgecloud/adapters/i2edge/client.py | 109 +++++++++++++----- .../edgecloud/core/gsma_schemas.py | 31 ++++- 2 files changed, 107 insertions(+), 33 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 70fa247..31a4253 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -8,7 +8,6 @@ # - César Cajas (cesar.cajas@i2cat.net) ## import json -from copy import deepcopy from typing import Dict, List, Optional from pydantic import ValidationError @@ -798,6 +797,23 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: response = i2edge_get(url, params=params, expected_status=200) response_json = response.json() + # TODO: fix malformed GPU field in i2Edge, it should be a list of objects, not strings + # --- Quick fix for malformed GPU entries --- + quota_limits = response_json.get("computeResourceQuotaLimits", []) + for item in quota_limits: + if isinstance(item, dict) and isinstance(item.get("gpu"), list): + fixed_gpu = [] + for g in item["gpu"]: + if isinstance(g, str): + try: + # Convert single quotes to double quotes for valid JSON + fixed_gpu.append(json.loads(g.replace("'", '"'))) + except json.JSONDecodeError: + continue # ignore invalid entries + else: + fixed_gpu.append(g) + item["gpu"] = fixed_gpu + # --- End quick fix --- mapped = map_zone(response_json) try: validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) @@ -829,15 +845,23 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: Response with artefact upload confirmation. """ try: - artefact_id = request_body["artefactId"] - artefact_name = request_body["artefactName"] - repo_data = request_body["artefactRepoLocation"] + # Validate input body with GSMA schema + gsma_validated_body = gsma_schemas.Artefact.model_validate(request_body) + body = gsma_validated_body.model_dump() + except ValidationError as e: + log.error(f"Invalid GSMA artefact request body: {e}") + raise + + try: + artefact_id = body["artefactId"] + artefact_name = body["artefactName"] + repo_data = body["artefactRepoLocation"] transformed = { "artefact_id": artefact_id, "artefact_name": artefact_name, "repo_name": repo_data.get("repoName", ""), - "repo_type": request_body.get("repoType"), + "repo_type": body.get("repoType"), "repo_url": repo_data["repoURL"], "user_name": repo_data.get("userName"), "password": repo_data.get("password"), @@ -855,6 +879,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=response.request, ) return response + except I2EdgeError as e: log.error(f"Failed to create artefact: {e}") raise @@ -870,7 +895,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): response = self.get_artefact(artefact_id) if response.status_code == 200: response_json = response.json() - content = gsma_schemas.Artefact( + content = gsma_schemas.ArtefactRetrieve( artefactId=response_json.get("artefact_id"), appProviderId=response_json.get("id"), artefactName=response_json.get("name"), @@ -889,7 +914,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ), ) try: - validated_data = gsma_schemas.Artefact.model_validate(content) + validated_data = gsma_schemas.ArtefactRetrieve.model_validate(content) except ValidationError as e: raise ValueError(f"Invalid schema: {e}") return build_custom_http_response( @@ -940,13 +965,18 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with onboarding info. :return: Response with onboarding confirmation. """ - body = deepcopy(request_body) try: - body["app_id"] = body.pop("appId") - body.pop("edgeAppFQDN", None) - data = body + # Validate input against GSMA schema + gsma_validated_body = gsma_schemas.AppOnboardManifestGSMA.model_validate(request_body) + data = gsma_validated_body.model_dump() + except ValidationError as e: + log.error(f"Invalid GSMA input schema: {e}") + raise + try: + data["app_id"] = data.pop("appId") + data.pop("edgeAppFQDN", None) payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) - url = "{}/application/onboarding".format(self.base_url) + url = f"{self.base_url}/application/onboarding" response = i2edge_post(url, payload, expected_status=201) return build_custom_http_response( status_code=200, @@ -1029,18 +1059,26 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with updated onboarding info. :return: Response with update confirmation. """ - url = f"{self.base_url}/application/onboarding/{app_id}" - params = {} - response = i2edge_get(url, params, expected_status=200) - response_json = response.json() - app_component_specs = request_body.get("appComponentSpecs") - app_qos_profile = request_body.get("appUpdQoSProfile") - response_json["profile_data"]["appQoSProfile"] = app_qos_profile - response_json["profile_data"]["appComponentSpecs"] = app_component_specs - data = response_json.get("profile_data") try: + # Validate input body using GSMA schema + gsma_validated_body = gsma_schemas.PatchOnboardedAppGSMA.model_validate(request_body) + patch_payload = gsma_validated_body.model_dump() + except ValidationError as e: + log.error(f"Invalid GSMA input schema: {e}") + raise + try: + url = f"{self.base_url}/application/onboarding/{app_id}" + params = {} + response = i2edge_get(url, params, expected_status=200) + response_json = response.json() + # Update fields + app_component_specs = patch_payload.get("appComponentSpecs") + app_qos_profile = patch_payload.get("appUpdQoSProfile") + response_json["profile_data"]["appQoSProfile"] = app_qos_profile + response_json["profile_data"]["appComponentSpecs"] = app_component_specs + data = response_json.get("profile_data") payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) - url = "{}/application/onboarding/{}".format(self.base_url, app_id) + url = f"{self.base_url}/application/onboarding/{app_id}" response = i2edge_patch(url, payload, expected_status=200) return build_custom_http_response( status_code=200, @@ -1088,10 +1126,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with deployment information. :return: Response with deployment details. """ - body = deepcopy(request_body) try: - zone_id = body.get("zoneInfo").get("zoneId") - flavour_id = body.get("zoneInfo").get("flavourId") + # Validate input against GSMA schema + gsma_validated_body = gsma_schemas.AppDeployPayloadGSMA.model_validate(request_body) + body = gsma_validated_body.model_dump() + except ValidationError as e: + log.error(f"Invalid GSMA input schema: {e}") + raise + try: + zone_id = body.get("zoneInfo", {}).get("zoneId") + flavour_id = body.get("zoneInfo", {}).get("flavourId") app_deploy_data = i2edge_schemas.AppDeployData( appId=body.get("appId"), appProviderId=body.get("appProviderId"), @@ -1099,18 +1143,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zoneInfo=i2edge_schemas.ZoneInfoRef(flavourId=flavour_id, zoneId=zone_id), ) payload = i2edge_schemas.AppDeploy(app_deploy_data=app_deploy_data) - url = "{}/application_instance".format(self.base_url) + url = f"{self.base_url}/application_instance" response = i2edge_post(url, payload, expected_status=202) - response_json = response.json() - content = gsma_schemas.AppInstance( + # Validate response against GSMA schema + app_instance = gsma_schemas.AppInstance( zoneId=response_json.get("zoneID"), appInstIdentifier=response_json.get("app_instance_id"), ) - try: - validated_data = gsma_schemas.AppInstance.model_validate(content) - except ValidationError as e: - raise ValueError(f"Invalid schema: {e}") + validated_data = gsma_schemas.AppInstance.model_validate(app_instance) return build_custom_http_response( status_code=202, content=validated_data.model_dump(), @@ -1123,6 +1164,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): log.error(f"Failed to deploy app: {e}") raise + except ValidationError as e: + log.error(f"Invalid GSMA response schema: {e}") + raise + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Response: """ Retrieves an application instance details from partner OP using GSMA federation. diff --git a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py index 1a1a661..af2bd51 100644 --- a/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py +++ b/src/sunrise6g_opensdk/edgecloud/core/gsma_schemas.py @@ -128,7 +128,20 @@ class ArtefactRepoLocation(BaseModel): token: Optional[str] = None -class Artefact(BaseModel): +class ArtefactComponentSpec(BaseModel): + componentName: str + images: List[str] + numOfInstances: int + restartPolicy: str + commandLineParams: Optional[dict] = None + exposedInterfaces: Optional[List[dict]] = None + computeResourceProfile: Optional[dict] = None + compEnvParams: Optional[List[dict]] = None + deploymentConfig: Optional[dict] = None + persistentVolumes: Optional[List[dict]] = None + + +class ArtefactRetrieve(BaseModel): artefactId: str appProviderId: Optional[str] = None artefactName: str @@ -142,6 +155,22 @@ class Artefact(BaseModel): artefactRepoLocation: Optional[ArtefactRepoLocation] = None +class Artefact(BaseModel): + artefactId: str + appProviderId: str + artefactName: str + artefactVersionInfo: str + artefactDescription: Optional[str] = None + artefactVirtType: Literal["VM_TYPE", "CONTAINER_TYPE"] + artefactFileName: Optional[str] = None + artefactFileFormat: Optional[Literal["ZIP", "TAR", "TEXT", "TARGZ"]] = None + artefactDescriptorType: Literal["HELM", "TERRAFORM", "ANSIBLE", "SHELL", "COMPONENTSPEC"] + repoType: Optional[Literal["PRIVATEREPO", "PUBLICREPO", "UPLOAD"]] = None + artefactRepoLocation: Optional[ArtefactRepoLocation] = None + artefactFile: Optional[str] = None + componentSpec: List[ArtefactComponentSpec] + + # --------------------------- # ApplicationOnboardingManagement # --------------------------- -- GitLab From f15dab091c4fce8101162a401da81eda49c5b165 Mon Sep 17 00:00:00 2001 From: cesarcajas Date: Wed, 15 Oct 2025 15:28:45 +0200 Subject: [PATCH 273/281] hotfix/gsma-schemas-and-methods-refactor: test artefact fixed --- tests/edgecloud/test_e2e_gsma.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/edgecloud/test_e2e_gsma.py b/tests/edgecloud/test_e2e_gsma.py index 089d0dc..0a6039b 100644 --- a/tests/edgecloud/test_e2e_gsma.py +++ b/tests/edgecloud/test_e2e_gsma.py @@ -188,7 +188,7 @@ def test_get_artefact_gsma(edgecloud_client): assert isinstance(artefact, dict) # GSMA schema validation for artefact - validated_artefact = gsma_schemas.Artefact(**artefact) + validated_artefact = gsma_schemas.ArtefactRetrieve(**artefact) # Logical validation: verify our expected artefact_id is in the dict assert ( -- GitLab From d76ae3b8409acce583f0286163105aaaa71f868d Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Fri, 17 Oct 2025 11:41:27 +0300 Subject: [PATCH 274/281] Update aerOS adapter framework. Adapt aerOS-CAMARA responses. Implement edge-zones GSMA TFs. --- .../adapters/aeros/camara2aeros_converter.py | 124 +++ .../edgecloud/adapters/aeros/client.py | 926 ++++++++++++------ .../adapters/aeros/continuum_client.py | 10 +- .../adapters/aeros/continuum_models.py | 269 +++++ .../edgecloud/adapters/aeros/errors.py | 30 + .../aeros/storageManagement/__init__.py | 5 + .../storageManagement/appStorageManager.py | 69 ++ .../storageManagement/inMemoryStorage.py | 123 +++ .../aeros/storageManagement/sqlite_storage.py | 229 +++++ .../edgecloud/adapters/aeros/utils.py | 68 +- .../edgecloud/adapters/i2edge/client.py | 2 +- 11 files changed, 1509 insertions(+), 346 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py new file mode 100644 index 0000000..da9d83d --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py @@ -0,0 +1,124 @@ +''' +Module: converter.py +This module provides functions to convert application manifests into TOSCA models. +It includes the `generate_tosca` function that constructs a TOSCA model based on +the application manifest and associated app zones. +''' +from typing import List, Dict, Any +import yaml +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppManifest, VisibilityType +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, + Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, + NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + +logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + + +def generate_tosca(app_manifest: AppManifest, + app_zones: List[Dict[str, Any]]) -> str: + ''' + Generate a TOSCA model from the application manifest and app zones. + Args: + app_manifest (AppManifest): The application manifest containing details about the app. + app_zones (List[Dict[str, Any]]): List of app zones where the app will be deployed. + Returns: + TOSCA yaml as string which can be used in a POST request with applcation type yaml + ''' + component = app_manifest.componentSpec[0] + image_path = app_manifest.appRepo.imagePath.root + image_file = image_path.split("/")[-1] + repository_url = "/".join( + image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" + + zone_id = app_zones[0].get("EdgeCloudZone", + {}).get("edgeCloudZoneId", "default-zone") + logger.info("DEBUG : %s", app_manifest.requiredResources.root) + # Extract minNodeMemory (fallback = 1024 MB) + + res = app_manifest.requiredResources.root + if hasattr(res, "applicationResources") and hasattr( + res.applicationResources.cpuPool.topology, "minNodeMemory"): + min_node_memory = res.applicationResources.cpuPool.topology.minNodeMemory + else: + min_node_memory = 1024 + + # Build exposed network ports + ports = { + iface.interfaceId: + ExposedPort(properties=PortProperties( + protocol=[iface.protocol.value.lower()], source=iface.port)) + for iface in component.networkInterfaces + } + + expose_ports = any( + iface.visibilityType == VisibilityType.VISIBILITY_EXTERNAL + for iface in component.networkInterfaces) + + # Define host property constraints + host_props = HostProperty( + cpu_arch={"equal": "x64"}, + realtime={"equal": False}, + cpu_usage={"less_or_equal": "0.4"}, + mem_size={"greater_or_equal": str(min_node_memory)}, + energy_efficiency={"greater_or_equal": "0"}, + green={"greater_or_equal": "0"}, + domain_id=DomainIdOperator(equal=zone_id), + ) + + # Create Node compute and network requirements + requirements = [ + CustomRequirement(network=NetworkRequirement( + properties=NetworkProperties(ports=ports, + exposePorts=expose_ports))), + CustomRequirement(host=HostRequirement(node_filter=NodeFilter( + capabilities=[{ + "host": HostCapability(properties=host_props) + }], + properties=None))) + ] + # Define the NodeTemplate + node_template = NodeTemplate( + type="tosca.nodes.Container.Application", + isJob=False, + requirements=requirements, + artifacts={ + "application_image": + ArtifactModel( + file=image_file, + type="tosca.artifacts.Deployment.Image.Container.Docker", + repository=repository_url, + is_private=app_manifest.appRepo.type == "PRIVATEREPO", + username=app_manifest.appRepo.userName, + password=app_manifest.appRepo.credentials) + }, + interfaces={ + "Standard": { + "create": { + "implementation": "application_image", + "inputs": { + "cliArgs": [], + "envVars": [] + } + } + } + }) + + # Assemble full TOSCA object + tosca = TOSCA(tosca_definitions_version="tosca_simple_yaml_1_3", + description=f"TOSCA for {app_manifest.name}", + serviceOverlay=False, + node_templates={component.componentName: node_template}) + + tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + + for template in tosca_dict.get("node_templates", {}).values(): + template["requirements"] = [{ + k: v + for k, v in req.items() if v is not None + } for req in template.get("requirements", [])] + + yaml_str = yaml.dump(tosca_dict, sort_keys=False) + return yaml_str diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index c287136..660bdd3 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -6,32 +6,45 @@ # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## import uuid +import json from typing import Any, Dict, List, Optional - -import yaml +from collections import defaultdict +from pydantic import ValidationError from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage +from sunrise6g_opensdk.edgecloud.adapters.aeros import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( - EdgeCloudManagementInterface, -) + EdgeCloudManagementInterface, ) +from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response +from sunrise6g_opensdk.edgecloud.core import camara_schemas, gsma_schemas from sunrise6g_opensdk.logger import setup_logger class EdgeApplicationManager(EdgeCloudManagementInterface): """ - aerOS Continuum Client - FIXME: Handle None responses from continuum client + aerOS Edge Application Manager Adapter implementing CAMARA and GSMA APIs. """ - def __init__(self, base_url: str, **kwargs): + def __init__(self, + base_url: str, + storage: Optional[AppStorageManager] = None, + **kwargs): + ''' + storage can + ''' self.base_url = base_url - self.logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) - self._app_store: Dict[str, Dict] = {} - self._deployed_services: Dict[str, List[str]] = {} - self._stopped_services: Dict[str, List[str]] = {} + self.logger = setup_logger(__name__, + is_debug=True, + file_name=config.LOG_FILE) + self.content_type_gsma = "application/json" + self.encoding_gsma = "utf-8" + self.storage = storage or inMemoryStorage.InMemoryAppStorage() # Overwrite config values if provided via kwargs if "aerOS_API_URL" in kwargs: @@ -48,259 +61,69 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not config.aerOS_HLO_TOKEN: raise ValueError("Missing 'aerOS_HLO_TOKEN'") - def onboard_app(self, app_manifest: Dict) -> Dict: - app_id = app_manifest.get("appId") - if not app_id: - raise EdgeCloudPlatformError("Missing 'appId' in app manifest") - - if app_id in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' already exists") - - self._app_store[app_id] = app_manifest - self.logger.debug("Onboarded application with id: %s", app_id) - return {"appId": app_id} - - def get_all_onboarded_apps(self) -> List[Dict]: - self.logger.debug("Onboarded applications: %s", list(self._app_store.keys())) - return list(self._app_store.values()) - - def get_onboarded_app(self, app_id: str) -> Dict: - if app_id not in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - self.logger.debug("Retrieved application with id: %s", app_id) - return self._app_store[app_id] - - def delete_onboarded_app(self, app_id: str) -> None: - if app_id not in self._app_store: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - service_instances = self._stopped_services.get(app_id, []) - self.logger.debug( - "Deleting application with id: %s and instances: %s", - app_id, - service_instances, - ) - for service_instance in service_instances: - self._purge_deployed_app_from_continuum(service_instance) - self.logger.debug("successfully purged service instance: %s", service_instance) - del self._stopped_services[app_id] # Clean up stopped services - del self._app_store[app_id] # Remove from onboarded apps - - def _generate_service_id(self, app_id: str) -> str: - return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" - - def _generate_tosca_yaml_dict(self, app_manifest: Dict, app_zones: List[Dict]) -> Dict: - component = app_manifest.get("componentSpec", [{}])[0] - component_name = component.get("componentName", "application") - - image_path = app_manifest.get("appRepo", {}).get("imagePath", "") - image_file = image_path.split("/")[-1] - repository_url = "/".join(image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" - zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") - - # Extract minNodeMemory - min_node_memory = ( - app_manifest.get("requiredResources", {}) - .get("applicationResources", {}) - .get("cpuPool", {}) - .get("topology", {}) - .get("minNodeMemory", 1024) - ) - - ports = {} - for iface in component.get("networkInterfaces", []): - interface_id = iface.get("interfaceId", "default") - protocol = iface.get("protocol", "TCP").lower() - port = iface.get("port", 8080) - ports[interface_id] = {"properties": {"protocol": [protocol], "source": port}} - - expose_ports = any( - iface.get("visibilityType") == "VISIBILITY_EXTERNAL" - for iface in component.get("networkInterfaces", []) - ) - - yaml_dict = { - "tosca_definitions_version": "tosca_simple_yaml_1_3", - "description": f"TOSCA for {app_manifest.get('name', 'application')}", - "serviceOverlay": False, - "node_templates": { - component_name: { - "type": "tosca.nodes.Container.Application", - "isJob": False, - "requirements": [ - { - "network": { - "properties": { - "ports": ports, - "exposePorts": expose_ports, - } - } - }, - { - "host": { - "node_filter": { - "capabilities": [ - { - "host": { - "properties": { - "cpu_arch": {"equal": "x64"}, - "realtime": {"equal": False}, - "cpu_usage": {"less_or_equal": "0.1"}, - "mem_size": { - "greater_or_equal": str(min_node_memory) - }, - "domain_id": {"equal": zone_id}, - } - } - } - ], - "properties": None, - } - } - }, - ], - "artifacts": { - "application_image": { - "file": image_file, - "type": "tosca.artifacts.Deployment.Image.Container.Docker", - "is_private": False, - "repository": repository_url, - } - }, - "interfaces": { - "Standard": { - "create": { - "implementation": "application_image", - "inputs": {"cliArgs": [], "envVars": []}, - } - } - }, - } - }, - } + # ######################################################################## + # CAMARA EDGE CLOUD MANAGEMENT API + # ######################################################################## - return yaml_dict + # ------------------------------------------------------------------------ + # Edge Cloud Zone Management (CAMARA) + # ------------------------------------------------------------------------ - def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Dict: - # 1. Get app CAMARA manifest - app_manifest = self._app_store.get(app_id) - if not app_manifest: - raise EdgeCloudPlatformError(f"Application with id '{app_id}' does not exist") - - # 2. Generate unique service ID - service_id = self._generate_service_id(app_id) - - # 3. Convert dict to YAML string - yaml_dict = self._generate_tosca_yaml_dict(app_manifest, app_zones) - tosca_yaml = yaml.dump(yaml_dict, sort_keys=False) - self.logger.info("Generated TOSCA YAML:") - self.logger.info(tosca_yaml) - - # 4. Instantiate client and call continuum to deploy service - aeros_client = ContinuumClient(self.base_url) - response = aeros_client.onboard_and_deploy_service(service_id, tosca_yaml) - - if "serviceId" not in response: - raise EdgeCloudPlatformError( - "Invalid response from onboard_service: missing 'serviceId'" - ) - - # 5. Track deployment - if app_id not in self._deployed_services: - self._deployed_services[app_id] = [] - self._deployed_services[app_id].append(service_id) - - # 6. Return expected format - return {"appInstanceId": response["serviceId"]} - - def get_all_deployed_apps( - self, - app_id: Optional[str] = None, - app_instance_id: Optional[str] = None, - region: Optional[str] = None, - ) -> List[Dict]: - deployed = [] - for stored_app_id, instance_ids in self._deployed_services.items(): - for instance_id in instance_ids: - deployed.append({"appId": stored_app_id, "appInstanceId": instance_id}) - return deployed - - def get_deployed_app( - self, app_instance_id: str, app_id: Optional[str] = None, region: Optional[str] = None - ) -> Response: + # Zones methods + def get_edge_cloud_zones(self, + region: Optional[str] = None, + status: Optional[str] = None) -> Response: """ - Placeholder implementation for CAMARA compliance. - Retrieves information of a specific application instance. + Retrieves a list of available Edge Cloud Zones. - :param app_instance_id: Unique identifier of the application instance - :param app_id: Optional filter by application ID - :param region: Optional filter by Edge Cloud region - :return: Response with application instance details + :param region: Filter by geographical region. + :param status: Filter by status (active, inactive, unknown). + :return: Response with list of Edge Cloud Zones in CAMARA format. """ - # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app - raise NotImplementedError("get_deployed_app is not yet implemented for aeros adapter") - - def _purge_deployed_app_from_continuum(self, app_id: str) -> None: - aeros_client = ContinuumClient(self.base_url) - response = aeros_client.purge_service(app_id) - if response: - self.logger.debug("Purged deployed application with id: %s", app_id) - else: - raise EdgeCloudPlatformError( - f"Failed to purg service with id from the continuum '{app_id}'" - ) - - def undeploy_app(self, app_instance_id: str) -> None: - # 1. Locate app_id corresponding to this instance - found_app_id = None - for app_id, instances in self._deployed_services.items(): - if app_instance_id in instances: - found_app_id = app_id - break - - if not found_app_id: - raise EdgeCloudPlatformError( - f"No deployed app instance with ID '{app_instance_id}' found" - ) - - # 2. Call the external undeploy_service - aeros_client = ContinuumClient(self.base_url) try: - aeros_client.undeploy_service(app_instance_id) - except Exception as e: - raise EdgeCloudPlatformError( - f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" - ) from e - - # We could do it here with a little wait but better all instances in the same app are purged at once - # 3. Purge the deployed app from continuum - # self._purge_deployed_app_from_continuum(app_instance_id) - - # 4. Clean up internal tracking - self._deployed_services[found_app_id].remove(app_instance_id) - # Add instance to _stopped_services to purge it later - if found_app_id not in self._stopped_services: - self._stopped_services[found_app_id] = [] - self._stopped_services[found_app_id].append(app_instance_id) - # If app has no instances left, remove it from deployed services - if not self._deployed_services[found_app_id]: - del self._deployed_services[found_app_id] - - def get_edge_cloud_zones( - self, region: Optional[str] = None, status: Optional[str] = None - ) -> List[Dict]: - aeros_client = ContinuumClient(self.base_url) - ngsild_params = "type=Domain&format=simplified" - aeros_domains = aeros_client.query_entities(ngsild_params) - return [ - { - "zoneId": domain["id"], - "status": domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": "NOT_USED", - } - for domain in aeros_domains - ] - - def get_edge_cloud_zones_details(self, zone_id: str, flavour_id: Optional[str] = None) -> Dict: + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Domain&format=simplified" + camara_response = aeros_client.query_entities(ngsild_params) + aeros_domains = camara_response.json() + zone_list = [{ + "zoneId": + domain["id"], + "status": + domain["domainStatus"].split(":")[-1].lower(), + "geographyDetails": + "NOT_USED", + } for domain in aeros_domains] + if status: + zone_list = [ + z for z in zone_list + if z["domainStatus"] == status.lower() + ] # FIXME: Check CAMARA status map to aerOS status literals + # if region: + # zone_list = [ + # z for z in zone_list if z.get("region") == region + # ] # No region for aerOS domains + + return build_custom_http_response( + status_code=camara_response.status_code, + content=zone_list, + headers={"Content-Type": "application/json"}, + encoding=camara_response.encoding, + url=camara_response.url, + request=camara_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def get_edge_cloud_zones_details(self, + zone_id: str, + flavour_id: Optional[str] = None) -> Dict: """ Get details of a specific edge cloud zone. :param zone_id: The ID of the edge cloud zone @@ -348,20 +171,37 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): zone_id, ngsild_params, ) - # Query the infrastructure elements for the specified zonese - aeros_domain_ies = aeros_client.query_entities(ngsild_params) - # Transform the infrastructure elements into the required format - # and return the details of the edge cloud zone - response = self.transform_infrastructure_elements( - domain_ies=aeros_domain_ies, domain=zone_id - ) - self.logger.debug("Transformed response: %s", response) - # Return the transformed response - return response - - def transform_infrastructure_elements( - self, domain_ies: List[Dict[str, Any]], domain: str - ) -> Dict[str, Any]: + try: + # Query the infrastructure elements for the specified zonese + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domain_ies = aeros_response.json() + # Transform the infrastructure elements into the required format + # and return the details of the edge cloud zone + camara_response = self.transform_infrastructure_elements( + domain_ies=aeros_domain_ies, domain=zone_id) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding=aeros_response.encoding, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def transform_infrastructure_elements(self, domain_ies: List[Dict[str, + Any]], + domain: str) -> Dict[str, Any]: """ Transform the infrastructure elements into a format suitable for the edge cloud zone details. @@ -386,75 +226,482 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Create a flavour per machine flavour = { - "flavourId": f"{element.get('hostname')}-{element.get('containerTechnology')}", - "cpuArchType": f"{element.get('cpuArchitecture')}", - "supportedOSTypes": [ - { - "architecture": f"{element.get('cpuArchitecture')}", - "distribution": f"{element.get('operatingSystem')}", # assume - "version": "OS_VERSION_UBUNTU_2204_LTS", - "license": "OS_LICENSE_TYPE_FREE", - } - ], - "numCPU": element.get("cpuCores", 0), - "memorySize": element.get("ramCapacity", 0), - "storageSize": element.get("diskCapacity", 0), + "flavourId": + f"{element.get('hostname')}-{element.get('containerTechnology')}", + "cpuArchType": + f"{element.get('cpuArchitecture')}", + "supportedOSTypes": [{ + "architecture": f"{element.get('cpuArchitecture')}", + "distribution": + f"{element.get('operatingSystem')}", # assume + "version": "OS_VERSION_UBUNTU_2204_LTS", + "license": "OS_LICENSE_TYPE_FREE", + }], + "numCPU": + element.get("cpuCores", 0), + "memorySize": + element.get("ramCapacity", 0), + "storageSize": + element.get("diskCapacity", 0), } flavours_supported.append(flavour) result = { - "zoneId": domain, - "reservedComputeResources": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu), - "memory": total_ram, - } - ], - "computeResourceQuotaLimits": [ - { - "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu * 2), # Assume quota is 2x total? - "memory": total_ram * 2, - } - ], - "flavoursSupported": flavours_supported, + "zoneId": + domain, + "reservedComputeResources": [{ + "cpuArchType": "ISA_X86_64", + "numCPU": str(total_cpu), + "memory": total_ram, + }], + "computeResourceQuotaLimits": [{ + "cpuArchType": "ISA_X86_64", + "numCPU": str(total_cpu * 2), # Assume quota is 2x total? + "memory": total_ram * 2, + }], + "flavoursSupported": + flavours_supported, } return result - # --- GSMA-specific methods --- + # ------------------------------------------------------------------------ + # Application Management (CAMARA-Compliant) + # ------------------------------------------------------------------------ + + # Onboarding methods + def onboard_app(self, app_manifest: Dict) -> Response: + # Validate CAMARA input + camara_schemas.AppManifest(**app_manifest) - # FederationManagement + app_id = app_manifest.get("appId") + if not app_id: + raise EdgeCloudPlatformError("Missing 'appId' in app manifest") - def get_edge_cloud_zones_list_gsma(self) -> List: + if self.storage.get_app(app_id=app_id): + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' already exists") + + self.storage.store_app(app_id, app_manifest) + self.logger.debug("Onboarded application with id: %s", app_id) + submitted_app = camara_schemas.SubmittedApp( + appId=camara_schemas.AppId(app_id)) + return build_custom_http_response( + status_code=201, + content=submitted_app.model_dump(mode="json"), + headers={"Content-Type": "application/json"}, + encoding="utf-8") + + def get_all_onboarded_apps(self) -> Response: + apps = self.storage.list_apps() + self.logger.debug("Onboarded applications: %s", apps) + return build_custom_http_response( + status_code=200, + content=apps, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + def get_onboarded_app(self, app_id: str) -> Response: + app_data = self.storage.get_app(app_id) + if not app_data: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + self.logger.debug("Retrieved application with id: %s", app_id) + + #Do we need to do this ?? + # app_manifest_response = { + # "appManifest": { + # "appId": app_data.get("app_id", app_id), + # "name": app_data.get("name", ""), + # "version": app_data.get("version", ""), + # "appProvider": app_data.get("appProvider", ""), + # # Add other required fields with defaults if not available + # "packageType": "CONTAINER", # Default value + # "appRepo": { + # "type": "PUBLICREPO", + # "imagePath": "not-available" + # }, + # "requiredResources": { + # "infraKind": "kubernetes", + # "applicationResources": {}, + # "isStandalone": False, + # }, + # "componentSpec": [], + # } + # } + # or this ? + # rr = app_data.get("requiredResources", + # {}) # shortcut for requiredResources + # app_repo = app_data.get("appRepo", {}) # shortcut for appRepo + + # app_manifest_response = { + # "appManifest": { + # "appId": app_data.get("appId", app_id), + # "name": app_data.get("name", ""), + # "version": app_data.get("version", ""), + # "appProvider": app_data.get("appProvider", ""), + # # Add other required fields with defaults if not available + # "packageType": app_data.get("packageType", "CONTAINER"), + # "appRepo": { + # "type": app_repo.get("type", "PUBLICREPO"), + # # Take it from the stored object: + # "imagePath": app_repo.get("imagePath", ""), + # }, + # "requiredResources": { + # "infraKind": rr.get("infraKind", "kubernetes"), + # # Copy the whole block as-is (cpuPool etc.) + # "applicationResources": rr.get("applicationResources", {}), + # # From stored object, default False if absent + # "isStandalone": rr.get("isStandalone", False), + # # (optional, since your stored object includes it) + # "version": rr.get("version", ""), + # }, + # # Pass through the list from the stored object + # "componentSpec": app_data.get("componentSpec", []), + # } + # } + # Build CAMARA-compliant response using schema + # Note: This is a partial AppManifest for get operation + return build_custom_http_response( + status_code=200, + content=app_data, # We already keep the app manifest when onboarding + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + def delete_onboarded_app(self, app_id: str) -> Response: + app = self.storage.get_app(app_id) + if not app: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + + service_instances = self.storage.get_stopped_instances(app_id=app_id) + if not service_instances: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' cannot be deleted — please stop it first" + ) + self.logger.debug( + "Deleting application with id: %s and instances: %s", + app_id, + service_instances, + ) + for service_instance in service_instances: + self._purge_deployed_app_from_continuum(service_instance) + self.logger.debug("successfully purged service instance: %s", + service_instance) + + self.storage.remove_stopped_instances(app_id) + self.storage.delete_app(app_id) + + return build_custom_http_response( + status_code=204, + content=b"", # absolutely no body for 204 + headers={"Content-Type": "application/json"}, + encoding="utf-8", + # url=None, + # request=None, + ) + + def _generate_service_id(self, app_id: str) -> str: + return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + + # Instantiation methods + def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: + # 1. Get app CAMARA manifest + app_manifest = self.storage.get_app(app_id) + if not app_manifest: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' does not exist") + app_manifest = camara_schemas.AppManifest.model_validate(app_manifest) + + # 2. Generate unique service ID + # (aerOS) service id <=> CAMARA appInstanceId + service_id = self._generate_service_id(app_id) + + # 3. Convert dict to YAML string + tosca_str = camara2aeros_converter.generate_tosca( + app_manifest=app_manifest, app_zones=app_zones) + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_str) + + # 4. Instantiate client and call continuum to deploy service + try: + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.onboard_and_deploy_service( + service_id, tosca_str) + + if "serviceId" not in aeros_response: + raise EdgeCloudPlatformError( + "Invalid response from onboard_service: missing 'serviceId'" + ) + + # Build CAMARA-compliant info + app_provider_id = app_manifest.get("appProvider", + "unknown-provider") + zone_id = app_zones[0].get("EdgeCloudZone", + {}).get("edgeCloudZoneId", + "default-zone") + app_instance_info = camara_schemas.AppInstanceInfo( + name=camara_schemas.AppInstanceName(service_id), + appId=camara_schemas.AppId(app_id), + appInstanceId=camara_schemas.AppInstanceId(service_id), + appProvider=camara_schemas.AppProvider(app_provider_id), + status=camara_schemas.Status.instantiating, + edgeCloudZoneId=camara_schemas.EdgeCloudZoneId(zone_id), + ) + + # 5. Track deployment + self.storage.store_deployment(app_instance=app_instance_info) + + # 6. Return expected format + self.logger.info("App deployment request submitted successfully") + + # CAMARA spec requires appInstances array wrapper + camara_response = app_instance_info.model_dump(mode="json") + # Add mandatory Location header + location_url = f"/appinstances/{service_id}" + camara_headers = { + "Content-Type": "application/json", + "Location": location_url + } + + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers=camara_headers, + encoding="utf-8", + url=aeros_response.url, + request=aeros_response.request) + except EdgeCloudPlatformError as ex: + # Catch all platform-specific errors. + # All custom exception types (InvalidArgumentError, UnauthenticatedError, etc.) + # inherit from EdgeCloudPlatformError, so a single handler here will capture + # any of them. We can further elaborate per eachone of needed. + self.logger.error("Failed to deploy app '%s': %s", app_id, str(ex)) + raise + + def get_all_deployed_apps( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> Response: + + instances = self.storage.find_deployments(app_id, app_instance_id, + region) + + # CAMARA spec format for multiple instances response + camara_response = {"appInstances": instances} + + self.logger.info("All app instances retrieved successfully") + return build_custom_http_response( + status_code=200, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + # url=response.url, + # request=response.request, + ) + + def get_deployed_app(self, + app_instance_id: str, + app_id: Optional[str] = None, + region: Optional[str] = None) -> Response: """ - Retrieves list of all Zones + Placeholder implementation for CAMARA compliance. + Retrieves information of a specific application instance. - :return: List. + :param app_instance_id: Unique identifier of the application instance + :param app_id: Optional filter by application ID + :param region: Optional filter by Edge Cloud region + :return: Response with application instance details """ - pass + # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app + raise NotImplementedError( + "get_deployed_app is not yet implemented for aeros adapter") + + def _purge_deployed_app_from_continuum(self, app_id: str) -> None: + aeros_client = ContinuumClient(self.base_url) + response = aeros_client.purge_service(app_id) + if response: + self.logger.debug("Purged deployed application with id: %s", + app_id) + else: + raise EdgeCloudPlatformError( + f"Failed to purg service with id from the continuum '{app_id}'" + ) + + def undeploy_app(self, app_instance_id: str) -> Response: + # 1. Locate app_id corresponding to this instance + app_id = self.storage.remove_deployment(app_instance_id) + if not app_id: + raise EdgeCloudPlatformError( + f"No deployed app instance with ID '{app_instance_id}' found") + + # 2. Call the external undeploy_service + aeros_client = ContinuumClient(self.base_url) + try: + aeros_response = aeros_client.undeploy_service(app_instance_id) + except Exception as e: + raise EdgeCloudPlatformError( + f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" + ) from e + + # We could do it here with a little wait but better all instances in the same app are purged at once + # 3. Purge the deployed app from continuum + # self._purge_deployed_app_from_continuum(app_instance_id) + + # 4. Clean up internal tracking + self.storage.store_stopped_instance(app_id, app_instance_id) + return build_custom_http_response( + status_code=204, + content="", + headers={"Content-Type": "application/json"}, + encoding="utf-8", + url=aeros_response.url, + request=aeros_response.request, + ) + + # ######################################################################## + # GSMA EDGE COMPUTING API (EWBI OPG) - FEDERATION + # ######################################################################## + + # ------------------------------------------------------------------------ + # Zone Management (GSMA) + # ------------------------------------------------------------------------ + + def get_edge_cloud_zones_list_gsma(self) -> Response: + """ + Retrieves details of all Zones for GSMA federation. + + :return: Response with zone details in GSMA format. + """ + try: + aeros_client = ContinuumClient(self.base_url) + ngsild_params = "type=Domain&format=simplified" + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domains = aeros_response.json() + zone_list = [{ + "zoneId": + domain["id"], + "status": + domain["domainStatus"].split(":")[-1].lower(), + "geographyDetails": + "NOT_USED", + } for domain in aeros_domains] + return build_custom_http_response( + status_code=aeros_domains.status_code, + content=zone_list, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise # AvailabilityZoneInfoSynchronization - def get_edge_cloud_zones_gsma(self) -> List: + def get_edge_cloud_zones_gsma(self) -> Response: """ - Retrieves details of all Zones + Retrieves details of all Zones with compute resources and flavours for GSMA federation. - :return: List. + :return: Response with zones and detailed resource information. """ - pass + aeros_client = ContinuumClient(self.base_url) + ngsild_params = 'format=simplified&type=InfrastructureElement' - def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Dict: + try: + # Query the infrastructure elements whithin the whole continuum + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_ies = aeros_response.json() # IEs as list of dicts + + # Create a dict that groups by "domain" + grouped_by_domain = defaultdict(list) + for item in aeros_ies: + domain = item["domain"] + grouped_by_domain[domain].append(item) + + # Transform the IEs to required format + # per domain and append to response list + camara_response = [] + for domain, ies in grouped_by_domain.items(): + result = self.transform_infrastructure_elements(domain_ies=ies, + domain=domain) + camara_response.append(result) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + def get_edge_cloud_zone_details_gsma(self, zone_id: str) -> Response: """ Retrieves details of a specific Edge Cloud Zone reserved - for the specified zone by the partner OP. + for the specified zone by the partner OP using GSMA federation. :param zone_id: Unique identifier of the Edge Cloud Zone. - :return: Dictionary with Edge Cloud Zone details. + :return: Response with Edge Cloud Zone details. """ - pass - - # ArtefactManagement + aeros_client = ContinuumClient(self.base_url) + ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) + try: + # Query the infrastructure elements for the specified zonese + aeros_response = aeros_client.query_entities(ngsild_params) + aeros_domain_ies = aeros_response.json() + # Transform the infrastructure elements into the required format + # and return the details of the edge cloud zone + camara_response = self.transform_infrastructure_elements( + domain_ies=aeros_domain_ies, domain=zone_id) + self.logger.debug("Transformed response: %s", camara_response) + # Return the transformed response + return build_custom_http_response( + status_code=aeros_response.status_code, + content=camara_response, + headers={"Content-Type": "application/json"}, + encoding=aeros_response.encoding, + url=aeros_response.url, + request=aeros_response.request, + ) + except json.JSONDecodeError as e: + self.logger.error("Invalid JSON in i2Edge response: %s", e) + raise + except KeyError as e: + self.logger.error("Missing expected field in i2Edge data: %s", e) + raise + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise + + # ------------------------------------------------------------------------ + # Artefact Management (GSMA) + # ------------------------------------------------------------------------ def create_artefact_gsma(self, request_body: dict): """ @@ -485,7 +732,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - # ApplicationOnboardingManagement + # ------------------------------------------------------------------------ + # Application Onboarding Management (GSMA) + # ------------------------------------------------------------------------ def onboard_app_gsma(self, request_body: dict): """ @@ -494,9 +743,33 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): resource validation and other pre-deployment operations. :param request_body: Payload with onboarding info. - :return: + :return: Response with onboarding confirmation. """ - pass + try: + # Validate input against GSMA schema + gsma_validated_body = gsma_schemas.AppOnboardManifestGSMA.model_validate( + request_body) + data = gsma_validated_body.model_dump() + except ValidationError as e: + self.logger.error("Invalid GSMA input schema: %s", e) + raise + try: + data["app_id"] = data.pop("appId") + data.pop("edgeAppFQDN", None) + # FIXME: payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) + url = f"{self.base_url}/application/onboarding" + # FIXME: response = i2edge_post(url, payload, expected_status=201) + return build_custom_http_response( + status_code=200, + content={"response": "Application onboarded successfully"}, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + # FIXME: url=response.url, + # FIXME: request=response.request, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving edge cloud zones: %s", e) + raise def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ @@ -527,7 +800,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - # ApplicationDeploymentManagement + # ------------------------------------------------------------------------ + # Application Deployment Management (GSMA) + # ------------------------------------------------------------------------ def deploy_app_gsma(self, request_body: dict) -> Dict: """ @@ -538,7 +813,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: + def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, + zone_id: str) -> Dict: """ Retrieves an application instance details from partner OP. @@ -549,7 +825,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def get_all_deployed_apps_gsma(self) -> Response: + def get_all_deployed_apps_gsma(self, app_id: str, + app_provider: str) -> List: """ Retrieves all instances for a given application of partner OP @@ -559,7 +836,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ pass - def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): + def undeploy_app_gsma(self, app_id: str, app_instance_id: str, + zone_id: str): """ Terminate an application instance on a partner OP zone. diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index e1bf149..40ade57 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -73,7 +73,7 @@ class ContinuumClient: return response.json() @catch_requests_exceptions - def query_entities(self, ngsild_params): + def query_entities(self, ngsild_params) -> requests.Response: """ Query entities with ngsi-ld params :input @@ -90,7 +90,7 @@ class ContinuumClient: # self.logger.debug("Query entities URL: %s", entities_url) # self.logger.debug("Query entities response: %s %s", # response.status_code, response.text) - return response.json() + return response @catch_requests_exceptions def deploy_service(self, service_id: str) -> dict: @@ -116,7 +116,7 @@ class ContinuumClient: return response.json() @catch_requests_exceptions - def undeploy_service(self, service_id: str) -> dict: + def undeploy_service(self, service_id: str) -> requests.Response: """ Undeploy service :input @@ -136,10 +136,10 @@ class ContinuumClient: response.status_code, response.text, ) - return response.json() + return response @catch_requests_exceptions - def onboard_and_deploy_service(self, service_id: str, tosca_str: str) -> dict: + def onboard_and_deploy_service(self, service_id: str, tosca_str: str) -> requests.Response: """ Onboard (& deploy) service on aerOS continuum :input diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py new file mode 100644 index 0000000..a0a7fe1 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_models.py @@ -0,0 +1,269 @@ +''' + aerOS continuum models +''' +from enum import Enum +from typing import List, Dict, Any, Union, Optional +from pydantic import BaseModel, Field + + +class ServiceNotFound(BaseModel): + ''' + Docstring + ''' + detail: str = "Service not found" + + +class CPUComparisonOperator(BaseModel): + """ + CPU requirment for now is that usage should be less than + """ + less_or_equal: Union[float, None] = None + + +class CPUArchComparisonOperator(BaseModel): + """ + CPU arch requirment, equal to str + """ + equal: Union[str, None] = None + + +class MEMComparisonOperator(BaseModel): + """ + RAM requirment for now is that available RAM should be more than + """ + greater_or_equal: Union[str, None] = None + + +class EnergyEfficienyComparisonOperator(BaseModel): + """ + Energy Efficiency requirment for now is that IE should have energy efficiency more than a % + """ + greater_or_equal: Union[str, None] = None + + +class GreenComparisonOperator(BaseModel): + """ + IE Green requirment for now is that IE should have green energy mix which us more than a % + """ + greater_or_equal: Union[str, None] = None + + +class RTComparisonOperator(BaseModel): + """ + Real Time requirment T/F + """ + equal: Union[bool, None] = None + + +class CpuArch(str, Enum): + ''' + Enumeration with possible cpu types + ''' + x86_64 = "x86_64" + arm64 = "arm64" + arm32 = "arm32" + + +class Coordinates(BaseModel): + ''' + IE coordinate requirements + ''' + coordinates: List[List[float]] + + +class DomainIdOperator(BaseModel): + """ + CPU arch requirment, equal to str + """ + equal: Union[str, None] = None + + +class Property(BaseModel): + ''' + IE capabilities + ''' + cpu_usage: CPUComparisonOperator = Field( + default_factory=CPUComparisonOperator) + cpu_arch: CPUArchComparisonOperator = Field( + default_factory=CPUArchComparisonOperator) + mem_size: MEMComparisonOperator = Field( + default_factory=MEMComparisonOperator) + realtime: RTComparisonOperator = Field( + default_factory=RTComparisonOperator) + area: Coordinates = None + energy_efficiency: EnergyEfficienyComparisonOperator = Field( + default_factory=EnergyEfficienyComparisonOperator) + green: GreenComparisonOperator = Field( + default_factory=GreenComparisonOperator) + domain_id: DomainIdOperator = Field(default_factory=DomainIdOperator) + + # @field_validator('mem_size') + # def validate_mem_size(cls, v): + # if not v or "MB" not in v: + # raise ValueError("mem_size must be in MB and specified") + # mem_size_value = int(v.split(" ")[0]) + # if mem_size_value < 2000: + # raise ValueError("mem_size must be greater or equal to 2000 MB") + # return v + + +class HostCapability(BaseModel): + ''' + Host properties + ''' + properties: Property + + +class NodeFilter(BaseModel): + ''' + Node filter, + How to filter continuum IE and select canditate list + ''' + properties: Optional[Dict[str, List[str]]] = None + capabilities: Optional[List[Dict[str, HostCapability]]] = None + + +class HostRequirement(BaseModel): + ''' + capabilities of node + ''' + # node_filter: Dict[str, List[Dict[str, HostCapability]]] + node_filter: NodeFilter + + +class PortProperties(BaseModel): + ''' + Workload port description + ''' + protocol: List[str] = Field(...) + source: int = Field(...) + + +class ExposedPort(BaseModel): + ''' + Workload exposed network ports + ''' + properties: PortProperties = Field(...) + + +class NetworkProperties(BaseModel): + ''' + Dict of network requirments, name of port and protperty = [protocol, port] mapping + ''' + ports: Dict[str, ExposedPort] = Field(...) + exposePorts: Optional[bool] + + +class NetworkRequirement(BaseModel): + ''' + Top level key of network requirments + ''' + properties: NetworkProperties + + +class CustomRequirement(BaseModel): + ''' + Define a custom requirement type that can be either a host or a network requirement + ''' + host: HostRequirement = None + network: NetworkRequirement = None + + +class ArtifactModel(BaseModel): + ''' + Artifact has a useer defined id and then a dict with the following keys: + ''' + file: str + type: str + repository: str + is_private: Optional[bool] = False + username: Optional[str] = None + password: Optional[str] = None + + +class NodeTemplate(BaseModel): + ''' + Node template "tosca.nodes.Container.Application" + ''' + type: str + requirements: List[CustomRequirement] + artifacts: Dict[str, ArtifactModel] + interfaces: Dict[str, Any] + isJob: Optional[bool] = False + + +class TOSCA(BaseModel): + ''' + The TOSCA structure + ''' + tosca_definitions_version: str + description: str + serviceOverlay: Optional[bool] = False + node_templates: Dict[str, NodeTemplate] + + +TOSCA_YAML_EXAMPLE = """ +tosca_definitions_version: tosca_simple_yaml_1_3 +description: A test service for testing TOSCA generation +serviceOverlay: false + +node_templates: + auto-component: + type: tosca.nodes.Container.Application + isJob: False + artifacts: + application_image: + file: aeros-public/common-deployments/nginx:latest + repository: registry.gitlab.aeros-project.eu + type: tosca.artifacts.Deployment.Image.Container.Docker + interfaces: + Standard: + create: + implementation: application_image + inputs: + cliArgs: + - -a: aa + envVars: + - URL: bb + requirements: + - network: + properties: + ports: + port1: + properties: + protocol: + - tcp + source: 80 + port2: + properties: + protocol: + - tcp + source: 443 + exposePorts: True + - host: + node_filter: + capabilities: + - host: + properties: + cpu_arch: + equal: x64 + realtime: + equal: false + cpu_usage: + less_or_equal: '0.4' + mem_size: + greater_or_equal: '1' + domain_id: + equal: urn:ngsi-ld:Domain:NCSRD + energy_efficiency: + greater_or_equal: '0.5' + green: + greater_or_equal: '0.5' + domain_id: + equal: urn:ngsi-ld:Domain:ncsrd01 + properties: null + + + + +""" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py new file mode 100644 index 0000000..2ca44f4 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/errors.py @@ -0,0 +1,30 @@ +''' +Custom aerOS adapter exceptions on top of EdgeCloudPlatformError +''' + +from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError + + +class InvalidArgumentError(EdgeCloudPlatformError): + """400 Bad Request""" + pass + + +class UnauthenticatedError(EdgeCloudPlatformError): + """401 Unauthorized""" + pass + + +class PermissionDeniedError(EdgeCloudPlatformError): + """403 Forbidden""" + pass + + +class ResourceNotFoundError(EdgeCloudPlatformError): + """404 Not Found""" + pass + + +class ServiceUnavailableError(EdgeCloudPlatformError): + """503 Service Unavailable""" + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py new file mode 100644 index 0000000..612b363 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/__init__.py @@ -0,0 +1,5 @@ +''' +This module contains the storage management implementations for aerOS. +''' +from .inMemoryStorage import InMemoryAppStorage +from .sqlite_storage import SQLiteAppStorage diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py new file mode 100644 index 0000000..16cff05 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py @@ -0,0 +1,69 @@ +''' +# Class: AppStorageManager +# Abstract base class for application storage backends. +# This module defines the interface for managing application storage, +# ''' +from abc import ABC, abstractmethod +from typing import Dict, List, Optional +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo + + +class AppStorageManager(ABC): + """Abstract base class for application storage backends.""" + + @abstractmethod + def store_app(self, app_id: str, manifest: Dict) -> None: + pass + + @abstractmethod + def get_app(self, app_id: str) -> Optional[Dict]: + pass + + @abstractmethod + def app_exists(self, app_id: str) -> bool: + pass + + @abstractmethod + def list_apps(self) -> List[Dict]: + pass + + @abstractmethod + def delete_app(self, app_id: str) -> None: + pass + + @abstractmethod + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + pass + + @abstractmethod + def get_deployments(self, + app_id: Optional[str] = None) -> Dict[str, List[str]]: + pass + + @abstractmethod + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None) -> List[AppInstanceInfo]: + pass + + @abstractmethod + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + """Removes the given instance ID and returns the corresponding app_id, if found.""" + pass + + @abstractmethod + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + pass + + @abstractmethod + def get_stopped_instances( + self, + app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: + pass + + @abstractmethod + def remove_stopped_instances(self, app_id: str) -> None: + pass diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py new file mode 100644 index 0000000..972421d --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -0,0 +1,123 @@ +''' +Class: InMemoryAppStorage +''' +from threading import Lock +from typing import Dict, List, Optional +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager \ + import AppStorageManager +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger + + +class SingletonMeta(type): + """Thread-safe Singleton metaclass.""" + _instances: Dict[type, object] = {} + _lock = Lock() + + def __call__(cls, *args, **kwargs): + # Double-checked locking pattern + if cls not in cls._instances: + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): + ''' + In-memory implementation of the AppStorageManager interface. + ''' + + def __init__(self): + + # Make __init__ idempotent so repeated calls don't reset state + if getattr(self, "_initialized", False): + return + + if config.DEBUG: + self.logger = setup_logger() + self.logger.info("Using InMemoryStorage") + self._apps: Dict[str, Dict] = {} + self._deployed: Dict[str, List[AppInstanceInfo]] = {} + self._stopped: Dict[str, List[str]] = {} + + self._initialized = True + + def reset(self) -> None: + ''' + Helpful for unit tests to clear global state + ''' + self._apps.clear() + self._deployed.clear() + self._stopped.clear() + + def store_app(self, app_id: str, manifest: Dict) -> None: + self._apps[app_id] = manifest + + def get_app(self, app_id: str) -> Optional[Dict]: + return self._apps.get(app_id) + + def app_exists(self, app_id: str) -> bool: + return app_id in self._apps + + def list_apps(self) -> List[Dict]: + return list(self._apps.values()) + + def delete_app(self, app_id: str) -> None: + self._apps.pop(app_id, None) + + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + app_id = str(app_instance.appId) + if app_id not in self._deployed: + self._deployed[app_id] = [] + self._deployed[app_id].append(app_instance) + + def get_deployments(self, + app_id: Optional[str] = None) -> List[AppInstanceInfo]: + if app_id: + return self._deployed.get(app_id, []) + + all_instances = [] + for instances in self._deployed.values(): + all_instances.extend(instances) + return all_instances + + def find_deployments(self, app_id=None, app_instance_id=None, region=None): + result = [] + for instances in self._deployed.values(): # iterate lists of instances + for instance in instances: # iterate individual AppInstanceInfo objects + if app_id and str(instance.appId) != app_id: + continue + if app_instance_id and str( + instance.appInstanceId) != app_instance_id: + continue + # Region filtering can go here if needed + result.append(instance) + return result + + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + for app_id, instances in self._deployed.items(): + for instance in instances: + if str(instance.appInstanceId) == app_instance_id: + instances.remove(instance) + if not instances: + del self._deployed[app_id] + return app_id # return the app_id that had this instance + return None + + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + if app_id not in self._stopped: + self._stopped[app_id] = [] + self._stopped[app_id].append(app_instance_id) + + def get_stopped_instances( + self, + app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: + if app_id: + return self._stopped.get(app_id, []) + return self._stopped + + def remove_stopped_instances(self, app_id: str) -> None: + self._stopped.pop(app_id, None) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py new file mode 100644 index 0000000..9fcb360 --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/sqlite_storage.py @@ -0,0 +1,229 @@ +''' +SQLite storage implementation +''' +import sqlite3 +import json +from functools import wraps +from typing import Dict, List, Optional, Union +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo, AppInstanceName,\ + AppProvider, AppId, AppInstanceId, EdgeCloudZoneId, Status +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager\ + import AppStorageManager +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.logger import setup_logger + +decorator_logger = setup_logger() + + +def debug_log(msg: str): + """ + Decorator that logs the given message if config.DEBUG is True. + """ + + def decorator(func): + + @wraps(func) + def wrapper(*args, **kwargs): + if config.DEBUG: + decorator_logger.debug("[DEBUG] %s", msg) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class SQLiteAppStorage(AppStorageManager): + ''' + SQLite storage implementation + ''' + + @debug_log("Initializing SQLITE storage manager") + def __init__(self, db_path: str = "app_storage.db"): + if config.DEBUG: + self.logger = setup_logger() + self.logger.info("DB Path: %s", db_path) + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self._init_schema() + + def _init_schema(self): + if config.DEBUG: + self.logger.info("Initializing db schema") + cursor = self.conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS apps ( + app_id TEXT PRIMARY KEY, + manifest TEXT + ); + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS deployments ( + app_instance_id TEXT PRIMARY KEY, + app_id TEXT, + name TEXT, + app_provider TEXT, + status TEXT, + component_endpoint_info TEXT, + kubernetes_cluster_ref TEXT, + edge_cloud_zone_id TEXT + ); + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stopped ( + app_id TEXT, + app_instance_id TEXT + ); + """) + self.conn.commit() + + @debug_log("In SQLITE store_app method ") + def store_app(self, app_id: str, manifest: Dict) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO apps (app_id, manifest) VALUES (?, ?);", + (app_id, json.dumps(manifest))) + self.conn.commit() + + @debug_log("In SQLITE get_app method ") + def get_app(self, app_id: str) -> Optional[Dict]: + row = self.conn.execute("SELECT manifest FROM apps WHERE app_id = ?;", + (app_id, )).fetchone() + return json.loads(row[0]) if row else None + + @debug_log("In SQLITE app_exists method ") + def app_exists(self, app_id: str) -> bool: + row = self.conn.execute("SELECT 1 FROM apps WHERE app_id = ?;", + (app_id, )).fetchone() + return row is not None + + @debug_log("In SQLITE list_apps method ") + def list_apps(self) -> List[Dict]: + rows = self.conn.execute("SELECT manifest FROM apps;").fetchall() + return [json.loads(row[0]) for row in rows] + + @debug_log("In SQLITE delete_app method ") + def delete_app(self, app_id: str) -> None: + self.conn.execute("DELETE FROM apps WHERE app_id = ?;", (app_id, )) + self.conn.commit() + + @debug_log("In SQLITE store_deployment method ") + def store_deployment(self, app_instance: AppInstanceInfo) -> None: + resolved_status = (str(app_instance.status.value) if hasattr( + app_instance.status, "value") else str(app_instance.status) + if app_instance.status else "unknown") + self.logger.info("Resolved status for DB insert: %s", resolved_status) + + self.conn.execute( + """ + INSERT OR REPLACE INTO deployments ( + app_instance_id, app_id, name, app_provider, status, + component_endpoint_info, kubernetes_cluster_ref, edge_cloud_zone_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """, + ( + str(app_instance.appInstanceId), + str(app_instance.appId), + str(app_instance.name.root), + str(app_instance.appProvider.root), + str(app_instance.status.value) if hasattr( + app_instance.status, "value") else + str(app_instance.status) if app_instance.status else "unknown", + json.dumps(app_instance.componentEndpointInfo) + if app_instance.componentEndpointInfo else None, + app_instance.kubernetesClusterRef, + str(app_instance.edgeCloudZoneId.root), + ), + ) + + self.conn.commit() + + @debug_log("In SQLITE get_deployments method ") + def get_deployments(self, + app_id: Optional[str] = None) -> Dict[str, List[str]]: + if app_id: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM deployments WHERE app_id = ?;", + (app_id, )).fetchall() + else: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM deployments;").fetchall() + + result: Dict[str, List[str]] = {} + for app_id_val, instance_id in rows: + result.setdefault(app_id_val, []).append(instance_id) + return result + + @debug_log("In SQLITE find_deployments method ") + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[AppInstanceInfo]: + query = "SELECT * FROM deployments WHERE 1=1" + params = [] + if app_id: + query += " AND app_id = ?" + params.append(app_id) + if app_instance_id: + query += " AND app_instance_id = ?" + params.append(app_instance_id) + + rows = self.conn.execute(query, params).fetchall() + + result = [] + for row in rows: + result.append( + AppInstanceInfo( + appInstanceId=AppInstanceId(row[0]), + appId=AppId(row[1]), + name=AppInstanceName(row[2]), + appProvider=AppProvider(row[3]), + status=Status(row[4]) if row[4] else Status.unknown, + componentEndpointInfo=json.loads(row[5]) + if row[5] else None, + kubernetesClusterRef=row[6], + edgeCloudZoneId=EdgeCloudZoneId(row[7]), + )) + + return result + + @debug_log("In SQLITE remove_deployments method ") + def remove_deployment(self, app_instance_id: str) -> Optional[str]: + row = self.conn.execute( + "SELECT app_id FROM deployments WHERE app_instance_id = ?;", + (app_instance_id, )).fetchone() + self.conn.execute("DELETE FROM deployments WHERE app_instance_id = ?;", + (app_instance_id, )) + self.conn.commit() + return row[0] if row else None + + @debug_log("In SQLITE store_stopped_instance method ") + def store_stopped_instance(self, app_id: str, + app_instance_id: str) -> None: + self.conn.execute( + "INSERT INTO stopped (app_id, app_instance_id) VALUES (?, ?);", + (app_id, app_instance_id)) + self.conn.commit() + + @debug_log("In SQLITE get_Stopped_instances method ") + def get_stopped_instances( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + if app_id: + rows = self.conn.execute( + "SELECT app_instance_id FROM stopped WHERE app_id = ?;", + (app_id, )).fetchall() + return [r[0] for r in rows] + else: + rows = self.conn.execute( + "SELECT app_id, app_instance_id FROM stopped;").fetchall() + result: Dict[str, List[str]] = {} + for aid, iid in rows: + result.setdefault(aid, []).append(iid) + return result + + @debug_log("In SQLITE remove_stopped_instances method ") + def remove_stopped_instances(self, app_id: str) -> None: + self.conn.execute("DELETE FROM stopped WHERE app_id = ?;", (app_id, )) + self.conn.commit() diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 67cc543..0298c84 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -6,38 +6,74 @@ # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## """ -Docstring +aerOS help methods """ from requests.exceptions import HTTPError, RequestException, Timeout import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config +import sunrise6g_opensdk.edgecloud.adapters.aeros.errors as errors from sunrise6g_opensdk.logger import setup_logger def catch_requests_exceptions(func): """ - Docstring + Decorator to catch and translate requests exceptions into custom app errors. """ logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) def wrapper(*args, **kwargs): try: - result = func(*args, **kwargs) - return result + return func(*args, **kwargs) + except HTTPError as e: - logger.info("4xx or 5xx: %s \n", {e}) - return None # raise our custom exception or log, etc. - except ConnectionError as e: - logger.info( - "Raised for connection-related issues (e.g., DNS resolution failure, network issues): %s \n", - {e}, - ) - return None # raise our custom exception or log, etc. + response = getattr(e, "response", None) + status_code = getattr(response, "status_code", None) + logger.error("HTTPError occurred: %s", e) + + if status_code == 401: + raise errors.UnauthenticatedError("Unauthorized access") from e + elif status_code == 403: + raise errors.PermissionDeniedError("Forbidden access") from e + elif status_code == 404: + raise errors.ResourceNotFoundError("Resource not found") from e + elif status_code == 400: + raise errors.InvalidArgumentError("Bad request") from e + elif status_code == 503: + raise errors.ServiceUnavailableError( + "Service unavailable") from e + + raise errors.EdgeCloudPlatformError( + f"Unhandled HTTP error: {status_code}") from e + except Timeout as e: - logger.info("Timeout occured: %s \n", {e}) - return None # raise our custom exception or log, etc. + logger.warning("Timeout occurred: %s", e) + raise errors.ServiceUnavailableError("Request timed out") from e + + except ConnectionError as e: + logger.warning("Connection error (e.g., DNS): %s", e) + raise errors.ServiceUnavailableError("Connection issue") from e + except RequestException as e: - logger.info("Request failed: %s \n", {e}) - return None # raise our custom exception or log, etc. + # Catch other unclassified request exceptions (non-HTTP) + logger.error("Request failed: %s", str(e)) + + if e.response is not None: + logger.error("Status Code: %s", e.response.status_code) + logger.error("Response Body (raw): %s", e.response.text) + + try: + json_data = e.response.json() + logger.debug("Parsed JSON response: %s", json_data) + except ValueError: + logger.warning("Response body is not valid JSON.") + + if e.request is not None: + logger.error("Request URL: %s", e.request.url) + logger.error("Request Method: %s", e.request.method) + logger.error("Request Headers: %s", e.request.headers) + logger.error("Request Body: %s", e.request.body) + + raise errors.EdgeCloudPlatformError( + "Unhandled request error") from e return wrapper diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py index 31a4253..f76d6a5 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/i2edge/client.py @@ -818,7 +818,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: validated_data = gsma_schemas.ZoneRegisteredData.model_validate(mapped) except ValidationError as e: - raise ValueError(f"Invalid schema: {e}") + raise ValueError(f"Invalid schema: {e}") from e return build_custom_http_response( status_code=200, content=validated_data.model_dump(), -- GitLab From 7a86888b078380a335a680bb002062dacd17688c Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Fri, 17 Oct 2025 12:34:29 +0300 Subject: [PATCH 275/281] Fix (thread safety) inMemory. --- .../storageManagement/inMemoryStorage.py | 166 +++++++++++------- 1 file changed, 102 insertions(+), 64 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 972421d..0be3a40 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -1,22 +1,21 @@ ''' Class: InMemoryAppStorage ''' -from threading import Lock -from typing import Dict, List, Optional -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager \ - import AppStorageManager -from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from threading import RLock +from typing import Dict, List, Optional, Union +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import AppStorageManager +from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.logger import setup_logger +# import copy # optional if you want deep copies class SingletonMeta(type): """Thread-safe Singleton metaclass.""" _instances: Dict[type, object] = {} - _lock = Lock() + _lock = RLock() def __call__(cls, *args, **kwargs): - # Double-checked locking pattern if cls not in cls._instances: with cls._lock: if cls not in cls._instances: @@ -25,99 +24,138 @@ class SingletonMeta(type): class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): - ''' - In-memory implementation of the AppStorageManager interface. - ''' + """ + In-memory implementation of the AppStorageManager interface (process-wide singleton). + """ def __init__(self): - - # Make __init__ idempotent so repeated calls don't reset state if getattr(self, "_initialized", False): return + # Initialize logger always; emit debug message conditionally + self.logger = setup_logger() if config.DEBUG: - self.logger = setup_logger() self.logger.info("Using InMemoryStorage") + + self._lock = RLock() self._apps: Dict[str, Dict] = {} self._deployed: Dict[str, List[AppInstanceInfo]] = {} self._stopped: Dict[str, List[str]] = {} - self._initialized = True def reset(self) -> None: ''' Helpful for unit tests to clear global state ''' - self._apps.clear() - self._deployed.clear() - self._stopped.clear() + with self._lock: + self._apps.clear() + self._deployed.clear() + self._stopped.clear() + # --- Apps --- def store_app(self, app_id: str, manifest: Dict) -> None: - self._apps[app_id] = manifest + with self._lock: + self._apps[app_id] = manifest def get_app(self, app_id: str) -> Optional[Dict]: - return self._apps.get(app_id) + with self._lock: + return self._apps.get(app_id) def app_exists(self, app_id: str) -> bool: - return app_id in self._apps + with self._lock: + return app_id in self._apps def list_apps(self) -> List[Dict]: - return list(self._apps.values()) + with self._lock: + # If you want full isolation, use deepcopy: + # return [copy.deepcopy(m) for m in self._apps.values()] + return [dict(m) for m in self._apps.values()] def delete_app(self, app_id: str) -> None: - self._apps.pop(app_id, None) + with self._lock: + self._apps.pop(app_id, None) + # --- Deployments --- def store_deployment(self, app_instance: AppInstanceInfo) -> None: - app_id = str(app_instance.appId) - if app_id not in self._deployed: - self._deployed[app_id] = [] - self._deployed[app_id].append(app_instance) + with self._lock: + aid = str(app_instance.appId) + self._deployed.setdefault(aid, []).append(app_instance) + # Conform to interface -> Dict[str, List[str]] def get_deployments(self, - app_id: Optional[str] = None) -> List[AppInstanceInfo]: - if app_id: - return self._deployed.get(app_id, []) - - all_instances = [] - for instances in self._deployed.values(): - all_instances.extend(instances) - return all_instances - - def find_deployments(self, app_id=None, app_instance_id=None, region=None): - result = [] - for instances in self._deployed.values(): # iterate lists of instances - for instance in instances: # iterate individual AppInstanceInfo objects - if app_id and str(instance.appId) != app_id: + app_id: Optional[str] = None) -> Dict[str, List[str]]: + with self._lock: + if app_id: + ids = [ + str(i.appInstanceId) + for i in self._deployed.get(app_id, []) + ] + return {app_id: ids} + return { + aid: [str(i.appInstanceId) for i in insts] + for aid, insts in self._deployed.items() + } + + def find_deployments( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + region: Optional[str] = None, + ) -> List[AppInstanceInfo]: + with self._lock: + # Fast path by instance id + if app_instance_id: + for insts in self._deployed.values(): + for inst in insts: + if str(inst.appInstanceId) == app_instance_id: + if app_id and str(inst.appId) != app_id: + return [] + if region is not None and getattr( + inst, "region", None) != region: + return [] + return [inst] + return [] + + results: List[AppInstanceInfo] = [] + for aid, insts in self._deployed.items(): + if app_id and aid != app_id: continue - if app_instance_id and str( - instance.appInstanceId) != app_instance_id: - continue - # Region filtering can go here if needed - result.append(instance) - return result + for inst in insts: + if region is not None and getattr(inst, "region", + None) != region: + continue + results.append(inst) + return results def remove_deployment(self, app_instance_id: str) -> Optional[str]: - for app_id, instances in self._deployed.items(): - for instance in instances: - if str(instance.appInstanceId) == app_instance_id: - instances.remove(instance) - if not instances: - del self._deployed[app_id] - return app_id # return the app_id that had this instance - return None - + with self._lock: + for aid, insts in list( + self._deployed.items()): # iterate over a copy of items + for idx, inst in enumerate(insts): + if str(inst.appInstanceId) == app_instance_id: + insts.pop(idx) + if not insts: + self._deployed.pop(aid, None) + return aid + return None + + # --- Stopped --- def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: - if app_id not in self._stopped: - self._stopped[app_id] = [] - self._stopped[app_id].append(app_instance_id) + with self._lock: + lst = self._stopped.setdefault(app_id, []) + if app_instance_id not in lst: # de-duplicate + lst.append(app_instance_id) def get_stopped_instances( - self, - app_id: Optional[str] = None) -> List[str] | Dict[str, List[str]]: - if app_id: - return self._stopped.get(app_id, []) - return self._stopped + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + with self._lock: + if app_id: + return list(self._stopped.get(app_id, [])) + return {aid: list(ids) for aid, ids in self._stopped.items()} def remove_stopped_instances(self, app_id: str) -> None: - self._stopped.pop(app_id, None) + with self._lock: + self._stopped.pop(app_id, None) -- GitLab From 16d657702d3c69bc0a981b346496074aa01145af Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 10:05:19 +0300 Subject: [PATCH 276/281] CAMARA updates valiadted, all tests pass --- .../edgecloud/adapters/aeros/client.py | 688 +++++++++++++----- .../edgecloud/adapters/aeros/config.py | 2 +- .../adapters/aeros/continuum_client.py | 4 +- .../adapters/aeros/converters/__init__.py | 0 .../camara2aeros_converter.py | 6 +- .../aeros/converters/gsma2aeros_converter.py | 155 ++++ .../storageManagement/appStorageManager.py | 105 ++- .../storageManagement/inMemoryStorage.py | 276 ++++++- .../edgecloud/adapters/aeros/utils.py | 76 ++ 9 files changed, 1114 insertions(+), 198 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py rename src/sunrise6g_opensdk/edgecloud/adapters/aeros/{ => converters}/camara2aeros_converter.py (95%) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 660bdd3..d3c6cd2 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -5,20 +5,29 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## +from time import sleep import uuid import json +import re from typing import Any, Dict, List, Optional from collections import defaultdict from pydantic import ValidationError from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( + urn_to_uuid, encode_app_instance_name) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import gsma2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage -from sunrise6g_opensdk.edgecloud.adapters.aeros import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import camara2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError +from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( + ResourceNotFoundError, + InvalidArgumentError, +) from sunrise6g_opensdk.edgecloud.core.edgecloud_interface import ( EdgeCloudManagementInterface, ) from sunrise6g_opensdk.edgecloud.core.utils import build_custom_http_response @@ -85,24 +94,42 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ngsild_params = "type=Domain&format=simplified" camara_response = aeros_client.query_entities(ngsild_params) aeros_domains = camara_response.json() - zone_list = [{ - "zoneId": - domain["id"], - "status": - domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": - "NOT_USED", - } for domain in aeros_domains] - if status: - zone_list = [ - z for z in zone_list - if z["domainStatus"] == status.lower() - ] # FIXME: Check CAMARA status map to aerOS status literals - # if region: - # zone_list = [ - # z for z in zone_list if z.get("region") == region - # ] # No region for aerOS domains - + if config.DEBUG: + self.logger.debug("aerOS edge cloud zones: %s", aeros_domains) + + zone_list = [] + for domain in aeros_domains: + domain_id = domain.get("id") + if not domain_id: + continue + + # Normalize status + raw_status = domain.get("domainStatus", "") + status_token = raw_status.split(":")[-1].strip().lower() + status = "Active" if status_token == "functional" else "Unknown" + + zone = { + "edgeCloudZoneId": + str(urn_to_uuid(domain_id)), + "edgeCloudZoneName": + domain_id, # or domain_id.split(":")[-1] if you prefer short name + "edgeCloudProvider": + (domain.get("owner", ["unknown"])[0] if isinstance( + domain.get("owner"), list) else domain.get( + "owner", "unknown")), + "status": + status, + "geographyDetails": + "NOT_USED", + } + zone_list.append(zone) + + # Store zones keyed by the aerOS domain id + self.storage.store_zones( + {d["edgeCloudZoneName"]: d + for d in zone_list}) + if config.DEBUG: + self.logger.debug("aerOS Local domains store: %s", zone_list) return build_custom_http_response( status_code=camara_response.status_code, content=zone_list, @@ -130,40 +157,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param flavour_id: Optional flavour ID to filter the results :return: Details of the edge cloud zone """ - # Minimal mocked response based on required fields of 'ZoneRegisteredData' in GSMA OPG E/WBI API - # return { - # "zoneId": - # zone_id, - # "reservedComputeResources": [{ - # "cpuArchType": "ISA_X86_64", - # "numCPU": "4", - # "memory": 8192, - # }], - # "computeResourceQuotaLimits": [{ - # "cpuArchType": "ISA_X86_64", - # "numCPU": "8", - # "memory": 16384, - # }], - # "flavoursSupported": [{ - # "flavourId": - # "medium-x86", - # "cpuArchType": - # "ISA_X86_64", - # "supportedOSTypes": [{ - # "architecture": "x86_64", - # "distribution": "UBUNTU", - # "version": "OS_VERSION_UBUNTU_2204_LTS", - # "license": "OS_LICENSE_TYPE_FREE", - # }], - # "numCPU": - # 4, - # "memorySize": - # 8192, - # "storageSize": - # 100, - # }], - # # - # } aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' self.logger.debug( @@ -308,63 +301,13 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"Application with id '{app_id}' does not exist") self.logger.debug("Retrieved application with id: %s", app_id) - #Do we need to do this ?? - # app_manifest_response = { - # "appManifest": { - # "appId": app_data.get("app_id", app_id), - # "name": app_data.get("name", ""), - # "version": app_data.get("version", ""), - # "appProvider": app_data.get("appProvider", ""), - # # Add other required fields with defaults if not available - # "packageType": "CONTAINER", # Default value - # "appRepo": { - # "type": "PUBLICREPO", - # "imagePath": "not-available" - # }, - # "requiredResources": { - # "infraKind": "kubernetes", - # "applicationResources": {}, - # "isStandalone": False, - # }, - # "componentSpec": [], - # } - # } - # or this ? - # rr = app_data.get("requiredResources", - # {}) # shortcut for requiredResources - # app_repo = app_data.get("appRepo", {}) # shortcut for appRepo - - # app_manifest_response = { - # "appManifest": { - # "appId": app_data.get("appId", app_id), - # "name": app_data.get("name", ""), - # "version": app_data.get("version", ""), - # "appProvider": app_data.get("appProvider", ""), - # # Add other required fields with defaults if not available - # "packageType": app_data.get("packageType", "CONTAINER"), - # "appRepo": { - # "type": app_repo.get("type", "PUBLICREPO"), - # # Take it from the stored object: - # "imagePath": app_repo.get("imagePath", ""), - # }, - # "requiredResources": { - # "infraKind": rr.get("infraKind", "kubernetes"), - # # Copy the whole block as-is (cpuPool etc.) - # "applicationResources": rr.get("applicationResources", {}), - # # From stored object, default False if absent - # "isStandalone": rr.get("isStandalone", False), - # # (optional, since your stored object includes it) - # "version": rr.get("version", ""), - # }, - # # Pass through the list from the stored object - # "componentSpec": app_data.get("componentSpec", []), - # } - # } - # Build CAMARA-compliant response using schema - # Note: This is a partial AppManifest for get operation + app_manifest_response = { + "appManifest": app_data + } # We already keep the app manifest when onboarding + return build_custom_http_response( status_code=200, - content=app_data, # We already keep the app manifest when onboarding + content=app_manifest_response, headers={"Content-Type": "application/json"}, encoding="utf-8", ) @@ -403,7 +346,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ) def _generate_service_id(self, app_id: str) -> str: - return f"urn:ngsi-ld:Service:{app_id}-{uuid.uuid4().hex[:4]}" + ''' + Generate a unique service ID for aerOS continuum. + The service ID is in the format of a NGSI-LD URN with a random suffix. + :param app_id: The application ID + :return: The generated service ID + ''' + return f"{app_id}-{uuid.uuid4().hex[:4]}" + + def _generate_aeros_service_id(self, camara_app_instance_id: str) -> str: + ''' + Convert CAMARA appInstanceId to aerOS service ID. + :param camara_app_instance_id: The CAMARA appInstanceId + :return: The corresponding aerOS service ID + ''' + return f"urn:ngsi-ld:Service:{camara_app_instance_id}" # Instantiation methods def deploy_app(self, app_id: str, app_zones: List[Dict]) -> Response: @@ -419,8 +376,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): service_id = self._generate_service_id(app_id) # 3. Convert dict to YAML string + # 3a. Get aerOS domain IDs from zones uuids + aeros_domain_ids = [ + self.storage.resolve_domain_id_by_zone_uuid( + z["EdgeCloudZone"]["edgeCloudZoneId"]) for z in app_zones + if z.get("EdgeCloudZone", {}).get("edgeCloudZoneId") + ] tosca_str = camara2aeros_converter.generate_tosca( - app_manifest=app_manifest, app_zones=app_zones) + app_manifest=app_manifest, app_zones=aeros_domain_ids) self.logger.info("Generated TOSCA YAML:") self.logger.info(tosca_str) @@ -428,21 +391,21 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): try: aeros_client = ContinuumClient(self.base_url) aeros_response = aeros_client.onboard_and_deploy_service( - service_id, tosca_str) + self._generate_aeros_service_id(service_id), tosca_str) - if "serviceId" not in aeros_response: + if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( "Invalid response from onboard_service: missing 'serviceId'" ) # Build CAMARA-compliant info - app_provider_id = app_manifest.get("appProvider", - "unknown-provider") + app_provider_id = app_manifest.appProvider.root zone_id = app_zones[0].get("EdgeCloudZone", {}).get("edgeCloudZoneId", "default-zone") app_instance_info = camara_schemas.AppInstanceInfo( - name=camara_schemas.AppInstanceName(service_id), + name=camara_schemas.AppInstanceName( + encode_app_instance_name(service_id)), appId=camara_schemas.AppId(app_id), appInstanceId=camara_schemas.AppInstanceId(service_id), appProvider=camara_schemas.AppProvider(app_provider_id), @@ -491,9 +454,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): region) # CAMARA spec format for multiple instances response - camara_response = {"appInstances": instances} + camara_response = { + "appInstances": [ + inst.model_dump( + mode="json") if hasattr(inst, "model_dump") else inst + for inst in instances + ] + } self.logger.info("All app instances retrieved successfully") + self.logger.debug("Onboarded applications: %s", camara_response) return build_custom_http_response( status_code=200, content=camara_response, @@ -516,24 +486,80 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param region: Optional filter by Edge Cloud region :return: Response with application instance details """ - # TODO: Implement actual aeros-specific logic for retrieving a specific deployed app - raise NotImplementedError( - "get_deployed_app is not yet implemented for aeros adapter") + try: + if not app_instance_id: + raise InvalidArgumentError("app_instance_id is required") + + # Look up the instance in CAMARA storage (returns List[AppInstanceInfo]) + self.logger.debug( + "@@@@@@ Retrieving deployed app instance '%s' (app_id=%s, region=%s) @@@@@@", + app_instance_id, app_id, region) + matches = self.storage.find_deployments( + app_id=app_id, + app_instance_id=app_instance_id, + region=region, + ) + self.logger.debug("@@@ Deployed app instance matches: %s @@@", + matches) + if not matches: + # Be explicit in the error so callers know what was used to filter + scope = [] + scope.append(f"instance_id={app_instance_id}") + if app_id: + scope.append(f"app_id={app_id}") + if region: + scope.append(f"region={region}") + raise ResourceNotFoundError( + f"Deployed app not found ({', '.join(scope)})") + + # If multiple matched (shouldn't normally happen after filtering by instance id), + # return the first deterministically. + inst = matches[0] + + # Serialize to JSON-safe dict + content = {"appInstance": inst.model_dump(mode="json")} + + return build_custom_http_response( + status_code=200, + content=content, + headers={"Content-Type": "application/json"}, + encoding="utf-8", + ) + + except (InvalidArgumentError, ResourceNotFoundError): + # Let well-typed domain errors propagate + raise + except EdgeCloudPlatformError: + raise + except Exception as e: + # Defensive catch-all with context + self.logger.exception( + "Unhandled error retrieving deployed app instance '%s' (app_id=%s, region=%s): %s", + app_instance_id, app_id, region, e) + raise EdgeCloudPlatformError(str(e)) def _purge_deployed_app_from_continuum(self, app_id: str) -> None: + ''' + Purge the deployed application from aerOS continuum. + :param app_id: The application ID to purge + All instances of this app should be stopped + ''' aeros_client = ContinuumClient(self.base_url) - response = aeros_client.purge_service(app_id) + response = aeros_client.purge_service( + self._generate_aeros_service_id(app_id)) if response: self.logger.debug("Purged deployed application with id: %s", - app_id) + self._generate_aeros_service_id(app_id)) else: raise EdgeCloudPlatformError( - f"Failed to purg service with id from the continuum '{app_id}'" + f"Failed to purge service with id from the continuum '{app_id}'" ) def undeploy_app(self, app_instance_id: str) -> Response: - # 1. Locate app_id corresponding to this instance - app_id = self.storage.remove_deployment(app_instance_id) + # 1. Locate app_id corresponding to this instance and + # remove from deployed instances for this appId + app_id = self.storage.remove_deployment( + app_instance_id=app_instance_id) if not app_id: raise EdgeCloudPlatformError( f"No deployed app instance with ID '{app_instance_id}' found") @@ -541,7 +567,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # 2. Call the external undeploy_service aeros_client = ContinuumClient(self.base_url) try: - aeros_response = aeros_client.undeploy_service(app_instance_id) + aeros_response = aeros_client.undeploy_service( + self._generate_aeros_service_id(app_instance_id)) except Exception as e: raise EdgeCloudPlatformError( f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" @@ -552,6 +579,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # self._purge_deployed_app_from_continuum(app_instance_id) # 4. Clean up internal tracking + self.storage.remove_deployment(app_instance_id) self.storage.store_stopped_instance(app_id, app_instance_id) return build_custom_http_response( status_code=204, @@ -636,7 +664,6 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): result = self.transform_infrastructure_elements(domain_ies=ies, domain=domain) camara_response.append(result) - self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -666,11 +693,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' - self.logger.debug( - "Querying infrastructure elements for zone %s with params: %s", - zone_id, - ngsild_params, - ) + if config.DEBUG: + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) try: # Query the infrastructure elements for the specified zonese aeros_response = aeros_client.query_entities(ngsild_params) @@ -679,7 +707,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # and return the details of the edge cloud zone camara_response = self.transform_infrastructure_elements( domain_ies=aeros_domain_ies, domain=zone_id) - self.logger.debug("Transformed response: %s", camara_response) + if config.DEBUG: + self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -703,7 +732,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Artefact Management (GSMA) # ------------------------------------------------------------------------ - def create_artefact_gsma(self, request_body: dict): + def create_artefact_gsma(self, request_body: dict) -> Response: """ Uploads application artefact on partner OP. Artefact is a zip file containing scripts and/or packaging files like Terraform or Helm @@ -712,30 +741,88 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with artefact information. :return: """ - pass + try: + artefact = gsma_schemas.Artefact.model_validate(request_body) + self.storage.store_artefact_gsma(artefact) + return build_custom_http_response( + status_code=201, + content=artefact.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except ValidationError as e: + self.logger.error("Invalid GSMA artefact schema: %s", e) + raise InvalidArgumentError(str(e)) - def get_artefact_gsma(self, artefact_id: str) -> Dict: + def get_artefact_gsma(self, artefact_id: str) -> Response: """ Retrieves details about an artefact :param artefact_id: Unique identifier of the artefact. :return: Dictionary with artefact details. """ - pass + art = self.storage.get_artefact_gsma(artefact_id) + if not art: + raise ResourceNotFoundError( + f"GSMA artefact '{artefact_id}' not found") + return build_custom_http_response( + status_code=200, + content=art.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + + def list_artefacts_gsma(self): + """List all GSMA Artefacts.""" + arts = [ + a.model_dump(mode="json") + for a in self.storage.list_artefacts_gsma() + ] + return build_custom_http_response( + status_code=200, + content=arts, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) - def delete_artefact_gsma(self, artefact_id: str): + def delete_artefact_gsma(self, artefact_id: str) -> Response: """ Removes an artefact from partners OP. :param artefact_id: Unique identifier of the artefact. :return: """ - pass + if not self.storage.get_artefact_gsma(artefact_id): + raise ResourceNotFoundError( + f"GSMA artefact '{artefact_id}' not found") + self.storage.delete_artefact_gsma(artefact_id) + return build_custom_http_response(status_code=204, + content=b"", + headers={}, + encoding=None) # ------------------------------------------------------------------------ # Application Onboarding Management (GSMA) # ------------------------------------------------------------------------ + def _to_application_model( + self, entry: gsma_schemas.AppOnboardManifestGSMA + ) -> gsma_schemas.ApplicationModel: + """Internal helper to convert GSMA onboarding entry into canonical ApplicationModel.""" + zones = [ + gsma_schemas.AppDeploymentZone(countryCode="XX", zoneInfo=z) + for z in entry.appDeploymentZones + ] + return gsma_schemas.ApplicationModel( + appId=entry.appId, + appProviderId=entry.appProviderId, + appDeploymentZones=zones, + appMetaData=entry.appMetaData, + appQoSProfile=entry.appQoSProfile, + appComponentSpecs=entry.appComponentSpecs, + onboardStatusInfo="ONBOARDED", + ) + def onboard_app_gsma(self, request_body: dict): """ Submits an application details to a partner OP. @@ -747,29 +834,38 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ try: # Validate input against GSMA schema - gsma_validated_body = gsma_schemas.AppOnboardManifestGSMA.model_validate( + entry = gsma_schemas.AppOnboardManifestGSMA.model_validate( request_body) - data = gsma_validated_body.model_dump() except ValidationError as e: self.logger.error("Invalid GSMA input schema: %s", e) - raise + raise InvalidArgumentError(str(e)) + try: - data["app_id"] = data.pop("appId") - data.pop("edgeAppFQDN", None) - # FIXME: payload = i2edge_schemas.ApplicationOnboardingRequest(profile_data=data) - url = f"{self.base_url}/application/onboarding" - # FIXME: response = i2edge_post(url, payload, expected_status=201) + # Convert to ApplicationModel (canonical onboarded representation) + app_model = self._to_application_model(entry) + + # Ensure uniqueness + if self.storage.get_app_gsma(app_model.appId): + raise InvalidArgumentError( + f"GSMA app '{app_model.appId}' already exists") + + # Store in GSMA apps storage + self.storage.store_app_gsma(app_model.appId, app_model) + + # Build and return confirmation response return build_custom_http_response( - status_code=200, - content={"response": "Application onboarded successfully"}, + status_code=201, + content=app_model.model_dump(mode="json"), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, - # FIXME: url=response.url, - # FIXME: request=response.request, ) except EdgeCloudPlatformError as e: - self.logger.error("Error retrieving edge cloud zones: %s", e) + self.logger.error("Error during GSMA app onboarding: %s", e) raise + except Exception as e: + self.logger.exception("Unhandled error during GSMA onboarding: %s", + e) + raise EdgeCloudPlatformError(str(e)) def get_onboarded_app_gsma(self, app_id: str) -> Dict: """ @@ -778,27 +874,118 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param app_id: Identifier of the application onboarded. :return: Dictionary with application details. """ - pass + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + return build_custom_http_response( + status_code=200, + content=app.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error retrieving GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception( + "Unhandled error retrieving GSMA app '%s': %s", app_id, e) + raise EdgeCloudPlatformError(str(e)) def patch_onboarded_app_gsma(self, app_id: str, request_body: dict): """ Updates partner OP about changes in application compute resource requirements, - QOS Profile, associated descriptor or change in associated components + QOS Profile, associated descriptor or change in associated components. :param app_id: Identifier of the application onboarded. :param request_body: Payload with updated onboarding info. - :return: + :return: Response with updated application details. """ - pass + try: + patch = gsma_schemas.PatchOnboardedAppGSMA.model_validate( + request_body) + except ValidationError as e: + self.logger.error("Invalid GSMA patch schema: %s", e) + raise InvalidArgumentError(str(e)) + + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + upd = patch.appUpdQoSProfile + + # Update QoS profile fields + if upd.latencyConstraints is not None: + app.appQoSProfile.latencyConstraints = upd.latencyConstraints + if upd.bandwidthRequired is not None: + app.appQoSProfile.bandwidthRequired = upd.bandwidthRequired + if upd.multiUserClients is not None: + app.appQoSProfile.multiUserClients = upd.multiUserClients + if upd.noOfUsersPerAppInst is not None: + app.appQoSProfile.noOfUsersPerAppInst = upd.noOfUsersPerAppInst + if upd.appProvisioning is not None: + app.appQoSProfile.appProvisioning = upd.appProvisioning + + # mobilitySupport lives under AppMetaData + if upd.mobilitySupport is not None: + app.appMetaData.mobilitySupport = upd.mobilitySupport + + # Replace component specs if provided + if patch.appComponentSpecs: + app.appComponentSpecs = [ + gsma_schemas.AppComponentSpec( + serviceNameNB=p.serviceNameNB, + serviceNameEW=p.serviceNameEW, + componentName=p.componentName, + artefactId=p.artefactId, + ) for p in patch.appComponentSpecs + ] + + # Persist updated model + self.storage.store_app_gsma(app_id, app) + + return build_custom_http_response( + status_code=200, + content=app.model_dump(mode="json"), + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error updating GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception("Unhandled error patching GSMA app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) def delete_onboarded_app_gsma(self, app_id: str): """ - Deboards an application from specific partner OP zones + Deboards an application from specific partner OP zones. :param app_id: Identifier of the application onboarded. - :return: + :return: 204 No Content on success. """ - pass + try: + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + self.storage.delete_app_gsma(app_id) + + return build_custom_http_response( + status_code=204, + content=b"", + headers={}, + encoding=None, + ) + except EdgeCloudPlatformError as e: + self.logger.error("Error deleting GSMA app '%s': %s", app_id, e) + raise + except Exception as e: + self.logger.exception("Unhandled error deleting GSMA app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) @@ -811,7 +998,84 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param request_body: Payload with deployment info. :return: Dictionary with deployment details. """ - pass + try: + payload = gsma_schemas.AppDeployPayloadGSMA.model_validate( + request_body) + except ValidationError as e: + self.logger.error("Invalid GSMA deploy schema: %s", e) + raise InvalidArgumentError(str(e)) + + try: + # Ensure app exists + onboarded_app = self.storage.get_app_gsma(payload.appId) + if not onboarded_app: + raise ResourceNotFoundError( + f"GSMA app '{payload.appId}' not found") + + # 2. Generate unique service ID + # (aerOS) service id <=> CAMARA appInstanceId + service_id = self._generate_service_id(onboarded_app.appId) + + # 3. Create TOSCA (yaml str) from GSMA onboarded_app + connected artefacts + # GSMA app corresponds to aerOS Service + # Each GSMA AppComponentSpec references an artefact which is mapped to aerOS Service Component + tosca_yaml = gsma2aeros_converter.generate_tosca_from_gsma_with_artefacts( + app_model=onboarded_app, + zone_id=payload.zoneInfo.zoneId, + artefact_resolver=self.storage.get_artefact_gsma, # cleaner + ) + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_yaml) + + # 4. Instantiate client and call continuum to deploy servic + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.onboard_and_deploy_service( + service_id, tosca_str=tosca_yaml) + + if "serviceId" not in aeros_response: + raise EdgeCloudPlatformError( + "Invalid response from onboard_service: missing 'serviceId'" + ) + + # 5. Track deployment (Store in GSMA deployment store) + # Build AppInstance and optional status (if you want to persist status later) + inst = gsma_schemas.AppInstance( + zoneId=payload.zoneInfo.zoneId, + appInstIdentifier=service_id, + ) + status = gsma_schemas.AppInstanceStatus( + appInstanceState= + "DEPLOYED", # or "PENDING" if you simulate async + accesspointInfo=[], + ) + + self.storage.store_deployment_gsma(onboarded_app.appId, + inst, + status=status) + + # 6. Return expected format (deployment details) + body = { + "appId": payload.appId, + "appVersion": payload.appVersion, + "appProviderId": payload.appProviderId, + "zoneId": payload.zoneInfo.zoneId, + "appInstance": inst.model_dump(mode="json"), + "status": status.model_dump(mode="json"), + } + + return build_custom_http_response( + status_code=201, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError as ex: + self.logger.error("Failed to deploy app '%s': %s", + onboarded_app.appId, str(ex)) + raise + except Exception as e: + self.logger.exception("Unhandled error during GSMA deploy: %s", e) + raise EdgeCloudPlatformError(str(e)) def get_deployed_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str) -> Dict: @@ -823,7 +1087,37 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param zone_id: Identifier of the zone :return: Dictionary with application instance details """ - pass + try: + # Ensure app exists + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + matches = self.storage.find_deployments_gsma( + app_id=app_id, + app_instance_id=app_instance_id, + zone_id=zone_id, + ) + if not matches: + raise ResourceNotFoundError( + f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" + ) + + inst = matches[0] + body = inst.model_dump(mode="json") + + return build_custom_http_response( + status_code=200, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error retrieving GSMA deployment '%s' (%s/%s): %s", + app_instance_id, app_id, zone_id, e) + raise EdgeCloudPlatformError(str(e)) def get_all_deployed_apps_gsma(self, app_id: str, app_provider: str) -> List: @@ -834,7 +1128,33 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param app_provider: App provider :return: List with application instances details """ - pass + try: + app = self.storage.get_app_gsma(app_id) + if not app: + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + # Optional provider check (keep if you want extra validation) + if app_provider and app.appProviderId != app_provider: + raise ResourceNotFoundError( + f"GSMA app '{app_id}' not found for provider '{app_provider}'" + ) + + insts = self.storage.find_deployments_gsma(app_id=app_id) + body = [i.model_dump(mode="json") for i in insts] + + return build_custom_http_response( + status_code=200, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error listing GSMA deployments for app '%s': %s", + app_id, e) + raise EdgeCloudPlatformError(str(e)) def undeploy_app_gsma(self, app_id: str, app_instance_id: str, zone_id: str): @@ -846,4 +1166,48 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :param zone_id: Identifier of the zone :return: """ - pass + try: + # Ensure app exists + if not self.storage.get_app_gsma(app_id): + raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + + # Ensure the (app_id, instance, zone) exists + matches = self.storage.find_deployments_gsma( + app_id=app_id, + app_instance_id=app_instance_id, + zone_id=zone_id) + if not matches: + raise ResourceNotFoundError( + f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" + ) + + # Placeholder: call aerOS undeploy here (GSMA → aerOS conversion) + # aeros_client.undeploy(instance_id=app_instance_id, zone_id=zone_id) + + # Remove from deployed and mark as stopped so it can be purged later + removed_app_id = self.storage.remove_deployment_gsma( + app_instance_id) + if removed_app_id: + self.storage.store_stopped_instance_gsma( + removed_app_id, app_instance_id) + + # Async-friendly: 202 Accepted (termination in progress) + body = { + "appId": app_id, + "appInstIdentifier": app_instance_id, + "zoneId": zone_id, + "state": "TERMINATING", + } + return build_custom_http_response( + status_code=202, + content=body, + headers={"Content-Type": self.content_type_gsma}, + encoding=self.encoding_gsma, + ) + except EdgeCloudPlatformError: + raise + except Exception as e: + self.logger.exception( + "Unhandled error undeploying GSMA app instance '%s' (app=%s zone=%s): %s", + app_instance_id, app_id, zone_id, e) + raise EdgeCloudPlatformError(str(e)) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py index 794cba5..31f1b41 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/config.py @@ -23,5 +23,5 @@ if not aerOS_ACCESS_TOKEN: aerOS_HLO_TOKEN = "harcoded_hlo_token" if not aerOS_HLO_TOKEN: raise ValueError("Environment variable 'aerOS_HLO_TOKEN' is not set.") -DEBUG = False +DEBUG = True LOG_FILE = ".log/aeros_client.log" diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 40ade57..1240d2c 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -127,8 +127,10 @@ class ContinuumClient: undeploy_url = f"{self.api_url}/hlo_fe/services/{service_id}" response = requests.delete(undeploy_url, headers=self.hlo_headers, timeout=15) if response is None: + self.logger.debug("In NONE Undeploy service URL: %s", undeploy_url) return None else: + self.logger.debug("In OK Undeploy and text: %s", response.text) if config.DEBUG: self.logger.debug("Re-allocate service URL: %s", undeploy_url) self.logger.debug( @@ -165,7 +167,7 @@ class ContinuumClient: response.status_code, response.text, ) - return response.json() + return response @catch_requests_exceptions def purge_service(self, service_id: str) -> bool: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py similarity index 95% rename from src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py rename to src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py index da9d83d..318d575 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/camara2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/camara2aeros_converter.py @@ -17,8 +17,7 @@ from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) -def generate_tosca(app_manifest: AppManifest, - app_zones: List[Dict[str, Any]]) -> str: +def generate_tosca(app_manifest: AppManifest, app_zones: List[str]) -> str: ''' Generate a TOSCA model from the application manifest and app zones. Args: @@ -33,8 +32,7 @@ def generate_tosca(app_manifest: AppManifest, repository_url = "/".join( image_path.split("/")[:-1]) if "/" in image_path else "docker_hub" - zone_id = app_zones[0].get("EdgeCloudZone", - {}).get("edgeCloudZoneId", "default-zone") + zone_id = app_zones[0] logger.info("DEBUG : %s", app_manifest.requiredResources.root) # Extract minNodeMemory (fallback = 1024 MB) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py new file mode 100644 index 0000000..a6cd1cd --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py @@ -0,0 +1,155 @@ +""" +Module: gsm2aeros_converter.py +Initial GSMA -> TOSCA generator. + +Notes: +- GSMA ApplicationModel does not include container image or ports directly. + (Those usually come from Artefacts, which we're ignoring for now.) +- We provide an `image_map` hook to resolve artefactId -> image string. +- Defaults to a public nginx image if nothing is provided. +- Network ports are omitted for now (exposePorts = False). +""" + +from typing import Optional, Callable +import yaml +from sunrise6g_opensdk.edgecloud.adapters.aeros import config +from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( + ResourceNotFoundError, InvalidArgumentError) +from sunrise6g_opensdk.logger import setup_logger +from sunrise6g_opensdk.edgecloud.core import gsma_schemas +from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( + TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, + Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, + NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + +logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) + + +def generate_tosca_from_gsma_with_artefacts( + app_model: gsma_schemas.ApplicationModel, + zone_id: str, + artefact_resolver: Callable[[str], Optional[gsma_schemas.Artefact]], +) -> str: + """ + Build TOSCA from GSMA ApplicationModel by resolving each component's artefactId. + + - One Node (under NodeTemplates) per AppComponentSpec (connects to Artefacts). + - Image pulled from Artefact.componentSpec[i].images[0] (first image). + - Optional ports read from Artefact.componentSpec[i].exposedInterfaces (if present). + Expected dict fields (best-effort): {"protocol": "TCP", "port": 8080} + """ + node_templates = {} + + for comp in app_model.appComponentSpecs: + artefact = artefact_resolver(comp.artefactId) + if not artefact: + raise ResourceNotFoundError( + f"GSMA artefact '{comp.artefactId}' not found") + + # We pick the FIRST componentSpec entry that matches componentName if present, + # else fall back to the first componentSpec entry. + comp_spec = None + if artefact.componentSpec: + # try exact match by name + for c in artefact.componentSpec: + if c.componentName == comp.componentName: + comp_spec = c + break + if not comp_spec: + comp_spec = artefact.componentSpec[0] + else: + raise InvalidArgumentError( + f"Artefact '{artefact.artefactId}' has no componentSpec") + + # Resolve image (first image in the list) + image = comp_spec.images[ + 0] if comp_spec.images else "docker.io/library/nginx:stable" + if "/" in image: + repository_url = "/".join(image.split("/")[:-1]) + image_file = image.split("/")[-1] + else: + repository_url, image_file = "docker_hub", image + + # Build ports (best-effort read from exposedInterfaces) + ports = {} + expose_ports = False + if comp_spec.exposedInterfaces: + for idx, iface in enumerate(comp_spec.exposedInterfaces): + protocol = str(iface.get("protocol", "TCP")).lower() + port = iface.get("port") + if isinstance(port, int): + ports_id = f"if{idx}" + ports[ports_id] = ExposedPort(properties=PortProperties( + protocol=[protocol], source=port)) + expose_ports = True + + host_props = HostProperty( + cpu_arch={"equal": "x64"}, + realtime={"equal": False}, + cpu_usage={"less_or_equal": "0.4"}, + mem_size={"greater_or_equal": "1024"}, + energy_efficiency={"greater_or_equal": "0"}, + green={"greater_or_equal": "0"}, + domain_id=DomainIdOperator(equal=zone_id), + ) + + requirements = [ + CustomRequirement(network=NetworkRequirement( + properties=NetworkProperties(ports=ports, + exposePorts=expose_ports))), + CustomRequirement(host=HostRequirement(node_filter=NodeFilter( + capabilities=[{ + "host": HostCapability(properties=host_props) + }], + properties=None, + ))), + ] + + node_templates[comp.componentName] = NodeTemplate( + type="tosca.nodes.Container.Application", + isJob=False, + requirements=requirements, + artifacts={ + "application_image": + ArtifactModel( + file=image_file, + type="tosca.artifacts.Deployment.Image.Container.Docker", + repository=repository_url, + is_private=(artefact.repoType == "PRIVATEREPO"), + username=(artefact.artefactRepoLocation.userName + if artefact.artefactRepoLocation else None), + password=(artefact.artefactRepoLocation.password + if artefact.artefactRepoLocation else None), + ) + }, + interfaces={ + "Standard": { + "create": { + "implementation": "application_image", + "inputs": { + "cliArgs": + [], # could map comp_spec.commandLineParams later + "envVars": + [], # could map comp_spec.compEnvParams later + } + } + } + }, + ) + + tosca = TOSCA( + tosca_definitions_version="tosca_simple_yaml_1_3", + description= + f"GSMA->TOSCA for {app_model.appMetaData.appName} ({app_model.appId})", + serviceOverlay=False, + node_templates=node_templates, + ) + + tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + for template in tosca_dict.get("node_templates", {}).values(): + template["requirements"] = [{ + k: v + for k, v in req.items() if v is not None + } for req in template.get("requirements", [])] + + return yaml.dump(tosca_dict, sort_keys=False) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py index 16cff05..9bff25a 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/appStorageManager.py @@ -4,13 +4,36 @@ # This module defines the interface for managing application storage, # ''' from abc import ABC, abstractmethod -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, + AppInstance, + AppInstanceStatus, + Artefact) class AppStorageManager(ABC): """Abstract base class for application storage backends.""" + # ------------------------------------------------------------------------ + # aerOS Domain → Zone mapping + # ------------------------------------------------------------------------ + @abstractmethod + def store_zones(self, zones: Dict[str, Dict]) -> None: + """Store or update the aerOS domain → zone info mapping.""" + + @abstractmethod + def list_zones(self) -> List[Dict]: + """Return a list of all stored zone records (values).""" + + @abstractmethod + def resolve_domain_id_by_zone_uuid(self, zone_uuid: str) -> Optional[str]: + """Return the aerOS domain id (key) for a given edgeCloudZoneId (UUID).""" + + # ------------------------------------------------------------------------ + # CAMARA + # ------------------------------------------------------------------------ + @abstractmethod def store_app(self, app_id: str, manifest: Dict) -> None: pass @@ -67,3 +90,83 @@ class AppStorageManager(ABC): @abstractmethod def remove_stopped_instances(self, app_id: str) -> None: pass + + # ------------------------------------------------------------------------ + # GSMA + # ------------------------------------------------------------------------ + @abstractmethod + def store_app_gsma(self, app_id: str, model: ApplicationModel) -> None: + ... + + @abstractmethod + def get_app_gsma(self, app_id: str) -> Optional[ApplicationModel]: + ... + + @abstractmethod + def list_apps_gsma(self) -> List[ApplicationModel]: + ... + + @abstractmethod + def delete_app_gsma(self, app_id: str) -> None: + ... + + @abstractmethod + def store_deployment_gsma( + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # optional future use + ) -> None: + ... + + @abstractmethod + def get_deployments_gsma(self, + app_id: Optional[str] = None + ) -> Dict[str, List[str]]: + ... + + @abstractmethod + def find_deployments_gsma( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + zone_id: Optional[str] = None, + ) -> List[AppInstance]: + ... + + @abstractmethod + def remove_deployment_gsma(self, app_instance_id: str) -> Optional[str]: + ... + + @abstractmethod + def store_stopped_instance_gsma(self, app_id: str, + app_instance_id: str) -> None: + ... + + @abstractmethod + def get_stopped_instances_gsma( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + ... + + @abstractmethod + def remove_stopped_instances_gsma(self, app_id: str) -> None: + ... + + # --- GSMA Artefacts --- + @abstractmethod + def store_artefact_gsma(self, artefact: Artefact) -> None: + ... + + @abstractmethod + def get_artefact_gsma(self, artefact_id: str) -> Optional[Artefact]: + ... + + @abstractmethod + def list_artefacts_gsma(self) -> List[Artefact]: + ... + + @abstractmethod + def delete_artefact_gsma(self, artefact_id: str) -> None: + ... diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 0be3a40..79e20b0 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -1,21 +1,31 @@ -''' +""" Class: InMemoryAppStorage -''' -from threading import RLock +Process-wide singleton, thread-safe with a single RLock. +Keeps CAMARA and GSMA stores separate to avoid schema confusion. +""" + +from abc import ABCMeta +from threading import RLock from typing import Dict, List, Optional, Union -from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import AppStorageManager + +from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( + AppStorageManager) from sunrise6g_opensdk.edgecloud.core.camara_schemas import AppInstanceInfo +from sunrise6g_opensdk.edgecloud.core.gsma_schemas import (ApplicationModel, + AppInstance, + AppInstanceStatus, + Artefact) from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.logger import setup_logger -# import copy # optional if you want deep copies -class SingletonMeta(type): - """Thread-safe Singleton metaclass.""" +class SingletonMeta(ABCMeta): + """Thread-safe Singleton metaclass (process-wide).""" _instances: Dict[type, object] = {} _lock = RLock() def __call__(cls, *args, **kwargs): + # Double-checked locking if cls not in cls._instances: with cls._lock: if cls not in cls._instances: @@ -25,34 +35,101 @@ class SingletonMeta(type): class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): """ - In-memory implementation of the AppStorageManager interface (process-wide singleton). + In-memory implementation of the AppStorageManager interface. + CAMARA and GSMA data are stored in separate namespaces. """ def __init__(self): if getattr(self, "_initialized", False): return - # Initialize logger always; emit debug message conditionally + # Always have a logger; gate noisy messages by DEBUG self.logger = setup_logger() if config.DEBUG: - self.logger.info("Using InMemoryStorage") + self.logger.info("Using InMemoryStorage (singleton)") self._lock = RLock() - self._apps: Dict[str, Dict] = {} - self._deployed: Dict[str, List[AppInstanceInfo]] = {} - self._stopped: Dict[str, List[str]] = {} + + # aerOS Domain → Zone mapping + self._zones: Dict[str, + Dict] = {} # {aeros_domain_id: camara_zone_dict} + + # CAMARA stores + self._apps: Dict[str, Dict] = {} # app_id -> manifest (CAMARA dict) + self._deployed: Dict[str, List[AppInstanceInfo]] = { + } # app_id -> [AppInstanceInfo] + self._stopped: Dict[str, + List[str]] = {} # app_id -> [stopped instance ids] + + # GSMA stores + self._apps_gsma: Dict[str, ApplicationModel] = { + } # app_id -> ApplicationModel + self._deployed_gsma: Dict[str, List[AppInstance]] = { + } # app_id -> [AppInstance] + self._stopped_gsma: Dict[str, List[str]] = { + } # app_id -> [stopped instance ids] + + self._artefacts_gsma: Dict[str, + Artefact] = {} # artefact_id -> Artefact + self._initialized = True + # ------------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------------ def reset(self) -> None: - ''' - Helpful for unit tests to clear global state - ''' + """Helper for tests to clear global state.""" with self._lock: + # CAMARA self._apps.clear() self._deployed.clear() self._stopped.clear() + # GSMA + self._apps_gsma.clear() + self._deployed_gsma.clear() + self._stopped_gsma.clear() + + # ------------------------------------------------------------------------ + # aerOS Domain → Zone mapping + # ------------------------------------------------------------------------ - # --- Apps --- + def store_zones(self, zones: Dict[str, Dict]) -> None: + """ + Directly store a mapping of aerOS domain_id -> zone_info dict. + Example: + { + "urn:ngsi-ld:Domain:Athens": { + "edgeCloudZoneId": "550e8400-e29b-41d4-a716-446655440000", + "edgeCloudZoneName": "Athens", + "edgeCloudProvider": "aeros_dev", + "status": "active", + "geographyDetails": "NOT_USED", + }, + ... + } + """ + with self._lock: + self._zones.update(zones) + + def list_zones(self) -> List[Dict]: + """Return all zone records as a list of dicts.""" + with self._lock: + return [dict(v) for v in self._zones.values()] + + def resolve_domain_id_by_zone_uuid(self, zone_uuid: str) -> Optional[str]: + """ + Given the edgeCloudZoneId (UUID string), return the original aerOS domain id. + Performs a simple scan — fine for small to medium sets. + """ + with self._lock: + for domain_id, zone in self._zones.items(): + if zone.get("edgeCloudZoneId") == zone_uuid: + return domain_id + return None + + # ------------------------------------------------------------------------ + # CAMARA + # ------------------------------------------------------------------------ def store_app(self, app_id: str, manifest: Dict) -> None: with self._lock: self._apps[app_id] = manifest @@ -67,21 +144,19 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def list_apps(self) -> List[Dict]: with self._lock: - # If you want full isolation, use deepcopy: - # return [copy.deepcopy(m) for m in self._apps.values()] + # shallow copies to avoid external mutation of nested dicts return [dict(m) for m in self._apps.values()] def delete_app(self, app_id: str) -> None: with self._lock: self._apps.pop(app_id, None) - # --- Deployments --- def store_deployment(self, app_instance: AppInstanceInfo) -> None: with self._lock: - aid = str(app_instance.appId) + # Ensure the key is a plain string + aid = getattr(app_instance.appId, "root", str(app_instance.appId)) self._deployed.setdefault(aid, []).append(app_instance) - # Conform to interface -> Dict[str, List[str]] def get_deployments(self, app_id: Optional[str] = None) -> Dict[str, List[str]]: with self._lock: @@ -107,13 +182,26 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if app_instance_id: for insts in self._deployed.values(): for inst in insts: - if str(inst.appInstanceId) == app_instance_id: + self.logger.debug( + "186: Checking deployed instance id='%s' against '%s'", + str(inst.appInstanceId), app_instance_id) + if str(inst.appInstanceId.root) == app_instance_id: if app_id and str(inst.appId) != app_id: + self.logger.debug( + "189: app_id mismatch: '%s' != '%s'", + str(inst.appId), app_id) return [] if region is not None and getattr( inst, "region", None) != region: + self.logger.debug( + "193: region mismatch: '%s' != '%s'", + getattr(inst, "region", None), region) return [] + self.logger.debug( + "197: Found matching instance: %s", inst) return [inst] + self.logger.debug("200: No matching instance found for id='%s'", + app_instance_id) return [] results: List[AppInstanceInfo] = [] @@ -129,22 +217,25 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def remove_deployment(self, app_instance_id: str) -> Optional[str]: with self._lock: - for aid, insts in list( - self._deployed.items()): # iterate over a copy of items + for aid, insts in list(self._deployed.items()): for idx, inst in enumerate(insts): - if str(inst.appInstanceId) == app_instance_id: + # Compare using the instance id string + inst_id = getattr(inst.appInstanceId, "root", + str(inst.appInstanceId)) + if inst_id == app_instance_id: insts.pop(idx) if not insts: self._deployed.pop(aid, None) - return aid + # Return a plain string app_id + aid_str = getattr(aid, "root", str(aid)) + return aid_str return None - # --- Stopped --- def store_stopped_instance(self, app_id: str, app_instance_id: str) -> None: with self._lock: lst = self._stopped.setdefault(app_id, []) - if app_instance_id not in lst: # de-duplicate + if app_instance_id not in lst: lst.append(app_instance_id) def get_stopped_instances( @@ -159,3 +250,130 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): def remove_stopped_instances(self, app_id: str) -> None: with self._lock: self._stopped.pop(app_id, None) + + # ------------------------------------------------------------------------ + # GSMA + # ------------------------------------------------------------------------ + def store_app_gsma(self, app_id: str, model: ApplicationModel) -> None: + with self._lock: + self._apps_gsma[app_id] = model + + def get_app_gsma(self, app_id: str) -> Optional[ApplicationModel]: + with self._lock: + return self._apps_gsma.get(app_id) + + def list_apps_gsma(self) -> List[ApplicationModel]: + with self._lock: + return list(self._apps_gsma.values()) + + def delete_app_gsma(self, app_id: str) -> None: + with self._lock: + self._apps_gsma.pop(app_id, None) + + def store_deployment_gsma( + self, + app_id: str, + inst: AppInstance, + status: Optional[AppInstanceStatus] = None, # not persisted yet + ) -> None: + with self._lock: + self._deployed_gsma.setdefault(app_id, []).append(inst) + # If you later want to persist status per instance, keep a side map: + # self._status_gsma[inst.appInstIdentifier] = status + + def get_deployments_gsma(self, + app_id: Optional[str] = None + ) -> Dict[str, List[str]]: + with self._lock: + if app_id: + ids = [ + str(i.appInstIdentifier) + for i in self._deployed_gsma.get(app_id, []) + ] + return {app_id: ids} + return { + aid: [str(i.appInstIdentifier) for i in insts] + for aid, insts in self._deployed_gsma.items() + } + + def find_deployments_gsma( + self, + app_id: Optional[str] = None, + app_instance_id: Optional[str] = None, + zone_id: Optional[str] = None, + ) -> List[AppInstance]: + with self._lock: + # Limit the search space if app_id is provided + iter_lists = ([self._deployed_gsma.get(app_id, [])] + if app_id else self._deployed_gsma.values()) + + # Fast path: instance id provided + if app_instance_id: + target_id = str(app_instance_id) + for insts in iter_lists: + for inst in insts: + if str(inst.appInstIdentifier) != target_id: + continue + if zone_id is not None and inst.zoneId != zone_id: + continue + return [inst] + return [] + + # General filtering + results: List[AppInstance] = [] + for insts in iter_lists: + for inst in insts: + if zone_id is not None and inst.zoneId != zone_id: + continue + results.append(inst) + return results + + def remove_deployment_gsma(self, app_instance_id: str) -> Optional[str]: + with self._lock: + for aid, insts in list(self._deployed_gsma.items()): + for idx, inst in enumerate(insts): + if str(inst.appInstIdentifier) == app_instance_id: + insts.pop(idx) + if not insts: + self._deployed_gsma.pop(aid, None) + return aid + return None + + def store_stopped_instance_gsma(self, app_id: str, + app_instance_id: str) -> None: + with self._lock: + lst = self._stopped_gsma.setdefault(app_id, []) + if app_instance_id not in lst: + lst.append(app_instance_id) + + def get_stopped_instances_gsma( + self, + app_id: Optional[str] = None + ) -> Union[List[str], Dict[str, List[str]]]: + with self._lock: + if app_id: + return list(self._stopped_gsma.get(app_id, [])) + return {aid: list(ids) for aid, ids in self._stopped_gsma.items()} + + def remove_stopped_instances_gsma(self, app_id: str) -> None: + with self._lock: + self._stopped_gsma.pop(app_id, None) + + # ------------------------------------------------------------------------ + # GSMA Artefacts + # ------------------------------------------------------------------------ + def store_artefact_gsma(self, artefact: Artefact) -> None: + with self._lock: + self._artefacts_gsma[artefact.artefactId] = artefact + + def get_artefact_gsma(self, artefact_id: str) -> Optional[Artefact]: + with self._lock: + return self._artefacts_gsma.get(artefact_id) + + def list_artefacts_gsma(self) -> List[Artefact]: + with self._lock: + return list(self._artefacts_gsma.values()) + + def delete_artefact_gsma(self, artefact_id: str) -> None: + with self._lock: + self._artefacts_gsma.pop(artefact_id, None) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 0298c84..5859046 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -8,12 +8,88 @@ """ aerOS help methods """ +import uuid +import string from requests.exceptions import HTTPError, RequestException, Timeout import sunrise6g_opensdk.edgecloud.adapters.aeros.config as config import sunrise6g_opensdk.edgecloud.adapters.aeros.errors as errors from sunrise6g_opensdk.logger import setup_logger +_HEX = "0123456789abcdef" +_ALLOWED = set( + string.ascii_letters + + string.digits) # no underscore here; underscore is always escaped +_PREFIX = "A0_" # ensures name starts with a letter; stripped during decode + + +def encode_app_instance_name(original: str, *, max_len: int = 64) -> str: + """ + aerOS to CAMARA AppInstanceName encoder. + Reversibly encode `original` into a string matching ^[A-Za-z][A-Za-z0-9_]{1,63}$. + Uses underscore + two hex digits to escape any non [A-Za-z0-9] chars, including '_' itself. + If the encoded result would exceed `max_len`, raise ValueError (reversibility would be lost otherwise). + """ + out = [] + for ch in original: + if ch in _ALLOWED: + out.append(ch) + elif ch == "_": + out.append("_5f") + else: + # escape any other byte as _hh (lowercase hex) + out.append("_" + format(ord(ch), "02x")) + + enc = "".join(out) + + # must start with a letter + if not enc or enc[0] not in string.ascii_letters: + enc = _PREFIX + enc + + if len(enc) > max_len: + raise ValueError( + f"Encoded name exceeds {max_len} chars; cannot keep reversibility without external mapping." + ) + return enc + + +def decode_app_instance_name(encoded: str) -> str: + """ + CAMARA AppInstanceName to aerOS original app_id decoder. + Reverse of encode_app_instance_name. Restores the exact original string. + """ + s = encoded + if s.startswith(_PREFIX): + s = s[len(_PREFIX):] + + # walk and decode _hh sequences; underscores never appear unescaped in the encoding + i = 0 + out = [] + while i < len(s): + ch = s[i] + if ch != "_": + out.append(ch) + i += 1 + continue + + # expect two hex digits after underscore + if i + 2 >= len(s): + raise ValueError("Invalid escape at end of string.") + h1 = s[i + 1].lower() + h2 = s[i + 2].lower() + if h1 not in _HEX or h2 not in _HEX: + raise ValueError(f"Invalid escape sequence: _{h1}{h2}") + code = int(h1 + h2, 16) + out.append(chr(code)) + i += 3 + + return "".join(out) + + +def urn_to_uuid(urn: str) -> uuid.UUID: + """Convert a (ngsi-ld) URN string to a deterministic UUID.""" + return uuid.uuid5(uuid.NAMESPACE_URL, urn) + def catch_requests_exceptions(func): """ -- GitLab From adda31e585d89f0aae2160a52ffc65674aa746eb Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 10:15:28 +0300 Subject: [PATCH 277/281] Clear debug logging --- .../edgecloud/adapters/aeros/client.py | 47 +++++++++---------- .../adapters/aeros/continuum_client.py | 1 - .../storageManagement/inMemoryStorage.py | 13 ----- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index d3c6cd2..dce9910 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -139,10 +139,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=camara_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -159,11 +159,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): """ aeros_client = ContinuumClient(self.base_url) ngsild_params = f'format=simplified&type=InfrastructureElement&q=domain=="{zone_id}"' - self.logger.debug( - "Querying infrastructure elements for zone %s with params: %s", - zone_id, - ngsild_params, - ) + if config.DEBUG: + self.logger.debug( + "Querying infrastructure elements for zone %s with params: %s", + zone_id, + ngsild_params, + ) try: # Query the infrastructure elements for the specified zonese aeros_response = aeros_client.query_entities(ngsild_params) @@ -172,7 +173,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # and return the details of the edge cloud zone camara_response = self.transform_infrastructure_elements( domain_ies=aeros_domain_ies, domain=zone_id) - self.logger.debug("Transformed response: %s", camara_response) + if config.DEBUG: + self.logger.debug("Transformed response: %s", camara_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, @@ -183,10 +185,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -384,8 +386,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): ] tosca_str = camara2aeros_converter.generate_tosca( app_manifest=app_manifest, app_zones=aeros_domain_ids) - self.logger.info("Generated TOSCA YAML:") - self.logger.info(tosca_str) + if config.DEBUG: + self.logger.info("Generated TOSCA YAML:") + self.logger.info(tosca_str) # 4. Instantiate client and call continuum to deploy service try: @@ -463,7 +466,8 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): } self.logger.info("All app instances retrieved successfully") - self.logger.debug("Onboarded applications: %s", camara_response) + if config.DEBUG: + self.logger.debug("Onboarded applications: %s", camara_response) return build_custom_http_response( status_code=200, content=camara_response, @@ -491,16 +495,11 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise InvalidArgumentError("app_instance_id is required") # Look up the instance in CAMARA storage (returns List[AppInstanceInfo]) - self.logger.debug( - "@@@@@@ Retrieving deployed app instance '%s' (app_id=%s, region=%s) @@@@@@", - app_instance_id, app_id, region) matches = self.storage.find_deployments( app_id=app_id, app_instance_id=app_instance_id, region=region, ) - self.logger.debug("@@@ Deployed app instance matches: %s @@@", - matches) if not matches: # Be explicit in the error so callers know what was used to filter scope = [] @@ -626,10 +625,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -674,10 +673,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) @@ -719,10 +718,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): request=aeros_response.request, ) except json.JSONDecodeError as e: - self.logger.error("Invalid JSON in i2Edge response: %s", e) + self.logger.error("Invalid JSON in aerOS response: %s", e) raise except KeyError as e: - self.logger.error("Missing expected field in i2Edge data: %s", e) + self.logger.error("Missing expected field in aerOS data: %s", e) raise except EdgeCloudPlatformError as e: self.logger.error("Error retrieving edge cloud zones: %s", e) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 1240d2c..1fcd7e1 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -127,7 +127,6 @@ class ContinuumClient: undeploy_url = f"{self.api_url}/hlo_fe/services/{service_id}" response = requests.delete(undeploy_url, headers=self.hlo_headers, timeout=15) if response is None: - self.logger.debug("In NONE Undeploy service URL: %s", undeploy_url) return None else: self.logger.debug("In OK Undeploy and text: %s", response.text) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py index 79e20b0..a2e27ef 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/storageManagement/inMemoryStorage.py @@ -182,26 +182,13 @@ class InMemoryAppStorage(AppStorageManager, metaclass=SingletonMeta): if app_instance_id: for insts in self._deployed.values(): for inst in insts: - self.logger.debug( - "186: Checking deployed instance id='%s' against '%s'", - str(inst.appInstanceId), app_instance_id) if str(inst.appInstanceId.root) == app_instance_id: if app_id and str(inst.appId) != app_id: - self.logger.debug( - "189: app_id mismatch: '%s' != '%s'", - str(inst.appId), app_id) return [] if region is not None and getattr( inst, "region", None) != region: - self.logger.debug( - "193: region mismatch: '%s' != '%s'", - getattr(inst, "region", None), region) return [] - self.logger.debug( - "197: Found matching instance: %s", inst) return [inst] - self.logger.debug("200: No matching instance found for id='%s'", - app_instance_id) return [] results: List[AppInstanceInfo] = [] -- GitLab From 3d7a71b482adfebe47236d9ac4d11b9446602523 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Sun, 19 Oct 2025 12:16:59 +0300 Subject: [PATCH 278/281] GSMA edge zones validated (3 tests) --- .../edgecloud/adapters/aeros/client.py | 39 ++--- .../converters/aeros2gsma_zone_details.py | 154 ++++++++++++++++++ 2 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index dce9910..28e0a22 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -5,10 +5,8 @@ # - Vasilis Pitsilis (vpitsilis@dat.demokritos.gr, vpitsilis@iit.demokritos.gr) # - Andreas Sakellaropoulos (asakellaropoulos@iit.demokritos.gr) ## -from time import sleep import uuid import json -import re from typing import Any, Dict, List, Optional from collections import defaultdict from pydantic import ValidationError @@ -18,9 +16,9 @@ from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( urn_to_uuid, encode_app_instance_name) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient -from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import gsma2aeros_converter from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage -from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import camara2aeros_converter +from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import ( + camara2aeros_converter, gsma2aeros_converter, aeros2gsma_zone_details) from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement.appStorageManager import ( AppStorageManager, ) from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError @@ -246,12 +244,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): domain, "reservedComputeResources": [{ "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu), + "numCPU": int(total_cpu), "memory": total_ram, }], "computeResourceQuotaLimits": [{ "cpuArchType": "ISA_X86_64", - "numCPU": str(total_cpu * 2), # Assume quota is 2x total? + "numCPU": int(total_cpu * 2), # Assume quota is 2x total? "memory": total_ram * 2, }], "flavoursSupported": @@ -609,15 +607,12 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_response = aeros_client.query_entities(ngsild_params) aeros_domains = aeros_response.json() zone_list = [{ - "zoneId": - domain["id"], - "status": - domain["domainStatus"].split(":")[-1].lower(), - "geographyDetails": - "NOT_USED", + "zoneId": domain["id"], + "geolocation": "NOT_Available", + "geographyDetails": domain["description"], } for domain in aeros_domains] return build_custom_http_response( - status_code=aeros_domains.status_code, + status_code=aeros_response.status_code, content=zone_list, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, @@ -658,15 +653,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): # Transform the IEs to required format # per domain and append to response list - camara_response = [] + gsma_response = [] for domain, ies in grouped_by_domain.items(): - result = self.transform_infrastructure_elements(domain_ies=ies, - domain=domain) - camara_response.append(result) + result = aeros2gsma_zone_details.transformer(domain_ies=ies, + domain=domain) + gsma_response.append(result) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, - content=camara_response, + content=gsma_response, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, url=aeros_response.url, @@ -704,14 +699,16 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_domain_ies = aeros_response.json() # Transform the infrastructure elements into the required format # and return the details of the edge cloud zone - camara_response = self.transform_infrastructure_elements( + # camara_response = self.transform_infrastructure_elements( + # domain_ies=aeros_domain_ies, domain=zone_id) + gsma_response = aeros2gsma_zone_details.transformer( domain_ies=aeros_domain_ies, domain=zone_id) if config.DEBUG: - self.logger.debug("Transformed response: %s", camara_response) + self.logger.debug("Transformed response: %s", gsma_response) # Return the transformed response return build_custom_http_response( status_code=aeros_response.status_code, - content=camara_response, + content=gsma_response, headers={"Content-Type": "application/json"}, encoding=aeros_response.encoding, url=aeros_response.url, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py new file mode 100644 index 0000000..b83228e --- /dev/null +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/aeros2gsma_zone_details.py @@ -0,0 +1,154 @@ +''' +aeros2gsma_zone_details.py +''' +from typing import List, Dict, Any + + +def transformer(domain_ies: List[Dict[str, Any]], + domain: str) -> Dict[str, Any]: + """ + Transform aerOS InfrastructureElements into GSMA ZoneRegisteredData structure. + :param domain_ies: List of aerOS InfrastructureElement dicts + :param domain: The ID of the edge cloud zone (zoneId) + :return: Dict matching gsma_schemas.ZoneRegisteredData (JSON-serializable) + """ + + def map_cpu_arch_to_isa(urn: str) -> str: + """ + Map aerOS cpuArchitecture URN to GSMA ISA_* literal. + Examples: + 'urn:ngsi-ld:CpuArchitecture:x64' -> 'ISA_X86_64' + 'urn:ngsi-ld:CpuArchitecture:arm64' -> 'ISA_ARM_64' + 'urn:ngsi-ld:CpuArchitecture:arm32' -> 'ISA_ARM_64' (closest) + 'urn:ngsi-ld:CpuArchitecture:x86' -> 'ISA_X86' + Fallback: 'ISA_X86_64' + """ + if not isinstance(urn, str): + return "ISA_X86_64" + tail = urn.split(":")[-1].lower() + if tail in ("x64", "x86_64", "amd64"): + return "ISA_X86_64" + if tail in ("x86", "i386", "i686"): + return "ISA_X86" + if tail in ("arm64", "aarch64"): + return "ISA_ARM_64" + if tail in ("arm32", "arm"): + # GSMA only has ARM_64 vs X86/X86_64; pick closest + return "ISA_ARM_64" + return "ISA_X86_64" + + def map_cpu_arch_to_ostype_arch(urn: str) -> str: + """ + Map aerOS cpuArchitecture URN to OSType.architecture literal: 'x86_64' or 'x86'. + Use 'x86_64' for x64/arm64 (closest allowed), and 'x86' for x86/arm32. + """ + if not isinstance(urn, str): + return "x86_64" + tail = urn.split(":")[-1].lower() + if tail in ("x64", "x86_64", "amd64", "arm64", "aarch64"): + return "x86_64" + if tail in ("x86", "i386", "i686", "arm32", "arm"): + return "x86" + return "x86_64" + + def map_os_distribution(_urn: str) -> str: + """ + aerOS uses 'urn:ngsi-ld:OperatingSystem:Linux' etc. + map Linux -> UBUNTU (assume), else OTHER. + """ + if isinstance(_urn, str) and _urn.split(":")[-1].lower() == "linux": + return "UBUNTU" + return "OTHER" + + def default_os_version(dist: str) -> str: + # You asked to assume Ubuntu 22.04 LTS for Linux + return "OS_VERSION_UBUNTU_2204_LTS" if dist == "UBUNTU" else "OTHER" + + # Totals (aggregate over elements) + total_cpu = 0 + total_ram = 0 + total_disk = 0 + total_available_ram = 0 + total_available_disk = 0 + + flavours_supported: List[Dict[str, Any]] = [] + seen_cpu_isas: set[str] = set() + + for element in domain_ies: + cpu_cores = int(element.get("cpuCores", 0) or 0) + ram_cap = int(element.get("ramCapacity", 0) or 0) # MB? + avail_ram = int(element.get("availableRam", 0) or 0) # MB? + disk_cap = int(element.get("diskCapacity", 0) + or 0) # MB/GB? (pass-through) + avail_disk = int(element.get("availableDisk", 0) or 0) + + total_cpu += cpu_cores + total_ram += ram_cap + total_available_ram += avail_ram + total_disk += disk_cap + total_available_disk += avail_disk + + cpu_arch_urn = element.get("cpuArchitecture", "") + os_urn = element.get("operatingSystem", "") + + isa = map_cpu_arch_to_isa(cpu_arch_urn) + seen_cpu_isas.add(isa) + ost_arch = map_cpu_arch_to_ostype_arch(cpu_arch_urn) + dist = map_os_distribution(os_urn) + ver = default_os_version(dist) + + # Create a flavour per machine + flavour = { + "flavourId": + f"{element.get('hostname', 'host')}-{element.get('containerTechnology', 'CT')}", + "cpuArchType": + isa, # Literal ISA_* + "supportedOSTypes": [{ + "architecture": ost_arch, # 'x86_64' or 'x86' + "distribution": dist, # 'UBUNTU' or 'OTHER' + "version": ver, # 'OS_VERSION_UBUNTU_2204_LTS' or 'OTHER' + "license": "OS_LICENSE_TYPE_FREE", + }], + "numCPU": + cpu_cores, + "memorySize": + ram_cap, + "storageSize": + disk_cap, + } + flavours_supported.append(flavour) + + # Decide a single ISA for the aggregate reserved/quota entries + # Preference order: X86_64, ARM_64, X86 + def pick_aggregate_isa() -> str: + if "ISA_X86_64" in seen_cpu_isas: + return "ISA_X86_64" + if "ISA_ARM_64" in seen_cpu_isas: + return "ISA_ARM_64" + if "ISA_X86" in seen_cpu_isas: + return "ISA_X86" + # fallback + return "ISA_X86_64" + + agg_isa = pick_aggregate_isa() + + result = { + "zoneId": + domain, + "reservedComputeResources": [{ + "cpuArchType": agg_isa, + "numCPU": int( + total_cpu + ), # Same as Quotas untill we have somem policy or data to differentiate + "memory": total_ram, # ditto + }], + "computeResourceQuotaLimits": [{ + "cpuArchType": agg_isa, + "numCPU": int(total_cpu), + "memory": total_ram, + }], + "flavoursSupported": + flavours_supported, + } + + return result -- GitLab From bc5fe4cb0104067849f8b0a1812a672ec5bf1ef6 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Thu, 23 Oct 2025 11:57:06 +0300 Subject: [PATCH 279/281] GSMA Validated --- .../edgecloud/adapters/aeros/client.py | 126 +++++++++++------ .../adapters/aeros/continuum_client.py | 4 +- .../aeros/converters/gsma2aeros_converter.py | 130 +++++++++++++----- .../edgecloud/adapters/aeros/utils.py | 24 ++++ 4 files changed, 206 insertions(+), 78 deletions(-) diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py index 28e0a22..cd85cab 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/client.py @@ -14,7 +14,7 @@ from requests import Response from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.utils import ( - urn_to_uuid, encode_app_instance_name) + urn_to_uuid, encode_app_instance_name, map_aeros_service_status_to_gsma) from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_client import ContinuumClient from sunrise6g_opensdk.edgecloud.adapters.aeros.storageManagement import inMemoryStorage from sunrise6g_opensdk.edgecloud.adapters.aeros.converters import ( @@ -967,6 +967,25 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not self.storage.get_app_gsma(app_id): raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") + # CHECKME: update for GSMA + service_instances = self.storage.get_stopped_instances_gsma( + app_id=app_id) + if not service_instances: + raise EdgeCloudPlatformError( + f"Application with id '{app_id}' cannot be deleted — please stop it first" + ) + self.logger.debug( + "Deleting application with id: %s and instances: %s", + app_id, + service_instances, + ) + for service_instance in service_instances: + self._purge_deployed_app_from_continuum_gsma(service_instance) + self.logger.debug("successfully purged service instance: %s", + service_instance) + + self.storage.remove_stopped_instances_gsma(app_id) + self.storage.delete_app_gsma(app_id) return build_custom_http_response( @@ -983,6 +1002,23 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_id, e) raise EdgeCloudPlatformError(str(e)) + def _purge_deployed_app_from_continuum_gsma(self, + app_instance_id: str) -> None: + ''' + Purge the deployed application from aerOS continuum. + :param app_id: The application ID to purge + All instances of this app should be stopped + ''' + aeros_client = ContinuumClient(self.base_url) + response = aeros_client.purge_service(app_instance_id) + if response: + self.logger.debug("Purged deployed application with id: %s", + app_instance_id) + else: + raise EdgeCloudPlatformError( + f"Failed to purge service with id from the continuum '{app_instance_id}'" + ) + # ------------------------------------------------------------------------ # Application Deployment Management (GSMA) # ------------------------------------------------------------------------ @@ -1009,8 +1045,9 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"GSMA app '{payload.appId}' not found") # 2. Generate unique service ID - # (aerOS) service id <=> CAMARA appInstanceId - service_id = self._generate_service_id(onboarded_app.appId) + # (aerOS) service id <=> GSMA appInstanceId + service_id = self._generate_aeros_service_id( + self._generate_service_id(onboarded_app.appId)) # 3. Create TOSCA (yaml str) from GSMA onboarded_app + connected artefacts # GSMA app corresponds to aerOS Service @@ -1028,7 +1065,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): aeros_response = aeros_client.onboard_and_deploy_service( service_id, tosca_str=tosca_yaml) - if "serviceId" not in aeros_response: + if "serviceId" not in aeros_response.json(): raise EdgeCloudPlatformError( "Invalid response from onboard_service: missing 'serviceId'" ) @@ -1040,8 +1077,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): appInstIdentifier=service_id, ) status = gsma_schemas.AppInstanceStatus( - appInstanceState= - "DEPLOYED", # or "PENDING" if you simulate async + appInstanceState="PENDING", accesspointInfo=[], ) @@ -1050,21 +1086,15 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): status=status) # 6. Return expected format (deployment details) - body = { - "appId": payload.appId, - "appVersion": payload.appVersion, - "appProviderId": payload.appProviderId, - "zoneId": payload.zoneInfo.zoneId, - "appInstance": inst.model_dump(mode="json"), - "status": status.model_dump(mode="json"), - } + body = inst.model_dump(mode="json") return build_custom_http_response( - status_code=201, + status_code=202, content=body, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, - ) + url=aeros_response.json().get("url", ""), + request=aeros_response.request) except EdgeCloudPlatformError as ex: self.logger.error("Failed to deploy app '%s': %s", onboarded_app.appId, str(ex)) @@ -1088,24 +1118,34 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): if not self.storage.get_app_gsma(app_id): raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") - matches = self.storage.find_deployments_gsma( - app_id=app_id, - app_instance_id=app_instance_id, - zone_id=zone_id, + # 4. Instantiate client and call continuum to deploy servic + aeros_client = ContinuumClient(self.base_url) + aeros_response = aeros_client.query_entity( + entity_id=app_instance_id, ngsild_params='format=simplified') + + response_json = aeros_response.json() + content = gsma_schemas.AppInstanceStatus( + appInstanceState=map_aeros_service_status_to_gsma( + response_json.get("actionType")), + accesspointInfo=[{ + "service_status": + f'{self.base_url}/entities/{app_instance_id}' + }, { + "serviceComponents_status": + f'{self.base_url}/hlo_fe/services//{app_instance_id}' + }], ) - if not matches: - raise ResourceNotFoundError( - f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" - ) - inst = matches[0] - body = inst.model_dump(mode="json") + validated_data = gsma_schemas.AppInstanceStatus.model_validate( + content) return build_custom_http_response( status_code=200, - content=body, + content=validated_data.model_dump(mode="json"), headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, + url=aeros_response.url, + request=aeros_response.request, ) except EdgeCloudPlatformError: raise @@ -1115,8 +1155,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): app_instance_id, app_id, zone_id, e) raise EdgeCloudPlatformError(str(e)) - def get_all_deployed_apps_gsma(self, app_id: str, - app_provider: str) -> List: + def get_all_deployed_apps_gsma(self) -> Response: """ Retrieves all instances for a given application of partner OP @@ -1125,18 +1164,10 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): :return: List with application instances details """ try: - app = self.storage.get_app_gsma(app_id) - if not app: - raise ResourceNotFoundError(f"GSMA app '{app_id}' not found") - - # Optional provider check (keep if you want extra validation) - if app_provider and app.appProviderId != app_provider: - raise ResourceNotFoundError( - f"GSMA app '{app_id}' not found for provider '{app_provider}'" - ) - - insts = self.storage.find_deployments_gsma(app_id=app_id) + insts = self.storage.find_deployments_gsma() body = [i.model_dump(mode="json") for i in insts] + self.logger.info("All GSMA app instances retrieved successfully") + self.logger.debug("Deployed GSMA applications: %s", body) return build_custom_http_response( status_code=200, @@ -1148,8 +1179,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): raise except Exception as e: self.logger.exception( - "Unhandled error listing GSMA deployments for app '%s': %s", - app_id, e) + "Unhandled error listing GSMA deployments: '%s'", e) raise EdgeCloudPlatformError(str(e)) def undeploy_app_gsma(self, app_id: str, app_instance_id: str, @@ -1177,8 +1207,14 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): f"Deployment not found (app_id={app_id}, instance={app_instance_id}, zone={zone_id})" ) - # Placeholder: call aerOS undeploy here (GSMA → aerOS conversion) - # aeros_client.undeploy(instance_id=app_instance_id, zone_id=zone_id) + # 2. Call the external undeploy_service + aeros_client = ContinuumClient(self.base_url) + try: + aeros_response = aeros_client.undeploy_service(app_instance_id) + except Exception as e: + raise EdgeCloudPlatformError( + f"Failed to undeploy app instance '{app_instance_id}': {str(e)}" + ) from e # Remove from deployed and mark as stopped so it can be purged later removed_app_id = self.storage.remove_deployment_gsma( @@ -1195,7 +1231,7 @@ class EdgeApplicationManager(EdgeCloudManagementInterface): "state": "TERMINATING", } return build_custom_http_response( - status_code=202, + status_code=aeros_response.status_code, content=body, headers={"Content-Type": self.content_type_gsma}, encoding=self.encoding_gsma, diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py index 1fcd7e1..413facf 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/continuum_client.py @@ -51,7 +51,7 @@ class ContinuumClient: } @catch_requests_exceptions - def query_entity(self, entity_id, ngsild_params) -> dict: + def query_entity(self, entity_id, ngsild_params) -> requests.Response: """ Query entity with ngsi-ld params :input @@ -70,7 +70,7 @@ class ContinuumClient: self.logger.debug( "Query entity response: %s %s", response.status_code, response.text ) - return response.json() + return response @catch_requests_exceptions def query_entities(self, ngsild_params) -> requests.Response: diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py index a6cd1cd..c3a0bae 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/converters/gsma2aeros_converter.py @@ -10,17 +10,30 @@ Notes: - Network ports are omitted for now (exposePorts = False). """ -from typing import Optional, Callable +from typing import Optional, Callable, Dict, Any, List import yaml from sunrise6g_opensdk.edgecloud.adapters.aeros import config from sunrise6g_opensdk.edgecloud.adapters.aeros.errors import ( - ResourceNotFoundError, InvalidArgumentError) + ResourceNotFoundError, + InvalidArgumentError, +) from sunrise6g_opensdk.logger import setup_logger from sunrise6g_opensdk.edgecloud.core import gsma_schemas from sunrise6g_opensdk.edgecloud.adapters.aeros.continuum_models import ( - TOSCA, NodeTemplate, CustomRequirement, HostRequirement, HostCapability, - Property as HostProperty, DomainIdOperator, NodeFilter, NetworkRequirement, - NetworkProperties, ExposedPort, PortProperties, ArtifactModel) + TOSCA, + NodeTemplate, + CustomRequirement, + HostRequirement, + HostCapability, + Property as HostProperty, + DomainIdOperator, + NodeFilter, + NetworkRequirement, + NetworkProperties, + ExposedPort, + PortProperties, + ArtifactModel, +) logger = setup_logger(__name__, is_debug=True, file_name=config.LOG_FILE) @@ -31,14 +44,28 @@ def generate_tosca_from_gsma_with_artefacts( artefact_resolver: Callable[[str], Optional[gsma_schemas.Artefact]], ) -> str: """ - Build TOSCA from GSMA ApplicationModel by resolving each component's artefactId. - - - One Node (under NodeTemplates) per AppComponentSpec (connects to Artefacts). - - Image pulled from Artefact.componentSpec[i].images[0] (first image). - - Optional ports read from Artefact.componentSpec[i].exposedInterfaces (if present). - Expected dict fields (best-effort): {"protocol": "TCP", "port": 8080} + Build a TOSCA YAML from a GSMA `ApplicationModel` by resolving each component's `artefactId`. + + Rules/assumptions: + - One node_template per `AppComponentSpec` in the application model. + - Container image is taken from the first entry of `artefact.componentSpec[i].images`. + - Ports come (best-effort) from `exposedInterfaces` items in the matching componentSpec, e.g. {"protocol": "TCP", "port": 8080}. + - Host filter includes domain_id == `zone_id` and basic CPU/mem constraints. + - For PUBLICREPO artefacts: set `is_private=False` and omit credentials entirely. + For PRIVATEREPO artefacts: set `is_private=True` and include non-empty username/password if present. + - `cliArgs` are derived from `commandLineParams` dict: + - bool True -> "flag" + - key/value -> "key=value" + `envVars` are derived from `compEnvParams` list: + - [{"name": "KEY", "value": "VAL"}] -> [{"KEY": "VAL"}, ...] + - If a component name mismatch occurs between app and artefact, fall back to the first artefact componentSpec. + + :param app_model: GSMA ApplicationModel (already validated) + :param zone_id: Target aerOS domain id/zone urn for host node filter + :param artefact_resolver: Callable that returns an Artefact for a given artefactId + :return: TOSCA YAML string (tosca_simple_yaml_1_3) """ - node_templates = {} + node_templates: Dict[str, NodeTemplate] = {} for comp in app_model.appComponentSpecs: artefact = artefact_resolver(comp.artefactId) @@ -46,22 +73,20 @@ def generate_tosca_from_gsma_with_artefacts( raise ResourceNotFoundError( f"GSMA artefact '{comp.artefactId}' not found") - # We pick the FIRST componentSpec entry that matches componentName if present, - # else fall back to the first componentSpec entry. + # pick the componentSpec that matches componentName, else first comp_spec = None if artefact.componentSpec: - # try exact match by name for c in artefact.componentSpec: if c.componentName == comp.componentName: comp_spec = c break - if not comp_spec: + if comp_spec is None: comp_spec = artefact.componentSpec[0] else: raise InvalidArgumentError( f"Artefact '{artefact.artefactId}' has no componentSpec") - # Resolve image (first image in the list) + # Resolve container image image = comp_spec.images[ 0] if comp_spec.images else "docker.io/library/nginx:stable" if "/" in image: @@ -70,19 +95,53 @@ def generate_tosca_from_gsma_with_artefacts( else: repository_url, image_file = "docker_hub", image - # Build ports (best-effort read from exposedInterfaces) - ports = {} + # Ports (best-effort) from exposedInterfaces + ports: Dict[str, ExposedPort] = {} expose_ports = False if comp_spec.exposedInterfaces: for idx, iface in enumerate(comp_spec.exposedInterfaces): protocol = str(iface.get("protocol", "TCP")).lower() port = iface.get("port") if isinstance(port, int): - ports_id = f"if{idx}" - ports[ports_id] = ExposedPort(properties=PortProperties( + ports[f"if{idx}"] = ExposedPort(properties=PortProperties( protocol=[protocol], source=port)) expose_ports = True + # Build cliArgs as a list of dicts: [{"KEY": "VAL"}, {"FLAG": ""}, ...] + cli_args: List[Dict[str, str]] = [] + cmd = getattr(comp_spec, "commandLineParams", None) + + if isinstance(cmd, dict): + for k, v in cmd.items(): + if v is True: + cli_args.append({str(k): ""}) # flag without value + elif v is False or v is None: + continue + else: + cli_args.append({str(k): str(v)}) + elif isinstance(cmd, list): + # if someone passes ["--flag", "--opt=1"] style + for item in cmd: + if isinstance(item, str): + if "=" in item: + k, v = item.split("=", 1) + cli_args.append({k: v}) + else: + cli_args.append({item: ""}) + + # Build envVars from compEnvParams list of {"name": "...", "value": "..."} + env_vars: List[Dict[str, str]] = [] + if isinstance(getattr(comp_spec, "compEnvParams", None), list): + for item in comp_spec.compEnvParams: + if isinstance(item, dict): + if "name" in item and "value" in item: + env_vars.append( + {str(item["name"]): str(item["value"])}) + elif len(item) == 1: # already mapping-like {"KEY": "VAL"} + k, v = next(iter(item.items())) + env_vars.append({str(k): str(v)}) + + # Host filter (basic example) host_props = HostProperty( cpu_arch={"equal": "x64"}, realtime={"equal": False}, @@ -105,6 +164,17 @@ def generate_tosca_from_gsma_with_artefacts( ))), ] + # PUBLICREPO => is_private=False and omit credentials + repo_type = getattr(artefact, "repoType", None) + is_private = bool(repo_type == "PRIVATEREPO") + username = None + password = None + if is_private and artefact.artefactRepoLocation: + u = artefact.artefactRepoLocation.userName + p = artefact.artefactRepoLocation.password + username = u if u else None + password = p if p else None + node_templates[comp.componentName] = NodeTemplate( type="tosca.nodes.Container.Application", isJob=False, @@ -115,11 +185,9 @@ def generate_tosca_from_gsma_with_artefacts( file=image_file, type="tosca.artifacts.Deployment.Image.Container.Docker", repository=repository_url, - is_private=(artefact.repoType == "PRIVATEREPO"), - username=(artefact.artefactRepoLocation.userName - if artefact.artefactRepoLocation else None), - password=(artefact.artefactRepoLocation.password - if artefact.artefactRepoLocation else None), + is_private=is_private, # False for PUBLICREPO + username=username, # None for PUBLICREPO + password=password, # None for PUBLICREPO ) }, interfaces={ @@ -127,16 +195,15 @@ def generate_tosca_from_gsma_with_artefacts( "create": { "implementation": "application_image", "inputs": { - "cliArgs": - [], # could map comp_spec.commandLineParams later - "envVars": - [], # could map comp_spec.compEnvParams later - } + "cliArgs": cli_args, + "envVars": env_vars, + }, } } }, ) + # Assemble and dump TOSCA tosca = TOSCA( tosca_definitions_version="tosca_simple_yaml_1_3", description= @@ -146,6 +213,7 @@ def generate_tosca_from_gsma_with_artefacts( ) tosca_dict = tosca.model_dump(by_alias=True, exclude_none=True) + # Clean requirements lists from None entries for template in tosca_dict.get("node_templates", {}).values(): template["requirements"] = [{ k: v diff --git a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py index 5859046..87f22e9 100644 --- a/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py +++ b/src/sunrise6g_opensdk/edgecloud/adapters/aeros/utils.py @@ -91,6 +91,30 @@ def urn_to_uuid(urn: str) -> uuid.UUID: return uuid.uuid5(uuid.NAMESPACE_URL, urn) +def map_aeros_service_status_to_gsma(status: str) -> str: + """ + Map aerOS service lifecycle states to GSMA-compliant status values. + + aerOS → GSMA + DEPLOYING → PENDING + DESTROYING → TERMINATING + DEPLOYED → DEPLOYED + FINISHED → No_Match + No_Match → READY + urn:ngsi-ld:null → No Match + """ + mapping = { + "DEPLOYING": "PENDING", + "DESTROYING": "TERMINATING", + "DEPLOYED": "DEPLOYED", + "FINISHED": "READY", + # "urn:ngsi-ld:null": "READY", + } + if not status: + return "FAILED" + return mapping.get(status.strip().upper(), "FAILED") + + def catch_requests_exceptions(func): """ Decorator to catch and translate requests exceptions into custom app errors. -- GitLab From 980bc6ca03cef1b76ae391ffd04d846aced11d73 Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Tue, 28 Oct 2025 12:02:10 +0200 Subject: [PATCH 280/281] payloads and configurations for aerOS e2e testing --- tests/edgecloud/test_config_camara.py | 14 +- tests/edgecloud/test_config_gsma.py | 198 +++++++++++++++++++++++--- 2 files changed, 187 insertions(+), 25 deletions(-) diff --git a/tests/edgecloud/test_config_camara.py b/tests/edgecloud/test_config_camara.py index 6ce39c2..9455456 100644 --- a/tests/edgecloud/test_config_camara.py +++ b/tests/edgecloud/test_config_camara.py @@ -68,14 +68,14 @@ CONFIG = { }, "aeros": { # Basic identifiers - "ZONE_ID": "", - "APP_ID": "", + "ZONE_ID": "8a4d95e8-8550-5664-8c67-b6c0c602f9be", + "APP_ID": "aeros-app-1", # CAMARA onboard_app payload "APP_ONBOARD_MANIFEST": { - "appId": "", - "name": "aeros-SDK-app", + "appId": "aeros-app-1", + "name": "aeros_SDK_app", "version": "1.0.0", - "appProvider": "aeros", + "appProvider": "aerOS_SDK", "packageType": "CONTAINER", "appRepo": { "type": "PUBLICREPO", @@ -113,11 +113,11 @@ CONFIG = { }, # CAMARA deploy_app payload "APP_DEPLOY_PAYLOAD": { - "appId": "", + "appId": "aeros-app-1", "appZones": [ { "EdgeCloudZone": { - "edgeCloudZoneId": "", + "edgeCloudZoneId": "8a4d95e8-8550-5664-8c67-b6c0c602f9be", "edgeCloudZoneName": "aeros-zone-1", "edgeCloudZoneStatus": "active", "edgeCloudProvider": "NCSRD", diff --git a/tests/edgecloud/test_config_gsma.py b/tests/edgecloud/test_config_gsma.py index 94fea67..5f2688f 100644 --- a/tests/edgecloud/test_config_gsma.py +++ b/tests/edgecloud/test_config_gsma.py @@ -7,8 +7,10 @@ CONFIG = { "REPO_TYPE": "PUBLICREPO", "REPO_URL": "https://cesarcajas.github.io/helm-charts-examples/", "APP_ONBOARD_MANIFEST_GSMA": { - "appId": "demo-app-id", - "appProviderId": "Y89TSlxMPDKlXZz7rN6vU2y", + "appId": + "demo-app-id", + "appProviderId": + "Y89TSlxMPDKlXZz7rN6vU2y", "appDeploymentZones": [ "Dmgoc-y2zv97lar0UKqQd53aS6MCTTdoGMY193yvRBYgI07zOAIktN2b9QB2THbl5Gqvbj5Zp92vmNeg7v4M" ], @@ -27,16 +29,20 @@ CONFIG = { "noOfUsersPerAppInst": 1, "appProvisioning": True, }, - "appComponentSpecs": [ - { - "serviceNameNB": "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", - "serviceNameEW": "iDm08OZN", - "componentName": "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", - "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", - } - ], - "appStatusCallbackLink": "string", - "edgeAppFQDN": "string", + "appComponentSpecs": [{ + "serviceNameNB": + "k8yyElSyJN4ctbNVqwodEQNUoGb2EzOEt4vQBjGnPii_5", + "serviceNameEW": + "iDm08OZN", + "componentName": + "HIEWqstajCmZJQmSFUj0kNHZ0xYvKWq720BKt8wjA41p", + "artefactId": + "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }], + "appStatusCallbackLink": + "string", + "edgeAppFQDN": + "string", }, "APP_DEPLOY_PAYLOAD_GSMA": { "appId": "demo-app-id", @@ -61,20 +67,25 @@ CONFIG = { }, "appComponentSpecs": [ { - "serviceNameNB": "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", - "serviceNameEW": "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", + "serviceNameNB": + "7CI_9d4lAK90vU4ASUkKxYdQjsv3y3IuwucISSQ6lG5_EMqeyVUHPIhwa5", + "serviceNameEW": + "tPihoUFj30938Bu9blpsHkvsec1iA7gqZZRMpsx6o7aSSj5", "componentName": "YCAhqPadfld8y68wJfTc6QNGguI41z", "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", }, { "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", - "serviceNameEW": "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "serviceNameEW": + "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", "componentName": "9aCfCEDe2Dv0Peg", "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", }, { - "serviceNameNB": "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", - "serviceNameEW": "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "serviceNameNB": + "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": + "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", "componentName": "3kTa4zKEX", "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", }, @@ -82,7 +93,158 @@ CONFIG = { }, }, "aeros": { - # PLACEHOLDER + "ZONE_ID": "urn:ngsi-ld:Domain:ncsrd01", + "ARTEFACT_ID": "artefact-nginx-001", + "ARTEFACT_NAME": "aeros-component", + "REPO_NAME": "dockerhub", + "REPO_TYPE": "PUBLICREPO", + "REPO_URL": "docker.io/library/nginx:stable", + "APP_ONBOARD_MANIFEST_GSMA": { + "appId": + "aeros-sdk-app", + "appProviderId": + "aeros-sdk-provider", + "appDeploymentZones": ["urn:ngsi-ld:Domain:ncsrd01"], + "appMetaData": { + "appName": "aeros_SDK_app", + "version": "string", + "appDescription": "test aeros sdk app", + "mobilitySupport": False, + "accessToken": "MfxADOjxDgBhMrqmBeG8XdQFLp2XviG3cZ_LM7uQKc9b", + "category": "IOT", + }, + "appQoSProfile": { + "latencyConstraints": "NONE", + "bandwidthRequired": 1, + "multiUserClients": "APP_TYPE_SINGLE_USER", + "noOfUsersPerAppInst": 1, + "appProvisioning": True, + }, + "appComponentSpecs": [{ + "serviceNameNB": + "gsma-deployed-app-service-nb", + "serviceNameEW": + "gsma-deployed-app-service-ew", + "componentName": + "nginx-component", + "artefactId": + "artefact-nginx-001", + }], + "appStatusCallbackLink": + "string", + "edgeAppFQDN": + "string", + }, + "APP_DEPLOY_PAYLOAD_GSMA": { + "appId": "aeros-sdk-app", + "appVersion": "1.0.0", + "appProviderId": "apps-sdk-deployer", + "zoneInfo": { + "zoneId": "urn:ngsi-ld:Domain:ncsrd01", + "flavourId": "FLAVOUR_BASIC", + "resourceConsumption": "RESERVED_RES_AVOID", + "resPool": "RESPOOL_DEFAULT", + }, + "appInstCallbackLink": "string", + }, + "PATCH_ONBOARDED_APP_GSMA": { + "appUpdQoSProfile": { + "latencyConstraints": "NONE", + "bandwidthRequired": 1, + "mobilitySupport": False, + "multiUserClients": "APP_TYPE_SINGLE_USER", + "noOfUsersPerAppInst": 1, + "appProvisioning": True, + }, + "appComponentSpecs": [ + { + "serviceNameNB": + "gsma-deployed-app-service-nb", + "serviceNameEW": + "gsma-deployed-app-service-ew", + "componentName": "nginx-component", + "artefactId": "artefact-nginx-001", + }, + { + "serviceNameNB": "JCjR0Lc3J0sm2PcItECdbHXtpCLQCfq3B", + "serviceNameEW": + "N8KBAdqT8L_sWOxeFZs3XYn6oykTTFHLiPKOS7kdYbw", + "componentName": "9aCfCEDe2Dv0Peg", + "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }, + { + "serviceNameNB": + "RIfXlfU9cDeLnrOBYzz9LJGdAjwPRp_3Mjp0Wq_RDlQiAPyXm", + "serviceNameEW": + "31y8sCwvvyNCXfwtLhwJw6hoblG7ZcFzEjyFdAnzq7M8cxiOtDik0", + "componentName": "3kTa4zKEX", + "artefactId": "9c9143f0-f44f-49df-939e-1e8b891ba8f5", + }, + ], + }, + "ARTEFACT_PAYLOAD_GSMA": { + "artefactId": + "artefact-nginx-001", + "appProviderId": + "ncsrd-provider", + "artefactName": + "nginx-web-server", + "artefactVersionInfo": + "1.0.0", + "artefactDescription": + "Containerized Nginx Web Server", + "artefactVirtType": + "CONTAINER_TYPE", + "artefactFileName": + "nginx-web-server-1.0.0.tgz", + "artefactFileFormat": + "TARGZ", + "artefactDescriptorType": + "COMPONENTSPEC", + "repoType": + "PUBLICREPO", + "artefactRepoLocation": { + "repoURL": "docker.io/library/nginx:stable", + "userName": "", + "password": "", + "token": "" + }, + "artefactFile": + "", + "componentSpec": [{ + "componentName": + "nginx-component", + "images": ["docker.io/library/nginx:stable"], + "numOfInstances": + 1, + "restartPolicy": + "Always", + "commandLineParams": { + }, + "exposedInterfaces": [{ + "name": "http-api", + "protocol": "TCP", + "port": 8080 + }], + "computeResourceProfile": { + "cpu": "2", + "memory": "4Gi" + }, + "compEnvParams": [{ + "name": "TEST_ENV", + "value": "TEST_VALUE_ENV" + }], + "deploymentConfig": { + "replicaStrategy": "RollingUpdate", + "maxUnavailable": 1 + }, + "persistentVolumes": [{ + "name": "NOT_USE", + "mountPath": "NOT_USED", + "size": "NOT_USED" + }] + }] + } }, "kubernetes": { # PLACEHOLDER -- GitLab From 27d8d9900fd10f994a1d25fd4513718b08ebb3cc Mon Sep 17 00:00:00 2001 From: vpitsilis Date: Tue, 28 Oct 2025 12:14:29 +0200 Subject: [PATCH 281/281] Adaptations for artefacts & minor fixes --- tests/edgecloud/test_e2e_gsma.py | 35 +++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/edgecloud/test_e2e_gsma.py b/tests/edgecloud/test_e2e_gsma.py index 0a6039b..efb47bd 100644 --- a/tests/edgecloud/test_e2e_gsma.py +++ b/tests/edgecloud/test_e2e_gsma.py @@ -32,6 +32,9 @@ from sunrise6g_opensdk.edgecloud.adapters.errors import EdgeCloudPlatformError from sunrise6g_opensdk.edgecloud.adapters.i2edge.client import ( EdgeApplicationManager as I2EdgeClient, ) +from sunrise6g_opensdk.edgecloud.adapters.aeros.client import ( + EdgeApplicationManager as aerosClient, +) from sunrise6g_opensdk.edgecloud.core import gsma_schemas from tests.edgecloud.test_cases import test_cases from tests.edgecloud.test_config_gsma import CONFIG @@ -72,6 +75,11 @@ def test_config_gsma_compliance(edgecloud_client): if "PATCH_ONBOARDED_APP_GSMA" in config: patch_payload = config["PATCH_ONBOARDED_APP_GSMA"] gsma_schemas.PatchOnboardedAppGSMA(**patch_payload) + + # Validate ARTEFACT creation payload is GSMA-compliant + if "ARTEFACT_PAYLOAD_GSMA" in config: + artefact_payload = config["ARTEFACT_PAYLOAD_GSMA"] + gsma_schemas.Artefact(**artefact_payload) except Exception as e: pytest.fail(f"Configuration is not GSMA-compliant for {edgecloud_client.client_name}: {e}") @@ -176,6 +184,21 @@ def test_artefact_methods_gsma(edgecloud_client): pytest.fail(f"Artefact creation failed: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_artefact_create_gsma(edgecloud_client): + config = CONFIG[edgecloud_client.client_name] + if isinstance(edgecloud_client, aerosClient): + try: + response = edgecloud_client.create_artefact_gsma( + request_body=config["ARTEFACT_PAYLOAD_GSMA"] + ) + assert response.status_code == 201 + except EdgeCloudPlatformError as e: + pytest.fail(f"Artefact creation failed: {e}") + + + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_get_artefact_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] @@ -207,7 +230,7 @@ def test_onboard_app_gsma(edgecloud_client): try: response = edgecloud_client.onboard_app_gsma(config["APP_ONBOARD_MANIFEST_GSMA"]) assert isinstance(response, Response) - assert response.status_code == 200 + assert response.status_code == 201 except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding failed: {e}") @@ -289,7 +312,7 @@ def test_get_all_deployed_apps_gsma(edgecloud_client): validated_instances = [] for instance_data in instances_data: - validated_instance = gsma_schemas.ZoneIdentifier(**instance_data) + validated_instance = gsma_schemas.AppInstance(**instance_data) validated_instances.append(validated_instance) except EdgeCloudPlatformError as e: @@ -330,6 +353,12 @@ def test_undeploy_app_gsma(edgecloud_client, app_instance_id_gsma): pytest.fail(f"App undeployment failed: {e}") +@pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) +def test_timer_wait_10_seconds_2(edgecloud_client): + time.sleep(10) + + + @pytest.mark.parametrize("edgecloud_client", test_cases, ids=id_func, indirect=True) def test_patch_onboarded_app_gsma(edgecloud_client): config = CONFIG[edgecloud_client.client_name] @@ -350,7 +379,7 @@ def test_delete_onboarded_app_gsma(edgecloud_client): app_id = config["APP_ONBOARD_MANIFEST_GSMA"]["appId"] response = edgecloud_client.delete_onboarded_app_gsma(app_id) assert isinstance(response, Response) - assert response.status_code == 200 + assert response.status_code == 204 except EdgeCloudPlatformError as e: pytest.fail(f"App onboarding deletion failed: {e}") -- GitLab