The one path that touches a customer's server, and the one the platform cannot undo. It is human-triggered, confirmed and audited; there is deliberately no code path from a pipeline run into it (D13). - deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the post-deploy command runs with cwd there, and only then does `current` move — atomically (relative symlink + mv -T). A failed hook or extraction never moves it, so the previous release keeps serving. Host key is pinned on first success (TOFU); a mismatch is a hard failure. Prunes to the newest five. - deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a backup, so a backup is mandatory: every file about to be overwritten is archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is extracted, then the build dir (dist/build/public/out) is chosen — a static deploy must not publish the repository. Archive members are sanitised: absolute paths, `..` and escaping links are refused. - deploy_target.py: the tester that dispatches on `kind`. - deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes. One deploy per project at a time; the org slug must be typed back; the target summary reports has_secret, never the secret. Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean. Report: docs/plan/reports/agent-3-phase-5.md
413 lines
15 KiB
Python
413 lines
15 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 (
|
|
DeployTargetRow,
|
|
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 DeployTargetRepository:
|
|
"""Persistence for a project's production deploy target.
|
|
|
|
The secret is stored encrypted and is only ever handed back through
|
|
:meth:`get_secret`, so a code path that merely *renders* a target never
|
|
touches the plaintext.
|
|
"""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def get_for_project(self, project_id: str) -> DeployTargetRow | None:
|
|
return await self.session.scalar(
|
|
select(DeployTargetRow)
|
|
.where(DeployTargetRow.project_id == project_id, DeployTargetRow.is_active.is_(True))
|
|
.order_by(DeployTargetRow.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
async def list_for_project(self, project_id: str) -> list[DeployTargetRow]:
|
|
stmt = (
|
|
select(DeployTargetRow)
|
|
.where(DeployTargetRow.project_id == project_id)
|
|
.order_by(DeployTargetRow.created_at.desc())
|
|
)
|
|
return list((await self.session.execute(stmt)).scalars())
|
|
|
|
async def upsert(self, *, values: dict) -> DeployTargetRow:
|
|
"""Create or replace the single active target of a project."""
|
|
project_id = str(values["project_id"])
|
|
existing = await self.get_for_project(project_id)
|
|
if existing is None:
|
|
row = DeployTargetRow(**values)
|
|
self.session.add(row)
|
|
else:
|
|
row = existing
|
|
for key, value in values.items():
|
|
setattr(row, key, value)
|
|
await self.session.flush()
|
|
return row
|
|
|
|
async def record_test(
|
|
self, *, target_id: str, ok: bool, message: str, fingerprint: str | None = None
|
|
) -> None:
|
|
row = await self.session.get(DeployTargetRow, target_id)
|
|
if row is None:
|
|
return
|
|
row.last_tested_at = utcnow()
|
|
row.last_test_ok = ok
|
|
row.last_test_message = message[:500]
|
|
if fingerprint:
|
|
row.host_fingerprint = fingerprint
|
|
await self.session.flush()
|
|
|
|
|
|
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}",
|
|
}
|