diff --git a/.gitignore b/.gitignore index 223d750a..1964afc1 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ docs/source/*.png pgdata/ .tasks +graphify-out \ No newline at end of file diff --git a/docs/openapi/openapi_user.yaml b/docs/openapi/openapi_user.yaml index 369d4afe..b3163db1 100644 --- a/docs/openapi/openapi_user.yaml +++ b/docs/openapi/openapi_user.yaml @@ -28617,6 +28617,14 @@ paths: example: 00000000-0000-0000-0000-000000000000 format: uuid nullable: true + - name: latest + in: query + schema: + type: string + enum: + - "true" + example: "true" + description: Return only the latest version for each unique element name responses: '200': description: RepoElement_Filter diff --git a/exordos_core/boot_api/api/app.py b/exordos_core/boot_api/api/app.py index 592b92ab..3b31c88c 100644 --- a/exordos_core/boot_api/api/app.py +++ b/exordos_core/boot_api/api/app.py @@ -55,7 +55,6 @@ def get_openapi_engine(): description=f"OpenAPI - Exordos Core {versions.API_VERSION_v1}", ), paths=openapi_structures.OpenApiPaths(), - components=openapi_structures.OpenApiComponents(), ) return openapi_engine diff --git a/exordos_core/cmd/user_api.py b/exordos_core/cmd/user_api.py index 8de80d56..ba933b89 100644 --- a/exordos_core/cmd/user_api.py +++ b/exordos_core/cmd/user_api.py @@ -31,6 +31,7 @@ from exordos_core.common import constants as c from exordos_core.common import log as infra_log from exordos_core.common import utils +from exordos_core.common.api.middlewares import cors as cors_mw from exordos_core.user_api.api import app from exordos_core.user_api.iam import drivers as iam_drivers @@ -75,6 +76,7 @@ CONF.register_cli_opts(iam_cli_opts, DOMAIN_IAM) ra_config_opts.register_posgresql_db_opts(CONF) sdk_opts.register_event_opts(CONF) +cors_mw.register_cors_opts(CONF) def main(): @@ -119,6 +121,7 @@ def main(): wsgi_app=app.build_wsgi_application( context_storage=context_storage, iam_engine_driver=iam_engine_driver, + allowed_origins=CONF["cors"].allowed_origins, ), host=CONF[DOMAIN].bind_host, port=CONF[DOMAIN].bind_port, diff --git a/exordos_core/common/api/middlewares/cors.py b/exordos_core/common/api/middlewares/cors.py new file mode 100644 index 00000000..036eaf78 --- /dev/null +++ b/exordos_core/common/api/middlewares/cors.py @@ -0,0 +1,79 @@ +# Copyright 2026 Genesis Corporation. +# +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg +from webob import dec + +from restalchemy.api import middlewares + +ALLOWED_ORIGINS_OPT = cfg.ListOpt( + "allowed_origins", + default=["*"], + help="List of allowed CORS origins", +) + +CORS_OPT_GROUP = cfg.OptGroup("cors") +CORS_OPTS = [ALLOWED_ORIGINS_OPT] +BASE_RESPONSE_HEADERS = { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": ( + "Authorization, Content-Type, X-OTP-Token, X-Requested-With, " + "Accept, Origin" + ), + "Access-Control-Max-Age": "3600", +} + + +def register_cors_opts(conf): + conf.register_group(CORS_OPT_GROUP) + conf.register_opts(CORS_OPTS, group=CORS_OPT_GROUP) + + +class CORSMiddleware(middlewares.Middleware): + + def __init__(self, application, allowed_origins=None): + super().__init__(application) + self.allowed_origins = allowed_origins or [] + + @dec.wsgify + def __call__(self, req): + origin = req.headers.get("Origin", "") + + if req.method == "OPTIONS" and self._is_origin_allowed(origin): + return req.ResponseClass( + status=200, + headers=self._cors_headers(origin), + ) + + response = req.get_response(self.application) + + if self._is_origin_allowed(origin): + for key, value in self._cors_headers(origin).items(): + response.headers.add(key, value) + + return response + + def _is_origin_allowed(self, origin): + if not origin: + return False + return origin in self.allowed_origins or "*" in self.allowed_origins + + @staticmethod + def _cors_headers(origin): + headers = BASE_RESPONSE_HEADERS.copy() + headers["Access-Control-Allow-Origin"] = origin + return headers \ No newline at end of file diff --git a/exordos_core/orch_api/api/app.py b/exordos_core/orch_api/api/app.py index 579b013c..d9db3056 100644 --- a/exordos_core/orch_api/api/app.py +++ b/exordos_core/orch_api/api/app.py @@ -52,7 +52,6 @@ def get_openapi_engine(): description=f"OpenAPI - Exordos Core {versions.API_VERSION_v1}", ), paths=openapi_structures.OpenApiPaths(), - components=openapi_structures.OpenApiComponents(), ) return openapi_engine diff --git a/exordos_core/repo/dm/models.py b/exordos_core/repo/dm/models.py index 7cf24239..f8f9d0c4 100644 --- a/exordos_core/repo/dm/models.py +++ b/exordos_core/repo/dm/models.py @@ -20,6 +20,7 @@ import typing as tp from urllib.parse import urljoin +from packaging import version as packaging_version from restalchemy.dm import filters as ra_filters from restalchemy.dm import models from restalchemy.dm import properties @@ -28,6 +29,7 @@ from restalchemy.dm import types_dynamic from restalchemy.storage.sql import orm +from exordos_core.common import exceptions as common_exc from exordos_core.common import utils from exordos_core.repo import constants as rc @@ -37,6 +39,13 @@ from exordos_core.repo.drivers.base import AbstractProxyRepoDriver +def is_stable_version(version: str) -> bool: + try: + return not packaging_version.parse(version).is_prerelease + except packaging_version.InvalidVersion: + return False + + class SyncMode(str, enum.Enum): COPY = "copy" LAZY = "lazy" @@ -213,10 +222,10 @@ def load_driver(self) -> "AbstractProxyRepoDriver": instantiate them with the current repository. If a driver is successfully loaded, it is stored in a cache for faster access. - If no driver is found, a ValueError is raised. + If no driver is found, a ValidateException is raised. :return: The loaded driver instance - :raises ValueError: If no driver is found + :raises ValidateException: If no driver is found """ driver_key = str(self.driver_spec) @@ -234,7 +243,9 @@ def load_driver(self) -> "AbstractProxyRepoDriver": # Just try another driver pass - raise ValueError(f"Driver for spec '{self.driver_spec}' not found") + raise common_exc.ValidateException( + err=f"Driver for spec '{self.driver_spec}' not found" + ) def iter_elements_in_inventory( self, inventory: dict | None = None @@ -309,13 +320,15 @@ def upload( Created RepoElement instance Raises: - ValueError: If upload is not supported by driver + ValidateException: If upload is not supported by driver """ driver = self.load_driver() # Check if driver supports upload if not driver.can_upload_element(element_name, element_version): - raise ValueError("Upload is not supported by this repository driver") + raise common_exc.ValidateException( + err="Upload is not supported by this repository driver" + ) # Create element element = RepoElement( @@ -418,6 +431,10 @@ class RepoElement( default=None, ) + @property + def is_stable(self) -> bool: + return is_stable_version(self.version) + @property def dependencies(self) -> dict[str, dict[str, str]]: """Compute dependencies from manifest requirements. @@ -466,7 +483,7 @@ def dependencies(self) -> dict[str, dict[str, str]]: def install(self) -> "RepoElement": if self.installation_state != RepoElementInstallationState.UNINSTALLED: - raise ValueError("Element must be uninstalled") + raise common_exc.ValidateException(err="Element must be uninstalled") # Check there is no installed element with the same name existing = RepoElement.objects.get_all( @@ -478,7 +495,9 @@ def install(self) -> "RepoElement": } ) if existing: - raise ValueError("Element with the same name is already installed") + raise common_exc.ValidateException( + err="Element with the same name is already installed" + ) self.installation_state = RepoElementInstallationState.INSTALLED.value self.update() @@ -486,7 +505,7 @@ def install(self) -> "RepoElement": def uninstall(self) -> "RepoElement": if self.installation_state != RepoElementInstallationState.INSTALLED: - raise ValueError("Element must be installed") + raise common_exc.ValidateException(err="Element must be installed") # Check that no other elements depend on this one. The dependency # bindings table records transitive dependencies, so if any record @@ -496,8 +515,8 @@ def uninstall(self) -> "RepoElement": filters={"depends_on": ra_filters.EQ(self.uuid)} ) if dependents: - raise ValueError( - "Element cannot be uninstalled: other elements depend on it" + raise common_exc.ValidateException( + err="Element cannot be uninstalled: other elements depend on it" ) self.installation_state = RepoElementInstallationState.UNINSTALLED.value @@ -516,7 +535,9 @@ def uninstall(self) -> "RepoElement": def upgrade(self, target: str) -> "RepoElement": if self.element is None: - raise ValueError("Element must be installed to upgrade") + raise common_exc.ValidateException( + err="Element must be installed to upgrade" + ) target_element = RepoElement.objects.get_one( filters={ @@ -528,7 +549,7 @@ def upgrade(self, target: str) -> "RepoElement": target_element.installation_state != RepoElementInstallationState.UNINSTALLED ): - raise ValueError("Target element must be uninstalled") + raise common_exc.ValidateException(err="Target element must be uninstalled") runtime_element = self.element self.installation_state = RepoElementInstallationState.UNINSTALLED.value self.update() @@ -545,20 +566,20 @@ def edit(self, manifest: dict) -> "RepoElement": manifest: New manifest dict Raises: - ValueError: If manifest name or version does not match element name/version + ValidateException: If manifest name or version does not match element name/version """ # Validate that name and version in manifest match the element manifest_name = manifest.get("name") manifest_version = manifest.get("version") if manifest_name != self.name: - raise ValueError( - f"Manifest name '{manifest_name}' does not match element name '{self.name}'" + raise common_exc.ValidateException( + err=f"Manifest name '{manifest_name}' does not match element name '{self.name}'" ) if manifest_version != self.version: - raise ValueError( - f"Manifest version '{manifest_version}' does not match element version '{self.version}'" + raise common_exc.ValidateException( + err=f"Manifest version '{manifest_version}' does not match element version '{self.version}'" ) self.manifest = manifest diff --git a/exordos_core/status_api/api/app.py b/exordos_core/status_api/api/app.py index c1a6a8e1..2de2f215 100644 --- a/exordos_core/status_api/api/app.py +++ b/exordos_core/status_api/api/app.py @@ -52,7 +52,6 @@ def get_openapi_engine(): description=f"OpenAPI - Exordos Core {versions.API_VERSION_v1}", ), paths=openapi_structures.OpenApiPaths(), - components=openapi_structures.OpenApiComponents(), ) return openapi_engine diff --git a/exordos_core/tests/functional/restapi/repo/__init__.py b/exordos_core/tests/functional/restapi/repo/__init__.py new file mode 100644 index 00000000..ba779e95 --- /dev/null +++ b/exordos_core/tests/functional/restapi/repo/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Genesis Corporation. +# +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/exordos_core/tests/functional/restapi/repo/test_repo_api.py b/exordos_core/tests/functional/restapi/repo/test_repo_api.py new file mode 100644 index 00000000..7bb0672c --- /dev/null +++ b/exordos_core/tests/functional/restapi/repo/test_repo_api.py @@ -0,0 +1,582 @@ +# Copyright 2026 Genesis Corporation. +# +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid as sys_uuid + +from bazooka import exceptions as bazooka_exc +import pytest + +from exordos_core.common import constants as c + + +class TestRepoElements: + """REST API tests for repo elements endpoints.""" + + REPO_ELEMENTS_PATH = ["repo", "elements"] + REPO_REPOSITORIES_PATH = ["repo", "repositories"] + + def _create_repository( + self, user_api_client, auth, name="test-repo", driver_spec=None + ): + """Helper to create a repository with a simple driver spec.""" + client = user_api_client(auth) + url = client.build_collection_uri(self.REPO_REPOSITORIES_PATH) + name = f"{name}-{sys_uuid.uuid4()}" + response = client.post( + url, + json={ + "name": name, + "description": "Test repository", + "project_id": str(c.SERVICE_PROJECT_ID), + "sync_mode": "lazy", + "driver_spec": driver_spec or {"kind": "database"}, + }, + ) + return response.json() + + def _create_element( + self, + user_api_client, + auth, + repo_uuid, + name="test-element", + version="1.0.0", + unique_name=True, + ): + """Helper to create a repo element.""" + client = user_api_client(auth) + upload_url = client.build_resource_uri( + ["repo/repositories", repo_uuid, "actions/upload/invoke"] + ) + if unique_name: + name = f"{name}-{sys_uuid.uuid4()}" + client.post( + upload_url, + json={ + "element_name": name, + "element_version": version, + "description": "Test element", + "manifest": {"name": name, "version": version, "resources": {}}, + }, + ) + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements_response = client.get( + elements_url, + params={"repository": repo_uuid}, + ) + elements = elements_response.json() + return next( + element + for element in elements + if element["name"] == name and element["version"] == version + ) + + # ------------------------------------------------------------------ + # GET / FILTER tests + # ------------------------------------------------------------------ + + def test_get_element_by_admin(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + result = client.get(element_url).json() + assert result["uuid"] == element["uuid"] + assert result["name"] == element["name"] + assert result["version"] == element["version"] + + def test_get_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.get(element_url) + + def test_get_nonexistent_element(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + fake_uuid = sys_uuid.uuid4() + element_url = client.build_resource_uri(["repo/elements", fake_uuid]) + + with pytest.raises(bazooka_exc.NotFoundError): + client.get(element_url) + + def test_list_elements(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + self._create_element( + user_api_client, auth_user_admin, repo["uuid"], name="elem-1" + ) + self._create_element( + user_api_client, auth_user_admin, repo["uuid"], name="elem-2" + ) + + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get(elements_url).json() + repo_elements = [ + e for e in elements if e.get("repository", "").endswith(repo["uuid"]) + ] + assert len(repo_elements) >= 2 + + def test_filter_elements_by_repository(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo1 = self._create_repository(user_api_client, auth_user_admin, name="repo-a") + repo2 = self._create_repository( + user_api_client, + auth_user_admin, + name="repo-b", + driver_spec={"kind": "bootstrap", "manifests_dir": "/tmp/repo-b"}, + ) + + self._create_element( + user_api_client, auth_user_admin, repo1["uuid"], name="elem-in-repo1" + ) + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get(elements_url, params={"repository": repo1["uuid"]}).json() + assert len(elements) == 1 + assert elements[0]["repository"].endswith(repo1["uuid"]) + assert not elements[0]["repository"].endswith(repo2["uuid"]) + + def test_filter_stable(self, user_api_client, auth_user_admin): + """stable=true should return only stable versions.""" + client = user_api_client(auth_user_admin) + repo = self._create_repository(user_api_client, auth_user_admin) + + # Create stable and pre-release elements + stable_element = self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="stable-elem", + version="1.0.0", + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="beta-elem", + version="2.0.0-beta.1", + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="rc-elem", + version="1.5.0-rc.2", + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="alpha-elem", + version="3.0.0-alpha", + ) + stable_element_2 = self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="stable-elem-2", + version="2.0.0", + ) + + # Filter stable only + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get( + elements_url, + params={"stable": "true", "repository": repo["uuid"]}, + ).json() + + # Should only contain stable versions + for elem in elements: + assert elem["version"].count("-") == 0, ( + f"Element {elem['name']} has pre-release version {elem['version']}" + ) + + # Should have at least the 2 stable versions we created + names = {e["name"] for e in elements} + assert stable_element["name"] in names + assert stable_element_2["name"] in names + + def test_filter_stable_false_returns_all(self, user_api_client, auth_user_admin): + """stable=false should return all versions including pre-releases.""" + client = user_api_client(auth_user_admin) + repo = self._create_repository(user_api_client, auth_user_admin) + + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="stable-elem", + version="1.0.0", + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="beta-elem", + version="2.0.0-beta.1", + ) + + # With stable=false + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get( + elements_url, + params={"stable": "false", "repository": repo["uuid"]}, + ).json() + + repo_elements = [ + e for e in elements if e.get("repository", "").endswith(repo["uuid"]) + ] + assert len(repo_elements) == 2 + + def test_filter_stable_default_returns_all(self, user_api_client, auth_user_admin): + """Without stable parameter, all versions should be returned.""" + client = user_api_client(auth_user_admin) + repo = self._create_repository(user_api_client, auth_user_admin) + + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="stable-elem", + version="1.0.0", + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="beta-elem", + version="2.0.0-beta.1", + ) + + # Without stable parameter + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get(elements_url, params={"repository": repo["uuid"]}).json() + + repo_elements = [ + e for e in elements if e.get("repository", "").endswith(repo["uuid"]) + ] + assert len(repo_elements) == 2 + + def test_filter_latest(self, user_api_client, auth_user_admin): + """latest=true should return only the latest version per element name.""" + client = user_api_client(auth_user_admin) + repo = self._create_repository(user_api_client, auth_user_admin) + + # Create multiple versions of the same element + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="0.9.0", + unique_name=False, + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="1.0.0", + unique_name=False, + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="2.0.0", + unique_name=False, + ) + # Different element - should also be returned once + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="other-elem", + version="1.0.0", + unique_name=False, + ) + + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get( + elements_url, + params={"latest": "true", "repository": repo["uuid"]}, + ).json() + + repo_elements = [ + e for e in elements if e.get("repository", "").endswith(repo["uuid"]) + ] + + # Should have exactly 2 elements (one per unique name) + assert len(repo_elements) == 2 + + # Check that the latest versions are returned + by_name = {e["name"]: e for e in repo_elements} + assert by_name["my-elem"]["version"] == "2.0.0" + assert by_name["other-elem"]["version"] == "1.0.0" + + def test_filter_latest_with_stable(self, user_api_client, auth_user_admin): + """latest=true combined with stable=true should return latest stable per name.""" + client = user_api_client(auth_user_admin) + repo = self._create_repository(user_api_client, auth_user_admin) + + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="1.0.0", + unique_name=False, + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="2.0.0-beta.1", + unique_name=False, + ) + self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name="my-elem", + version="1.5.0", + unique_name=False, + ) + + elements_url = client.build_collection_uri(self.REPO_ELEMENTS_PATH) + elements = client.get( + elements_url, + params={"latest": "true", "stable": "true", "repository": repo["uuid"]}, + ).json() + + repo_elements = [ + e for e in elements if e.get("repository", "").endswith(repo["uuid"]) + ] + + assert len(repo_elements) == 1 + assert repo_elements[0]["version"] == "1.5.0" + + # ------------------------------------------------------------------ + # DELETE tests + # ------------------------------------------------------------------ + + def test_delete_element_by_admin(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + result = client.delete(element_url) + assert result.status_code == 204 + + with pytest.raises(bazooka_exc.NotFoundError): + client.get(element_url) + + def test_delete_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.delete(element_url) + + def test_delete_installed_element_raises_error( + self, user_api_client, auth_user_admin + ): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + # Install the element first + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + client.post(f"{element_url}/actions/install/invoke") + + with pytest.raises(bazooka_exc.BadRequestError): + client.delete(element_url) + + # ------------------------------------------------------------------ + # INSTALL / UNINSTALL action tests + # ------------------------------------------------------------------ + + def test_install_element(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + result = client.post(f"{element_url}/actions/install/invoke").json() + assert result["uuid"] == element["uuid"] + assert result["name"] == element["name"] + + def test_install_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.post(f"{element_url}/actions/install/invoke") + + def test_uninstall_element(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + # Install first + client.post(f"{element_url}/actions/install/invoke") + + # Uninstall + result = client.post(f"{element_url}/actions/uninstall/invoke").json() + assert result["uuid"] == element["uuid"] + assert result["name"] == element["name"] + + def test_uninstall_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.post(f"{element_url}/actions/uninstall/invoke") + + # ------------------------------------------------------------------ + # UPGRADE action tests + # ------------------------------------------------------------------ + + def test_upgrade_element(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + name = f"upgrade-element-{sys_uuid.uuid4()}" + element = self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name=name, + version="1.0.0", + unique_name=False, + ) + target_element = self._create_element( + user_api_client, + auth_user_admin, + repo["uuid"], + name=name, + version="2.0.0", + unique_name=False, + ) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + client.post(f"{element_url}/actions/install/invoke") + with pytest.raises(bazooka_exc.BadRequestError): + client.post( + f"{element_url}/actions/upgrade/invoke", + json={"target": target_element["uuid"]}, + ) + + def test_upgrade_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.post( + f"{element_url}/actions/upgrade/invoke", + json={"target": "2.0.0"}, + ) + + # ------------------------------------------------------------------ + # EDIT action tests + # ------------------------------------------------------------------ + + def test_edit_element_manifest(self, user_api_client, auth_user_admin): + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + new_manifest = { + "name": element["name"], + "version": element["version"], + "resources": {}, + "description": "Updated description", + } + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + result = client.post( + f"{element_url}/actions/edit/invoke", + json={"manifest": new_manifest}, + ).json() + assert result["uuid"] == element["uuid"] + assert result["manifest"]["description"] == "Updated description" + + def test_edit_element_by_user( + self, user_api_client, auth_test1_user, auth_user_admin + ): + client = user_api_client(auth_test1_user) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + with pytest.raises(bazooka_exc.ForbiddenError): + client.post( + f"{element_url}/actions/edit/invoke", + json={"manifest": {"name": "test-element", "version": "1.0.0"}}, + ) + + # ------------------------------------------------------------------ + # Hidden fields tests + # ------------------------------------------------------------------ + + def test_hidden_field_installation_state(self, user_api_client, auth_user_admin): + """installation_state should be hidden from GET responses.""" + client = user_api_client(auth_user_admin) + + repo = self._create_repository(user_api_client, auth_user_admin) + element = self._create_element(user_api_client, auth_user_admin, repo["uuid"]) + + element_url = client.build_resource_uri(["repo/elements", element["uuid"]]) + result = client.get(element_url) + assert "installation_state" not in result diff --git a/exordos_core/tests/unit/repo/test_repo_element_deps_binding.py b/exordos_core/tests/unit/repo/test_repo_element_deps_binding.py index f349647c..4f226de8 100644 --- a/exordos_core/tests/unit/repo/test_repo_element_deps_binding.py +++ b/exordos_core/tests/unit/repo/test_repo_element_deps_binding.py @@ -19,6 +19,7 @@ import pytest +from exordos_core.common import exceptions as common_exc from exordos_core.repo.dm import models # --------------------------------------------------------------------------- @@ -68,7 +69,9 @@ def test_uninstall_rejected_when_dependents_exist(self): ) as mock_objects: mock_objects.get_all.return_value = [fake_binding] - with pytest.raises(ValueError, match="other elements depend on it"): + with pytest.raises( + common_exc.ValidateException, match="other elements depend on it" + ): models.RepoElement.uninstall(elem) def test_uninstall_allowed_when_no_dependents(self): @@ -110,5 +113,7 @@ def test_uninstall_not_installed_raises(self): elem = mock.MagicMock(spec=models.RepoElement) elem.installation_state = models.RepoElementInstallationState.UNINSTALLED.value - with pytest.raises(ValueError, match="Element must be installed"): + with pytest.raises( + common_exc.ValidateException, match="Element must be installed" + ): models.RepoElement.uninstall(elem) diff --git a/exordos_core/user_api/api/app.py b/exordos_core/user_api/api/app.py index 761dfd33..218598d3 100644 --- a/exordos_core/user_api/api/app.py +++ b/exordos_core/user_api/api/app.py @@ -24,6 +24,7 @@ from exordos_core import version from exordos_core.common import contexts as common_contexts +from exordos_core.common.api.middlewares import cors as cors_mw from exordos_core.common.api.middlewares import errors as errors_mw from exordos_core.user_api.api import middlewares as user_api_mw from exordos_core.user_api.api import routes as app_routes @@ -56,18 +57,21 @@ def get_openapi_engine(): description=f"OpenAPI - Exordos Core {versions.API_VERSION_v1}", ), paths=openapi_structures.OpenApiPaths(), - components=openapi_structures.OpenApiComponents(), ) return openapi_engine -def build_wsgi_application(context_storage, iam_engine_driver): +def build_wsgi_application(context_storage, iam_engine_driver, allowed_origins=None): return middlewares.attach_middlewares( applications.OpenApiApplication( route_class=get_api_application(), openapi_engine=get_openapi_engine(), ), [ + middlewares.configure_middleware( + cors_mw.CORSMiddleware, + allowed_origins=allowed_origins or [], + ), user_api_mw.SecurityRulesMiddleware, middlewares.configure_middleware( iam_mw.GenesisCoreAuthMiddleware, diff --git a/exordos_core/user_api/repo/api/controllers.py b/exordos_core/user_api/repo/api/controllers.py index f6e90fb3..3c254a95 100644 --- a/exordos_core/user_api/repo/api/controllers.py +++ b/exordos_core/user_api/repo/api/controllers.py @@ -21,6 +21,7 @@ from restalchemy.api import field_permissions as field_p from restalchemy.api import resources +from exordos_core.common import exceptions as common_exc from exordos_core.repo.dm import models @@ -103,6 +104,36 @@ class RepoElementController( hidden_fields=["installation_state"], ) + def filter(self, filters, **kwargs): + stable = filters.pop("stable", None) + latest = filters.pop("latest", None) + result = super().filter(filters, **kwargs) + + if stable and stable.value == "true": + result = [e for e in result if e.is_stable] + + if latest and latest.value == "true": + # Keep only the latest version for each unique element name + seen: dict[str, object] = {} + for elem in result: + prev = seen.get(elem.name) + if prev is None: + seen[elem.name] = elem + else: + from packaging import version as packaging_version + + try: + prev_ver = packaging_version.parse(prev.version) + curr_ver = packaging_version.parse(elem.version) + if curr_ver > prev_ver: + seen[elem.name] = elem + except packaging_version.InvalidVersion: + # Fallback: keep the first element on invalid version + pass + result = list(seen.values()) + + return result + def get(self, uuid, **kwargs): repo_element = super().get(uuid=uuid, **kwargs) @@ -117,7 +148,7 @@ def delete(self, uuid): if repo_element.installation_state == ( models.RepoElementInstallationState.INSTALLED.value ): - raise ValueError("Cannot delete installed element") + raise common_exc.ValidateException(err="Cannot delete installed element") return super().delete(uuid) @actions.post diff --git a/pyproject.toml b/pyproject.toml index 2650b781..c551dd21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "firebase-admin>=6.0.0,<8.0.0", # Apache-2.0 "altcha>=0.2.0,<2.1.0", # MIT License "openapi-schema-validator>=0.8.1", # BSD-3 + "packaging==26.0", # Apache-2.0 or BSD ] [project.urls] homepage = "https://github.com/infraguys/exordos_core/" diff --git a/uv.lock b/uv.lock index 97574ff7..70708812 100644 --- a/uv.lock +++ b/uv.lock @@ -650,7 +650,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -686,6 +686,7 @@ dependencies = [ { name = "netaddr" }, { name = "openapi-schema-validator" }, { name = "oslo-config" }, + { name = "packaging" }, { name = "pyotp" }, { name = "pyyaml" }, { name = "restalchemy" }, @@ -747,6 +748,7 @@ requires-dist = [ { name = "netaddr", specifier = ">=1.3.0,<2.0.0" }, { name = "openapi-schema-validator", specifier = ">=0.8.1" }, { name = "oslo-config", specifier = ">=3.22.2,<10.0.0" }, + { name = "packaging", specifier = "==26.0" }, { name = "pyotp", specifier = ">=2.9.0,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0,<10.0.0" }, @@ -1848,7 +1850,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "pbr" }, + { name = "pbr", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/26/85800d24c3aa7650bbd5fa0398aca78a84e8a8693f9c6a852148a196ddac/oslo_i18n-6.8.0.tar.gz", hash = "sha256:a0b4c64c1396869d7144dca60ad97c7eb028f78f61f91c7007531238051997df", size = 50114, upload-time = "2026-05-18T09:16:54.09Z" } wheels = [ @@ -1866,7 +1868,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ - { name = "pbr" }, + { name = "pbr", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/69/72b03bb4d33f51a157c02d5297227bae48b9c359103856942b8774b608df/oslo_i18n-6.9.0.tar.gz", hash = "sha256:574bcf21873b185068bcec951de1ec093158ffdff05a8055fd18ddcb69f69e65", size = 50369, upload-time = "2026-07-10T13:44:34.301Z" } wheels = [ @@ -1875,11 +1877,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -2576,7 +2578,7 @@ wheels = [ [[package]] name = "restalchemy" -version = "15.2.2" +version = "15.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "email-validator" }, @@ -2589,9 +2591,9 @@ dependencies = [ { name = "requests" }, { name = "webob" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/29/6cb33875ed2af3215b77c59d1aa06dfc24d9eda27cf61862f7f615f1c87c/restalchemy-15.2.2.tar.gz", hash = "sha256:25a528de37024e67412d6781fc9a02e8f99c8a774ea86c8bf3f949284ad27063", size = 454543, upload-time = "2026-07-04T21:07:44.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7b/710b6c98e5705f86a26c64286b6eb0cd761e972ba366ef4ac9b7a50c7b55/restalchemy-15.2.8.tar.gz", hash = "sha256:07419f6d0bb1de2396ef20e35d5cfc8031e668b639401aef4627918c9ed8dff3", size = 468468, upload-time = "2026-07-24T10:13:52.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/d6/7528871d1c540fcd375394f8e95bbb0d47083aed11d3b06ea9c39ee69b44/restalchemy-15.2.2-py3-none-any.whl", hash = "sha256:3cce4c4eef6ba334a531bda98a4c964e15175278288699be0456c624a3c0a396", size = 512597, upload-time = "2026-07-04T21:07:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/27/9c4edf3f6cddb6d4ff7c67b04746ebd07fecd2e8dd3c72b2bfba5d0f8c5a/restalchemy-15.2.8-py3-none-any.whl", hash = "sha256:1c68d9327c23267f1ccf1139d8d94b5bc578c329662618539246869996336f79", size = 527843, upload-time = "2026-07-24T10:13:50.524Z" }, ] [[package]]