Skip to content
Merged
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ exordos_core/
tox -e py310,py312,py314

# Run unit tests only
tox -e py310
tox -e py314

# Run functional tests
tox -e py310-functional
tox -e py314-functional

# Run linters
tox -e ruff-check # Check code style
Expand Down
3 changes: 3 additions & 0 deletions exordos_core/compute/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from exordos_core.common import utils
from exordos_core.common.dm import models as cm
from exordos_core.compute import constants as nc
from exordos_core.quota.dm.models import QuotaModelMixin

if tp.TYPE_CHECKING:
from exordos_core.compute.pool.drivers.base import AbstractPoolDriver
Expand Down Expand Up @@ -301,6 +302,7 @@ class UnscheduledVolume(models.ModelWithUUID, orm.SQLStorableMixin):
class NodeSet(
infra_models.NodeSet,
ua_models.InstanceWithDerivativesMixin,
QuotaModelMixin,
orm.SQLStorableMixin,
):
__tablename__ = "compute_sets"
Expand Down Expand Up @@ -340,6 +342,7 @@ def set_active(self):

class Node(
infra_models.Node,
QuotaModelMixin,
orm.SQLStorableWithJSONFieldsMixin,
):
__tablename__ = "nodes"
Expand Down
15 changes: 15 additions & 0 deletions exordos_core/quota/__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.
Empty file.
202 changes: 202 additions & 0 deletions exordos_core/quota/dm/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# 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 importlib
import logging
import typing as tp
import uuid as sys_uuid

from restalchemy.common import exceptions as ra_e
from restalchemy.dm import filters as dm_filters
from restalchemy.dm import models
from restalchemy.dm import properties
from restalchemy.dm import types
from restalchemy.storage.sql import orm

LOG = logging.getLogger(__name__)


class QuotaExceededError(ra_e.ValidationErrorException):
message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s"

def __init__(
self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID
):
super().__init__(
resource_name=resource_name,
limit=limit,
current=current,
project_id=project_id,
)
self.resource_name = resource_name
self.limit = limit
self.current = current
self.project_id = project_id


DEFAULT_QUOTA_LIMIT = 1000
DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = {
"net_lb": DEFAULT_QUOTA_LIMIT,
"compute_sets": DEFAULT_QUOTA_LIMIT,
"nodes": DEFAULT_QUOTA_LIMIT,
"secret_passwords": DEFAULT_QUOTA_LIMIT,
"secret_certificates": DEFAULT_QUOTA_LIMIT,
"secret_rsa_keys": DEFAULT_QUOTA_LIMIT,
"secret_ssh_keys": DEFAULT_QUOTA_LIMIT,
}
Comment thread
slashburygin marked this conversation as resolved.
DEFAULT_QUOTA_FIELD_LIMITS: tp.Dict[str, tp.Dict[str, int]] = {
"nodes": {"cores": 10000},
}
QUOTA_RESOURCE_MODELS = {
"net_lb": "exordos_core.user_api.network.dm.models:LB",
"compute_sets": "exordos_core.compute.dm.models:NodeSet",
"nodes": "exordos_core.compute.dm.models:Node",
"secret_passwords": "exordos_core.secret.dm.models:Password",
"secret_certificates": "exordos_core.secret.dm.models:Certificate",
"secret_rsa_keys": "exordos_core.secret.dm.models:RSAKey",
"secret_ssh_keys": "exordos_core.secret.dm.models:SSHKey",
}


def get_quota_resource_model(resource_name: str) -> type:
Comment thread
akremenetsky marked this conversation as resolved.
try:
module_name, class_name = QUOTA_RESOURCE_MODELS[resource_name].split(":")
except KeyError:
raise ValueError(f"Unknown quota resource: {resource_name}")

module = importlib.import_module(module_name)
return getattr(module, class_name)


class QuotaModelMixin:
def _quota_limits(self, session) -> tp.Collection["QuotaLimit"]:
limits = list(
QuotaLimit.objects.get_all(
session=session,
filters={
"project_id": dm_filters.EQ(self.project_id),
"resource_name": dm_filters.EQ(self.__tablename__),
},
)
)
limit_fields = {limit.field_name for limit in limits}
if "" not in limit_fields:
default_limit = DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
if default_limit is not None:
limits.append(
QuotaLimit(
project_id=self.project_id,
resource_name=self.__tablename__,
field_name="",
limit=default_limit,
)
)

for field_name, default_limit in DEFAULT_QUOTA_FIELD_LIMITS.get(
self.__tablename__, {}
).items():
if field_name not in limit_fields:
limits.append(
QuotaLimit(
project_id=self.project_id,
resource_name=self.__tablename__,
field_name=field_name,
limit=default_limit,
)
)
return limits

def _quota_check(self, session) -> None:
# Check entity-count and aggregate-field limits before inserting.
try:
limits = self._quota_limits(session)
except ValueError:
LOG.exception("Invalid quota configuration for %s", self.__tablename__)
return

if not limits:
return

field_limits = [limit for limit in limits if limit.field_name]
count_limits = [limit for limit in limits if not limit.field_name]
aggregate_fields = ", ".join(
f"SUM({field_name}) AS {field_name}"
for field_name in sorted({limit.field_name for limit in field_limits})
)
selected_fields = ", ".join(
field
for field in ("COUNT(uuid) AS entity_count", aggregate_fields)
if field
)
result = session.execute(
f"SELECT {selected_fields} FROM {self.__tablename__} WHERE project_id = %s",
(self.project_id,),
)
row = result.fetchone()

for quota_limit in field_limits:
current = (row[quota_limit.field_name] or 0) + getattr(
self, quota_limit.field_name
)
if current > quota_limit.limit:
raise QuotaExceededError(
resource_name=f"{self.__tablename__}.{quota_limit.field_name}",
limit=quota_limit.limit,
current=current,
project_id=self.project_id,
)

current_count = row["entity_count"] + 1
for quota_limit in count_limits:
if current_count > quota_limit.limit:
raise QuotaExceededError(
resource_name=self.__tablename__,
limit=quota_limit.limit,
current=current_count,
project_id=self.project_id,
)

def insert(self, session=None):
# Reserve quota capacity by checking entity-count and field totals.
if session is None:
with self._get_engine().session_manager(session=session) as s:
self._quota_check(s)
super().insert(session=s)
else:
self._quota_check(session)
super().insert(session=session)

Comment thread
slashburygin marked this conversation as resolved.

class QuotaLimit(
models.ModelWithUUID,
models.ModelWithTimestamp,
models.ModelWithProject,
orm.SQLStorableMixin,
):
__tablename__ = "quota_limits"

resource_name = properties.property(
types.String(max_length=255),
required=True,
)
field_name = properties.property(
types.String(max_length=255),
default="",
)
limit = properties.property(
types.Integer(min_value=0),
required=True,
)
5 changes: 5 additions & 0 deletions exordos_core/secret/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from exordos_core.common import constants as c
from exordos_core.common.dm import models as cm
from exordos_core.common.dm import targets as ct
from exordos_core.quota.dm.models import QuotaModelMixin
from exordos_core.secret import constants as sc


Expand Down Expand Up @@ -65,6 +66,7 @@ class Secret(

class Password(
Secret,
QuotaModelMixin,
orm.SQLStorableMixin,
ua_models.TargetResourceSQLStorableMixin,
):
Expand Down Expand Up @@ -136,6 +138,7 @@ class DNSCoreCertificateMethod(AbstractCertificateMethod):

class Certificate(
Secret,
QuotaModelMixin,
orm.SQLStorableWithJSONFieldsMixin,
ua_models.TargetResourceSQLStorableMixin,
):
Expand Down Expand Up @@ -217,6 +220,7 @@ def get_deleted_certificates(

class RSAKey(
Secret,
QuotaModelMixin,
orm.SQLStorableMixin,
ua_models.TargetResourceSQLStorableMixin,
):
Expand Down Expand Up @@ -293,6 +297,7 @@ def get_resource_target_fields(self) -> tp.Set[str]:

class SSHKey(
Secret,
QuotaModelMixin,
orm.SQLStorableMixin,
ua_models.TargetResourceSQLStorableMixin,
):
Expand Down
10 changes: 9 additions & 1 deletion exordos_core/secret/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from gcl_sdk.agents.universal.dm import models as ua_models
from restalchemy.common import contexts
from restalchemy.dm import filters as dm_filters
from restalchemy.storage import exceptions as ra_exceptions

from exordos_core.common import constants as c
from exordos_core.compute.dm import models as nm
Expand Down Expand Up @@ -358,7 +359,14 @@ def _actualize_new_ssh_key(
node=node.uuid,
status=sc.SecretStatus.IN_PROGRESS,
)
key_host_resource.insert()
try:
key_host_resource.insert()
except ra_exceptions.ConflictRecords:
LOG.debug(
"SSH key resource %s for node %s already exists",
key_resource.uuid,
node.uuid,
)

key.status = sc.SecretStatus.IN_PROGRESS.value
key.save()
Expand Down
33 changes: 33 additions & 0 deletions exordos_core/tests/functional/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,39 @@ def factory(
return factory


@pytest.fixture
def node_factory_with_model():
def factory(
uuid: tp.Optional[sys_uuid.UUID] = None,
name: str = "node",
cores: int = 1,
ram: int = 1024,
image: str = "ubuntu_24.04",
project_id: sys_uuid.UUID = c.SERVICE_PROJECT_ID,
status: tp.Optional[str] = None,
**kwargs,
) -> tp.Tuple[tp.Dict[str, tp.Any], node_models.Node]:
uuid = uuid or _make_uuid()
status_value = nc.NodeStatus.NEW.value if status is None else status.value
node = node_models.Node(
uuid=uuid,
name=name,
cores=cores,
ram=ram,
project_id=project_id,
status=status_value,
disk_spec=sdk_infra_models.RootDiskSpec(image=image),
**kwargs,
)
view = node.dump_to_simple_view()
if status is None:
view.pop("status")
view.pop("node_set")
return view, node

return factory


@pytest.fixture
def node_set_factory():
def factory(
Expand Down
15 changes: 15 additions & 0 deletions exordos_core/tests/functional/restapi/quota/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2025 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