Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,4 @@ docs/source/*.png
pgdata/

.tasks
graphify-out
8 changes: 8 additions & 0 deletions docs/openapi/openapi_user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion exordos_core/boot_api/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions exordos_core/cmd/user_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions exordos_core/common/api/middlewares/cors.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion exordos_core/orch_api/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 38 additions & 17 deletions exordos_core/repo/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -478,15 +495,17 @@ 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()
return self

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
Expand All @@ -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
Expand All @@ -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={
Expand All @@ -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()
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion exordos_core/status_api/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions exordos_core/tests/functional/restapi/repo/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading