feat(quotas): per-user resource limits for VM/LXC deployment
Add per-user quotas (max instances, vCPU, RAM, disk) enforced at deploy time and surfaced to users self-service. Backend: - UserQuota model (one-to-one, NULL = unlimited) + migration #28 - quota_service: usage from vm_instances cache, check_quota raises 429 - enforce in VM (templates.py) and LXC (lxc.py) deploy paths - RBAC quota:view / quota:manage, granted to admin - admin API GET/PUT /admin/api/users/{id}/quota - self-service GET /settings/api/quota - tighten VM list isolation via can_view_all_instances Frontend: - admin quota dialog in UsersPage with usage bars - quota card on Settings page (own limits) - remaining-quota hint in CreateInstanceWizard config step - useUserQuota/useSetUserQuota, useMyQuota hooks; RU/EN locales Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
82
backend/app/services/quota_service.py
Normal file
82
backend/app/services/quota_service.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Quota Service - Per-user resource limits (quotas)
|
||||
|
||||
Enforces per-user limits on the number of instances and the summed vCPU, RAM
|
||||
and disk across a user's VMs/LXC. Current usage is computed from the
|
||||
``vm_instances`` cache (column ``owner_id``), where ``memory`` is stored in MB
|
||||
and ``disk_size`` in GB by convention (see the monitoring worker conversions),
|
||||
so no unit conversion is needed here.
|
||||
|
||||
A missing ``UserQuota`` row — or a ``NULL`` column — means *unlimited* for that
|
||||
metric, so users without an explicit quota (including admins) are never blocked.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import VMInstance, UserQuota
|
||||
|
||||
|
||||
def get_user_usage(db: Session, user_id: int) -> Dict[str, int]:
|
||||
"""Return current resource usage for a user from the vm_instances cache.
|
||||
|
||||
Only non-deleted instances owned by the user are counted.
|
||||
"""
|
||||
row = (
|
||||
db.query(
|
||||
func.count(VMInstance.id),
|
||||
func.coalesce(func.sum(VMInstance.cores), 0),
|
||||
func.coalesce(func.sum(VMInstance.memory), 0),
|
||||
func.coalesce(func.sum(VMInstance.disk_size), 0),
|
||||
)
|
||||
.filter(
|
||||
VMInstance.owner_id == user_id,
|
||||
VMInstance.deleted_at.is_(None),
|
||||
VMInstance.is_template.isnot(True),
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return {
|
||||
"instances": int(row[0] or 0),
|
||||
"cores": int(row[1] or 0),
|
||||
"memory_mb": int(row[2] or 0),
|
||||
"disk_gb": int(row[3] or 0),
|
||||
}
|
||||
|
||||
|
||||
def check_quota(
|
||||
db: Session,
|
||||
owner_id: int,
|
||||
add_cores: int = 0,
|
||||
add_memory_mb: int = 0,
|
||||
add_disk_gb: int = 0,
|
||||
) -> None:
|
||||
"""Raise HTTP 429 if deploying a new instance would exceed the user's quota.
|
||||
|
||||
Each metric is checked independently; a ``NULL`` limit (or no quota row)
|
||||
skips that metric.
|
||||
"""
|
||||
quota = db.query(UserQuota).filter(UserQuota.user_id == owner_id).first()
|
||||
if quota is None:
|
||||
return
|
||||
|
||||
usage = get_user_usage(db, owner_id)
|
||||
|
||||
checks = [
|
||||
(quota.max_instances, usage["instances"] + 1, "инстансов", "instances"),
|
||||
(quota.max_cores, usage["cores"] + (add_cores or 0), "vCPU (ядер)", "vCPU"),
|
||||
(quota.max_memory_mb, usage["memory_mb"] + (add_memory_mb or 0), "MB памяти", "MB of RAM"),
|
||||
(quota.max_disk_gb, usage["disk_gb"] + (add_disk_gb or 0), "GB диска", "GB of disk"),
|
||||
]
|
||||
|
||||
for limit, projected, unit_ru, unit_en in checks:
|
||||
if limit is not None and projected > limit:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=(
|
||||
f"Превышена квота: {projected} > {limit} {unit_ru}. "
|
||||
f"Quota exceeded: {projected} > {limit} {unit_en}."
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user