feat(quotas): per-user resource limits for VM/LXC deployment
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 8s

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:
2026-06-26 18:15:02 +05:00
parent d59d787bed
commit 35dcbfd18f
21 changed files with 707 additions and 67 deletions

View 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}."
),
)