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
189 lines
5.3 KiB
Python
189 lines
5.3 KiB
Python
"""Delivery domain: CI/CD runs, releases and the demo runtime.
|
|
|
|
Framework-free, like every other domain package. The interesting rules live in
|
|
the *state* enums rather than in behaviour, because the behaviour is all about
|
|
ordering: a run is `queued → running → succeeded|failed|cancelled`, a step is
|
|
`pending → running → succeeded|failed|skipped`, and a release only becomes the
|
|
live demo one when the healthcheck has passed and the route has been switched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
|
|
from app.domain.base import new_id, utcnow
|
|
|
|
|
|
class RunTrigger(StrEnum):
|
|
"""Why a pipeline run exists.
|
|
|
|
``engine`` and ``poll`` are the two automatic sources; ``manual`` is an
|
|
org admin pressing re-run. The distinction matters in the audit trail: "who
|
|
started this build?" is the first question asked when a deploy goes wrong.
|
|
"""
|
|
|
|
ENGINE = "engine"
|
|
POLL = "poll"
|
|
MANUAL = "manual"
|
|
|
|
|
|
class RunStatus(StrEnum):
|
|
QUEUED = "queued"
|
|
RUNNING = "running"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
#: A newer run for the same project replaced this one before it started.
|
|
SUPERSEDED = "superseded"
|
|
|
|
|
|
class StepStatus(StrEnum):
|
|
PENDING = "pending"
|
|
RUNNING = "running"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
#: The step had nothing to do (an empty build_command, say). Not a failure.
|
|
SKIPPED = "skipped"
|
|
|
|
|
|
class RuntimeStatus(StrEnum):
|
|
PROVISIONING = "provisioning"
|
|
LIVE = "live"
|
|
DEGRADED = "degraded"
|
|
STOPPED = "stopped"
|
|
|
|
|
|
#: The eight steps of a run, in order. The names are the product contract: the
|
|
#: UI renders them verbatim and the tests assert on the exact sequence.
|
|
PIPELINE_STEPS: tuple[str, ...] = (
|
|
"checkout",
|
|
"setup",
|
|
"build",
|
|
"test",
|
|
"package",
|
|
"deploy_demo",
|
|
"healthcheck",
|
|
"route",
|
|
)
|
|
|
|
#: Steps that are skipped (not failed) when the app profile leaves the command
|
|
#: empty. `checkout`, `deploy_demo`, `healthcheck` and `route` always run.
|
|
OPTIONAL_STEPS: frozenset[str] = frozenset({"setup", "build", "test"})
|
|
|
|
#: Statuses that mean "this run is no longer interesting".
|
|
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset(
|
|
{
|
|
RunStatus.SUCCEEDED.value,
|
|
RunStatus.FAILED.value,
|
|
RunStatus.CANCELLED.value,
|
|
RunStatus.SUPERSEDED.value,
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class PipelineRun:
|
|
organization_id: str
|
|
project_id: str
|
|
branch: str = "dev"
|
|
sha: str = ""
|
|
trigger: str = RunTrigger.ENGINE.value
|
|
status: str = RunStatus.QUEUED.value
|
|
error_summary: str = ""
|
|
id: str = field(default_factory=new_id)
|
|
started_at: datetime | None = None
|
|
finished_at: datetime | None = None
|
|
created_at: datetime = field(default_factory=utcnow)
|
|
|
|
@property
|
|
def is_terminal(self) -> bool:
|
|
return self.status in TERMINAL_RUN_STATUSES
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
return self.status in {RunStatus.QUEUED.value, RunStatus.RUNNING.value}
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class PipelineStep:
|
|
run_id: str
|
|
index: int
|
|
name: str
|
|
status: str = StepStatus.PENDING.value
|
|
exit_code: int | None = None
|
|
log_tail: str = ""
|
|
id: str = field(default_factory=new_id)
|
|
started_at: datetime | None = None
|
|
finished_at: datetime | None = None
|
|
|
|
@property
|
|
def duration_ms(self) -> int:
|
|
if not self.started_at or not self.finished_at:
|
|
return 0
|
|
return max(0, int((self.finished_at - self.started_at).total_seconds() * 1000))
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class Release:
|
|
organization_id: str
|
|
project_id: str
|
|
run_id: str
|
|
sha: str
|
|
artifact_path: str = ""
|
|
is_demo_live: bool = False
|
|
id: str = field(default_factory=new_id)
|
|
created_at: datetime = field(default_factory=utcnow)
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class AppRuntime:
|
|
"""The one demo container a project has.
|
|
|
|
``host_port`` is the *live* port; a blue/green switch starts the replacement
|
|
on a second port and only rewrites this row once the healthcheck passes.
|
|
"""
|
|
|
|
project_id: str
|
|
organization_id: str
|
|
demo_host: str = ""
|
|
host_port: int | None = None
|
|
demo_container_id: str | None = None
|
|
status: str = RuntimeStatus.PROVISIONING.value
|
|
current_release_id: str | None = None
|
|
updated_at: datetime = field(default_factory=utcnow)
|
|
|
|
@property
|
|
def demo_url(self) -> str:
|
|
return f"https://{self.demo_host}" if self.demo_host else ""
|
|
|
|
|
|
class DeployStatus(StrEnum):
|
|
QUEUED = "queued"
|
|
RUNNING = "running"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
#: A deploy that undid an earlier one. Kept as its own row so "what was
|
|
#: live when?" is answerable from the table alone.
|
|
ROLLED_BACK = "rolled_back"
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class ProductionDeploy:
|
|
organization_id: str
|
|
project_id: str
|
|
kind: str
|
|
host: str = ""
|
|
release_id: str | None = None
|
|
target_id: str | None = None
|
|
status: str = DeployStatus.QUEUED.value
|
|
message: str = ""
|
|
log_tail: str = ""
|
|
rolled_back_from: str | None = None
|
|
backup_path: str = ""
|
|
id: str = field(default_factory=new_id)
|
|
started_at: datetime | None = None
|
|
finished_at: datetime | None = None
|
|
created_at: datetime = field(default_factory=utcnow)
|