Skip to content

Commit 3518aa3

Browse files
committed
add quota
1 parent 67187c4 commit 3518aa3

18 files changed

Lines changed: 947 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
@@ -302,6 +303,7 @@ class UnscheduledVolume(models.ModelWithUUID, orm.SQLStorableMixin):
302303
class NodeSet(
303304
infra_models.NodeSet,
304305
ua_models.InstanceWithDerivativesMixin,
306+
QuotaModelMixin,
305307
orm.SQLStorableMixin,
306308
):
307309
__tablename__ = "compute_sets"
@@ -341,6 +343,7 @@ def set_active(self):
341343

342344
class Node(
343345
infra_models.Node,
346+
QuotaModelMixin,
344347
orm.SQLStorableWithJSONFieldsMixin,
345348
):
346349
__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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
DEFAULT_QUOTA_FIELD_LIMITS: tp.Dict[str, tp.Dict[str, int]] = {
60+
"nodes": {"cores": 10000},
61+
}
62+
63+
64+
class QuotaModelMixin:
65+
def _quota_limits(self, session) -> tp.Collection["QuotaLimit"]:
66+
limits = list(
67+
QuotaLimit.objects.get_all(
68+
session=session,
69+
filters={
70+
"project_id": dm_filters.EQ(self.project_id),
71+
"resource_name": dm_filters.EQ(self.__tablename__),
72+
},
73+
)
74+
)
75+
limit_fields = {limit.field_name for limit in limits}
76+
if "" not in limit_fields:
77+
default_limit = DEFAULT_QUOTA_LIMITS.get(self.__tablename__)
78+
if default_limit is not None:
79+
limits.append(
80+
QuotaLimit(
81+
project_id=self.project_id,
82+
resource_name=self.__tablename__,
83+
field_name="",
84+
limit=default_limit,
85+
)
86+
)
87+
88+
for field_name, default_limit in DEFAULT_QUOTA_FIELD_LIMITS.get(
89+
self.__tablename__, {}
90+
).items():
91+
if field_name not in limit_fields:
92+
limits.append(
93+
QuotaLimit(
94+
project_id=self.project_id,
95+
resource_name=self.__tablename__,
96+
field_name=field_name,
97+
limit=default_limit,
98+
)
99+
)
100+
return limits
101+
102+
def _quota_check(self, session) -> None:
103+
# Check entity-count and aggregate-field limits before inserting.
104+
limits = self._quota_limits(session)
105+
if not limits:
106+
return
107+
108+
filters = {"project_id": dm_filters.EQ(self.project_id)}
109+
field_limits = [limit for limit in limits if limit.field_name]
110+
count_limits = [limit for limit in limits if not limit.field_name]
111+
112+
if field_limits:
113+
field_names = {limit.field_name for limit in field_limits}
114+
invalid_field_names = field_names - set(self.properties.properties)
115+
if invalid_field_names:
116+
raise ValueError(
117+
f"Unknown quota field(s): {', '.join(sorted(invalid_field_names))}"
118+
)
119+
120+
fields = ", ".join(sorted(field_names))
121+
result = session.execute(
122+
f"SELECT {fields} FROM {self.__tablename__} WHERE project_id = %s",
123+
(self.project_id,),
124+
)
125+
rows = result.fetchall()
126+
for quota_limit in field_limits:
127+
current = sum(row[quota_limit.field_name] for row in rows) + getattr(
128+
self, quota_limit.field_name
129+
)
130+
if current > quota_limit.limit:
131+
raise QuotaExceededError(
132+
resource_name=f"{self.__tablename__}.{quota_limit.field_name}",
133+
limit=quota_limit.limit,
134+
current=current,
135+
project_id=self.project_id,
136+
)
137+
138+
if count_limits:
139+
current = self.objects.count(session=session, filters=filters) + 1
140+
for quota_limit in count_limits:
141+
if current > quota_limit.limit:
142+
raise QuotaExceededError(
143+
resource_name=self.__tablename__,
144+
limit=quota_limit.limit,
145+
current=current,
146+
project_id=self.project_id,
147+
)
148+
149+
def insert(self, session=None):
150+
# Reserve quota capacity by checking entity-count and field totals.
151+
if session is None:
152+
with self._get_engine().session_manager(session=session) as s:
153+
self._quota_check(s)
154+
super().insert(session=s)
155+
else:
156+
self._quota_check(session)
157+
super().insert(session=session)
158+
159+
160+
class QuotaLimit(
161+
models.ModelWithUUID,
162+
models.ModelWithTimestamp,
163+
models.ModelWithProject,
164+
orm.SQLStorableMixin,
165+
):
166+
__tablename__ = "quota_limits"
167+
168+
resource_name = properties.property(
169+
types.String(max_length=255),
170+
required=True,
171+
)
172+
field_name = properties.property(
173+
types.String(max_length=255),
174+
default="",
175+
)
176+
limit = properties.property(
177+
types.Integer(min_value=0),
178+
required=True,
179+
)

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)