Files
smart-org/apps/api/app/infrastructure/repositories/projects.py
T
mjalmoudy94 82cea1e690 feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4)
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
2026-10-14 00:57:08 +03:30

357 lines
13 KiB
Python

"""Repositories for Projects, their Git repositories, credentials and preview
domains."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.projects.models import (
Project,
ProjectExternalGit,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import (
PreviewDomainRow,
PreviewPortRow,
ProjectExternalGitRow,
ProjectGitCredentialRow,
ProjectRepositoryRow,
ProjectRow,
)
def _project_to_domain(row: ProjectRow) -> Project:
return Project(
id=row.id, organization_id=row.organization_id, name=row.name, slug=row.slug,
description=row.description, created_by_user_id=row.created_by_user_id,
default_branch=row.default_branch, created_at=row.created_at,
)
def _repo_to_domain(row: ProjectRepositoryRow) -> ProjectGitRepo:
return ProjectGitRepo(
id=row.id, project_id=row.project_id, provider=row.provider, owner=row.owner,
repo_name=row.repo_name, clone_url=row.clone_url, web_url=row.web_url,
default_branch=row.default_branch, created_at=row.created_at,
)
class ProjectExternalGitRepository:
"""Persistence for a user-supplied remote's credentials.
The secret is stored encrypted and is *never* returned by this repository:
callers that need it (the git workspace adapter) ask for it explicitly
through :meth:`get_secret`, which keeps the plaintext out of every code path
that only needs to render a status.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> ProjectExternalGit | None:
row = await self.session.get(ProjectExternalGitRow, project_id)
return _external_git_to_domain(row) if row else None
async def upsert(self, record: ProjectExternalGit) -> ProjectExternalGit:
row = await self.session.get(ProjectExternalGitRow, record.project_id)
if row is None:
row = ProjectExternalGitRow(project_id=record.project_id)
self.session.add(row)
row.auth_type = record.auth_type
row.username = record.username
row.secret_encrypted = record.secret_encrypted
row.known_hosts = record.known_hosts
row.updated_at = utcnow()
await self.session.flush()
return _external_git_to_domain(row)
async def delete_for_project(self, project_id: str) -> None:
row = await self.session.get(ProjectExternalGitRow, project_id)
if row is not None:
await self.session.delete(row)
await self.session.flush()
def _external_git_to_domain(row: ProjectExternalGitRow) -> ProjectExternalGit:
return ProjectExternalGit(
project_id=row.project_id,
auth_type=row.auth_type,
username=row.username,
secret_encrypted=row.secret_encrypted,
known_hosts=row.known_hosts,
updated_at=row.updated_at,
)
class ProjectGitRepoRepository:
"""Persistence for the Git repository attached to a project."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> ProjectGitRepo | None:
stmt = select(ProjectRepositoryRow).where(
ProjectRepositoryRow.project_id == project_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _repo_to_domain(row) if row else None
async def upsert(self, repo: ProjectGitRepo) -> ProjectGitRepo:
existing = await self.get_for_project(repo.project_id)
if existing:
row = await self.session.get(ProjectRepositoryRow, existing.id)
assert row is not None
row.provider = repo.provider
row.owner = repo.owner
row.repo_name = repo.repo_name
row.clone_url = repo.clone_url
row.web_url = repo.web_url
row.default_branch = repo.default_branch
await self.session.flush()
return _repo_to_domain(row)
row = ProjectRepositoryRow(
id=repo.id, project_id=repo.project_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,
created_at=repo.created_at,
)
self.session.add(row)
await self.session.flush()
return _repo_to_domain(row)
class ProjectGitCredentialRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_for_project(self, project_id: str) -> ProjectGitCredential | None:
stmt = select(ProjectGitCredentialRow).where(
ProjectGitCredentialRow.project_id == project_id
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
if row is None:
return None
return ProjectGitCredential(
id=row.id, project_id=row.project_id, username=row.username,
encrypted_password=row.encrypted_password, created_at=row.created_at,
)
async def upsert(self, credential: ProjectGitCredential) -> ProjectGitCredential:
existing = await self.get_for_project(credential.project_id)
if existing:
row = await self.session.get(ProjectGitCredentialRow, existing.id)
assert row is not None
row.username = credential.username
row.encrypted_password = credential.encrypted_password
await self.session.flush()
return ProjectGitCredential(
id=row.id, project_id=row.project_id, username=row.username,
encrypted_password=row.encrypted_password, created_at=row.created_at,
)
row = ProjectGitCredentialRow(
id=credential.id, project_id=credential.project_id,
username=credential.username,
encrypted_password=credential.encrypted_password,
created_at=credential.created_at,
)
self.session.add(row)
await self.session.flush()
return credential
class ProjectRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, project_id: str) -> Project | None:
row = await self.session.get(ProjectRow, project_id)
return _project_to_domain(row) if row else None
async def list_for_org(
self, organization_id: str, *, limit: int = 100, offset: int = 0
) -> list[Project]:
stmt = (
select(ProjectRow)
.where(ProjectRow.organization_id == organization_id)
.order_by(ProjectRow.created_at.desc())
.limit(min(limit, 500))
.offset(offset)
)
return [_project_to_domain(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_slugs(self, organization_id: str) -> set[str]:
stmt = select(ProjectRow.slug).where(ProjectRow.organization_id == organization_id)
return set((await self.session.execute(stmt)).scalars().all())
async def create(self, project: Project) -> Project:
row = ProjectRow(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
created_by_user_id=project.created_by_user_id,
default_branch=project.default_branch, created_at=project.created_at,
)
self.session.add(row)
await self.session.flush()
return _project_to_domain(row)
async def delete(self, project_id: str) -> None:
row = await self.session.get(ProjectRow, project_id)
if row:
await self.session.delete(row)
await self.session.flush()
class PreviewDomainRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- ports ---------------------------------------------------------------
async def get_port(self, project_id: str, service_name: str) -> int | None:
stmt = select(PreviewPortRow.port).where(
PreviewPortRow.project_id == project_id,
PreviewPortRow.service_name == service_name,
)
return (await self.session.execute(stmt)).scalar_one_or_none()
async def list_used_ports(self, *, start: int, end: int) -> set[int]:
stmt = select(PreviewPortRow.port).where(
PreviewPortRow.port.between(start, end)
)
return set((await self.session.execute(stmt)).scalars().all())
async def add_port(
self, *, organization_id: str, project_id: str, service_name: str, port: int
) -> None:
self.session.add(
PreviewPortRow(
organization_id=organization_id, project_id=project_id,
service_name=service_name, port=port, status="active",
)
)
await self.session.flush()
async def set_port(
self, *, project_id: str, service_name: str, port: int
) -> None:
"""Re-point an existing service to a new port.
The blue/green switch uses this: the *service name* (`demo`) always means
"the live port", so a deploy rewrites it rather than accumulating one
ledger row per release.
"""
row = (
await self.session.execute(
select(PreviewPortRow).where(
PreviewPortRow.project_id == project_id,
PreviewPortRow.service_name == service_name,
)
)
).scalar_one_or_none()
if row is None:
return
row.port = port
await self.session.flush()
async def delete_port(self, *, project_id: str, service_name: str) -> None:
"""Release a transient port reservation (a staging port, say)."""
row = (
await self.session.execute(
select(PreviewPortRow).where(
PreviewPortRow.project_id == project_id,
PreviewPortRow.service_name == service_name,
)
)
).scalar_one_or_none()
if row is not None:
await self.session.delete(row)
await self.session.flush()
# -- domains -------------------------------------------------------------
async def get_by_host(self, host: str) -> dict | None:
stmt = select(PreviewDomainRow).where(PreviewDomainRow.host == host)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row else None
async def get(self, domain_id: str) -> dict | None:
row = await self.session.get(PreviewDomainRow, domain_id)
return self._row_to_dict(row) if row else None
async def list_for_project(self, project_id: str) -> list[dict]:
stmt = (
select(PreviewDomainRow)
.where(PreviewDomainRow.project_id == project_id)
.order_by(PreviewDomainRow.created_at)
)
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_for_project(self, project_id: str) -> int:
return len(await self.list_for_project(project_id))
async def list_all(self, *, limit: int = 2_000) -> list[dict]:
"""Every preview domain on the platform.
Deliberately **not tenant-scoped**: this is the owner console's view of
the whole routing table, and it is reachable only from an
``require_platform_owner`` route. Never use it for authorization.
"""
stmt = (
select(PreviewDomainRow)
.order_by(PreviewDomainRow.host.asc())
.limit(min(max(limit, 1), 5_000))
)
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(
self,
*,
organization_id: str,
project_id: str,
host: str,
port: int,
created_by_user_id: str | None,
) -> dict:
row = PreviewDomainRow(
organization_id=organization_id, project_id=project_id, host=host,
port=port, status="active", protected=False,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
await self.session.flush()
return self._row_to_dict(row)
async def set_status(self, domain_id: str, status: str) -> None:
row = await self.session.get(PreviewDomainRow, domain_id)
if row:
row.status = status
await self.session.flush()
async def delete(self, domain_id: str) -> None:
row = await self.session.get(PreviewDomainRow, domain_id)
if row:
await self.session.delete(row)
await self.session.flush()
@staticmethod
def _row_to_dict(row: PreviewDomainRow) -> dict:
return {
"id": row.id,
"organization_id": row.organization_id,
"project_id": row.project_id,
"host": row.host,
"port": row.port,
"status": row.status,
"protected": row.protected,
"created_by_user_id": row.created_by_user_id,
"created_at": row.created_at,
# Derived, not stored: the scheme is always https and the host is the
# identity. Kept here so every caller renders the same URL.
"url": f"https://{row.host}",
}