Domains are infrastructure, and in v2 infrastructure is the platform owner's alone (D9). A tenant gets one automatic demo host and nothing else. - admin_domains.py (owner-only): list every route with its owning app, attach an extra host, detach with the host typed back as confirmation (audited), re-sync the router from the database, and read the suggested MCP gateway route. - domain/platform/rules.py: validate_extra_host. Two shapes only — a single label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS is on. A wildcard is refused in both (it would match another tenant's subdomain), and so are 30 protected platform labels (git, mcp, panel, api...). Enforced in the router *and* again in the adapter. - Both router adapters gained routes_fingerprint / list_all_routes / sync_routes. Re-sync writes and reloads only when the rendered state differs: a reload drops every in-flight connection on the host, including live demos. Adapter-level extras, not additions to the frozen DomainRouterPort. - The member-facing preview-domain routes are owner-only now. They are kept because the demo host is still created through them at app-creation time. Tests: 473 API (+58); ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-4.md
390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""Project, repository and preview-domain endpoints.
|
|
|
|
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
|
|
context is resolved by ``get_org_context`` before any handler body runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, status
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.application.services.audit_service import record_audit
|
|
from app.application.services.project_service import ProjectService
|
|
from app.core.database import get_session
|
|
from app.core.errors import NotFoundError
|
|
from app.domain.audit.models import AuditAction
|
|
from app.domain.identity.models import CAN_MANAGE_PROJECTS, User
|
|
from app.interfaces.http.deps import (
|
|
OrgContext,
|
|
client_meta,
|
|
get_org_context,
|
|
get_project_service,
|
|
require_platform_owner,
|
|
)
|
|
|
|
router = APIRouter(
|
|
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ProjectOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: str
|
|
organization_id: str
|
|
name: str
|
|
slug: str
|
|
description: str
|
|
default_branch: str
|
|
created_at: datetime
|
|
|
|
|
|
class CreateProjectRequest(BaseModel):
|
|
name: str = Field(min_length=2, max_length=160)
|
|
description: str = Field(default="", max_length=2000)
|
|
|
|
|
|
class RepoOut(BaseModel):
|
|
id: str
|
|
provider: str
|
|
owner: str
|
|
repo_name: str
|
|
clone_url: str
|
|
web_url: str
|
|
default_branch: str
|
|
private: bool = True
|
|
|
|
|
|
class RepoStatusOut(BaseModel):
|
|
exists: bool
|
|
private: bool
|
|
default_branch: str
|
|
branches: list[str]
|
|
open_pull_requests: int
|
|
|
|
|
|
class CreateBranchRequest(BaseModel):
|
|
branch_name: str = Field(min_length=1, max_length=120)
|
|
from_branch: str = Field(default="main", max_length=120)
|
|
|
|
|
|
class BranchOut(BaseModel):
|
|
name: str
|
|
sha: str
|
|
|
|
|
|
class OpenPullRequestRequest(BaseModel):
|
|
head: str = Field(min_length=1, max_length=120)
|
|
base: str = Field(default="main", max_length=120)
|
|
title: str = Field(min_length=1, max_length=255)
|
|
body: str = Field(default="", max_length=10_000)
|
|
|
|
|
|
class PullRequestOut(BaseModel):
|
|
number: int
|
|
url: str
|
|
state: str
|
|
|
|
|
|
class PreviewDomainOut(BaseModel):
|
|
id: str
|
|
host: str
|
|
port: int
|
|
status: str
|
|
protected: bool
|
|
url: str
|
|
|
|
|
|
class CreatePreviewDomainRequest(BaseModel):
|
|
subdomain: str = Field(min_length=3, max_length=40)
|
|
port: int | None = Field(default=None, ge=1, le=65535)
|
|
|
|
|
|
class AllocatePortRequest(BaseModel):
|
|
service_name: str = Field(default="web", max_length=80)
|
|
|
|
|
|
class PortOut(BaseModel):
|
|
port: int
|
|
service_name: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Projects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_project(
|
|
payload: CreateProjectRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> ProjectOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
|
|
project = await service.create_project(
|
|
organization_id=context.org_id,
|
|
organization_slug=context.organization.slug,
|
|
created_by_user_id=context.user.id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="project", target_id=project.id,
|
|
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
|
|
)
|
|
return ProjectOut(
|
|
id=project.id, organization_id=project.organization_id, name=project.name,
|
|
slug=project.slug, description=project.description,
|
|
default_branch=project.default_branch, created_at=project.created_at,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[ProjectOut])
|
|
async def list_projects(
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> list[ProjectOut]:
|
|
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
|
|
return [
|
|
ProjectOut(
|
|
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
|
|
description=p.description, default_branch=p.default_branch,
|
|
created_at=p.created_at,
|
|
)
|
|
for p in projects
|
|
]
|
|
|
|
|
|
@router.get("/{project_id}", response_model=ProjectOut)
|
|
async def get_project(
|
|
project_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> ProjectOut:
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
return ProjectOut(
|
|
id=project.id, organization_id=project.organization_id, name=project.name,
|
|
slug=project.slug, description=project.description,
|
|
default_branch=project.default_branch, created_at=project.created_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repository
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
|
|
async def ensure_repo(
|
|
project_id: str,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> RepoOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
|
|
repo = await service.ensure_repository(
|
|
organization_slug=context.organization.slug, project=project
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
|
|
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return RepoOut(
|
|
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
|
clone_url=repo.clone_url, web_url=repo.web_url,
|
|
default_branch=repo.default_branch, private=True,
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/repo", response_model=RepoOut)
|
|
async def get_repo(
|
|
project_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> RepoOut:
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
repo = await service.repos.get_for_project(project.id)
|
|
if repo is None:
|
|
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
|
|
# clone_url is a plain https URL; credentials are never included.
|
|
return RepoOut(
|
|
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
|
clone_url=repo.clone_url, web_url=repo.web_url,
|
|
default_branch=repo.default_branch, private=True,
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
|
|
async def get_repo_status(
|
|
project_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> RepoStatusOut:
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
status_result = await service.repo_status(project=project)
|
|
return RepoStatusOut(
|
|
exists=status_result.exists,
|
|
private=status_result.private,
|
|
default_branch=status_result.default_branch,
|
|
branches=list(status_result.branches),
|
|
open_pull_requests=status_result.open_pull_requests,
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
|
|
async def create_branch(
|
|
project_id: str,
|
|
payload: CreateBranchRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> BranchOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
branch = await service.git.create_branch(
|
|
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
|
|
)
|
|
return BranchOut(name=branch.name, sha=branch.sha)
|
|
|
|
|
|
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
|
|
async def open_pull_request(
|
|
project_id: str,
|
|
payload: OpenPullRequestRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> PullRequestOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
pr = await service.git.open_pull_request(
|
|
project_id=project.id, head=payload.head, base=payload.base,
|
|
title=payload.title, body=payload.body,
|
|
)
|
|
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview domains
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{project_id}/preview/ports", response_model=PortOut)
|
|
async def allocate_port(
|
|
project_id: str,
|
|
payload: AllocatePortRequest,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> PortOut:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
port = await service.allocate_port(project=project, service_name=payload.service_name)
|
|
return PortOut(port=port, service_name=payload.service_name)
|
|
|
|
|
|
@router.post(
|
|
"/{project_id}/preview/domains",
|
|
response_model=PreviewDomainOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_preview_domain(
|
|
project_id: str,
|
|
payload: CreatePreviewDomainRequest,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
owner: User = Depends(require_platform_owner),
|
|
) -> PreviewDomainOut:
|
|
# v2 (D9): attaching a host writes a routing rule and a certificate on a
|
|
# shared host — infrastructure, and infrastructure is the platform owner's
|
|
# alone. The demo host is still created through this route at app-creation
|
|
# time; the owner is simply the only actor who can reach it.
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
_ = owner
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
|
|
record = await service.create_preview_domain(
|
|
project=project,
|
|
requested_subdomain=payload.subdomain,
|
|
created_by_user_id=context.user.id,
|
|
port=payload.port,
|
|
)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="preview_domain",
|
|
target_id=record["id"],
|
|
metadata={"host": record["host"], "port": record["port"]},
|
|
ip=ip, user_agent=ua,
|
|
)
|
|
return PreviewDomainOut(
|
|
id=record["id"], host=record["host"], port=record["port"],
|
|
status=record["status"], protected=record["protected"], url=record["url"],
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
|
|
async def list_preview_domains(
|
|
project_id: str,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
) -> list[PreviewDomainOut]:
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
records = await service.list_preview_domains(project=project)
|
|
return [
|
|
PreviewDomainOut(
|
|
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
|
|
protected=r["protected"], url=r["url"],
|
|
)
|
|
for r in records
|
|
]
|
|
|
|
|
|
@router.delete(
|
|
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
|
|
)
|
|
async def delete_preview_domain(
|
|
project_id: str,
|
|
domain_id: str,
|
|
request: Request,
|
|
context: OrgContext = Depends(get_org_context),
|
|
service: ProjectService = Depends(get_project_service),
|
|
session: AsyncSession = Depends(get_session),
|
|
owner: User = Depends(require_platform_owner),
|
|
) -> None:
|
|
context.require(CAN_MANAGE_PROJECTS)
|
|
_ = owner
|
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
|
await service.remove_preview_domain(project=project, domain_id=domain_id)
|
|
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
|
|
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
|
|
ip=ip, user_agent=ua,
|
|
)
|