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
574 lines
19 KiB
Python
574 lines
19 KiB
Python
"""Repositories for CI/CD runs, their steps, releases and the demo runtime."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.domain.base import utcnow
|
|
from app.domain.delivery.models import (
|
|
AppRuntime,
|
|
DeployStatus,
|
|
PipelineRun,
|
|
PipelineStep,
|
|
ProductionDeploy,
|
|
Release,
|
|
RunStatus,
|
|
StepStatus,
|
|
)
|
|
from app.infrastructure.db.models_delivery import (
|
|
AppRuntimeRow,
|
|
PipelineRunRow,
|
|
PipelineStepRow,
|
|
ProductionDeployRow,
|
|
ReleaseRow,
|
|
)
|
|
|
|
#: Statuses that count as "in flight" for the one-active-run-per-project rule.
|
|
ACTIVE_STATUSES = (RunStatus.QUEUED.value, RunStatus.RUNNING.value)
|
|
|
|
|
|
def _run(row: PipelineRunRow) -> PipelineRun:
|
|
return PipelineRun(
|
|
id=row.id,
|
|
organization_id=row.organization_id,
|
|
project_id=row.project_id,
|
|
branch=row.branch,
|
|
sha=row.sha,
|
|
trigger=row.trigger,
|
|
status=row.status,
|
|
error_summary=row.error_summary,
|
|
started_at=row.started_at,
|
|
finished_at=row.finished_at,
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
def _step(row: PipelineStepRow) -> PipelineStep:
|
|
return PipelineStep(
|
|
id=row.id,
|
|
run_id=row.run_id,
|
|
index=row.index,
|
|
name=row.name,
|
|
status=row.status,
|
|
exit_code=row.exit_code,
|
|
log_tail=row.log_tail,
|
|
started_at=row.started_at,
|
|
finished_at=row.finished_at,
|
|
)
|
|
|
|
|
|
def _release(row: ReleaseRow) -> Release:
|
|
return Release(
|
|
id=row.id,
|
|
organization_id=row.organization_id,
|
|
project_id=row.project_id,
|
|
run_id=row.run_id,
|
|
sha=row.sha,
|
|
artifact_path=row.artifact_path,
|
|
is_demo_live=row.is_demo_live,
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
def _runtime(row: AppRuntimeRow) -> AppRuntime:
|
|
return AppRuntime(
|
|
project_id=row.project_id,
|
|
organization_id=row.organization_id,
|
|
demo_container_id=row.demo_container_id,
|
|
host_port=row.host_port,
|
|
demo_host=row.demo_host,
|
|
status=row.status,
|
|
current_release_id=row.current_release_id,
|
|
updated_at=row.updated_at,
|
|
)
|
|
|
|
|
|
class PipelineRunRepository:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def get(self, run_id: str) -> PipelineRun | None:
|
|
row = await self.session.get(PipelineRunRow, run_id)
|
|
return _run(row) if row else None
|
|
|
|
async def get_for_org(self, organization_id: str, run_id: str) -> PipelineRun | None:
|
|
"""Tenant-scoped lookup: a foreign run id is indistinguishable from a missing one."""
|
|
row = await self.session.scalar(
|
|
select(PipelineRunRow).where(
|
|
PipelineRunRow.id == run_id,
|
|
PipelineRunRow.organization_id == organization_id,
|
|
)
|
|
)
|
|
return _run(row) if row else None
|
|
|
|
async def list_for_project(
|
|
self, *, organization_id: str, project_id: str, limit: int = 50, offset: int = 0
|
|
) -> list[PipelineRun]:
|
|
stmt = (
|
|
select(PipelineRunRow)
|
|
.where(
|
|
PipelineRunRow.organization_id == organization_id,
|
|
PipelineRunRow.project_id == project_id,
|
|
)
|
|
.order_by(PipelineRunRow.created_at.desc())
|
|
.limit(min(max(limit, 1), 200))
|
|
.offset(max(offset, 0))
|
|
)
|
|
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def active_for_project(self, project_id: str) -> PipelineRun | None:
|
|
row = await self.session.scalar(
|
|
select(PipelineRunRow)
|
|
.where(
|
|
PipelineRunRow.project_id == project_id,
|
|
PipelineRunRow.status.in_(ACTIVE_STATUSES),
|
|
)
|
|
.order_by(PipelineRunRow.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
return _run(row) if row else None
|
|
|
|
async def last_succeeded_sha(self, project_id: str) -> str | None:
|
|
return await self.session.scalar(
|
|
select(PipelineRunRow.sha)
|
|
.where(
|
|
PipelineRunRow.project_id == project_id,
|
|
PipelineRunRow.status == RunStatus.SUCCEEDED.value,
|
|
)
|
|
.order_by(PipelineRunRow.finished_at.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
async def queue(self, run: PipelineRun) -> PipelineRun:
|
|
row = PipelineRunRow(
|
|
id=run.id,
|
|
organization_id=run.organization_id,
|
|
project_id=run.project_id,
|
|
trigger=run.trigger,
|
|
branch=run.branch,
|
|
sha=run.sha,
|
|
status=run.status,
|
|
error_summary=run.error_summary,
|
|
started_at=run.started_at,
|
|
finished_at=run.finished_at,
|
|
created_at=run.created_at,
|
|
)
|
|
self.session.add(row)
|
|
await self.session.flush()
|
|
return _run(row)
|
|
|
|
async def list_queued(self, *, limit: int = 5) -> list[PipelineRun]:
|
|
"""Oldest queued run first — the worker's hot query."""
|
|
stmt = (
|
|
select(PipelineRunRow)
|
|
.where(PipelineRunRow.status == RunStatus.QUEUED.value)
|
|
.order_by(PipelineRunRow.created_at.asc())
|
|
.limit(min(max(limit, 1), 50))
|
|
)
|
|
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def list_running(self, *, limit: int = 50) -> list[PipelineRun]:
|
|
stmt = (
|
|
select(PipelineRunRow)
|
|
.where(PipelineRunRow.status == RunStatus.RUNNING.value)
|
|
.order_by(PipelineRunRow.started_at.asc())
|
|
.limit(min(max(limit, 1), 200))
|
|
)
|
|
return [_run(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def supersede_queued(self, *, project_id: str, keep_id: str) -> int:
|
|
"""Mark every *queued* run of a project except ``keep_id`` as superseded.
|
|
|
|
Only queued ones: a run that has already started is doing real work and
|
|
cancelling it mid-flight would leave a half-built container behind.
|
|
"""
|
|
result = await self.session.execute(
|
|
update(PipelineRunRow)
|
|
.where(
|
|
PipelineRunRow.project_id == project_id,
|
|
PipelineRunRow.status == RunStatus.QUEUED.value,
|
|
PipelineRunRow.id != keep_id,
|
|
)
|
|
.values(status=RunStatus.SUPERSEDED.value, finished_at=utcnow())
|
|
)
|
|
await self.session.flush()
|
|
# `session.execute(update(...))` returns a CursorResult at runtime; the
|
|
# typed signature is the generic Result, which has no `rowcount`.
|
|
return int(getattr(result, "rowcount", 0) or 0)
|
|
|
|
async def claim(self, run_id: str) -> bool:
|
|
"""Atomically move a *queued* run to running. True when we won the race.
|
|
|
|
A conditional UPDATE rather than a read-then-write: two worker processes
|
|
polling the same queue would otherwise both see `queued` and both start a
|
|
build, publishing two containers to the same demo port.
|
|
"""
|
|
result = await self.session.execute(
|
|
update(PipelineRunRow)
|
|
.where(
|
|
PipelineRunRow.id == run_id,
|
|
PipelineRunRow.status == RunStatus.QUEUED.value,
|
|
)
|
|
.values(status=RunStatus.RUNNING.value, started_at=utcnow())
|
|
)
|
|
await self.session.flush()
|
|
return int(getattr(result, "rowcount", 0) or 0) == 1
|
|
|
|
async def set_status(
|
|
self,
|
|
run_id: str,
|
|
status: str,
|
|
*,
|
|
error_summary: str | None = None,
|
|
started: bool = False,
|
|
finished: bool = False,
|
|
) -> None:
|
|
row = await self.session.get(PipelineRunRow, run_id)
|
|
if row is None:
|
|
return
|
|
row.status = status
|
|
if error_summary is not None:
|
|
row.error_summary = error_summary
|
|
if started and row.started_at is None:
|
|
row.started_at = utcnow()
|
|
if finished:
|
|
row.finished_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
async def count_by_status(self, *, organization_id: str) -> dict[str, int]:
|
|
stmt = (
|
|
select(PipelineRunRow.status, func.count())
|
|
.where(PipelineRunRow.organization_id == organization_id)
|
|
.group_by(PipelineRunRow.status)
|
|
)
|
|
return dict((await self.session.execute(stmt)).all())
|
|
|
|
|
|
class PipelineStepRepository:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def list_for_run(self, run_id: str) -> list[PipelineStep]:
|
|
stmt = (
|
|
select(PipelineStepRow)
|
|
.where(PipelineStepRow.run_id == run_id)
|
|
.order_by(PipelineStepRow.index.asc())
|
|
)
|
|
return [_step(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def create_many(self, steps: list[PipelineStep]) -> list[PipelineStep]:
|
|
rows = [
|
|
PipelineStepRow(
|
|
id=step.id,
|
|
run_id=step.run_id,
|
|
index=step.index,
|
|
name=step.name,
|
|
status=step.status,
|
|
exit_code=step.exit_code,
|
|
log_tail=step.log_tail,
|
|
started_at=step.started_at,
|
|
finished_at=step.finished_at,
|
|
)
|
|
for step in steps
|
|
]
|
|
self.session.add_all(rows)
|
|
await self.session.flush()
|
|
return [_step(r) for r in rows]
|
|
|
|
async def mark(
|
|
self,
|
|
step_id: str,
|
|
status: str,
|
|
*,
|
|
exit_code: int | None = None,
|
|
log_tail: str | None = None,
|
|
started: bool = False,
|
|
finished: bool = False,
|
|
) -> None:
|
|
row = await self.session.get(PipelineStepRow, step_id)
|
|
if row is None:
|
|
return
|
|
row.status = status
|
|
if exit_code is not None:
|
|
row.exit_code = exit_code
|
|
if log_tail is not None:
|
|
row.log_tail = log_tail
|
|
if started and row.started_at is None:
|
|
row.started_at = utcnow()
|
|
if finished:
|
|
row.finished_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
|
|
class ReleaseRepository:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def get(self, release_id: str) -> Release | None:
|
|
row = await self.session.get(ReleaseRow, release_id)
|
|
return _release(row) if row else None
|
|
|
|
async def get_for_org(self, organization_id: str, release_id: str) -> Release | None:
|
|
row = await self.session.scalar(
|
|
select(ReleaseRow).where(
|
|
ReleaseRow.id == release_id,
|
|
ReleaseRow.organization_id == organization_id,
|
|
)
|
|
)
|
|
return _release(row) if row else None
|
|
|
|
async def live_for_project(self, project_id: str) -> Release | None:
|
|
row = await self.session.scalar(
|
|
select(ReleaseRow).where(
|
|
ReleaseRow.project_id == project_id, ReleaseRow.is_demo_live.is_(True)
|
|
)
|
|
)
|
|
return _release(row) if row else None
|
|
|
|
async def list_for_project(
|
|
self, *, project_id: str, limit: int = 20
|
|
) -> list[Release]:
|
|
stmt = (
|
|
select(ReleaseRow)
|
|
.where(ReleaseRow.project_id == project_id)
|
|
.order_by(ReleaseRow.created_at.desc())
|
|
.limit(min(max(limit, 1), 100))
|
|
)
|
|
return [_release(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def create(self, release: Release) -> Release:
|
|
row = ReleaseRow(
|
|
id=release.id,
|
|
organization_id=release.organization_id,
|
|
project_id=release.project_id,
|
|
run_id=release.run_id,
|
|
sha=release.sha,
|
|
artifact_path=release.artifact_path,
|
|
is_demo_live=release.is_demo_live,
|
|
created_at=release.created_at,
|
|
)
|
|
self.session.add(row)
|
|
await self.session.flush()
|
|
return _release(row)
|
|
|
|
async def mark_live(self, *, project_id: str, release_id: str) -> None:
|
|
"""Exactly one live release per project, switched in one transaction."""
|
|
await self.session.execute(
|
|
update(ReleaseRow)
|
|
.where(ReleaseRow.project_id == project_id, ReleaseRow.id != release_id)
|
|
.values(is_demo_live=False)
|
|
)
|
|
await self.session.execute(
|
|
update(ReleaseRow)
|
|
.where(ReleaseRow.id == release_id)
|
|
.values(is_demo_live=True)
|
|
)
|
|
await self.session.flush()
|
|
|
|
|
|
class AppRuntimeRepository:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def get(self, project_id: str) -> AppRuntime | None:
|
|
row = await self.session.get(AppRuntimeRow, project_id)
|
|
return _runtime(row) if row else None
|
|
|
|
async def get_for_org(self, organization_id: str, project_id: str) -> AppRuntime | None:
|
|
row = await self.session.scalar(
|
|
select(AppRuntimeRow).where(
|
|
AppRuntimeRow.project_id == project_id,
|
|
AppRuntimeRow.organization_id == organization_id,
|
|
)
|
|
)
|
|
return _runtime(row) if row else None
|
|
|
|
async def list_for_org(self, organization_id: str) -> list[AppRuntime]:
|
|
stmt = select(AppRuntimeRow).where(
|
|
AppRuntimeRow.organization_id == organization_id
|
|
)
|
|
return [_runtime(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def list_all(self, *, limit: int = 500) -> list[AppRuntime]:
|
|
"""Every app runtime, for the delivery worker's remote poll.
|
|
|
|
Deliberately not tenant-scoped: the poll is platform housekeeping and is
|
|
never reachable from a tenant-scoped route.
|
|
"""
|
|
stmt = select(AppRuntimeRow).limit(min(max(limit, 1), 2_000))
|
|
return [_runtime(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def upsert(self, runtime: AppRuntime) -> AppRuntime:
|
|
row = await self.session.get(AppRuntimeRow, runtime.project_id)
|
|
if row is None:
|
|
row = AppRuntimeRow(
|
|
project_id=runtime.project_id, organization_id=runtime.organization_id
|
|
)
|
|
self.session.add(row)
|
|
row.demo_container_id = runtime.demo_container_id
|
|
row.host_port = runtime.host_port
|
|
row.demo_host = runtime.demo_host
|
|
row.status = runtime.status
|
|
row.current_release_id = runtime.current_release_id
|
|
row.updated_at = utcnow()
|
|
await self.session.flush()
|
|
return _runtime(row)
|
|
|
|
async def set_status(self, project_id: str, status: str) -> None:
|
|
row = await self.session.get(AppRuntimeRow, project_id)
|
|
if row is not None:
|
|
row.status = status
|
|
row.updated_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
async def set_live(
|
|
self,
|
|
project_id: str,
|
|
*,
|
|
container_id: str | None,
|
|
host_port: int | None,
|
|
release_id: str | None,
|
|
status: str = "live",
|
|
) -> None:
|
|
"""Commit the blue/green switch: container, port and release together."""
|
|
row = await self.session.get(AppRuntimeRow, project_id)
|
|
if row is None:
|
|
return
|
|
row.demo_container_id = container_id
|
|
row.host_port = host_port
|
|
row.current_release_id = release_id
|
|
row.status = status
|
|
row.updated_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
|
|
def _deploy(row: ProductionDeployRow) -> ProductionDeploy:
|
|
return ProductionDeploy(
|
|
id=row.id,
|
|
organization_id=row.organization_id,
|
|
project_id=row.project_id,
|
|
kind=row.kind,
|
|
host=row.host,
|
|
release_id=row.release_id,
|
|
target_id=row.target_id,
|
|
status=row.status,
|
|
message=row.message,
|
|
log_tail=row.log_tail,
|
|
rolled_back_from=row.rolled_back_from,
|
|
backup_path=row.backup_path,
|
|
started_at=row.started_at,
|
|
finished_at=row.finished_at,
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
class ProductionDeployRepository:
|
|
"""The production deploy ledger."""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def get(self, deploy_id: str) -> ProductionDeploy | None:
|
|
row = await self.session.get(ProductionDeployRow, deploy_id)
|
|
return _deploy(row) if row else None
|
|
|
|
async def get_for_org(self, organization_id: str, deploy_id: str) -> ProductionDeploy | None:
|
|
row = await self.session.scalar(
|
|
select(ProductionDeployRow).where(
|
|
ProductionDeployRow.id == deploy_id,
|
|
ProductionDeployRow.organization_id == organization_id,
|
|
)
|
|
)
|
|
return _deploy(row) if row else None
|
|
|
|
async def list_for_project(
|
|
self, *, project_id: str, limit: int = 20
|
|
) -> list[ProductionDeploy]:
|
|
stmt = (
|
|
select(ProductionDeployRow)
|
|
.where(ProductionDeployRow.project_id == project_id)
|
|
.order_by(ProductionDeployRow.created_at.desc())
|
|
.limit(min(max(limit, 1), 100))
|
|
)
|
|
return [_deploy(r) for r in (await self.session.execute(stmt)).scalars()]
|
|
|
|
async def active_for_project(self, project_id: str) -> ProductionDeploy | None:
|
|
"""One production deploy per project at a time.
|
|
|
|
Two concurrent deploys to the same target would interleave their writes
|
|
on a shared host, which is exactly the failure FTP has no way to undo.
|
|
"""
|
|
row = await self.session.scalar(
|
|
select(ProductionDeployRow)
|
|
.where(
|
|
ProductionDeployRow.project_id == project_id,
|
|
ProductionDeployRow.status.in_(
|
|
[DeployStatus.QUEUED.value, DeployStatus.RUNNING.value]
|
|
),
|
|
)
|
|
.limit(1)
|
|
)
|
|
return _deploy(row) if row else None
|
|
|
|
async def create(self, deploy: ProductionDeploy) -> ProductionDeploy:
|
|
row = ProductionDeployRow(
|
|
id=deploy.id,
|
|
organization_id=deploy.organization_id,
|
|
project_id=deploy.project_id,
|
|
release_id=deploy.release_id,
|
|
target_id=deploy.target_id,
|
|
kind=deploy.kind,
|
|
host=deploy.host,
|
|
status=deploy.status,
|
|
message=deploy.message,
|
|
log_tail=deploy.log_tail,
|
|
rolled_back_from=deploy.rolled_back_from,
|
|
backup_path=deploy.backup_path,
|
|
started_at=deploy.started_at,
|
|
finished_at=deploy.finished_at,
|
|
created_at=deploy.created_at,
|
|
)
|
|
self.session.add(row)
|
|
await self.session.flush()
|
|
return _deploy(row)
|
|
|
|
async def finish(
|
|
self,
|
|
deploy_id: str,
|
|
*,
|
|
status: str,
|
|
message: str = "",
|
|
log_tail: str = "",
|
|
backup_path: str = "",
|
|
) -> None:
|
|
row = await self.session.get(ProductionDeployRow, deploy_id)
|
|
if row is None:
|
|
return
|
|
row.status = status
|
|
row.message = message[:2_000]
|
|
row.log_tail = log_tail[-65_536:]
|
|
if backup_path:
|
|
row.backup_path = backup_path
|
|
row.finished_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
async def mark_running(self, deploy_id: str) -> None:
|
|
row = await self.session.get(ProductionDeployRow, deploy_id)
|
|
if row is None:
|
|
return
|
|
row.status = DeployStatus.RUNNING.value
|
|
row.started_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
|
|
__all__ = [
|
|
"ACTIVE_STATUSES",
|
|
"AppRuntimeRepository",
|
|
"PipelineRunRepository",
|
|
"PipelineStepRepository",
|
|
"ProductionDeployRepository",
|
|
"ReleaseRepository",
|
|
"StepStatus",
|
|
]
|