|
| 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 | +# |
| 18 | +# Quota flow |
| 19 | +# ---------- |
| 20 | +# |
| 21 | +# Two tables control quotas: |
| 22 | +# quota_limits — admin-defined cap per resource (e.g. net_lb → 5) |
| 23 | +# quotas_reservations — per-entity reservation rows tying a quota slot to an entity UUID |
| 24 | +# |
| 25 | +# QuotaModelMixin overrides insert() and delete() on tracked entities (LB, NodeSet, Node). |
| 26 | +# |
| 27 | +# On insert(): |
| 28 | +# 1. _quota_reserve(s) — in the caller's transaction: |
| 29 | +# a. Look up the limit for this resource (self.__tablename__). |
| 30 | +# If no limit is set, quota is unlimited — skip. |
| 31 | +# b. Acquire a pg_advisory_xact_lock keyed by {project_id}:{resource_name} |
| 32 | +# to serialize concurrent reservations for the same project+resource. |
| 33 | +# c. Count existing reservations for this project+resource. |
| 34 | +# If at or above the limit, raise QuotaExceededError (rolls back the transaction). |
| 35 | +# d. Insert a QuotaReservation row with uuid=self.uuid — the entity's own UUID |
| 36 | +# links the reservation to the entity. |
| 37 | +# 2. super().insert(s) — persist the entity itself in the same transaction. |
| 38 | +# 3. If step 2 raises, the transaction rolls back and the reservation is |
| 39 | +# automatically cleaned up — no explicit release needed. |
| 40 | +# |
| 41 | +# On delete(): |
| 42 | +# 1. _quota_release_all(s) — delete the reservation row (frees the quota slot). |
| 43 | +# 2. super().delete(s) — delete the entity itself. |
| 44 | +# |
| 45 | +# reconcile_quota_reservations(session): |
| 46 | +# Find all reservations whose uuid no longer exists in the entity table. |
| 47 | +# This handles edge cases where reservation cleanup was missed. |
| 48 | +# Designed to be run periodically or on-demand as a repair mechanism. |
| 49 | +# |
| 50 | + |
| 51 | +import hashlib |
| 52 | +import logging |
| 53 | +import typing as tp |
| 54 | +import uuid as sys_uuid |
| 55 | + |
| 56 | +from restalchemy.common import exceptions as ra_e |
| 57 | +from restalchemy.dm import filters as dm_filters |
| 58 | +from restalchemy.dm import models |
| 59 | +from restalchemy.dm import properties |
| 60 | +from restalchemy.dm import types |
| 61 | +from restalchemy.storage import exceptions as rs_e |
| 62 | +from restalchemy.storage.sql import orm |
| 63 | + |
| 64 | +LOG = logging.getLogger(__name__) |
| 65 | + |
| 66 | + |
| 67 | +class QuotaExceededError(ra_e.ValidationErrorException): |
| 68 | + message = "Quota exceeded for resource '%(resource_name)s' in project %(project_id)s: %(current)s > %(limit)s" |
| 69 | + |
| 70 | + def __init__( |
| 71 | + self, resource_name: str, limit: int, current: int, project_id: sys_uuid.UUID |
| 72 | + ): |
| 73 | + super().__init__( |
| 74 | + resource_name=resource_name, |
| 75 | + limit=limit, |
| 76 | + current=current, |
| 77 | + project_id=project_id, |
| 78 | + ) |
| 79 | + self.resource_name = resource_name |
| 80 | + self.limit = limit |
| 81 | + self.current = current |
| 82 | + self.project_id = project_id |
| 83 | + |
| 84 | + |
| 85 | +# Built-in default quota limits keyed by resource_name (__tablename__). |
| 86 | +# These apply when no explicit QuotaLimit row exists for the project+resource. |
| 87 | +# A resource not listed here or in the DB has no quota limit (unlimited). |
| 88 | +DEFAULT_QUOTA_LIMIT = 1000 |
| 89 | +DEFAULT_QUOTA_LIMITS: tp.Dict[str, int] = { |
| 90 | + "net_lb": DEFAULT_QUOTA_LIMIT, |
| 91 | + "compute_sets": DEFAULT_QUOTA_LIMIT, |
| 92 | + "nodes": DEFAULT_QUOTA_LIMIT, |
| 93 | + "secret_passwords": DEFAULT_QUOTA_LIMIT, |
| 94 | + "secret_certificates": DEFAULT_QUOTA_LIMIT, |
| 95 | + "secret_rsa_keys": DEFAULT_QUOTA_LIMIT, |
| 96 | + "secret_ssh_keys": DEFAULT_QUOTA_LIMIT, |
| 97 | +} |
| 98 | + |
| 99 | + |
| 100 | +class QuotaModelMixin: |
| 101 | + def _quota_limit(self, session) -> tp.Optional[int]: |
| 102 | + limit = QuotaLimit.objects.get_one_or_none( |
| 103 | + session=session, |
| 104 | + filters={ |
| 105 | + "project_id": dm_filters.EQ(self.project_id), |
| 106 | + "resource_name": dm_filters.EQ(self.__tablename__), |
| 107 | + }, |
| 108 | + ) |
| 109 | + if limit is not None: |
| 110 | + return limit.limit |
| 111 | + return DEFAULT_QUOTA_LIMITS.get(self.__tablename__) |
| 112 | + |
| 113 | + @staticmethod |
| 114 | + def _quota_lock_key(project_id: sys_uuid.UUID, resource_name: str) -> int: |
| 115 | + """Derive a positive bigint lock key for pg_advisory_xact_lock. |
| 116 | +
|
| 117 | + The key is a deterministic 63-bit hash of ``{project_id}:{resource_name}``, |
| 118 | + so concurrent inserts for the same project+resource contend on the same |
| 119 | + lock, while different project+resource pairs do not block each other. |
| 120 | +
|
| 121 | + Examples |
| 122 | + -------- |
| 123 | + >>> import uuid |
| 124 | + >>> pid = uuid.UUID("00000000-0000-0000-0000-000000000000") |
| 125 | + >>> _quota_lock_key(pid, "net_lb") |
| 126 | + 8255477843906886675 |
| 127 | + >>> _quota_lock_key(pid, "compute_sets") |
| 128 | + 2792567584429316197 |
| 129 | + >>> # Different project → different key |
| 130 | + >>> pid2 = uuid.UUID("12345678-1234-5678-1234-567812345678") |
| 131 | + >>> _quota_lock_key(pid2, "net_lb") |
| 132 | + 1636646552482098955 |
| 133 | + """ |
| 134 | + key = f"{project_id}:{resource_name}" |
| 135 | + digest = hashlib.sha256(key.encode()).digest()[:8] |
| 136 | + return ( |
| 137 | + int.from_bytes(digest, byteorder="big", signed=False) & 0x7FFFFFFFFFFFFFFF |
| 138 | + ) |
| 139 | + |
| 140 | + def _quota_acquire_lock(self, session: tp.Any) -> None: |
| 141 | + lock_key = self._quota_lock_key(self.project_id, self.__tablename__) |
| 142 | + session.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) |
| 143 | + |
| 144 | + def _quota_reserve(self, session) -> None: |
| 145 | + # Check limit, count existing reservations, create reservation. |
| 146 | + # If limit exceeded, raises QuotaExceededError. |
| 147 | + limit = self._quota_limit(session) |
| 148 | + if limit is None: |
| 149 | + return |
| 150 | + |
| 151 | + self._quota_acquire_lock(session) |
| 152 | + |
| 153 | + current = QuotaReservation.objects.count( |
| 154 | + session=session, |
| 155 | + filters={ |
| 156 | + "project_id": dm_filters.EQ(self.project_id), |
| 157 | + "resource_name": dm_filters.EQ(self.__tablename__), |
| 158 | + }, |
| 159 | + ) |
| 160 | + |
| 161 | + if current + 1 > limit: |
| 162 | + raise QuotaExceededError( |
| 163 | + resource_name=self.__tablename__, |
| 164 | + limit=limit, |
| 165 | + current=current + 1, |
| 166 | + project_id=self.project_id, |
| 167 | + ) |
| 168 | + |
| 169 | + reservation = QuotaReservation( |
| 170 | + uuid=self.uuid, |
| 171 | + project_id=self.project_id, |
| 172 | + resource_name=self.__tablename__, |
| 173 | + ) |
| 174 | + try: |
| 175 | + reservation.insert(session=session) |
| 176 | + except rs_e.ConflictRecords: |
| 177 | + LOG.debug( |
| 178 | + "Quota reservation already exists for %s", |
| 179 | + self.uuid, |
| 180 | + ) |
| 181 | + |
| 182 | + def _quota_release_all(self, session) -> None: |
| 183 | + # Delete all reservations for this entity. |
| 184 | + for reservation in QuotaReservation.objects.get_all( |
| 185 | + filters={ |
| 186 | + "uuid": dm_filters.EQ(self.uuid), |
| 187 | + "project_id": dm_filters.EQ(self.project_id), |
| 188 | + "resource_name": dm_filters.EQ(self.__tablename__), |
| 189 | + }, |
| 190 | + session=session, |
| 191 | + ): |
| 192 | + reservation.delete(session=session) |
| 193 | + |
| 194 | + def insert(self, session=None): |
| 195 | + # Reserve quota slot, then insert entity. |
| 196 | + # The reservation is in the same transaction as the entity, so if the |
| 197 | + # entity insert fails the entire transaction is rolled back and the |
| 198 | + # reservation is cleaned up automatically — no explicit release needed. |
| 199 | + if session is None: |
| 200 | + engine = QuotaReservation._get_engine() |
| 201 | + with engine.session_manager() as s: |
| 202 | + self._quota_reserve(s) |
| 203 | + super().insert(session=s) |
| 204 | + else: |
| 205 | + self._quota_reserve(session) |
| 206 | + super().insert(session=session) |
| 207 | + |
| 208 | + def delete(self, session=None): |
| 209 | + # Release reservation, then delete entity. |
| 210 | + if session is None: |
| 211 | + engine = QuotaReservation._get_engine() |
| 212 | + with engine.session_manager() as s: |
| 213 | + self._quota_release_all(s) |
| 214 | + super().delete(session=s) |
| 215 | + else: |
| 216 | + self._quota_release_all(session) |
| 217 | + super().delete(session=session) |
| 218 | + |
| 219 | + |
| 220 | +def reconcile_quota_reservations(session): |
| 221 | + cursor = session.execute( |
| 222 | + "SELECT DISTINCT resource_name FROM quotas_reservations", |
| 223 | + None, |
| 224 | + ) |
| 225 | + resource_names = [row["resource_name"] for row in cursor] |
| 226 | + for rname in resource_names: |
| 227 | + session.execute( |
| 228 | + "DELETE FROM quotas_reservations r " |
| 229 | + "WHERE r.resource_name = %s " |
| 230 | + "AND NOT EXISTS (SELECT 1 FROM " + rname + " e WHERE e.uuid = r.uuid)", |
| 231 | + [rname], |
| 232 | + ) |
| 233 | + |
| 234 | + |
| 235 | +class QuotaReservation( |
| 236 | + models.ModelWithUUID, |
| 237 | + models.ModelWithTimestamp, |
| 238 | + models.ModelWithProject, |
| 239 | + orm.SQLStorableMixin, |
| 240 | +): |
| 241 | + __tablename__ = "quotas_reservations" |
| 242 | + |
| 243 | + resource_name = properties.property( |
| 244 | + types.String(max_length=255), |
| 245 | + required=True, |
| 246 | + read_only=True, |
| 247 | + ) |
| 248 | + |
| 249 | + @classmethod |
| 250 | + def get_project_quota_summary( |
| 251 | + cls, |
| 252 | + session: tp.Optional[tp.Any] = None, |
| 253 | + project_id: sys_uuid.UUID | str | None = None, |
| 254 | + ) -> tp.List[tp.Dict[str, tp.Any]]: |
| 255 | + """Get aggregated quota reservation information. |
| 256 | +
|
| 257 | + Returns a list of dictionaries with keys: |
| 258 | + - project_id: UUID of the project |
| 259 | + - resource_name: name of the resource |
| 260 | + - reserved_count: number of reservations for this project+resource |
| 261 | +
|
| 262 | + If project_id is provided, filters to that specific project. |
| 263 | + Otherwise, returns summaries for all projects. |
| 264 | +
|
| 265 | + If session is not provided, creates a temporary session using the default engine. |
| 266 | + """ |
| 267 | + |
| 268 | + def req(): |
| 269 | + if project_id is not None: |
| 270 | + return session.execute( |
| 271 | + "SELECT project_id, resource_name, COUNT(*) as reserved_count " |
| 272 | + "FROM quotas_reservations " |
| 273 | + "WHERE project_id = %s " |
| 274 | + "GROUP BY project_id, resource_name", |
| 275 | + [project_id], |
| 276 | + ) |
| 277 | + else: |
| 278 | + return session.execute( |
| 279 | + "SELECT project_id, resource_name, COUNT(*) as reserved_count " |
| 280 | + "FROM quotas_reservations " |
| 281 | + "GROUP BY project_id, resource_name", |
| 282 | + None, |
| 283 | + ) |
| 284 | + |
| 285 | + if session is None: |
| 286 | + engine = cls._get_engine() |
| 287 | + with engine.session_manager() as session: |
| 288 | + cursor = req() |
| 289 | + else: |
| 290 | + cursor = req() |
| 291 | + |
| 292 | + result = [ |
| 293 | + { |
| 294 | + "project_id": str(row["project_id"]), |
| 295 | + "resource_name": row["resource_name"], |
| 296 | + "reserved_count": row["reserved_count"], |
| 297 | + } |
| 298 | + for row in cursor |
| 299 | + ] |
| 300 | + |
| 301 | + return result |
| 302 | + |
| 303 | + |
| 304 | +class QuotaLimit( |
| 305 | + models.ModelWithUUID, |
| 306 | + models.ModelWithTimestamp, |
| 307 | + models.ModelWithProject, |
| 308 | + orm.SQLStorableMixin, |
| 309 | +): |
| 310 | + __tablename__ = "quota_limits" |
| 311 | + |
| 312 | + resource_name = properties.property( |
| 313 | + types.String(max_length=255), |
| 314 | + required=True, |
| 315 | + ) |
| 316 | + limit = properties.property( |
| 317 | + types.Integer(min_value=0), |
| 318 | + required=True, |
| 319 | + ) |
0 commit comments