mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[ENV-LADDER] Add per-project ordered environment ladder column + shim
Replace the single default_branch with an ordered environments ladder
(list[{name,branch}]; first=head=PR target, last=prod=release target,
middle=intermediates). Migration 073 adds nullable JSONB projects.environments;
a read-time shim (roboco/models/env_branches.py) synthesizes a degenerate
single-branch ladder from default_branch when environments is null, so
behavior is unchanged until the operator declares a real split in the panel.
Wired through Project/ProjectCreate/ProjectUpdate models (normalize_environments
validator: rejects empty name/branch + dup branches), ProjectTable column,
API request/response schemas, create+update routes + service. EnvSyncEngine
+ consumer rewires follow.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""Per-project ordered environment ladder column.
|
||||
|
||||
Replaces the single ``default_branch`` as the source of truth for a project's
|
||||
PR target (head rung) and release target (prod rung). The ladder is an ordered
|
||||
``list[{name, branch}]``: index 0 = head (where dev/cell/leaf PRs land), index
|
||||
-1 = prod (where the gated release executor commits + tags), middle rungs =
|
||||
intermediates (qa/stag). The ``EnvSyncEngine`` cascades prod→…→head so dev
|
||||
never falls behind prod, and the CEO-gated release promotes the full chain
|
||||
head→…→prod before bumping.
|
||||
|
||||
Additive and nullable: an unset (null) ``environments`` falls back to a
|
||||
degenerate single-branch ladder synthesized from ``default_branch`` at read
|
||||
time (``roboco/services/env_branches.py``), so every existing project keeps
|
||||
behaving byte-for-byte as before until the operator declares a real ladder
|
||||
in the panel. ``default_branch`` is retained as the legacy/shim source.
|
||||
|
||||
Revision ID: 073_project_environments
|
||||
Revises: 072_project_sandbox_extensions
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "073_project_environments"
|
||||
down_revision = "072_project_sandbox_extensions"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column("environments", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("projects", "environments")
|
||||
@@ -141,6 +141,7 @@ async def create_project(
|
||||
git_url=data.git_url,
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=protected_branches,
|
||||
environments=data.environments,
|
||||
assigned_cell=data.assigned_cell,
|
||||
git_token=data.git_token,
|
||||
test_command=data.test_command,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectResponse(BaseModel):
|
||||
git_url: str
|
||||
default_branch: str
|
||||
protected_branches: list[str]
|
||||
environments: list[dict[str, str]] | None = None
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication status (token never exposed, only boolean)
|
||||
@@ -105,6 +106,14 @@ class ProjectCreateRequest(BaseModel):
|
||||
default=None,
|
||||
description="Branches to protect. Defaults to [default_branch].",
|
||||
)
|
||||
environments: list[dict[str, str]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Ordered environment ladder [{name, branch}]; first = head (PR "
|
||||
"target), last = prod (release target). null → inherits "
|
||||
"default_branch (head == prod)."
|
||||
),
|
||||
)
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication (will be encrypted and stored securely)
|
||||
@@ -129,6 +138,7 @@ class ProjectUpdateRequest(BaseModel):
|
||||
git_url: str | None = None
|
||||
default_branch: str | None = None
|
||||
protected_branches: list[str] | None = None
|
||||
environments: list[dict[str, str]] | None = None
|
||||
assigned_cell: Team | None = None
|
||||
|
||||
# Git authentication (empty string clears token, None leaves unchanged)
|
||||
@@ -226,6 +236,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
||||
git_url=str(project.git_url),
|
||||
default_branch=str(default_branch) if default_branch else "master",
|
||||
protected_branches=list(project.protected_branches or []),
|
||||
environments=list(project.environments) if project.environments else None,
|
||||
assigned_cell=project.assigned_cell,
|
||||
has_git_token=bool(project.git_token_encrypted),
|
||||
test_command=project.test_command,
|
||||
@@ -266,3 +277,4 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
|
||||
has_git_token=bool(project.git_token_encrypted),
|
||||
video_engine_enabled=bool(project.video_engine_enabled),
|
||||
)
|
||||
|
||||
|
||||
@@ -498,6 +498,14 @@ class ProjectTable(Base):
|
||||
protected_branches: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String), default=lambda: ["main", "master"]
|
||||
)
|
||||
# Ordered environment ladder [{name, branch}]: index 0 = head (PR target),
|
||||
# index -1 = prod (release target), middle = intermediates. Null →
|
||||
# roboco.services.env_branches synthesizes a degenerate single-branch
|
||||
# ladder from default_branch (head == prod == default_branch), so behavior
|
||||
# is unchanged until the operator declares a real split.
|
||||
environments: Mapped[list[dict[str, Any]] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
git_token_encrypted: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
) # Fernet-encrypted GitHub PAT
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Per-project environment ladder helpers.
|
||||
|
||||
A project's environment ladder is an ordered ``list[{name, branch}]``: index 0
|
||||
is **head** (the branch dev/cell/leaf PRs target — the dev trunk), index -1 is
|
||||
**prod** (the branch the gated release executor commits + tags on), and the
|
||||
middle rungs are intermediates (qa/stag). This separates "where work lands"
|
||||
from "what prod is", which a single ``default_branch`` could not.
|
||||
|
||||
When ``environments`` is null/empty the ladder is synthesized from
|
||||
``default_branch`` as a degenerate single-branch ladder (head == prod ==
|
||||
``default_branch``), so every consumer — PR target, release target, sync —
|
||||
keeps behaving exactly as before the column existed. Operators declare a real
|
||||
split in the panel.
|
||||
|
||||
These helpers are the single chokepoint every consumer routes through; never
|
||||
read ``project.environments`` raw. Pure domain logic (pydantic-only), kept in
|
||||
``roboco.models`` so the Project model can import it without a services-cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# A project or project-row duck-type: both the pydantic ``Project`` and the
|
||||
# SQLAlchemy ``ProjectTable`` expose ``environments`` and ``default_branch``.
|
||||
_ENV_LADDER = "head"
|
||||
_PROD_LADDER = "prod"
|
||||
|
||||
|
||||
class EnvRung(BaseModel):
|
||||
"""One rung of the environment ladder."""
|
||||
|
||||
name: str
|
||||
branch: str
|
||||
|
||||
|
||||
def _coerce_rungs(
|
||||
raw: list[dict[str, str]] | list[EnvRung] | None,
|
||||
) -> list[EnvRung] | None:
|
||||
"""Normalize a raw list of dicts/models into EnvRungs; None/empty -> None."""
|
||||
if not raw:
|
||||
return None
|
||||
out: list[EnvRung] = []
|
||||
for item in raw:
|
||||
if isinstance(item, EnvRung):
|
||||
out.append(item)
|
||||
else:
|
||||
out.append(EnvRung.model_validate(item))
|
||||
return out
|
||||
|
||||
|
||||
def effective_environments(project: object) -> list[EnvRung]:
|
||||
"""Return the project's resolved environment ladder (never empty).
|
||||
|
||||
Falls back to a degenerate single-branch ladder synthesized from
|
||||
``default_branch`` when ``environments`` is null/empty, so behavior is
|
||||
unchanged until the operator declares a real ladder.
|
||||
"""
|
||||
rungs = _coerce_rungs(getattr(project, "environments", None)) # type: ignore[arg-type]
|
||||
if rungs:
|
||||
return rungs
|
||||
fallback = str(getattr(project, "default_branch", None) or "master")
|
||||
return [
|
||||
EnvRung(name=_ENV_LADDER, branch=fallback),
|
||||
EnvRung(name=_PROD_LADDER, branch=fallback),
|
||||
]
|
||||
|
||||
|
||||
def head_branch(project: object) -> str:
|
||||
"""The branch dev/cell/leaf PRs target (ladder index 0)."""
|
||||
return effective_environments(project)[0].branch
|
||||
|
||||
|
||||
def prod_branch(project: object) -> str:
|
||||
"""The branch the gated release executor commits + tags on (last rung)."""
|
||||
return effective_environments(project)[-1].branch
|
||||
|
||||
|
||||
def ladder_pairs(project: object) -> list[tuple[EnvRung, EnvRung]]:
|
||||
"""Adjacent rung pairs for the prod->head cascade, top-down.
|
||||
|
||||
For ``[head, qa, stag, prod]`` (index 0..3) returns
|
||||
``[(prod, stag), (stag, qa), (qa, head)]`` — each ``(upper, lower)`` pair
|
||||
means "merge ``upper`` into ``lower``". Empty for a 1-rung ladder.
|
||||
"""
|
||||
rungs = effective_environments(project)
|
||||
return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)]
|
||||
|
||||
|
||||
def normalize_environments(
|
||||
value: list[dict[str, str]] | list[EnvRung] | None,
|
||||
) -> list[dict[str, str]] | None:
|
||||
"""Validate + normalize a ladder before persistence.
|
||||
|
||||
Rejects empty name/branch, de-dupes by branch, preserves the declared
|
||||
order. Returns None for None/empty so the column stays null (the shim
|
||||
synthesizes the degenerate ladder from ``default_branch`` at read time).
|
||||
"""
|
||||
rungs = _coerce_rungs(value)
|
||||
if not rungs:
|
||||
return None
|
||||
seen: set[str] = set()
|
||||
out: list[dict[str, str]] = []
|
||||
for rung in rungs:
|
||||
name = rung.name.strip()
|
||||
branch = rung.branch.strip()
|
||||
if not name or not branch:
|
||||
raise ValueError(
|
||||
"each environment rung needs a non-empty name and branch"
|
||||
)
|
||||
if branch in seen:
|
||||
raise ValueError(
|
||||
f"duplicate environment branch {branch!r}; "
|
||||
"each rung must target a distinct branch"
|
||||
)
|
||||
seen.add(branch)
|
||||
out.append({"name": name, "branch": branch})
|
||||
return out
|
||||
|
||||
@@ -13,6 +13,7 @@ from uuid import UUID, uuid4
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from roboco.models.base import RobocoBase, Team, TimestampMixin
|
||||
from roboco.models.env_branches import normalize_environments
|
||||
from roboco.models.sandbox import (
|
||||
SANDBOX_ENGINE_FEATURES,
|
||||
SANDBOX_ENGINES,
|
||||
@@ -119,6 +120,26 @@ class Project(TimestampMixin):
|
||||
default_factory=lambda: ["main", "master"],
|
||||
description="Branches that cannot be pushed to directly",
|
||||
)
|
||||
# Ordered environment ladder: index 0 = head (PR target / dev trunk),
|
||||
# index -1 = prod (release target), middle = intermediates (qa/stag).
|
||||
# null/empty → synthesized from default_branch as a degenerate single-
|
||||
# branch ladder (head == prod == default_branch), so behavior is unchanged
|
||||
# until the operator declares a real split in the panel.
|
||||
environments: list[dict[str, str]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Ordered environment ladder [{name, branch}]; first = head (PR "
|
||||
"target), last = prod (release target). null → inherits "
|
||||
"default_branch (head == prod). Validated by _check_environments."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("environments")
|
||||
@classmethod
|
||||
def _check_environments(
|
||||
cls, v: list[dict[str, str]] | None
|
||||
) -> list[dict[str, str]] | None:
|
||||
return normalize_environments(v)
|
||||
|
||||
# CI/CD Commands (optional - project may not have all)
|
||||
test_command: str | None = Field(
|
||||
@@ -239,6 +260,7 @@ class ProjectCreate(RobocoBase):
|
||||
git_url: str
|
||||
default_branch: str = "master"
|
||||
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
|
||||
environments: list[dict[str, str]] | None = None
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication (will be encrypted and stored securely)
|
||||
@@ -263,6 +285,7 @@ class ProjectUpdate(RobocoBase):
|
||||
git_url: str | None = None
|
||||
default_branch: str | None = None
|
||||
protected_branches: list[str] | None = None
|
||||
environments: list[dict[str, str]] | None = None
|
||||
|
||||
# Git authentication (empty string clears token, None leaves unchanged)
|
||||
git_token: str | None = Field(
|
||||
@@ -298,3 +321,10 @@ class ProjectUpdate(RobocoBase):
|
||||
cls, v: dict[str, list[str]] | None
|
||||
) -> dict[str, list[str]] | None:
|
||||
return _normalize_sandbox_extensions(v)
|
||||
|
||||
@field_validator("environments")
|
||||
@classmethod
|
||||
def _check_environments(
|
||||
cls, v: list[dict[str, str]] | None
|
||||
) -> list[dict[str, str]] | None:
|
||||
return normalize_environments(v)
|
||||
|
||||
@@ -96,6 +96,7 @@ class ProjectService(BaseService):
|
||||
git_url=data.git_url,
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=data.protected_branches,
|
||||
environments=data.environments,
|
||||
assigned_cell=data.assigned_cell,
|
||||
git_token_encrypted=encrypted_token,
|
||||
test_command=data.test_command,
|
||||
|
||||
Reference in New Issue
Block a user