Skip to content

Commit e8f6a54

Browse files
committed
add quota
1 parent 54fea43 commit e8f6a54

18 files changed

Lines changed: 802 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ exordos_core/
8989
tox -e py310,py312,py314
9090

9191
# Run unit tests only
92-
tox -e py310
92+
tox -e py314
9393

9494
# Run functional tests
95-
tox -e py310-functional
95+
tox -e py314-functional
9696

9797
# Run linters
9898
tox -e ruff-check # Check code style

exordos_core/compute/dm/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from exordos_core.common import utils
3737
from exordos_core.common.dm import models as cm
3838
from exordos_core.compute import constants as nc
39+
from exordos_core.quota.dm.models import QuotaModelMixin
3940

4041
if tp.TYPE_CHECKING:
4142
from exordos_core.compute.pool.drivers.base import AbstractPoolDriver
@@ -261,6 +262,7 @@ class UnscheduledVolume(models.ModelWithUUID, orm.SQLStorableMixin):
261262
class NodeSet(
262263
infra_models.NodeSet,
263264
ua_models.InstanceWithDerivativesMixin,
265+
QuotaModelMixin,
264266
orm.SQLStorableMixin,
265267
):
266268
__tablename__ = "compute_sets"
@@ -300,6 +302,7 @@ def set_active(self):
300302

301303
class Node(
302304
infra_models.Node,
305+
QuotaModelMixin,
303306
orm.SQLStorableWithJSONFieldsMixin,
304307
):
305308
__tablename__ = "nodes"

exordos_core/quota/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.

exordos_core/quota/dm/__init__.py

Whitespace-only changes.

exordos_core/quota/dm/models.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
import logging
18+
import typing as tp
19+
import uuid as sys_uuid
20+
21+
from restalchemy.common import exceptions as ra_e
22+
from restalchemy.dm import filters as dm_filters
23+
from restalchemy.dm import models
24+
from restalchemy.dm import properties
25+
from restalchemy.dm import types
26+
from restalchemy.storage.sql import orm
27+
28+
LOG = logging.getLogger(__name__)
29+
30+
31+
class QuotaExceededError(ra_e.ValidationErrorException):
32+
message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s"
33+
34+
def __init__(
35+
self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID
36+
):
37+
super().__init__(
38+
resource_name=resource_name,
39+
limit=limit,
40+
current=current,
41+
project_id=project_id,
42+
)
43+
self.resource_name = resource_name
44+
self.limit = limit
45+
self.current = current
46+
self.project_id = project_id
47+
48+
49+
DEFAULT_QUOTA_LIMIT = 1000
50+
DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = {
51+
"net_lb": DEFAULT_QUOTA_LIMIT,
52+
"compute_sets": DEFAULT_QUOTA_LIMIT,
53+
"nodes": DEFAULT_QUOTA_LIMIT,
54+
"secret_passwords": DEFAULT_QUOTA_LIMIT,
55+
"secret_certificates": DEFAULT_QUOTA_LIMIT,
56+
"secret_rsa_keys": DEFAULT_QUOTA_LIMIT,
57+
"secret_ssh_keys": DEFAULT_QUOTA_LIMIT,
58+
}
59+
60+
61+
class QuotaModelMixin:
62+
def _quota_limit(self, session) -> tp.Optional[int]:
63+
limit = QuotaLimit.objects.get_one_or_none(
64+
session=session,
65+
filters={
66+
"project_id": dm_filters.EQ(self.project_id),
67+
"resource_name": dm_filters.EQ(self.__tablename__),
68+
},
69+
)
70+
if limit is not None:
71+
return limit.limit
72+
return DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
73+
74+
def _quota_check(self, session) -> None:
75+
# Check limit by counting entities in the table.
76+
# If limit exceeded, raises QuotaExceededError.
77+
limit = self._quota_limit(session)
78+
if limit is None:
79+
return
80+
81+
current = self.objects.count(
82+
session=session, filters={"project_id": dm_filters.EQ(self.project_id)}
83+
)
84+
85+
if current + 1 > limit:
86+
raise QuotaExceededError(
87+
resource_name=self.__tablename__,
88+
limit=limit,
89+
current=current + 1,
90+
project_id=self.project_id,
91+
)
92+
93+
def insert(self, session=None):
94+
# Reserve quota slot by checking entity count, then insert entity.
95+
if session is None:
96+
with self._get_engine().session_manager(session=session) as s:
97+
self._quota_check(s)
98+
super().insert(session=s)
99+
else:
100+
self._quota_check(session)
101+
super().insert(session=session)
102+
103+
104+
class QuotaLimit(
105+
models.ModelWithUUID,
106+
models.ModelWithTimestamp,
107+
models.ModelWithProject,
108+
orm.SQLStorableMixin,
109+
):
110+
__tablename__ = "quota_limits"
111+
112+
resource_name = properties.property(
113+
types.String(max_length=255),
114+
required=True,
115+
)
116+
limit = properties.property(
117+
types.Integer(min_value=0),
118+
required=True,
119+
)

exordos_core/secret/dm/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from exordos_core.common import constants as c
3030
from exordos_core.common.dm import models as cm
3131
from exordos_core.common.dm import targets as ct
32+
from exordos_core.quota.dm.models import QuotaModelMixin
3233
from exordos_core.secret import constants as sc
3334

3435

@@ -65,6 +66,7 @@ class Secret(
6566

6667
class Password(
6768
Secret,
69+
QuotaModelMixin,
6870
orm.SQLStorableMixin,
6971
ua_models.TargetResourceSQLStorableMixin,
7072
):
@@ -136,6 +138,7 @@ class DNSCoreCertificateMethod(AbstractCertificateMethod):
136138

137139
class Certificate(
138140
Secret,
141+
QuotaModelMixin,
139142
orm.SQLStorableWithJSONFieldsMixin,
140143
ua_models.TargetResourceSQLStorableMixin,
141144
):
@@ -217,6 +220,7 @@ def get_deleted_certificates(
217220

218221
class RSAKey(
219222
Secret,
223+
QuotaModelMixin,
220224
orm.SQLStorableMixin,
221225
ua_models.TargetResourceSQLStorableMixin,
222226
):
@@ -293,6 +297,7 @@ def get_resource_target_fields(self) -> tp.Set[str]:
293297

294298
class SSHKey(
295299
Secret,
300+
QuotaModelMixin,
296301
orm.SQLStorableMixin,
297302
ua_models.TargetResourceSQLStorableMixin,
298303
):

exordos_core/secret/service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from gcl_sdk.agents.universal.dm import models as ua_models
2323
from restalchemy.common import contexts
2424
from restalchemy.dm import filters as dm_filters
25+
from restalchemy.storage import exceptions as ra_exceptions
2526

2627
from exordos_core.common import constants as c
2728
from exordos_core.compute.dm import models as nm
@@ -358,7 +359,14 @@ def _actualize_new_ssh_key(
358359
node=node.uuid,
359360
status=sc.SecretStatus.IN_PROGRESS,
360361
)
361-
key_host_resource.insert()
362+
try:
363+
key_host_resource.insert()
364+
except ra_exceptions.ConflictRecords:
365+
LOG.debug(
366+
"SSH key resource %s for node %s already exists",
367+
key_resource.uuid,
368+
node.uuid,
369+
)
362370

363371
key.status = sc.SecretStatus.IN_PROGRESS.value
364372
key.save()

exordos_core/tests/functional/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,39 @@ def factory(
512512
return factory
513513

514514

515+
@pytest.fixture
516+
def node_factory_with_model():
517+
def factory(
518+
uuid: tp.Optional[sys_uuid.UUID] = None,
519+
name: str = "node",
520+
cores: int = 1,
521+
ram: int = 1024,
522+
image: str = "ubuntu_24.04",
523+
project_id: sys_uuid.UUID = c.SERVICE_PROJECT_ID,
524+
status: tp.Optional[str] = None,
525+
**kwargs,
526+
) -> tp.Tuple[tp.Dict[str, tp.Any], node_models.Node]:
527+
uuid = uuid or _make_uuid()
528+
status_value = nc.NodeStatus.NEW.value if status is None else status.value
529+
node = node_models.Node(
530+
uuid=uuid,
531+
name=name,
532+
cores=cores,
533+
ram=ram,
534+
project_id=project_id,
535+
status=status_value,
536+
disk_spec=sdk_infra_models.RootDiskSpec(image=image),
537+
**kwargs,
538+
)
539+
view = node.dump_to_simple_view()
540+
if status is None:
541+
view.pop("status")
542+
view.pop("node_set")
543+
return view, node
544+
545+
return factory
546+
547+
515548
@pytest.fixture
516549
def node_set_factory():
517550
def factory(
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.

0 commit comments

Comments
 (0)