Skip to content

Commit facac89

Browse files
committed
add quota
1 parent 6cc20e2 commit facac89

20 files changed

Lines changed: 1018 additions & 16 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
@@ -35,6 +35,7 @@
3535
from exordos_core.common import utils
3636
from exordos_core.common.dm import models as cm
3737
from exordos_core.compute import constants as nc
38+
from exordos_core.quota.dm.models import QuotaModelMixin
3839

3940
if tp.TYPE_CHECKING:
4041
from exordos_core.compute.pool.drivers.base import AbstractPoolDriver
@@ -301,6 +302,7 @@ class UnscheduledVolume(models.ModelWithUUID, orm.SQLStorableMixin):
301302
class NodeSet(
302303
infra_models.NodeSet,
303304
ua_models.InstanceWithDerivativesMixin,
305+
QuotaModelMixin,
304306
orm.SQLStorableMixin,
305307
):
306308
__tablename__ = "compute_sets"
@@ -340,6 +342,7 @@ def set_active(self):
340342

341343
class Node(
342344
infra_models.Node,
345+
QuotaModelMixin,
343346
orm.SQLStorableWithJSONFieldsMixin,
344347
):
345348
__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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 importlib
18+
import logging
19+
import typing as tp
20+
import uuid as sys_uuid
21+
22+
from restalchemy.common import exceptions as ra_e
23+
from restalchemy.dm import filters as dm_filters
24+
from restalchemy.dm import models
25+
from restalchemy.dm import properties
26+
from restalchemy.dm import types
27+
from restalchemy.storage.sql import orm
28+
29+
LOG = logging.getLogger(__name__)
30+
31+
32+
class QuotaExceededError(ra_e.ValidationErrorException):
33+
message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s"
34+
35+
def __init__(
36+
self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID
37+
):
38+
super().__init__(
39+
resource_name=resource_name,
40+
limit=limit,
41+
current=current,
42+
project_id=project_id,
43+
)
44+
self.resource_name = resource_name
45+
self.limit = limit
46+
self.current = current
47+
self.project_id = project_id
48+
49+
50+
DEFAULT_QUOTA_LIMIT = 1000
51+
DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = {
52+
"net_lb": DEFAULT_QUOTA_LIMIT,
53+
"compute_sets": DEFAULT_QUOTA_LIMIT,
54+
"nodes": DEFAULT_QUOTA_LIMIT,
55+
"secret_passwords": DEFAULT_QUOTA_LIMIT,
56+
"secret_certificates": DEFAULT_QUOTA_LIMIT,
57+
"secret_rsa_keys": DEFAULT_QUOTA_LIMIT,
58+
"secret_ssh_keys": DEFAULT_QUOTA_LIMIT,
59+
}
60+
DEFAULT_QUOTA_FIELD_LIMITS: tp.Dict[str, tp.Dict[str, int]] = {
61+
"nodes": {"cores": 10000},
62+
}
63+
QUOTA_RESOURCE_MODELS = {
64+
"net_lb": "exordos_core.user_api.network.dm.models:LB",
65+
"compute_sets": "exordos_core.compute.dm.models:NodeSet",
66+
"nodes": "exordos_core.compute.dm.models:Node",
67+
"secret_passwords": "exordos_core.secret.dm.models:Password",
68+
"secret_certificates": "exordos_core.secret.dm.models:Certificate",
69+
"secret_rsa_keys": "exordos_core.secret.dm.models:RSAKey",
70+
"secret_ssh_keys": "exordos_core.secret.dm.models:SSHKey",
71+
}
72+
73+
74+
def get_quota_resource_model(resource_name: str) -> type:
75+
try:
76+
module_name, class_name = QUOTA_RESOURCE_MODELS[resource_name].split(":")
77+
except KeyError:
78+
raise ValueError(f"Unknown quota resource: {resource_name}")
79+
80+
module = importlib.import_module(module_name)
81+
return getattr(module, class_name)
82+
83+
84+
class QuotaModelMixin:
85+
def _quota_limits(self, session) -> tp.Collection["QuotaLimit"]:
86+
limits = list(
87+
QuotaLimit.objects.get_all(
88+
session=session,
89+
filters={
90+
"project_id": dm_filters.EQ(self.project_id),
91+
"resource_name": dm_filters.EQ(self.__tablename__),
92+
},
93+
)
94+
)
95+
limit_fields = {limit.field_name for limit in limits}
96+
if "" not in limit_fields:
97+
default_limit = DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
98+
if default_limit is not None:
99+
limits.append(
100+
QuotaLimit(
101+
project_id=self.project_id,
102+
resource_name=self.__tablename__,
103+
field_name="",
104+
limit=default_limit,
105+
)
106+
)
107+
108+
for field_name, default_limit in DEFAULT_QUOTA_FIELD_LIMITS.get(
109+
self.__tablename__, {}
110+
).items():
111+
if field_name not in limit_fields:
112+
limits.append(
113+
QuotaLimit(
114+
project_id=self.project_id,
115+
resource_name=self.__tablename__,
116+
field_name=field_name,
117+
limit=default_limit,
118+
)
119+
)
120+
return limits
121+
122+
def _quota_check(self, session) -> None:
123+
# Check entity-count and aggregate-field limits before inserting.
124+
try:
125+
limits = self._quota_limits(session)
126+
except ValueError:
127+
LOG.exception("Invalid quota configuration for %s", self.__tablename__)
128+
return
129+
130+
if not limits:
131+
return
132+
133+
field_limits = [limit for limit in limits if limit.field_name]
134+
count_limits = [limit for limit in limits if not limit.field_name]
135+
aggregate_fields = ", ".join(
136+
f"SUM({field_name}) AS {field_name}"
137+
for field_name in sorted({limit.field_name for limit in field_limits})
138+
)
139+
selected_fields = ", ".join(
140+
field
141+
for field in ("COUNT(uuid) AS entity_count", aggregate_fields)
142+
if field
143+
)
144+
result = session.execute(
145+
f"SELECT {selected_fields} FROM {self.__tablename__} WHERE project_id = %s",
146+
(self.project_id,),
147+
)
148+
row = result.fetchone()
149+
150+
for quota_limit in field_limits:
151+
current = (row[quota_limit.field_name] or 0) + getattr(
152+
self, quota_limit.field_name
153+
)
154+
if current > quota_limit.limit:
155+
raise QuotaExceededError(
156+
resource_name=f"{self.__tablename__}.{quota_limit.field_name}",
157+
limit=quota_limit.limit,
158+
current=current,
159+
project_id=self.project_id,
160+
)
161+
162+
current_count = row["entity_count"] + 1
163+
for quota_limit in count_limits:
164+
if current_count > quota_limit.limit:
165+
raise QuotaExceededError(
166+
resource_name=self.__tablename__,
167+
limit=quota_limit.limit,
168+
current=current_count,
169+
project_id=self.project_id,
170+
)
171+
172+
def insert(self, session=None):
173+
# Reserve quota capacity by checking entity-count and field totals.
174+
if session is None:
175+
with self._get_engine().session_manager(session=session) as s:
176+
self._quota_check(s)
177+
super().insert(session=s)
178+
else:
179+
self._quota_check(session)
180+
super().insert(session=session)
181+
182+
183+
class QuotaLimit(
184+
models.ModelWithUUID,
185+
models.ModelWithTimestamp,
186+
models.ModelWithProject,
187+
orm.SQLStorableMixin,
188+
):
189+
__tablename__ = "quota_limits"
190+
191+
resource_name = properties.property(
192+
types.String(max_length=255),
193+
required=True,
194+
)
195+
field_name = properties.property(
196+
types.String(max_length=255),
197+
default="",
198+
)
199+
limit = properties.property(
200+
types.Integer(min_value=0),
201+
required=True,
202+
)
203+
204+
def validate(self):
205+
super().validate()
206+
resource_model = get_quota_resource_model(self.resource_name)
207+
if not self.field_name:
208+
return
209+
210+
quota_property = resource_model.properties.properties.get(self.field_name)
211+
if quota_property is None:
212+
raise ValueError(f"Unknown quota field: {self.field_name}")
213+
if not isinstance(quota_property.get_property_type(), types.Integer):
214+
raise ValueError(f"Quota field must be an integer: {self.field_name}")

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
@@ -513,6 +513,39 @@ def factory(
513513
return factory
514514

515515

516+
@pytest.fixture
517+
def node_factory_with_model():
518+
def factory(
519+
uuid: tp.Optional[sys_uuid.UUID] = None,
520+
name: str = "node",
521+
cores: int = 1,
522+
ram: int = 1024,
523+
image: str = "ubuntu_24.04",
524+
project_id: sys_uuid.UUID = c.SERVICE_PROJECT_ID,
525+
status: tp.Optional[str] = None,
526+
**kwargs,
527+
) -> tp.Tuple[tp.Dict[str, tp.Any], node_models.Node]:
528+
uuid = uuid or _make_uuid()
529+
status_value = nc.NodeStatus.NEW.value if status is None else status.value
530+
node = node_models.Node(
531+
uuid=uuid,
532+
name=name,
533+
cores=cores,
534+
ram=ram,
535+
project_id=project_id,
536+
status=status_value,
537+
disk_spec=sdk_infra_models.RootDiskSpec(image=image),
538+
**kwargs,
539+
)
540+
view = node.dump_to_simple_view()
541+
if status is None:
542+
view.pop("status")
543+
view.pop("node_set")
544+
return view, node
545+
546+
return factory
547+
548+
516549
@pytest.fixture
517550
def node_set_factory():
518551
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)