Gap-closing on top of the merged A3-3..A3-6 work: - ProjectService.create_preview_domain now refuses a label that is a reserved platform name (git, mcp, api, panel, ...) via PROTECTED_HOST_LABELS. The rules set existed but only the owner-facing validate_extra_host consulted it, so the tenant path could reserve one. - process.py: signal.SIGKILL is resolved through getattr with a SIGTERM fallback, so the module imports and type-checks on Windows where SIGKILL does not exist. - Three more Windows test repairs: a git-created .git must be unlinked and recreated rather than overwritten in place; POSIX-only chmod and symlink assertions are gated on os.name (the security property they assert is checked everywhere, the platform-specific mechanics only where they apply). - docs/plan/reports/agent-3-final.md: the integration hand-off — host requirements, every setting, every endpoint added or changed, the tables, the open CHANGE REQUESTS, a 12-step manual QA script and the known limitations. Checks: ruff clean, mypy clean (136 files), 521 API tests pass, 83 web tests pass, tsc clean, next lint clean.
252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
"""Project and preview-domain use cases.
|
|
|
|
This is the application layer: it orchestrates domain rules, repositories and the
|
|
driven ports (Git, domain router). It knows nothing about HTTP.
|
|
|
|
Security properties enforced here (not in the router, not only in the adapter):
|
|
|
|
* a project repository is always created **private**
|
|
* the repository name is collision-free across tenants
|
|
* the per-project Git password is encrypted before it is stored and never
|
|
returned to a caller
|
|
* a preview host must be a single label under the configured root domain
|
|
* a preview host is unique platform-wide
|
|
* the proxy target port must be allocatable and must not be a protected
|
|
platform port
|
|
* a project may hold at most ``max_subdomains_per_project`` hosts
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
|
|
from app.application.ports import (
|
|
DomainRouterPort,
|
|
GitCredentials,
|
|
GitProviderPort,
|
|
RepoInfo,
|
|
RepoStatus,
|
|
)
|
|
from app.core.config import settings
|
|
from app.core.crypto import decrypt, encrypt
|
|
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
|
|
from app.domain.base import ensure_unique_slug, slugify
|
|
from app.domain.platform.rules import (
|
|
PROTECTED_HOST_LABELS,
|
|
assert_not_protected_port,
|
|
assert_port_allocatable,
|
|
build_host,
|
|
validate_subdomain_label,
|
|
)
|
|
from app.domain.projects.models import (
|
|
Project,
|
|
ProjectGitCredential,
|
|
ProjectGitRepo,
|
|
)
|
|
from app.infrastructure.repositories.projects import (
|
|
PreviewDomainRepository,
|
|
ProjectGitCredentialRepository,
|
|
ProjectGitRepoRepository,
|
|
ProjectRepository,
|
|
)
|
|
|
|
|
|
class ProjectService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
projects: ProjectRepository,
|
|
repos: ProjectGitRepoRepository,
|
|
credentials: ProjectGitCredentialRepository,
|
|
domains: PreviewDomainRepository,
|
|
git: GitProviderPort,
|
|
router: DomainRouterPort,
|
|
) -> None:
|
|
self.projects = projects
|
|
self.repos = repos
|
|
self.credentials = credentials
|
|
self.domains = domains
|
|
self.git = git
|
|
self.router = router
|
|
|
|
# -- projects ------------------------------------------------------------
|
|
|
|
async def create_project(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
organization_slug: str,
|
|
created_by_user_id: str,
|
|
name: str,
|
|
description: str = "",
|
|
) -> Project:
|
|
slug = ensure_unique_slug(slugify(name), await self.projects.list_slugs(organization_id))
|
|
return await self.projects.create(
|
|
Project(
|
|
organization_id=organization_id,
|
|
name=name.strip(),
|
|
slug=slug,
|
|
description=description,
|
|
created_by_user_id=created_by_user_id,
|
|
)
|
|
)
|
|
|
|
async def get_or_404(self, *, organization_id: str, project_id: str) -> Project:
|
|
project = await self.projects.get(project_id)
|
|
# Returning 404 (not 403) keeps the existence of other tenants' projects
|
|
# out of the response.
|
|
if project is None or project.organization_id != organization_id:
|
|
raise NotFoundError("پروژه یافت نشد.")
|
|
return project
|
|
|
|
@staticmethod
|
|
def repo_name_for(*, organization_slug: str, project: Project) -> str:
|
|
"""``orgslug-projectslug-shortid`` — stable and collision-free."""
|
|
digest = hashlib.sha1(f"{organization_slug}:{project.id}".encode()).hexdigest()[:6]
|
|
raw = f"{organization_slug}-{project.slug}-{digest}"
|
|
return raw[:100].strip("-")
|
|
|
|
# -- git -----------------------------------------------------------------
|
|
|
|
async def ensure_repository(
|
|
self, *, organization_slug: str, project: Project
|
|
) -> ProjectGitRepo:
|
|
repo_name = self.repo_name_for(organization_slug=organization_slug, project=project)
|
|
info: RepoInfo = await self.git.ensure_project_repo(
|
|
project_id=project.id,
|
|
repo_name=repo_name,
|
|
description=f"مخزن پروژهٔ {project.name}",
|
|
)
|
|
|
|
credentials: GitCredentials = await self.git.ensure_credentials(
|
|
project_id=project.id, owner=info.owner, repo_name=info.repo_name
|
|
)
|
|
await self.credentials.upsert(
|
|
ProjectGitCredential(
|
|
project_id=project.id,
|
|
username=credentials.username,
|
|
# Encrypted at rest. The plaintext only exists inside this method.
|
|
encrypted_password=encrypt(credentials.password),
|
|
)
|
|
)
|
|
|
|
return await self.repos.upsert(
|
|
ProjectGitRepo(
|
|
project_id=project.id,
|
|
provider=settings.git_provider,
|
|
owner=info.owner,
|
|
repo_name=info.repo_name,
|
|
clone_url=info.clone_url,
|
|
web_url=info.web_url,
|
|
default_branch=info.default_branch,
|
|
)
|
|
)
|
|
|
|
async def repo_status(self, *, project: Project) -> RepoStatus:
|
|
record = await self.repos.get_for_project(project.id)
|
|
if record is None:
|
|
return RepoStatus(exists=False)
|
|
# Identity comes from our own record so the adapter stays stateless.
|
|
return await self.git.get_repo_status(
|
|
project_id=project.id, owner=record.owner, repo_name=record.repo_name
|
|
)
|
|
|
|
async def clone_credentials(self, *, project: Project) -> tuple[str, str]:
|
|
"""``(username, password)`` for a one-off clone.
|
|
|
|
Only the application layer may call this, and the router must never
|
|
serialise the result.
|
|
"""
|
|
record = await self.credentials.get_for_project(project.id)
|
|
if record is None:
|
|
raise NotFoundError("اطلاعات دسترسی مخزن ثبت نشده است.")
|
|
return record.username, decrypt(record.encrypted_password)
|
|
|
|
# -- preview domains -----------------------------------------------------
|
|
|
|
async def allocate_port(self, *, project: Project, service_name: str = "web") -> int:
|
|
existing = await self.domains.get_port(project.id, service_name)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
start, end = settings.preview_port_start, settings.preview_port_end
|
|
used = await self.domains.list_used_ports(start=start, end=end)
|
|
for port in range(start, end + 1):
|
|
if port in used:
|
|
continue
|
|
try:
|
|
assert_port_allocatable(port, start=start, end=end)
|
|
except ValueError:
|
|
continue
|
|
await self.domains.add_port(
|
|
organization_id=project.organization_id,
|
|
project_id=project.id,
|
|
service_name=service_name,
|
|
port=port,
|
|
)
|
|
return port
|
|
raise ConflictError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
|
|
|
async def create_preview_domain(
|
|
self,
|
|
*,
|
|
project: Project,
|
|
requested_subdomain: str,
|
|
created_by_user_id: str,
|
|
port: int | None = None,
|
|
) -> dict:
|
|
label = validate_subdomain_label(requested_subdomain)
|
|
# ``git``/``mcp``/``api``/``panel``… are platform services. A tenant host
|
|
# must never be able to shadow one on the same root domain — the rules
|
|
# module defines the reserved set (see PROTECTED_HOST_LABELS).
|
|
if label in PROTECTED_HOST_LABELS:
|
|
raise PermissionDeniedError(
|
|
f"نام «{label}» برای سرویسهای سامانه رزرو شده است."
|
|
)
|
|
host = build_host(label, settings.preview_root_domain)
|
|
|
|
if await self.domains.get_by_host(host) is not None:
|
|
raise ConflictError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
|
|
|
count = await self.domains.count_for_project(project.id)
|
|
if count >= settings.max_subdomains_per_project:
|
|
raise ConflictError(
|
|
f"هر پروژه حداکثر {settings.max_subdomains_per_project} دامنه میتواند داشته باشد."
|
|
)
|
|
|
|
target_port = port if port is not None else await self.allocate_port(project=project)
|
|
# A tenant must never be able to expose an internal platform service.
|
|
assert_not_protected_port(target_port)
|
|
|
|
result = await self.router.configure_reverse_proxy(host=host, port=target_port)
|
|
if not result.ok:
|
|
raise ValidationError(result.message or "پیکربندی دامنه ناموفق بود.")
|
|
|
|
record = await self.domains.create(
|
|
organization_id=project.organization_id,
|
|
project_id=project.id,
|
|
host=host,
|
|
port=target_port,
|
|
created_by_user_id=created_by_user_id,
|
|
)
|
|
record["url"] = result.url or f"https://{host}"
|
|
return record
|
|
|
|
async def remove_preview_domain(self, *, project: Project, domain_id: str) -> None:
|
|
record = await self.domains.get(domain_id)
|
|
if record is None or record["project_id"] != project.id:
|
|
raise NotFoundError("دامنه یافت نشد.")
|
|
if record["protected"]:
|
|
raise PermissionDeniedError(
|
|
"این دامنه محافظتشده است و فقط مالک سامانه میتواند آن را تغییر دهد."
|
|
)
|
|
|
|
await self.router.remove_preview_domain(domain_id=record["host"])
|
|
await self.domains.delete(domain_id)
|
|
|
|
async def list_preview_domains(self, *, project: Project) -> list[dict]:
|
|
records = await self.domains.list_for_project(project.id)
|
|
for record in records:
|
|
record["url"] = f"https://{record['host']}"
|
|
return records
|