fix migration resume and release workflow
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# #region Alembic.AddDashboardReleases [C:3] [TYPE Module] [SEMANTICS alembic,git,release]
|
||||
# @defgroup Alembic Persist dashboard release records and repository policy overrides.
|
||||
|
||||
"""add dashboard releases
|
||||
|
||||
Revision ID: f5e6d7c8b9a0
|
||||
Revises: d4e5f6a7b8c9
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f5e6d7c8b9a0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
# #region Alembic.AddDashboardReleases.Upgrade [C:3] [TYPE Function] [SEMANTICS alembic,git,release]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Add release ledger and optional per-repository policy JSON.
|
||||
def upgrade() -> None:
|
||||
op.add_column("git_repositories", sa.Column("release_policy", sa.JSON(), nullable=True))
|
||||
op.create_table(
|
||||
"dashboard_releases",
|
||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||
sa.Column("repository_id", sa.String(length=36), sa.ForeignKey("git_repositories.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("deployment_id", sa.Integer(), sa.ForeignKey("deployment_records.id", ondelete="RESTRICT"), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("version", sa.String(length=100), nullable=False),
|
||||
sa.Column("notes", sa.Text(), nullable=False),
|
||||
sa.Column("commit_hash", sa.String(length=40), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("approved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("approved_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("approval_comment", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("published_by", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint("repository_id", "version", name="uq_dashboard_release_repository_version"),
|
||||
)
|
||||
op.create_index("ix_dashboard_releases_repository_id", "dashboard_releases", ["repository_id"])
|
||||
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases.Upgrade
|
||||
|
||||
# #region Alembic.AddDashboardReleases.Downgrade [C:2] [TYPE Function] [SEMANTICS alembic,git,release]
|
||||
# @ingroup Alembic
|
||||
# @BRIEF Remove dashboard release persistence.
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_dashboard_releases_repository_id", table_name="dashboard_releases")
|
||||
op.drop_table("dashboard_releases")
|
||||
op.drop_column("git_repositories", "release_policy")
|
||||
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases.Downgrade
|
||||
|
||||
# #endregion Alembic.AddDashboardReleases
|
||||
@@ -36,6 +36,9 @@ from ._merge_routes import abort_merge, continue_merge, get_merge_conflicts, get
|
||||
# -- Repo lifecycle routes (sync, promote, deploy) --
|
||||
from ._repo_lifecycle_routes import deploy_dashboard, promote_dashboard, sync_dashboard # noqa: F401
|
||||
|
||||
# -- Dashboard release ledger and approval policy --
|
||||
from ._release_routes import approve_release, create_release, get_release_policy, list_releases, publish_release, update_release_policy # noqa: F401
|
||||
|
||||
# -- Repo operations routes (commit, push, pull, status, diff, history, generate-message) --
|
||||
from ._repo_operations_routes import commit_changes, generate_commit_message, get_branch_commits, get_commit_diff, get_history, get_repository_diff, get_repository_status, get_repository_status_batch, pull_changes, push_changes, rollback_commit # noqa: F401
|
||||
|
||||
|
||||
273
backend/src/api/routes/git/_release_routes.py
Normal file
273
backend/src/api/routes/git/_release_routes.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# #region GitReleaseRoutes [C:5] [TYPE Module] [SEMANTICS fastapi,git,release,approval,publication]
|
||||
# @defgroup Api Dashboard release ledger, repository policy overrides, and approval transitions.
|
||||
# @LAYER API
|
||||
# @INVARIANT A release is created only from the latest validated PREPROD deployment.
|
||||
# @INVARIANT Repository policy overrides fall back to the installation policy when absent.
|
||||
# @RATIONALE Named releases make the user-visible publication decision auditable and independent of branch names.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.routes.git_schemas import (
|
||||
DashboardReleaseSchema,
|
||||
ReleaseApprovalRequest,
|
||||
ReleaseCreateRequest,
|
||||
ReleasePolicySchema,
|
||||
DeployRequest,
|
||||
)
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user, has_permission
|
||||
from src.models.auth import User
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.deployment import DeploymentRecord
|
||||
from src.models.git import GitRepository
|
||||
|
||||
from ._repo_lifecycle_routes import _enforce_approval_policy, _probe_drift, _resolve_stage_environment
|
||||
from ._router import router
|
||||
|
||||
|
||||
# #region GitRelease.resolve_policy [C:3] [TYPE Function] [SEMANTICS git,release,policy]
|
||||
# @ingroup Api
|
||||
# @BRIEF Resolve a repository override or the installation-wide release policy.
|
||||
def _resolve_policy(repository: GitRepository, config_manager) -> ReleasePolicySchema:
|
||||
fallback = config_manager.get_config().settings.git_release
|
||||
values = fallback.model_dump() if hasattr(fallback, "model_dump") else dict(fallback)
|
||||
if repository.release_policy:
|
||||
values.update(repository.release_policy)
|
||||
return ReleasePolicySchema(**values, is_override=bool(repository.release_policy))
|
||||
|
||||
|
||||
# #endregion GitRelease.resolve_policy
|
||||
|
||||
|
||||
# #region GitRelease.require_admin [C:2] [TYPE Function] [SEMANTICS git,release,rbac]
|
||||
# @ingroup Api
|
||||
# @BRIEF Restrict repository policy changes to administrators.
|
||||
def _require_admin(user: User) -> None:
|
||||
if not any(getattr(role, "is_admin", False) or role.name == "Admin" for role in user.roles):
|
||||
raise HTTPException(status_code=403, detail="Only administrators can change dashboard release policy")
|
||||
|
||||
|
||||
# #endregion GitRelease.require_admin
|
||||
|
||||
|
||||
# #region GitRelease.get_repository [C:2] [TYPE Function] [SEMANTICS git,release,repository]
|
||||
# @ingroup Api
|
||||
# @BRIEF Resolve the Git repository associated with a dashboard reference.
|
||||
async def _get_repository(dashboard_ref: str, env_id: str | None, config_manager, db: Session) -> GitRepository:
|
||||
from . import _resolve_dashboard_id_from_ref
|
||||
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
repository = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
|
||||
if not repository:
|
||||
raise HTTPException(status_code=404, detail="Dashboard repository is not initialized")
|
||||
return repository
|
||||
|
||||
|
||||
# #endregion GitRelease.get_repository
|
||||
|
||||
|
||||
# #region get_release_policy [C:3] [TYPE Function] [SEMANTICS git,release,policy,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Return the effective release policy for one dashboard repository.
|
||||
@router.get("/repositories/{dashboard_ref}/release-policy", response_model=ReleasePolicySchema)
|
||||
async def get_release_policy(
|
||||
dashboard_ref: str,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
return _resolve_policy(repository, config_manager)
|
||||
|
||||
|
||||
# #endregion get_release_policy
|
||||
|
||||
|
||||
# #region update_release_policy [C:4] [TYPE Function] [SEMANTICS git,release,policy,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Persist an administrator-managed release policy override for one dashboard.
|
||||
# @SIDE_EFFECT Updates the Git repository policy JSON.
|
||||
@router.put("/repositories/{dashboard_ref}/release-policy", response_model=ReleasePolicySchema)
|
||||
async def update_release_policy(
|
||||
dashboard_ref: str,
|
||||
policy: ReleasePolicySchema,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
repository.release_policy = policy.model_dump(exclude={"is_override"})
|
||||
db.commit()
|
||||
return _resolve_policy(repository, config_manager)
|
||||
|
||||
|
||||
# #endregion update_release_policy
|
||||
|
||||
|
||||
# #region list_releases [C:3] [TYPE Function] [SEMANTICS git,release,history,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Return the dashboard's release history, newest first.
|
||||
@router.get("/repositories/{dashboard_ref}/releases", response_model=list[DashboardReleaseSchema])
|
||||
async def list_releases(
|
||||
dashboard_ref: str,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
return (
|
||||
db.query(DashboardRelease)
|
||||
.filter(DashboardRelease.repository_id == repository.id)
|
||||
.order_by(DashboardRelease.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# #endregion list_releases
|
||||
|
||||
|
||||
# #region create_release [C:5] [TYPE Function] [SEMANTICS git,release,preprod,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Create a named immutable release from the active validated PREPROD deployment.
|
||||
# @PRE PREPROD has a latest successful deployment whose validation is current.
|
||||
# @POST Release stores exactly that deployment's commit and content hash.
|
||||
# @SIDE_EFFECT Inserts one dashboard release record.
|
||||
# @INVARIANT The chosen PREPROD deployment cannot already have another named release.
|
||||
@router.post("/repositories/{dashboard_ref}/releases", response_model=DashboardReleaseSchema, status_code=201)
|
||||
async def create_release(
|
||||
dashboard_ref: str,
|
||||
payload: ReleaseCreateRequest,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
policy = _resolve_policy(repository, config_manager)
|
||||
preprod_environment = _resolve_stage_environment("preprod", db, config_manager)
|
||||
candidate = (
|
||||
db.query(DeploymentRecord)
|
||||
.filter(
|
||||
DeploymentRecord.repository_id == repository.id,
|
||||
DeploymentRecord.environment_id == preprod_environment.id,
|
||||
DeploymentRecord.status == "success",
|
||||
)
|
||||
.order_by(DeploymentRecord.deployed_at.desc(), DeploymentRecord.id.desc())
|
||||
.first()
|
||||
)
|
||||
if not candidate or candidate.validation_status != "validated":
|
||||
raise HTTPException(status_code=409, detail="Validate the current PREPROD deployment before creating a release")
|
||||
if policy.block_publish_on_drift:
|
||||
drift_status, _ = await _probe_drift(dashboard_ref, candidate.environment_id, candidate.content_hash, config_manager)
|
||||
if drift_status != "in_sync":
|
||||
raise HTTPException(status_code=409, detail="PREPROD differs from the recorded candidate; synchronize or redeploy before creating a release")
|
||||
if db.query(DashboardRelease).filter(DashboardRelease.deployment_id == candidate.id).first():
|
||||
raise HTTPException(status_code=409, detail="This PREPROD deployment already has a named release")
|
||||
|
||||
release = DashboardRelease(
|
||||
repository_id=repository.id,
|
||||
deployment_id=candidate.id,
|
||||
name=payload.name.strip(),
|
||||
version=payload.version.strip(),
|
||||
notes=payload.notes.strip(),
|
||||
commit_hash=candidate.commit_hash,
|
||||
content_hash=candidate.content_hash,
|
||||
status="awaiting_approval" if policy.require_prod_approval else "ready_to_publish",
|
||||
created_by=current_user.username,
|
||||
)
|
||||
db.add(release)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Release version already exists for this dashboard") from error
|
||||
db.refresh(release)
|
||||
return release
|
||||
|
||||
|
||||
# #endregion create_release
|
||||
|
||||
|
||||
# #region approve_release [C:4] [TYPE Function] [SEMANTICS git,release,approval,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Approve a release when its repository policy requires a PROD gate.
|
||||
# @SIDE_EFFECT Records approver identity, timestamp, and optional policy-required comment.
|
||||
@router.post("/repositories/{dashboard_ref}/releases/{release_id}/approve", response_model=DashboardReleaseSchema)
|
||||
async def approve_release(
|
||||
dashboard_ref: str,
|
||||
release_id: str,
|
||||
payload: ReleaseApprovalRequest,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
release = db.query(DashboardRelease).filter(
|
||||
DashboardRelease.id == release_id,
|
||||
DashboardRelease.repository_id == repository.id,
|
||||
).first()
|
||||
if not release:
|
||||
raise HTTPException(status_code=404, detail="Release not found")
|
||||
if release.status != "awaiting_approval":
|
||||
raise HTTPException(status_code=409, detail="This release is not awaiting approval")
|
||||
policy = _resolve_policy(repository, config_manager)
|
||||
_enforce_approval_policy(policy, current_user, payload.comment)
|
||||
release.status = "ready_to_publish"
|
||||
release.approved_at = datetime.now(UTC)
|
||||
release.approved_by = current_user.username
|
||||
release.approval_comment = str(payload.comment or "").strip() or None
|
||||
db.commit()
|
||||
db.refresh(release)
|
||||
return release
|
||||
|
||||
|
||||
# #endregion approve_release
|
||||
|
||||
|
||||
# #region publish_release [C:4] [TYPE Function] [SEMANTICS git,release,publication,api]
|
||||
# @ingroup Api
|
||||
# @BRIEF Publish a named ready release through the guarded PROD deployment endpoint.
|
||||
# @SIDE_EFFECT Deploys the exact release commit to PROD and marks the release published on success.
|
||||
@router.post("/repositories/{dashboard_ref}/releases/{release_id}/publish")
|
||||
async def publish_release(
|
||||
dashboard_ref: str,
|
||||
release_id: str,
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
repository = await _get_repository(dashboard_ref, env_id, config_manager, db)
|
||||
release = db.query(DashboardRelease).filter(
|
||||
DashboardRelease.id == release_id,
|
||||
DashboardRelease.repository_id == repository.id,
|
||||
).first()
|
||||
if not release:
|
||||
raise HTTPException(status_code=404, detail="Release not found")
|
||||
from ._repo_lifecycle_routes import deploy_dashboard
|
||||
|
||||
return await deploy_dashboard(
|
||||
dashboard_ref,
|
||||
DeployRequest(stage="prod", commit_hash=release.commit_hash, release_id=release.id),
|
||||
env_id,
|
||||
config_manager,
|
||||
db,
|
||||
current_user,
|
||||
)
|
||||
|
||||
|
||||
# #endregion publish_release
|
||||
|
||||
# #endregion GitReleaseRoutes
|
||||
@@ -20,12 +20,14 @@ from src.api.routes.git_schemas import (
|
||||
EnvironmentDeploymentStatus,
|
||||
PromoteRequest,
|
||||
PromoteResponse,
|
||||
ReleasePolicySchema,
|
||||
)
|
||||
from src.core.database import get_db
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.dependencies import get_config_manager, get_current_user, has_permission
|
||||
from src.models.auth import User
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
from src.models.git import GitProvider, GitRepository
|
||||
|
||||
from ._deps import get_git_service
|
||||
@@ -122,6 +124,20 @@ def _enforce_approval_policy(policy, user: User, comment: str | None) -> None:
|
||||
# #endregion GitDeployment.enforce_approval_policy
|
||||
|
||||
|
||||
# #region GitDeployment.resolve_repository_policy [C:3] [TYPE Function] [SEMANTICS git,release,policy]
|
||||
# @ingroup Api
|
||||
# @BRIEF Overlay a repository policy override onto the installation defaults.
|
||||
def _resolve_repository_policy(repository: GitRepository, config_manager) -> ReleasePolicySchema:
|
||||
defaults = config_manager.get_config().settings.git_release
|
||||
values = defaults.model_dump() if hasattr(defaults, "model_dump") else dict(defaults)
|
||||
if repository.release_policy:
|
||||
values.update(repository.release_policy)
|
||||
return ReleasePolicySchema(**values)
|
||||
|
||||
|
||||
# #endregion GitDeployment.resolve_repository_policy
|
||||
|
||||
|
||||
# #region GitDeployment.probe_drift [C:4] [TYPE Function] [SEMANTICS git,deployment,drift,superset]
|
||||
# @ingroup Api
|
||||
# @BRIEF Compare a recorded candidate fingerprint with the dashboard currently exported from its target Superset.
|
||||
@@ -492,6 +508,7 @@ async def deploy_dashboard(
|
||||
env_id: str | None = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(has_permission("plugin:git", "EXECUTE")),
|
||||
):
|
||||
with belief_scope("deploy_dashboard"):
|
||||
@@ -503,8 +520,8 @@ async def deploy_dashboard(
|
||||
try:
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
target = _resolve_stage_environment(deploy_data.stage, db, config_manager)
|
||||
release_to_publish: DashboardRelease | None = None
|
||||
if deploy_data.stage == "prod":
|
||||
policy = config_manager.get_config().settings.git_release
|
||||
repository = (
|
||||
db.query(GitRepository)
|
||||
.filter(GitRepository.dashboard_id == dashboard_id)
|
||||
@@ -512,6 +529,17 @@ async def deploy_dashboard(
|
||||
)
|
||||
if not repository:
|
||||
raise HTTPException(status_code=409, detail="Dashboard repository is not initialized")
|
||||
policy = _resolve_repository_policy(repository, config_manager)
|
||||
if not deploy_data.release_id:
|
||||
raise HTTPException(status_code=409, detail="Create and publish a named dashboard release before deploying to PROD")
|
||||
release_to_publish = db.query(DashboardRelease).filter(
|
||||
DashboardRelease.id == deploy_data.release_id,
|
||||
DashboardRelease.repository_id == repository.id,
|
||||
).first()
|
||||
if not release_to_publish:
|
||||
raise HTTPException(status_code=404, detail="Dashboard release not found")
|
||||
if release_to_publish.status != "ready_to_publish":
|
||||
raise HTTPException(status_code=409, detail="Dashboard release must be approved before publication")
|
||||
latest_preprod = (
|
||||
db.query(DeploymentRecord)
|
||||
.filter(
|
||||
@@ -536,13 +564,20 @@ async def deploy_dashboard(
|
||||
repo = await GitService().get_repo(dashboard_id)
|
||||
selected_commit = repo.commit(deploy_data.commit_hash).hexsha if deploy_data.commit_hash else None
|
||||
current_hash = _compute_content_hash(Path(repo.working_dir))
|
||||
if (
|
||||
not latest_preprod
|
||||
or latest_preprod.commit_hash != release_to_publish.commit_hash
|
||||
or latest_preprod.content_hash != release_to_publish.content_hash
|
||||
or (selected_commit is not None and selected_commit != release_to_publish.commit_hash)
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="PREPROD no longer matches the named release; create a new release")
|
||||
if (
|
||||
not latest_preprod
|
||||
or (policy.require_prod_approval and latest_preprod.validation_status != "validated")
|
||||
or (
|
||||
policy.approval_expires_hours > 0
|
||||
and latest_preprod.validated_at
|
||||
and (datetime.now(UTC) - latest_preprod.validated_at.replace(tzinfo=UTC)).total_seconds() > policy.approval_expires_hours * 3600
|
||||
and release_to_publish.approved_at
|
||||
and (datetime.now(UTC) - release_to_publish.approved_at.replace(tzinfo=UTC)).total_seconds() > policy.approval_expires_hours * 3600
|
||||
)
|
||||
or (
|
||||
latest_preprod.commit_hash != selected_commit
|
||||
@@ -595,6 +630,20 @@ async def deploy_dashboard(
|
||||
)
|
||||
.update({"status": "superseded", "validation_status": "superseded"}, synchronize_session=False)
|
||||
)
|
||||
(
|
||||
db.query(DashboardRelease)
|
||||
.filter(
|
||||
DashboardRelease.repository_id == repository.id,
|
||||
DashboardRelease.deployment_id != current_candidate.id,
|
||||
DashboardRelease.status.in_(["awaiting_approval", "ready_to_publish"]),
|
||||
)
|
||||
.update({"status": "superseded"}, synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
elif release_to_publish:
|
||||
release_to_publish.status = "published"
|
||||
release_to_publish.published_at = datetime.now(UTC)
|
||||
release_to_publish.published_by = current_user.username
|
||||
db.commit()
|
||||
return result
|
||||
except HTTPException:
|
||||
|
||||
@@ -265,6 +265,7 @@ class DeployRequest(BaseModel):
|
||||
stage: str = Field(..., pattern="^(preprod|prod)$", description="Canonical release stage; server connection is resolved internally")
|
||||
commit_hash: str | None = Field(None, min_length=7, max_length=40, description="Optional immutable commit to deploy instead of current checkout")
|
||||
source_branch: str | None = Field(None, min_length=1, max_length=255, description="Branch that created a PREPROD candidate; inherited by PROD")
|
||||
release_id: str | None = Field(None, min_length=1, max_length=36, description="Required named dashboard release when publishing to PROD")
|
||||
|
||||
|
||||
# #endregion DeployRequest
|
||||
@@ -534,4 +535,66 @@ class DeploymentStatusResponse(BaseModel):
|
||||
# #endregion DeploymentStatusResponse
|
||||
|
||||
|
||||
# #region ReleasePolicySchema [C:1] [TYPE Class]
|
||||
# @ingroup Api
|
||||
# @BRIEF Per-dashboard override for Git release approval behavior.
|
||||
class ReleasePolicySchema(BaseModel):
|
||||
require_prod_approval: bool = True
|
||||
approval_roles: list[str] = Field(default_factory=lambda: ["Admin"])
|
||||
require_approval_comment: bool = False
|
||||
approval_expires_hours: int = Field(default=0, ge=0, le=720)
|
||||
block_publish_on_drift: bool = True
|
||||
is_override: bool = False
|
||||
|
||||
|
||||
# #endregion ReleasePolicySchema
|
||||
|
||||
|
||||
# #region ReleaseCreateRequest [C:1] [TYPE Class]
|
||||
# @ingroup Api
|
||||
# @BRIEF User-supplied metadata for a named dashboard release.
|
||||
class ReleaseCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
version: str = Field(min_length=1, max_length=100)
|
||||
notes: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
# #endregion ReleaseCreateRequest
|
||||
|
||||
|
||||
# #region ReleaseApprovalRequest [C:1] [TYPE Class]
|
||||
# @ingroup Api
|
||||
# @BRIEF Optional approval comment for a dashboard release.
|
||||
class ReleaseApprovalRequest(BaseModel):
|
||||
comment: str | None = Field(None, max_length=1000)
|
||||
|
||||
|
||||
# #endregion ReleaseApprovalRequest
|
||||
|
||||
|
||||
# #region DashboardReleaseSchema [C:1] [TYPE Class]
|
||||
# @ingroup Api
|
||||
# @BRIEF Read model for the dashboard release ledger.
|
||||
class DashboardReleaseSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
notes: str
|
||||
commit_hash: str
|
||||
content_hash: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
approved_at: datetime | None = None
|
||||
approved_by: str | None = None
|
||||
approval_comment: str | None = None
|
||||
published_at: datetime | None = None
|
||||
published_by: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# #endregion DashboardReleaseSchema
|
||||
|
||||
|
||||
# #endregion GitSchemas
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.orm import Session
|
||||
from ...models.dashboard import DashboardSelection
|
||||
from ...models.mapping import DatabaseMapping
|
||||
from ..logger import belief_scope, logger
|
||||
from ..mapping_service import IdMappingService
|
||||
from ..migration_engine import MigrationEngine
|
||||
from ..superset_client import SupersetClient
|
||||
from ..utils.fileio import create_temp_file
|
||||
@@ -71,7 +72,7 @@ class MigrationDryRunService:
|
||||
"Starting dry-run pipeline",
|
||||
extra={"src": "MigrationDryRunService.run"},
|
||||
)
|
||||
engine = MigrationEngine()
|
||||
engine = MigrationEngine(mapping_service=IdMappingService(db))
|
||||
db_mapping = (
|
||||
self._load_db_mapping(db, selection)
|
||||
if selection.replace_db_config
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# @SIDE_EFFECT Reads and writes temporary archive contents during transformation workflows and emits structured belief-state logs.
|
||||
# @DATA_CONTRACT Input[zip_path, output_path, db_mapping, target_env_id?, fix_cross_filters?] -> Output[Transformed Superset archive]
|
||||
# @INVARIANT ZIP structure and non-targeted metadata must remain valid after transformation.
|
||||
# @INVARIANT An import archive contains only mapped database resources referenced by its datasets.
|
||||
# @RATIONALE Dedicated module for Superset export ZIP transformation (extract → transform → re-package) because archive manipulation has distinct lifecycle requirements that benefit from isolation from API routing and task orchestration layers.
|
||||
# @REJECTED Performing ZIP transformations inline within API route handlers was rejected — it would duplicate extraction/packaging logic across endpoints and make archive corruption handling inconsistent.
|
||||
import json
|
||||
@@ -91,6 +92,18 @@ class MigrationEngine:
|
||||
logger.reason(
|
||||
f"Transforming {len(dataset_files)} dataset YAML files"
|
||||
)
|
||||
required_source_database_uuids = self._collect_dataset_database_uuids(
|
||||
dataset_files
|
||||
)
|
||||
unmapped_database_uuids = (
|
||||
required_source_database_uuids - set(db_mapping)
|
||||
)
|
||||
if unmapped_database_uuids:
|
||||
logger.explore(
|
||||
"Archive references databases without a target mapping; refusing unsafe import",
|
||||
payload={"unmapped_database_count": len(unmapped_database_uuids)},
|
||||
)
|
||||
return False
|
||||
for ds_file in dataset_files:
|
||||
self._transform_yaml(ds_file, db_mapping)
|
||||
# 2.1 Transform YAMLs (Databases — replace UUID with target UUID)
|
||||
@@ -101,12 +114,19 @@ class MigrationEngine:
|
||||
temp_dir.glob("**/databases/*.yaml")
|
||||
)
|
||||
db_files = list(set(db_files))
|
||||
referenced_database_files: set[Path] = set()
|
||||
if db_files:
|
||||
logger.reason(
|
||||
f"Transforming {len(db_files)} database YAML files"
|
||||
)
|
||||
for db_file in db_files:
|
||||
if self._database_yaml_uuid(db_file) in required_source_database_uuids:
|
||||
self._transform_database_yaml(db_file, db_mapping)
|
||||
referenced_database_files.add(db_file)
|
||||
else:
|
||||
logger.reason(
|
||||
f"Excluding unreferenced database resource {db_file.name}"
|
||||
)
|
||||
# 2.5 Patch Cross-Filters (Dashboards)
|
||||
if fix_cross_filters:
|
||||
if self.mapping_service and target_env_id:
|
||||
@@ -136,10 +156,13 @@ class MigrationEngine:
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
rel_root = Path(root).relative_to(temp_dir)
|
||||
if strip_databases and "databases" in rel_root.parts:
|
||||
continue
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
if "databases" in rel_root.parts and (
|
||||
strip_databases
|
||||
or file_path not in referenced_database_files
|
||||
):
|
||||
continue
|
||||
arcname = file_path.relative_to(temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
logger.reflect("ZIP transformation completed successfully")
|
||||
@@ -148,6 +171,32 @@ class MigrationEngine:
|
||||
logger.explore(f"Error transforming ZIP: {e}")
|
||||
return False
|
||||
# #endregion transform_zip
|
||||
# #region _collect_dataset_database_uuids [C:2] [TYPE Function] [SEMANTICS migration,archive,database,mapping]
|
||||
# @BRIEF Read source database UUIDs referenced by the datasets in an export archive.
|
||||
# @PRE Each path is a readable Superset dataset YAML file.
|
||||
# @POST Returns exactly the non-empty database_uuid values declared by dataset YAMLs.
|
||||
# @INVARIANT Only declared dataset dependencies can cause a database resource to be imported.
|
||||
def _collect_dataset_database_uuids(self, dataset_files: list[Path]) -> set[str]:
|
||||
database_uuids: set[str] = set()
|
||||
for dataset_file in dataset_files:
|
||||
with open(dataset_file) as stream:
|
||||
data = yaml.safe_load(stream) or {}
|
||||
database_uuid = data.get("database_uuid")
|
||||
if isinstance(database_uuid, str) and database_uuid:
|
||||
database_uuids.add(database_uuid)
|
||||
return database_uuids
|
||||
# #endregion _collect_dataset_database_uuids
|
||||
|
||||
# #region _database_yaml_uuid [C:1] [TYPE Function] [SEMANTICS migration,archive,database]
|
||||
# @BRIEF Return the UUID declared by an exported database resource.
|
||||
# @POST Returns None for empty or malformed database YAML without making it importable.
|
||||
def _database_yaml_uuid(self, file_path: Path) -> str | None:
|
||||
with open(file_path) as stream:
|
||||
data = yaml.safe_load(stream) or {}
|
||||
database_uuid = data.get("uuid")
|
||||
return database_uuid if isinstance(database_uuid, str) and database_uuid else None
|
||||
# #endregion _database_yaml_uuid
|
||||
|
||||
# #region _transform_yaml [TYPE Function]
|
||||
# @PURPOSE: Replaces database_uuid in a single YAML file.
|
||||
# @PARAM file_path (Path) - Path to the YAML file.
|
||||
|
||||
@@ -149,11 +149,12 @@ class TaskGraph:
|
||||
# #endregion create_future
|
||||
|
||||
# #region resolve_future [C:1] [TYPE Function]
|
||||
# @BRIEF Resolve a paused task's future and remove it from the map.
|
||||
# @BRIEF Resolve a paused task's future; the waiting lifecycle cleans it up.
|
||||
# @INVARIANT A result delivered before the waiter starts remains available to that waiter.
|
||||
def resolve_future(self, task_id: str, result: bool = True) -> None:
|
||||
if task_id in self.task_futures:
|
||||
self.task_futures[task_id].set_result(result)
|
||||
del self.task_futures[task_id]
|
||||
future = self.task_futures.get(task_id)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(result)
|
||||
# #endregion resolve_future
|
||||
|
||||
# #region remove_future [C:1] [TYPE Function]
|
||||
|
||||
@@ -344,17 +344,20 @@ class JobLifecycle:
|
||||
# @BRIEF Pauses execution and waits for a resolution signal.
|
||||
# @PRE Task exists.
|
||||
# @POST Execution pauses until future is set.
|
||||
# @INVARIANT The resume future exists before AWAITING_MAPPING is broadcast, preventing an early UI response from being lost.
|
||||
async def wait_for_resolution(self, task_id: str, timeout: float = 3600.0) -> None:
|
||||
with belief_scope("JobLifecycle.wait_for_resolution", f"task_id={task_id}"):
|
||||
task = self.graph.get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
future = self.graph.task_futures.get(task_id)
|
||||
if future is None:
|
||||
future = loop.create_future()
|
||||
self.graph.create_future(task_id, future)
|
||||
task.status = TaskStatus.AWAITING_MAPPING
|
||||
self.persistence_service.persist_task(task)
|
||||
await self._broadcast_task_status(task)
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
self.graph.create_future(task_id, future)
|
||||
try:
|
||||
await asyncio.wait_for(future, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -372,12 +375,15 @@ class JobLifecycle:
|
||||
# @BRIEF Pauses execution and waits for user input.
|
||||
# @PRE Task exists.
|
||||
# @POST Execution pauses until future is set via resume_task_with_password.
|
||||
# @INVARIANT Reuses the pause future created by await_input so an immediate resume is not lost.
|
||||
async def wait_for_input(self, task_id: str, timeout: float = 3600.0) -> None:
|
||||
with belief_scope("JobLifecycle.wait_for_input", f"task_id={task_id}"):
|
||||
task = self.graph.get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
future = self.graph.task_futures.get(task_id)
|
||||
if future is None:
|
||||
future = loop.create_future()
|
||||
self.graph.create_future(task_id, future)
|
||||
try:
|
||||
@@ -397,6 +403,7 @@ class JobLifecycle:
|
||||
# @BRIEF Transition a task to AWAITING_INPUT state with input request.
|
||||
# @PRE Task exists and is in RUNNING state.
|
||||
# @POST Task status changed to AWAITING_INPUT, input_request set, persisted.
|
||||
# @INVARIANT The input future is registered before the state broadcast so an immediate resume is durable.
|
||||
# @RAISES ValueError if task not found or not RUNNING.
|
||||
async def await_input(
|
||||
self, task_id: str, input_request: dict[str, Any],
|
||||
@@ -410,6 +417,9 @@ class JobLifecycle:
|
||||
raise ValueError(
|
||||
f"Task {task_id} is not RUNNING (current: {task.status})"
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
if self.graph.task_futures.get(task_id) is None:
|
||||
self.graph.create_future(task_id, loop.create_future())
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
task.input_required = True
|
||||
task.input_request = input_request
|
||||
|
||||
44
backend/src/models/dashboard_release.py
Normal file
44
backend/src/models/dashboard_release.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# #region DashboardReleaseModels [C:4] [TYPE Module] [SEMANTICS sqlalchemy,git,release,dashboard]
|
||||
# @defgroup Models Persist immutable dashboard releases created from validated PREPROD deployments.
|
||||
# @LAYER Domain
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from src.models.mapping import Base
|
||||
|
||||
|
||||
# #region DashboardRelease [C:5] [TYPE Class] [SEMANTICS git,release,publication,approval]
|
||||
# @ingroup Models
|
||||
# @BRIEF Immutable business release bound to one exact PREPROD deployment record.
|
||||
# @INVARIANT A release never changes its source commit or content hash after creation.
|
||||
# @INVARIANT Version is unique within one dashboard Git repository.
|
||||
# @RATIONALE A separate record preserves release intent and approval history without overloading deployment records.
|
||||
# @REJECTED Reusing clean-release candidates was rejected because they model artifact compliance, not dashboard deployment state.
|
||||
class DashboardRelease(Base):
|
||||
__tablename__ = "dashboard_releases"
|
||||
__table_args__ = (UniqueConstraint("repository_id", "version", name="uq_dashboard_release_repository_version"),)
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
repository_id = Column(String(36), ForeignKey("git_repositories.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
deployment_id = Column(Integer, ForeignKey("deployment_records.id", ondelete="RESTRICT"), nullable=False, unique=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
version = Column(String(100), nullable=False)
|
||||
notes = Column(Text, nullable=False)
|
||||
commit_hash = Column(String(40), nullable=False)
|
||||
content_hash = Column(String(64), nullable=False)
|
||||
status = Column(String(32), nullable=False, default="ready_to_publish")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
created_by = Column(String(255), nullable=False)
|
||||
approved_at = Column(DateTime, nullable=True)
|
||||
approved_by = Column(String(255), nullable=True)
|
||||
approval_comment = Column(Text, nullable=True)
|
||||
published_at = Column(DateTime, nullable=True)
|
||||
published_by = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
# #endregion DashboardRelease
|
||||
|
||||
# #endregion DashboardReleaseModels
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, JSON, String
|
||||
|
||||
from src.models.mapping import Base
|
||||
|
||||
@@ -59,6 +59,9 @@ class GitRepository(Base):
|
||||
local_path = Column(String(255), nullable=False)
|
||||
current_branch = Column(String(255), default="dev")
|
||||
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
|
||||
# A dashboard may override the installation-wide Git release defaults.
|
||||
# None deliberately means "inherit global policy" for backward compatibility.
|
||||
release_policy = Column(JSON, nullable=True)
|
||||
|
||||
|
||||
# #endregion GitRepository
|
||||
|
||||
@@ -368,12 +368,11 @@ class MigrationPlugin(PluginBase):
|
||||
app_logger.explore("Missing DB password detected during ingestion. Escalating to UI.", extra={"db_name": db_name})
|
||||
|
||||
if task_id:
|
||||
add_log = context._logger._add_log if context else None
|
||||
await tm.await_input(task_id, {
|
||||
"type": "database_password",
|
||||
"databases": [db_name],
|
||||
"error_message": "A database password is required to continue this migration.",
|
||||
}, add_log_callback=add_log)
|
||||
})
|
||||
|
||||
await tm.wait_for_input(task_id)
|
||||
task = tm.get_task(task_id)
|
||||
|
||||
@@ -314,4 +314,18 @@ class TestDeployDashboard:
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad-ref/deploy", json={"stage": "prod"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_prod_requires_named_release(self, mock_db_repo):
|
||||
"""The legacy deploy endpoint cannot bypass the named-release publication gate."""
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
patch("src.api.routes.git._repo_lifecycle_routes._resolve_stage_environment", return_value=self._target_environment()),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/deploy", json={"stage": "prod"})
|
||||
assert resp.status_code == 409
|
||||
assert "named dashboard release" in resp.json()["detail"]
|
||||
# #endregion Test.Api.GitRepoLifecycleRoutes
|
||||
|
||||
@@ -182,10 +182,11 @@ class TestCreateFuture:
|
||||
class TestResolveFuture:
|
||||
def test_resolves(self, graph):
|
||||
future = MagicMock()
|
||||
future.done.return_value = False
|
||||
graph.task_futures["t1"] = future
|
||||
graph.resolve_future("t1", result=True)
|
||||
future.set_result.assert_called_once_with(True)
|
||||
assert "t1" not in graph.task_futures
|
||||
assert graph.task_futures["t1"] is future
|
||||
|
||||
def test_resolve_nonexistent(self, graph):
|
||||
graph.resolve_future("nonexistent") # should not raise
|
||||
|
||||
@@ -40,6 +40,16 @@ def mock_graph():
|
||||
graph = MagicMock()
|
||||
graph.get_task.return_value = None # default: not found
|
||||
graph.tasks = {}
|
||||
graph.task_futures = {}
|
||||
|
||||
def create_future(task_id, future):
|
||||
graph.task_futures[task_id] = future
|
||||
|
||||
def remove_future(task_id):
|
||||
graph.task_futures.pop(task_id, None)
|
||||
|
||||
graph.create_future.side_effect = create_future
|
||||
graph.remove_future.side_effect = remove_future
|
||||
return graph
|
||||
|
||||
|
||||
|
||||
@@ -331,6 +331,53 @@ def test_transform_zip_end_to_end():
|
||||
# #endregion test_transform_zip_end_to_end
|
||||
|
||||
|
||||
# #region test_transform_zip_keeps_only_referenced_mapped_database [C:2] [TYPE Function]
|
||||
# @BRIEF An exported archive must not import unused database resources or request their passwords.
|
||||
# @TEST_EDGE unreferenced_database_with_password -> excluded from transformed archive.
|
||||
def test_transform_zip_keeps_only_referenced_mapped_database():
|
||||
engine = MigrationEngine()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
source = root / "source.zip"
|
||||
target = root / "target.zip"
|
||||
archive = root / "archive"
|
||||
(archive / "datasets").mkdir(parents=True)
|
||||
(archive / "databases").mkdir()
|
||||
(archive / "datasets" / "orders.yaml").write_text(
|
||||
"database_uuid: source-used\ntable_name: orders\n"
|
||||
)
|
||||
(archive / "databases" / "used.yaml").write_text("uuid: source-used\n")
|
||||
(archive / "databases" / "unrelated.yaml").write_text("uuid: source-unrelated\n")
|
||||
with zipfile.ZipFile(source, "w") as zf:
|
||||
for path in archive.rglob("*.yaml"):
|
||||
zf.write(path, path.relative_to(archive))
|
||||
|
||||
assert engine.transform_zip(
|
||||
str(source), str(target), {"source-used": "target-used"},
|
||||
strip_databases=False,
|
||||
)
|
||||
with zipfile.ZipFile(target) as zf:
|
||||
assert "databases/used.yaml" in zf.namelist()
|
||||
assert "databases/unrelated.yaml" not in zf.namelist()
|
||||
dataset = yaml.safe_load(zf.read("datasets/orders.yaml"))
|
||||
assert dataset["database_uuid"] == "target-used"
|
||||
|
||||
|
||||
def test_transform_zip_rejects_dataset_without_database_mapping():
|
||||
"""@TEST_EDGE An unmapped dataset dependency cannot fall through to a password prompt."""
|
||||
engine = MigrationEngine()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
source = Path(td) / "source.zip"
|
||||
target = Path(td) / "target.zip"
|
||||
with zipfile.ZipFile(source, "w") as zf:
|
||||
zf.writestr("datasets/orders.yaml", "database_uuid: source-unmapped\n")
|
||||
zf.writestr("databases/unmapped.yaml", "uuid: source-unmapped\n")
|
||||
|
||||
assert not engine.transform_zip(str(source), str(target), {}, strip_databases=False)
|
||||
assert not target.exists()
|
||||
# #endregion test_transform_zip_keeps_only_referenced_mapped_database
|
||||
|
||||
|
||||
# #region test_transform_zip_invalid_path [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestMigrationEngine]
|
||||
# @PURPOSE: Verify transform_zip returns False when source archive path does not exist.
|
||||
|
||||
34
backend/tests/models/test_dashboard_release_model.py
Normal file
34
backend/tests/models/test_dashboard_release_model.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# #region Test.DashboardReleaseModel [C:3] [TYPE Module] [SEMANTICS test,git,release,model]
|
||||
# @BRIEF Verify immutable dashboard release ledger fields.
|
||||
# @RELATION BINDS_TO -> [DashboardRelease]
|
||||
# @TEST_CONTRACT: Valid release input -> immutable repository-scoped release record.
|
||||
# @TEST_EDGE: duplicate_version -> database unique constraint owns rejection.
|
||||
# @TEST_EDGE: missing_notes -> API schema rejects before persistence.
|
||||
# @TEST_EDGE: changed_preprod -> release route marks the active release superseded.
|
||||
# @TEST_INVARIANT: SourceHashesImmutable -> VERIFIED_BY: stores_pinned_candidate_hashes.
|
||||
|
||||
from src.models.dashboard_release import DashboardRelease
|
||||
|
||||
|
||||
class TestDashboardRelease:
|
||||
# #region test_stores_pinned_candidate_hashes [C:2] [TYPE Function]
|
||||
# @BRIEF A release retains the exact commit and semantic content hash selected in PREPROD.
|
||||
def test_stores_pinned_candidate_hashes(self):
|
||||
release = DashboardRelease(
|
||||
repository_id="repo-1",
|
||||
deployment_id=17,
|
||||
name="July dashboard",
|
||||
version="2026.07.16",
|
||||
notes="Новый фильтр региона",
|
||||
commit_hash="a" * 40,
|
||||
content_hash="b" * 64,
|
||||
status="awaiting_approval",
|
||||
created_by="analyst",
|
||||
)
|
||||
assert release.commit_hash == "a" * 40
|
||||
assert release.content_hash == "b" * 64
|
||||
assert release.status == "awaiting_approval"
|
||||
# #endregion test_stores_pinned_candidate_hashes
|
||||
|
||||
|
||||
# #endregion Test.DashboardReleaseModel
|
||||
795
backend/tests/plugins/test_migration_plugin_password.py
Normal file
795
backend/tests/plugins/test_migration_plugin_password.py
Normal file
@@ -0,0 +1,795 @@
|
||||
#region Test.MigrationPlugin.Password [C:3] [TYPE Module] [SEMANTICS test,migration,password,await_input,regression]
|
||||
# @BRIEF Regression & integration tests for password injection flow during dashboard migration.
|
||||
# @RELATION BINDS_TO -> [MigrationPlugin]
|
||||
# @TEST_EDGE: await_input_no_add_log_callback -> Regression: await_input called without add_log_callback kwarg
|
||||
# @TEST_EDGE: password_injection_full_flow -> await_input payload verified, wait_for_input called, retry with passwords
|
||||
# @TEST_EDGE: password_error_unknown_pattern -> Falls back to db_name="unknown" when no regex matches
|
||||
# @TEST_EDGE: multiple_dashboards_partial_password -> Some dashboards succeed, some fail on password
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.plugins.migration import MigrationPlugin
|
||||
|
||||
|
||||
# ── Helpers (mirror from test_migration_plugin.py) ──
|
||||
|
||||
|
||||
def _make_env(id_val="env-1", name="Source Env"):
|
||||
env = MagicMock()
|
||||
env.id = id_val
|
||||
env.name = name
|
||||
env.url = "https://superset.example.com"
|
||||
env.username = "admin"
|
||||
env.password = "secret"
|
||||
return env
|
||||
|
||||
|
||||
def _make_dashboard(dash_id=1, title="Test Dash"):
|
||||
return {"id": dash_id, "slug": f"slug-{dash_id}", "dashboard_title": title}
|
||||
|
||||
|
||||
def _make_mock_superset_client():
|
||||
client = MagicMock()
|
||||
client.aclose = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
def _make_mock_mapping_service():
|
||||
svc = MagicMock()
|
||||
svc.sync_environment = AsyncMock()
|
||||
return svc
|
||||
|
||||
|
||||
def _make_mock_ctf(path="/tmp/test.zip"):
|
||||
mock_ctf = MagicMock()
|
||||
mock_ctf.return_value.__enter__ = MagicMock(return_value=path)
|
||||
return mock_ctf
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# CORE REGRESSION: await_input called without add_log_callback
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPasswordInjectionRegression:
|
||||
"""Verify the await_input call signature — the bug from production logs."""
|
||||
|
||||
# #region test_await_input_no_add_log_callback [C:2] [TYPE Function]
|
||||
# @BRIEF Regression: tm.await_input() must NOT receive add_log_callback kwarg.
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_no_add_log_callback(self):
|
||||
"""
|
||||
Production bug (2026-07-16): migration plugin passed add_log_callback=add_log
|
||||
to tm.await_input(), which raised TypeError because manager.await_input()
|
||||
only accepts (task_id, input_request). Manager handles add_log_callback
|
||||
internally via self._add_log.
|
||||
"""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"ClickHouse": "s3cret"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError(
|
||||
"Must provide a password for the database databases/Dev_Clickhouse_Node_1.yaml"
|
||||
),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
result = await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-regress-1",
|
||||
})
|
||||
|
||||
assert result["status"] == "SUCCESS"
|
||||
|
||||
# KEY ASSERTION: await_input was called WITHOUT add_log_callback
|
||||
mock_task_manager.await_input.assert_called_once_with(
|
||||
"task-regress-1",
|
||||
{
|
||||
"type": "database_password",
|
||||
"databases": ["Dev_Clickhouse_Node_1"],
|
||||
"error_message": "A database password is required to continue this migration.",
|
||||
},
|
||||
)
|
||||
# #endregion test_await_input_no_add_log_callback
|
||||
|
||||
# #region test_await_input_called_without_extra_kwargs [C:2] [TYPE Function]
|
||||
# @BRIEF Regression: await_input call must not include any kwargs beyond task_id + input_request.
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_called_without_extra_kwargs(self):
|
||||
"""Explicitly verify no extra keyword arguments leak into await_input call."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"PG": "pwd"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'PostgreSQL'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-extra-kwargs-1",
|
||||
})
|
||||
|
||||
# Verify call signature: exactly 2 positional args, no extra kwargs
|
||||
call_args = mock_task_manager.await_input.call_args
|
||||
assert len(call_args[0]) == 2 # task_id, input_request (positional)
|
||||
assert call_args[1] == {} # no keyword arguments
|
||||
assert call_args[0][0] == "task-extra-kwargs-1"
|
||||
assert call_args[0][1]["type"] == "database_password"
|
||||
# #endregion test_await_input_called_without_extra_kwargs
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# FULL FLOW: password injection end-to-end verification
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPasswordInjectionFullFlow:
|
||||
"""Verify the complete password injection lifecycle."""
|
||||
|
||||
# #region test_full_flow_wait_for_input_called [C:2] [TYPE Function]
|
||||
# @BRIEF After await_input, wait_for_input is called before reading passwords.
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_flow_wait_for_input_called(self):
|
||||
"""Verify wait_for_input is called after await_input, before get_task."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
call_order = []
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"ClickHouse": "secret"}}
|
||||
)
|
||||
|
||||
async def _await_input(task_id, input_request):
|
||||
call_order.append("await_input")
|
||||
|
||||
async def _wait_for_input(task_id):
|
||||
call_order.append("wait_for_input")
|
||||
|
||||
mock_task_manager.await_input = _await_input
|
||||
mock_task_manager.wait_for_input = _wait_for_input
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'ClickHouse'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
result = await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-order-1",
|
||||
})
|
||||
|
||||
assert result["status"] == "SUCCESS"
|
||||
# Call order must be: await_input → wait_for_input → get_task
|
||||
assert call_order == ["await_input", "wait_for_input"]
|
||||
# #endregion test_full_flow_wait_for_input_called
|
||||
|
||||
# #region test_full_flow_import_retry_with_passwords [C:2] [TYPE Function]
|
||||
# @BRIEF After password injection, import is retried with passwords parameter.
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_flow_import_retry_with_passwords(self):
|
||||
"""Verify the retry import_dashboard call includes the passwords kwarg."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"Dev_Clickhouse": "p@ssw0rd"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'Dev_Clickhouse'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-retry-1",
|
||||
})
|
||||
|
||||
# Verify import_dashboard was called twice (fail + retry)
|
||||
assert mock_tgt_client.import_dashboard.call_count == 2
|
||||
|
||||
# Second call (retry) must include passwords kwarg
|
||||
retry_kwargs = mock_tgt_client.import_dashboard.call_args_list[1][1]
|
||||
assert "passwords" in retry_kwargs
|
||||
assert retry_kwargs["passwords"] == {"Dev_Clickhouse": "p@ssw0rd"}
|
||||
# #endregion test_full_flow_import_retry_with_passwords
|
||||
|
||||
# #region test_full_flow_passwords_cleaned_after_retry [C:2] [TYPE Function]
|
||||
# @BRIEF Password params must be deleted from task after successful retry.
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_flow_passwords_cleaned_after_retry(self):
|
||||
"""Verify passwords are popped from task.params after retry (security)."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
task_mock_params = {"passwords": {"PG": "secret123"}}
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params=task_mock_params
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'PG'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-clean-1",
|
||||
})
|
||||
|
||||
# After retry, passwords must be removed from task params
|
||||
assert "passwords" not in task_mock_params
|
||||
# #endregion test_full_flow_passwords_cleaned_after_retry
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EDGE CASES: unknown db_name patterns, multi-dashboard
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPasswordInjectionEdgeCases:
|
||||
"""Cover edge cases for the password error handling."""
|
||||
|
||||
# #region test_password_error_unknown_pattern [C:2] [TYPE Function]
|
||||
# @BRIEF Edge: password error message with format not matching known regex → db_name="unknown".
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_error_unknown_pattern(self):
|
||||
"""When the error message doesn't match databases/*.yaml or 'db_name', fallback to 'unknown'."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {}} # empty passwords → retry fails → partial
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Broken Import")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
# Error message with no recognizable db name pattern at all
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-unknown-1",
|
||||
})
|
||||
|
||||
# Fallback to "unknown" in the input_request
|
||||
mock_task_manager.await_input.assert_called_once_with(
|
||||
"task-unknown-1",
|
||||
{
|
||||
"type": "database_password",
|
||||
"databases": ["unknown"],
|
||||
"error_message": "A database password is required to continue this migration.",
|
||||
},
|
||||
)
|
||||
# #endregion test_password_error_unknown_pattern
|
||||
|
||||
# #region test_partial_password_multiple_dashboards [C:2] [TYPE Function]
|
||||
# @BRIEF Edge: 3 dashboards — 1st succeeds, 2nd password error → recovered, 3rd succeeds.
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_password_multiple_dashboards(self):
|
||||
"""Multi-dashboard migration where one hits a password error and recovers."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"ClickHouse": "p@ss"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
dashboards = [
|
||||
_make_dashboard(1, "Dash A"),
|
||||
_make_dashboard(2, "Dash B (needs password)"),
|
||||
_make_dashboard(3, "Dash C"),
|
||||
]
|
||||
mock_src_client.get_dashboards = AsyncMock(return_value=(True, dashboards))
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
# Dash 1 → success, Dash 2 → password error → retry success, Dash 3 → success
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
None, # dash 1: success
|
||||
RuntimeError(
|
||||
"Must provide a password for the database databases/ClickHouse.yaml"
|
||||
), # dash 2: fail
|
||||
None, # dash 2: retry success
|
||||
None, # dash 3: success
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
result = await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1, 2, 3],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-multi-1",
|
||||
})
|
||||
|
||||
# All 3 dashboards should succeed (2nd via password injection)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert len(result["migrated_dashboards"]) == 3
|
||||
assert len(result["failed_dashboards"]) == 0
|
||||
|
||||
# await_input should be called exactly once (for dash 2)
|
||||
assert mock_task_manager.await_input.call_count == 1
|
||||
assert mock_task_manager.wait_for_input.call_count == 1
|
||||
|
||||
# import_dashboard called 4 times (dash1, dash2-fail, dash2-retry, dash3)
|
||||
assert mock_tgt_client.import_dashboard.call_count == 4
|
||||
# #endregion test_partial_password_multiple_dashboards
|
||||
|
||||
# #region test_password_error_yaml_with_dots_in_name [C:2] [TYPE Function]
|
||||
# @BRIEF Edge: YAML filename with dots (e.g. Dev_Clickhouse_Node_1.yaml) → correct extraction.
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_error_yaml_with_dots_in_name(self):
|
||||
"""Path like databases/Dev_Clickhouse_Node_1.yaml with dots → extract correctly."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"Dev_Clickhouse_Node_1": "pwd123"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError(
|
||||
"Must provide a password for the database databases/Dev_Clickhouse_Node_1.yaml"
|
||||
),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-dots-1",
|
||||
})
|
||||
|
||||
# Regex r"databases/([^.]+)\.yaml" extracts up to first dot: "Dev_Clickhouse_Node_1"
|
||||
mock_task_manager.await_input.assert_called_once_with(
|
||||
"task-dots-1",
|
||||
{
|
||||
"type": "database_password",
|
||||
"databases": ["Dev_Clickhouse_Node_1"],
|
||||
"error_message": "A database password is required to continue this migration.",
|
||||
},
|
||||
)
|
||||
# #endregion test_password_error_yaml_with_dots_in_name
|
||||
|
||||
# #region test_password_error_no_task_id_skips_await [C:2] [TYPE Function]
|
||||
# @BRIEF Edge: password error without task_id — await_input not called, dashboard marked failed.
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_error_no_task_id_skips_await(self):
|
||||
"""When _task_id is not provided, skip await_input and mark dashboard as failed."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError(
|
||||
"Must provide a password for the database databases/ClickHouse.yaml"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
result = await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
# No _task_id — should skip await_input
|
||||
})
|
||||
|
||||
# await_input must NOT be called without task_id
|
||||
mock_task_manager.await_input.assert_not_called()
|
||||
|
||||
# Dashboard must be marked as failed
|
||||
assert len(result["failed_dashboards"]) == 1
|
||||
assert result["failed_dashboards"][0]["title"] == "Dash"
|
||||
# #endregion test_password_error_no_task_id_skips_await
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# INTEGRATION-STYLE: cross-filter + password injection interplay
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPasswordInjectionIntegration:
|
||||
"""Integration-style tests combining password injection with other migration features."""
|
||||
|
||||
# #region test_fix_cross_filters_passed_to_transform_zip [C:2] [TYPE Function]
|
||||
# @BRIEF Integration: fix_cross_filters param flows through to transform_zip even during password errors.
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_cross_filters_passed_to_transform_zip(self):
|
||||
"""Verify fix_cross_filters=True reaches transform_zip during password injection flow."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"PG": "s3cret"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'PG'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
await plugin.execute({
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"fix_cross_filters": True,
|
||||
"_task_id": "task-xfilt-1",
|
||||
})
|
||||
|
||||
# transform_zip must be called with fix_cross_filters=True
|
||||
assert mock_engine.transform_zip.call_count == 1
|
||||
transform_kwargs = mock_engine.transform_zip.call_args[1]
|
||||
assert transform_kwargs["fix_cross_filters"] is True
|
||||
# #endregion test_fix_cross_filters_passed_to_transform_zip
|
||||
|
||||
# #region test_password_injection_with_task_context [C:2] [TYPE Function]
|
||||
# @BRIEF Integration: password injection flow works correctly with TaskContext logger.
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_injection_with_task_context(self):
|
||||
"""Ensure password injection works when TaskContext is provided (production path)."""
|
||||
plugin = MigrationPlugin()
|
||||
src_env = _make_env("env-1", "Source")
|
||||
tgt_env = _make_env("env-2", "Target")
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.get_environments.return_value = [src_env, tgt_env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_task.return_value = MagicMock(
|
||||
params={"passwords": {"PG": "pwd"}}
|
||||
)
|
||||
mock_task_manager.await_input = AsyncMock()
|
||||
mock_task_manager.wait_for_input = AsyncMock()
|
||||
|
||||
mock_src_client = _make_mock_superset_client()
|
||||
mock_src_client.get_dashboards = AsyncMock(
|
||||
return_value=(True, [_make_dashboard(1, "Dash")])
|
||||
)
|
||||
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
|
||||
mock_tgt_client = _make_mock_superset_client()
|
||||
mock_tgt_client.import_dashboard = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Must provide a password for the database 'PG'"),
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.transform_zip.return_value = True
|
||||
|
||||
# Create a TaskContext with a mock logger
|
||||
ctx = MagicMock()
|
||||
ctx.logger = MagicMock()
|
||||
ctx.logger.with_source.return_value = MagicMock()
|
||||
|
||||
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
|
||||
patch("src.plugins.migration.SupersetClient") as MockSC, \
|
||||
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
|
||||
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
|
||||
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
|
||||
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
|
||||
patch("src.plugins.migration.SessionLocal"):
|
||||
|
||||
MockSC.side_effect = [mock_src_client, mock_tgt_client]
|
||||
|
||||
result = await plugin.execute(
|
||||
{
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1],
|
||||
"replace_db_config": False,
|
||||
"_task_id": "task-ctx-1",
|
||||
},
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert len(result["migrated_dashboards"]) == 1
|
||||
# #endregion test_password_injection_with_task_context
|
||||
|
||||
# #endregion Test.MigrationPlugin.Password
|
||||
@@ -568,6 +568,27 @@ class TestTaskManagerInput:
|
||||
finally:
|
||||
_cleanup_manager(mgr)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immediate_password_resume_is_not_lost_before_wait(self):
|
||||
"""@TEST_EDGE Resume arriving immediately after the input prompt must unblock migration."""
|
||||
mgr, _, _, _ = _make_manager()
|
||||
try:
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
|
||||
task = Task(plugin_id="p1", params={})
|
||||
task.status = TaskStatus.RUNNING
|
||||
mgr.tasks[task.id] = task
|
||||
mgr._add_log = AsyncMock()
|
||||
|
||||
await mgr.await_input(task.id, {"type": "database_password"})
|
||||
await mgr.resume_task_with_password(task.id, {"db1": "secret"})
|
||||
|
||||
await asyncio.wait_for(mgr.wait_for_input(task.id), timeout=0.1)
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
assert task.id not in mgr.graph.task_futures
|
||||
finally:
|
||||
_cleanup_manager(mgr)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_not_running_raises(self):
|
||||
mgr, _, _, _ = _make_manager()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- @UX_STATE NoVersion -> Explains that the dashboard must be synchronized before migration. -->
|
||||
<!-- @UX_STATE AwaitingPreprod -> Directs the user to deploy the current DEV content to PREPROD. -->
|
||||
<!-- @UX_STATE AwaitingValidation -> Enables PREPROD validation for the exact deployed content. -->
|
||||
<!-- @UX_STATE ReadyForProd -> Enables guarded publication of the validated PREPROD version. -->
|
||||
<!-- @UX_STATE ReadyForProd -> Directs the user to create a named release from the validated PREPROD version. -->
|
||||
<!-- @UX_STATE Current -> Confirms that users see the current validated version. -->
|
||||
<!-- @UX_FEEDBACK Primary action is unique and changes only with the semantic deployment state. -->
|
||||
<!-- @UX_RECOVERY Compare versions or refresh status when a stage is outdated. -->
|
||||
@@ -40,7 +40,7 @@
|
||||
onSync = () => {},
|
||||
onDeployPreprod = () => {},
|
||||
onValidatePreprod = () => {},
|
||||
onDeployProd = (_commitHash: string) => {},
|
||||
onOpenRelease = () => {},
|
||||
onCompare = () => {},
|
||||
} = $props();
|
||||
|
||||
@@ -70,14 +70,14 @@
|
||||
const git = $t.git || {};
|
||||
return git[`pipeline_action_${action}`] || {
|
||||
sync: 'Синхронизировать дашборд', deploy_preprod: 'Создать новый кандидат PREPROD',
|
||||
validate_preprod: 'Подтвердить проверку PREPROD', deploy_prod: 'Опубликовать в PROD', current: 'Версия опубликована',
|
||||
validate_preprod: 'Подтвердить проверку PREPROD', deploy_prod: 'Оформить релиз', current: 'Версия опубликована',
|
||||
}[action];
|
||||
});
|
||||
let actionExplanation = $derived.by(() => {
|
||||
const git = $t.git || {};
|
||||
if (action === 'deploy_prod' && preprod?.commit_hash) {
|
||||
const version = preprod.commit_hash.slice(0, 12);
|
||||
const base = (git.pipeline_message_deploy_prod || 'В PROD будет опубликована проверенная версия {version}.').replace('{version}', version);
|
||||
const base = (git.pipeline_message_deploy_prod || 'Создайте релиз из проверенной версии {version}; он зафиксирует состав перед публикацией в PROD.').replace('{version}', version);
|
||||
const publication = prodHasSameGitVersionButDifferentContent
|
||||
? `${base} ${git.pipeline_message_resolve_drift || 'Публикация перезапишет отличающееся содержимое PROD и устранит drift.'}`
|
||||
: base;
|
||||
@@ -134,7 +134,7 @@
|
||||
if (action === 'sync') onSync();
|
||||
if (action === 'deploy_preprod') onDeployPreprod();
|
||||
if (action === 'validate_preprod') onValidatePreprod();
|
||||
if (action === 'deploy_prod' && preprod?.commit_hash) onDeployProd(preprod.commit_hash);
|
||||
if (action === 'deploy_prod' && preprod?.commit_hash) onOpenRelease();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
onSync={() => model.handleSync()}
|
||||
onDeployPreprod={() => model.openDeployModal('PREPROD')}
|
||||
onValidatePreprod={() => model.validatePreprodDeployment()}
|
||||
onDeployProd={(commitHash) => model.openDeployModal('PROD', commitHash)}
|
||||
onOpenRelease={() => (model.activeTab = 'release')}
|
||||
onCompare={async () => {
|
||||
const prod = model.environmentHistories.prod?.[0];
|
||||
const dev = model.environmentHistories.dev?.[0];
|
||||
@@ -354,7 +354,21 @@
|
||||
{#if model.activeTab === 'workspace'}
|
||||
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceStatus={model.workspaceStatus} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} workspaceSummary={model.workspaceSummary} workspaceSummaryState={model.workspaceSummaryState} workspaceSummaryError={model.workspaceSummaryError} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onGenerateSummary={() => model.handleGenerateWorkspaceSummary(true)} onCommit={() => model.handleCommit()} />
|
||||
{:else if model.activeTab === 'release'}
|
||||
<GitReleasePanel currentEnvStage={model.currentEnvStage} bind:promoteFromBranch={model.promoteFromBranch} bind:promoteToBranch={model.promoteToBranch} bind:promoteMode={model.promoteMode} bind:promoteReason={model.promoteReason} preferredDeployTargetStage={model.preferredDeployTargetStage} bind:showAdvancedPromote={model.showAdvancedPromote} promoting={model.promoting} onPromote={() => model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} />
|
||||
<GitReleasePanel
|
||||
deploymentStatus={model.deploymentStatus}
|
||||
releases={model.releases}
|
||||
bind:releasePolicy={model.releasePolicy}
|
||||
releasesLoading={model.releasesLoading}
|
||||
releaseActionLoading={model.releaseActionLoading}
|
||||
bind:releaseName={model.releaseName}
|
||||
bind:releaseVersion={model.releaseVersion}
|
||||
bind:releaseNotes={model.releaseNotes}
|
||||
bind:releaseApprovalComment={model.releaseApprovalComment}
|
||||
onCreate={() => model.createRelease()}
|
||||
onApprove={() => model.approveRelease()}
|
||||
onPublish={() => model.publishRelease()}
|
||||
onSavePolicy={() => model.saveReleasePolicy()}
|
||||
/>
|
||||
{:else}
|
||||
<GitOperationsPanel isPulling={model.isPulling} isPushing={model.isPushing} workspaceStatus={model.workspaceStatus} onPull={() => model.handlePull()} onPush={() => model.handlePush()} />
|
||||
{/if}
|
||||
|
||||
@@ -1,135 +1,171 @@
|
||||
<!-- #region GitReleasePanel [C:3] [TYPE Component] [SEMANTICS git, release, promote, merge, branch] -->
|
||||
<!-- #region GitReleasePanel [C:5] [TYPE Component] [SEMANTICS git,release,approval,publish,history] -->
|
||||
<!-- @ingroup Components -->
|
||||
<!-- @BRIEF Git release panel: promote branches via MR or direct mode. -->
|
||||
<!-- @BRIEF Named dashboard release workflow, from validated PREPROD candidate through PROD publication. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [GitUtils] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
|
||||
<!-- @UX_STATE Idle -> Pipeline status and promote button. -->
|
||||
<!-- @UX_STATE Advanced -> Expandable branch/reason settings. -->
|
||||
<!-- @RELATION BINDS_TO -> [Git.ManagerModel] -->
|
||||
<!-- @UX_STATE AwaitingCandidate -> Explains that PREPROD validation is required before the release form. -->
|
||||
<!-- @UX_STATE Create -> Shows required name, version, and notes fields for the immutable release. -->
|
||||
<!-- @UX_STATE AwaitingApproval -> Shows role-gated approval action and comment field. -->
|
||||
<!-- @UX_STATE ReadyToPublish -> Shows the single guarded publication action. -->
|
||||
<!-- @UX_STATE Published -> Shows history and no repeat publication action. -->
|
||||
<!-- @UX_FEEDBACK Action buttons show loading and the parent model supplies toast/error feedback. -->
|
||||
<!-- @UX_RECOVERY User can return to the publication path to validate/redeploy PREPROD. -->
|
||||
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(authState). -->
|
||||
<!-- @UX_TEST: AwaitingApproval -> {click: approve, expected: ReadyToPublish}. -->
|
||||
<script lang="ts">
|
||||
import { Button, Input, Select } from "$lib/ui";
|
||||
import { stageBadgeClass } from "../../../services/git-utils.js";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, Input } from '$lib/ui';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { auth } from '$lib/auth/store.svelte.js';
|
||||
import { isAdminUser } from '$lib/auth/permissions';
|
||||
|
||||
type Release = {
|
||||
id: string; name: string; version: string; notes: string; commit_hash: string; content_hash: string;
|
||||
status: string; created_at: string; created_by: string; approved_at?: string | null; approved_by?: string | null;
|
||||
approval_comment?: string | null; published_at?: string | null; published_by?: string | null;
|
||||
};
|
||||
type ReleasePolicy = {
|
||||
require_prod_approval: boolean; approval_roles: string[]; require_approval_comment: boolean;
|
||||
approval_expires_hours: number; block_publish_on_drift: boolean; is_override: boolean;
|
||||
};
|
||||
type DeploymentStatus = { environments: Array<{ stage: string; commit_hash: string | null; content_hash: string | null; validation_status: string | null; drift_status?: string | null }> };
|
||||
|
||||
let {
|
||||
promoteFromBranch = $bindable(),
|
||||
promoteToBranch = $bindable(),
|
||||
promoteMode = $bindable(),
|
||||
promoteReason = $bindable(),
|
||||
preferredDeployTargetStage,
|
||||
showAdvancedPromote = $bindable(),
|
||||
promoting,
|
||||
onPromote,
|
||||
/** Injected from GitManager — deploy action. */
|
||||
currentEnvStage = '',
|
||||
onDeploy = () => {},
|
||||
onOpenHistory = () => {},
|
||||
deploymentStatus = null as DeploymentStatus | null,
|
||||
releases = [] as Release[],
|
||||
releasePolicy = $bindable(null as ReleasePolicy | null),
|
||||
releasesLoading = false,
|
||||
releaseActionLoading = false,
|
||||
releaseName = $bindable(''),
|
||||
releaseVersion = $bindable(''),
|
||||
releaseNotes = $bindable(''),
|
||||
releaseApprovalComment = $bindable(''),
|
||||
onCreate = () => {},
|
||||
onApprove = () => {},
|
||||
onPublish = () => {},
|
||||
onSavePolicy = () => {},
|
||||
} = $props();
|
||||
|
||||
let hasNextPromotion = $derived(Boolean(
|
||||
preferredDeployTargetStage
|
||||
&& promoteFromBranch
|
||||
&& promoteToBranch
|
||||
&& promoteFromBranch !== promoteToBranch
|
||||
));
|
||||
let directMergeNeedsReason = $derived(promoteMode === 'direct' && !String(promoteReason || '').trim());
|
||||
let authState = $state<{ user: unknown | null }>({ user: null });
|
||||
let preprod = $derived(deploymentStatus?.environments.find((environment) => environment.stage === 'preprod') || null);
|
||||
let candidateReady = $derived(Boolean(preprod?.commit_hash && preprod.validation_status === 'validated' && preprod.drift_status !== 'drifted'));
|
||||
let activeRelease = $derived(releases.find((release) => ['awaiting_approval', 'ready_to_publish'].includes(release.status)) || null);
|
||||
let canConfigurePolicy = $derived(isAdminUser(authState.user as { roles?: Array<{ name?: string }> }));
|
||||
|
||||
onMount(() => auth.subscribe((state) => { authState = state; }));
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
awaiting_approval: $t.git?.release?.status_awaiting_approval || 'Ожидает согласования',
|
||||
ready_to_publish: $t.git?.release?.status_ready || 'Готов к публикации',
|
||||
published: $t.git?.release?.status_published || 'Опубликован',
|
||||
superseded: $t.git?.release?.status_superseded || 'Заменён новым кандидатом',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-border bg-surface-page p-4">
|
||||
<div class="mb-3 text-sm font-semibold text-text">{$t.git?.release?.pipeline_status || 'Текущий статус пайплайна'}</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class={`rounded-full border px-3 py-1 font-semibold ${stageBadgeClass('DEV')}`}>DEV (dev)</span>
|
||||
<span class="text-text-subtle">➔</span>
|
||||
<span class={`rounded-full border px-3 py-1 font-semibold ${stageBadgeClass('PREPROD')}`}>PREPROD (preprod)</span>
|
||||
<span class="text-text-subtle">➔</span>
|
||||
<span class={`rounded-full border px-3 py-1 font-semibold ${stageBadgeClass('PROD')}`}>PROD (prod)</span>
|
||||
<section class="rounded-lg border border-border bg-surface-page p-4" aria-label={$t.git?.release?.title || 'Релиз дашборда'}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-text">{$t.git?.release?.title || 'Релиз дашборда'}</h3>
|
||||
<p class="mt-1 text-sm text-text-muted">{$t.git?.release?.intro || 'Релиз фиксирует проверенную версию PREPROD и публикует именно её в PROD.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={onPromote}
|
||||
disabled={promoting || !hasNextPromotion || directMergeNeedsReason}
|
||||
isLoading={promoting}
|
||||
class={`w-full ${promoteMode === 'direct' ? 'bg-destructive hover:bg-destructive-hover focus-visible:ring-destructive-ring' : ''}`}
|
||||
>
|
||||
{!hasNextPromotion
|
||||
? ($t.git?.release?.no_next_promotion || 'No next promotion')
|
||||
: promoteMode === 'direct'
|
||||
? ($t.git?.release?.promote_direct || 'Прямой перенос {from} ➔ {to} (unsafe)').replace('{from}', promoteFromBranch).replace('{to}', promoteToBranch)
|
||||
: ($t.git?.release?.promote_mr || 'Создать Merge Request ({from} ➔ {to})').replace('{from}', promoteFromBranch).replace('{to}', promoteToBranch)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
class="text-sm text-text-muted hover:text-text"
|
||||
onclick={() => (showAdvancedPromote = !showAdvancedPromote)}
|
||||
>
|
||||
{showAdvancedPromote ? ($t.git?.release?.advanced_hide || '▴ Скрыть расширенные настройки') : ($t.git?.release?.advanced_show || '▾ Расширенные настройки')}
|
||||
</button>
|
||||
|
||||
{#if showAdvancedPromote}
|
||||
<div class="space-y-3 rounded-lg border border-border p-3">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<Input
|
||||
label={$t.git?.release?.from_branch || 'From branch'}
|
||||
bind:value={promoteFromBranch}
|
||||
placeholder="dev"
|
||||
/>
|
||||
<Input
|
||||
label={$t.git?.release?.to_branch || 'To branch'}
|
||||
bind:value={promoteToBranch}
|
||||
placeholder="preprod"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label={$t.git?.release?.promotion_mode || 'Режим переноса'}
|
||||
bind:value={promoteMode}
|
||||
options={[
|
||||
{ value: 'mr', label: $t.git?.release?.mode_mr || 'Create MR/PR (Safe)' },
|
||||
{ value: 'direct', label: $t.git?.release?.mode_direct || 'Direct merge without MR (Unsafe)' },
|
||||
]}
|
||||
/>
|
||||
{#if promoteMode === 'direct'}
|
||||
<div class="rounded-lg border border-destructive-ring bg-destructive-light p-3 text-sm text-destructive">
|
||||
<div class="font-semibold">{$t.git?.release?.direct_warning_title || 'Внимание: прямой перенос без MR'}</div>
|
||||
<div class="mt-1">{$t.git?.release?.direct_warning_desc || 'Это обходит процесс аппрува и записывается в audit лог.'}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={$t.git?.release?.reason_label || 'Причина (обязательно)'}
|
||||
bind:value={promoteReason}
|
||||
placeholder={$t.git?.release?.reason_placeholder || 'Почему bypass MR?'}
|
||||
/>
|
||||
{#if directMergeNeedsReason}
|
||||
<div class="text-xs font-medium text-destructive">
|
||||
{$t.git?.release?.reason_required || 'Direct merge requires an audit reason.'}
|
||||
</div>
|
||||
{/if}
|
||||
{#if preprod?.commit_hash}
|
||||
<span class={`rounded-full border px-2.5 py-1 text-xs font-medium ${candidateReady ? 'border-success/30 bg-success-light text-success' : 'border-warning/30 bg-warning-light text-warning'}`}>
|
||||
{candidateReady ? ($t.git?.release?.preprod_validated || 'PREPROD проверен') : ($t.git?.release?.preprod_pending || 'PREPROD ожидает проверки')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if releasesLoading}
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4 text-sm text-text-muted" aria-busy="true">{$t.common?.loading || 'Загрузка…'}</div>
|
||||
{:else if activeRelease}
|
||||
<section class={`rounded-lg border p-4 ${activeRelease.status === 'ready_to_publish' ? 'border-success/30 bg-success-light' : 'border-warning/30 bg-warning-light'}`} aria-label={$t.git?.release?.active || 'Активный релиз'}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.git?.release?.active || 'Активный релиз'}</p>
|
||||
<h4 class="mt-1 font-semibold text-text">{activeRelease.name} <span class="font-mono text-sm text-text-muted">v{activeRelease.version}</span></h4>
|
||||
<p class="mt-1 text-sm text-text-muted">{activeRelease.notes}</p>
|
||||
<p class="mt-2 text-xs text-text-muted">{$t.git?.release?.pinned_version || 'Зафиксированная версия'}: <span class="font-mono">{activeRelease.commit_hash.slice(0, 12)}</span></p>
|
||||
</div>
|
||||
<span class="rounded-full border border-border bg-surface-card px-2.5 py-1 text-xs font-medium text-text">{statusLabel(activeRelease.status)}</span>
|
||||
</div>
|
||||
|
||||
{#if activeRelease.status === 'awaiting_approval'}
|
||||
<div class="mt-4 space-y-3 border-t border-warning/30 pt-4">
|
||||
<p class="text-sm text-text">{$t.git?.release?.approval_hint || 'Согласуйте релиз, прежде чем публиковать его в PROD.'}</p>
|
||||
{#if releasePolicy?.require_approval_comment}<Input label={$t.git?.release?.approval_comment || 'Комментарий к согласованию'} bind:value={releaseApprovalComment} />{/if}
|
||||
<Button onclick={onApprove} isLoading={releaseActionLoading} disabled={releaseActionLoading}>{$t.git?.release?.approve || 'Согласовать релиз'}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 border-t border-success/30 pt-4">
|
||||
<p class="mb-3 text-sm text-text">{$t.git?.release?.publish_hint || 'В PROD будет опубликован ровно этот проверенный состав релиза.'}</p>
|
||||
<Button onclick={onPublish} isLoading={releaseActionLoading} disabled={releaseActionLoading}>{$t.git?.release?.publish || 'Опубликовать релиз в PROD'}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if candidateReady}
|
||||
<section class="rounded-lg border border-primary/30 bg-primary-light p-4">
|
||||
<h4 class="font-semibold text-text">{$t.git?.release?.create_title || 'Создать релиз из проверенного PREPROD'}</h4>
|
||||
<p class="mt-1 text-sm text-text-muted">{$t.git?.release?.create_hint || 'Версия и описание станут частью неизменяемой истории выпуска.'}</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<Input label={$t.git?.release?.name || 'Название релиза'} bind:value={releaseName} />
|
||||
<Input label={$t.git?.release?.version || 'Версия'} bind:value={releaseVersion} placeholder="2026.07.16" />
|
||||
</div>
|
||||
<label class="mt-3 block text-sm font-medium text-text">
|
||||
{$t.git?.release?.notes || 'Описание изменений'}
|
||||
<textarea bind:value={releaseNotes} class="mt-1 min-h-28 w-full rounded-lg border border-border-strong bg-surface-card p-2.5 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring" required></textarea>
|
||||
</label>
|
||||
<Button class="mt-4" onclick={onCreate} isLoading={releaseActionLoading} disabled={releaseActionLoading || !releaseName.trim() || !releaseVersion.trim() || !releaseNotes.trim()}>{$t.git?.release?.create || 'Создать релиз'}</Button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="rounded-lg border border-warning/30 bg-warning-light p-4 text-sm text-text">
|
||||
<h4 class="font-semibold">{$t.git?.release?.candidate_required || 'Сначала подготовьте проверенный кандидат'}</h4>
|
||||
<p class="mt-1 text-text-muted">{$t.git?.release?.candidate_required_hint || 'Разверните версию в PREPROD и подтвердите её проверку в пути публикации выше.'}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Deploy section — final pipeline step -->
|
||||
<hr class="border-border">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-text">{$t.git?.deployment || 'Deployment'}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => onDeploy()}
|
||||
class={currentEnvStage === 'PROD' ? 'bg-destructive hover:bg-destructive-hover' : 'bg-success hover:bg-success'}
|
||||
>
|
||||
🚀 {$t.git?.deploy || 'Deploy to Environment'}
|
||||
</Button>
|
||||
<details class="rounded-lg border border-border bg-surface-card" open={releases.length > 0}>
|
||||
<summary class="cursor-pointer px-4 py-3 text-sm font-semibold text-text">{$t.git?.release?.history || 'История релизов'} ({releases.length})</summary>
|
||||
{#if releases.length === 0}
|
||||
<p class="border-t border-border px-4 py-3 text-sm text-text-muted">{$t.git?.release?.history_empty || 'Релизов пока нет.'}</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-border border-t border-border">
|
||||
{#each releases as release (release.id)}
|
||||
<details class="px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm text-text"><span class="font-semibold">{release.name}</span> <span class="font-mono text-text-muted">v{release.version}</span> <span class="ml-2 text-xs text-text-muted">{statusLabel(release.status)}</span></summary>
|
||||
<div class="mt-3 space-y-1 text-xs text-text-muted">
|
||||
<p>{release.notes}</p><p>{$t.git?.release?.created || 'Создан'}: {release.created_by} · {formatDate(release.created_at)}</p>
|
||||
<p>{$t.git?.release?.pinned_version || 'Зафиксированная версия'}: <span class="font-mono">{release.commit_hash}</span></p>
|
||||
{#if release.approved_by}<p>{$t.git?.release?.approved || 'Согласован'}: {release.approved_by} · {formatDate(release.approved_at)}</p>{/if}
|
||||
{#if release.published_by}<p>{$t.git?.release?.published || 'Опубликован'}: {release.published_by} · {formatDate(release.published_at)}</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
|
||||
{#if currentEnvStage === 'PROD'}
|
||||
<div class="rounded-lg border border-warning-ring bg-warning-light p-3 text-sm text-warning">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>{$t.git?.prod_rollback_hint || 'PROD rollback is available from commit history.'}</span>
|
||||
<Button variant="ghost" size="sm" onclick={() => onOpenHistory()} class="border border-warning-ring bg-surface-card text-warning">
|
||||
{$t.git?.open_history_for_rollback || 'Open history'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if canConfigurePolicy && releasePolicy}
|
||||
<details class="rounded-lg border border-border bg-surface-page p-4">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-text">{$t.git?.release?.policy || 'Политика релизов дашборда'}</summary>
|
||||
<div class="mt-3 grid gap-3 text-sm text-text md:grid-cols-2">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" bind:checked={releasePolicy.require_prod_approval} /> {$t.git?.release?.require_approval || 'Требовать согласование перед PROD'}</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" bind:checked={releasePolicy.require_approval_comment} /> {$t.git?.release?.require_comment || 'Требовать комментарий'}</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" bind:checked={releasePolicy.block_publish_on_drift} /> {$t.git?.release?.block_drift || 'Блокировать публикацию при drift'}</label>
|
||||
<Input label={$t.git?.release?.approval_roles || 'Роли согласования'} value={releasePolicy.approval_roles.join(', ')} onchange={(event) => releasePolicy.approval_roles = event.currentTarget.value.split(',').map((role) => role.trim()).filter(Boolean)} />
|
||||
</div>
|
||||
<Button class="mt-4" size="sm" onclick={onSavePolicy} isLoading={releaseActionLoading}>{$t.common?.save || 'Сохранить'}</Button>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion GitReleasePanel -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @BRIEF Verify BI-facing publication guidance for the DEV → PREPROD → PROD flow.
|
||||
// @RELATION BINDS_TO -> [GitDeploymentPipeline]
|
||||
// @TEST_CONTRACT: DeploymentStatus + workspace state -> one unambiguous next action.
|
||||
// @TEST_SCENARIO: validated_candidate_with_edits -> publish action identifies the immutable candidate and excludes edits.
|
||||
// @TEST_SCENARIO: validated_candidate_with_edits -> release action identifies the immutable candidate and excludes edits.
|
||||
// @TEST_SCENARIO: pending_validation -> validation is the only primary action.
|
||||
// @TEST_SCENARIO: current_version -> publication action is disabled.
|
||||
// @TEST_EDGE: missing_preprod -> directs user to PREPROD, never PROD.
|
||||
@@ -33,25 +33,25 @@ const validatedCandidate = {
|
||||
};
|
||||
|
||||
describe('GitDeploymentPipeline', () => {
|
||||
it('explains that publishing uses the validated candidate and excludes unsaved edits', () => {
|
||||
it('directs a validated candidate to named release creation and excludes unsaved edits', () => {
|
||||
render(GitDeploymentPipeline, {
|
||||
deploymentStatus: validatedCandidate,
|
||||
hasWorkspaceChanges: true,
|
||||
changedFilesCount: 4,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Опубликовать в PROD' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Оформить релиз' })).toBeTruthy();
|
||||
expect(screen.getAllByText(/candidate-12/).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText(/несохранённые изменения \(4 файлов\) в публикацию не войдут/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deploys exactly the validated PREPROD commit when user publishes', async () => {
|
||||
const onDeployProd = vi.fn();
|
||||
render(GitDeploymentPipeline, { deploymentStatus: validatedCandidate, onDeployProd });
|
||||
it('opens the release flow instead of deploying directly to PROD', async () => {
|
||||
const onOpenRelease = vi.fn();
|
||||
render(GitDeploymentPipeline, { deploymentStatus: validatedCandidate, onOpenRelease });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Опубликовать в PROD' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Оформить релиз' }));
|
||||
|
||||
expect(onDeployProd).toHaveBeenCalledWith('candidate-123456789');
|
||||
expect(onOpenRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('requires validation before publication for a pending candidate', () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// #region Test.Git.ReleasePanel [C:3] [TYPE Module] [SEMANTICS test,git,release,ui]
|
||||
// @BRIEF Verify named release UI states and their single safe primary actions.
|
||||
// @RELATION BINDS_TO -> [GitReleasePanel]
|
||||
// @TEST_CONTRACT: Validated PREPROD + release state -> correct release action.
|
||||
// @TEST_SCENARIO: validated_candidate -> required release form is shown.
|
||||
// @TEST_SCENARIO: pending_approval -> approval action replaces creation form.
|
||||
// @TEST_EDGE: unvalidated_preprod -> release creation is unavailable.
|
||||
// @TEST_EDGE: no_active_release -> no direct PROD publication action.
|
||||
// @TEST_EDGE: missing_release_metadata -> create action remains disabled.
|
||||
// @TEST_INVARIANT: UniqueReleaseAction -> VERIFIED_BY: validated_candidate, pending_approval.
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import GitReleasePanel from '../GitReleasePanel.svelte';
|
||||
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
t: { subscribe(run: (value: { git: Record<string, unknown>; common: Record<string, unknown> }) => void) { run({ git: {}, common: {} }); return () => {}; } },
|
||||
}));
|
||||
|
||||
const validatedDeployment = {
|
||||
environments: [{ stage: 'preprod', commit_hash: 'a'.repeat(40), content_hash: 'b'.repeat(64), validation_status: 'validated' }],
|
||||
};
|
||||
|
||||
describe('GitReleasePanel', () => {
|
||||
it('requires release metadata before creating a release from validated PREPROD', async () => {
|
||||
const onCreate = vi.fn();
|
||||
render(GitReleasePanel, { deploymentStatus: validatedDeployment, onCreate });
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Создать релиз' });
|
||||
expect(button).toHaveProperty('disabled', true);
|
||||
await fireEvent.input(screen.getByLabelText('Название релиза'), { target: { value: 'Июльский выпуск' } });
|
||||
await fireEvent.input(screen.getByLabelText('Версия'), { target: { value: '2026.07.16' } });
|
||||
await fireEvent.input(screen.getByLabelText('Описание изменений'), { target: { value: 'Добавлен фильтр региона' } });
|
||||
await fireEvent.click(button);
|
||||
expect(onCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows approval instead of a direct PROD deployment for an active release', () => {
|
||||
const onApprove = vi.fn();
|
||||
render(GitReleasePanel, {
|
||||
deploymentStatus: validatedDeployment,
|
||||
releases: [{
|
||||
id: 'rel-1', name: 'Июльский выпуск', version: '2026.07.16', notes: 'Добавлен фильтр региона',
|
||||
commit_hash: 'a'.repeat(40), content_hash: 'b'.repeat(64), status: 'awaiting_approval',
|
||||
created_at: '2026-07-16T08:00:00Z', created_by: 'analyst',
|
||||
}],
|
||||
onApprove,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Согласовать релиз' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Опубликовать в PROD' })).toBeNull();
|
||||
});
|
||||
});
|
||||
// #endregion Test.Git.ReleasePanel
|
||||
@@ -25,7 +25,7 @@
|
||||
show?: boolean;
|
||||
databases?: string[];
|
||||
errorMessage?: string;
|
||||
onresume?: (_payload: { passwords: Record<string, string> }) => void;
|
||||
onresume?: (_payload: { passwords: Record<string, string> }) => void | Promise<void>;
|
||||
oncancel?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
// @ingroup Components
|
||||
// @BRIEF Validates all passwords and calls parent onresume callback.
|
||||
// @PRE All database passwords must be entered; submitting flag is false.
|
||||
// @POST Parent onresume callback receives passwords payload; submitting remains true until parent resets.
|
||||
function handleSubmit() {
|
||||
// @POST Parent onresume callback receives passwords payload; submitting is reset when the callback finishes or the modal closes.
|
||||
async function handleSubmit() {
|
||||
if (submitting) return;
|
||||
|
||||
const missing = databases.filter((db) => !passwords[db]);
|
||||
@@ -47,7 +47,12 @@
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
onresume({ passwords });
|
||||
try {
|
||||
await onresume({ passwords });
|
||||
} finally {
|
||||
// A failed resume keeps the modal open, so it must be retryable.
|
||||
if (show) submitting = false;
|
||||
}
|
||||
}
|
||||
// #endregion handleSubmit
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// #region Test.PasswordPrompt [C:2] [TYPE Module] [SEMANTICS test,migration,password,resume]
|
||||
// @RELATION BINDS_TO -> [PasswordPrompt]
|
||||
// @TEST_INVARIANT PasswordPrompt passes its callback a payload, never a DOM CustomEvent.
|
||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PasswordPrompt from "../PasswordPrompt.svelte";
|
||||
|
||||
describe("PasswordPrompt", () => {
|
||||
it("submits the entered password as the callback payload", async () => {
|
||||
const onresume = vi.fn().mockResolvedValue(undefined);
|
||||
render(PasswordPrompt, {
|
||||
show: true,
|
||||
databases: ["Dev_Clickhouse_Node_1"],
|
||||
onresume,
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/password for dev_clickhouse_node_1/i), {
|
||||
target: { value: "secret" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /resume migration/i }));
|
||||
|
||||
expect(onresume).toHaveBeenCalledWith({
|
||||
passwords: { Dev_Clickhouse_Node_1: "secret" },
|
||||
});
|
||||
expect((screen.getByRole("button", { name: /resume migration/i }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
// #endregion Test.PasswordPrompt
|
||||
@@ -234,12 +234,12 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
|
||||
// @ingroup Tasks
|
||||
/**
|
||||
* @purpose Submits passwords and resumes paused migration task.
|
||||
* @pre event.detail contains passwords object.
|
||||
* @pre payload contains passwords object.
|
||||
* @post Task resumed with passwords, connection status restored to connected.
|
||||
*/
|
||||
async function handlePasswordResume(event) {
|
||||
async function handlePasswordResume({ passwords }: { passwords: Record<string, string> }) {
|
||||
const task = selectedTask.current;
|
||||
const { passwords } = event.detail;
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
await api.postApi(`/tasks/${task.id}/resume`, { passwords });
|
||||
|
||||
@@ -236,7 +236,40 @@
|
||||
"direct_warning_desc": "This bypasses the approval process and is recorded in the audit log.",
|
||||
"reason_label": "Reason (required)",
|
||||
"reason_placeholder": "Why bypass MR?",
|
||||
"reason_required": "Direct merge requires an audit reason."
|
||||
"reason_required": "Direct merge requires an audit reason.",
|
||||
"title": "Dashboard release",
|
||||
"intro": "A release pins the tested PREPROD version and publishes exactly it to PROD.",
|
||||
"preprod_validated": "PREPROD validated",
|
||||
"preprod_pending": "PREPROD validation pending",
|
||||
"active": "Active release",
|
||||
"pinned_version": "Pinned version",
|
||||
"status_awaiting_approval": "Awaiting approval",
|
||||
"status_ready": "Ready to publish",
|
||||
"status_published": "Published",
|
||||
"status_superseded": "Superseded by a new candidate",
|
||||
"approval_hint": "Approve the release before publishing it to PROD.",
|
||||
"approval_comment": "Approval comment",
|
||||
"approve": "Approve release",
|
||||
"publish_hint": "PROD will receive exactly this tested release content.",
|
||||
"publish": "Publish release to PROD",
|
||||
"create_title": "Create a release from validated PREPROD",
|
||||
"create_hint": "The version and notes become part of the immutable release history.",
|
||||
"name": "Release name",
|
||||
"version": "Version",
|
||||
"notes": "Release notes",
|
||||
"create": "Create release",
|
||||
"candidate_required": "Prepare a validated candidate first",
|
||||
"candidate_required_hint": "Deploy a version to PREPROD and validate it in the publication path above.",
|
||||
"history": "Release history",
|
||||
"history_empty": "No releases yet.",
|
||||
"created": "Created",
|
||||
"approved": "Approved",
|
||||
"published": "Published",
|
||||
"policy": "Dashboard release policy",
|
||||
"require_approval": "Require approval before PROD",
|
||||
"require_comment": "Require a comment",
|
||||
"block_drift": "Block publication on drift",
|
||||
"approval_roles": "Approval roles"
|
||||
},
|
||||
"diff_loading": "Loading changes...",
|
||||
"diff_show_more": "Show {count} more files",
|
||||
|
||||
@@ -236,7 +236,40 @@
|
||||
"direct_warning_desc": "Это обходит процесс аппрува и записывается в audit лог.",
|
||||
"reason_label": "Причина (обязательно)",
|
||||
"reason_placeholder": "Почему bypass MR?",
|
||||
"reason_required": "Для direct merge нужна причина для audit trail."
|
||||
"reason_required": "Для direct merge нужна причина для audit trail.",
|
||||
"title": "Релиз дашборда",
|
||||
"intro": "Релиз фиксирует проверенную версию PREPROD и публикует именно её в PROD.",
|
||||
"preprod_validated": "PREPROD проверен",
|
||||
"preprod_pending": "PREPROD ожидает проверки",
|
||||
"active": "Активный релиз",
|
||||
"pinned_version": "Зафиксированная версия",
|
||||
"status_awaiting_approval": "Ожидает согласования",
|
||||
"status_ready": "Готов к публикации",
|
||||
"status_published": "Опубликован",
|
||||
"status_superseded": "Заменён новым кандидатом",
|
||||
"approval_hint": "Согласуйте релиз, прежде чем публиковать его в PROD.",
|
||||
"approval_comment": "Комментарий к согласованию",
|
||||
"approve": "Согласовать релиз",
|
||||
"publish_hint": "В PROD будет опубликован ровно этот проверенный состав релиза.",
|
||||
"publish": "Опубликовать релиз в PROD",
|
||||
"create_title": "Создать релиз из проверенного PREPROD",
|
||||
"create_hint": "Версия и описание станут частью неизменяемой истории выпуска.",
|
||||
"name": "Название релиза",
|
||||
"version": "Версия",
|
||||
"notes": "Описание изменений",
|
||||
"create": "Создать релиз",
|
||||
"candidate_required": "Сначала подготовьте проверенный кандидат",
|
||||
"candidate_required_hint": "Разверните версию в PREPROD и подтвердите её проверку в пути публикации выше.",
|
||||
"history": "История релизов",
|
||||
"history_empty": "Релизов пока нет.",
|
||||
"created": "Создан",
|
||||
"approved": "Согласован",
|
||||
"published": "Опубликован",
|
||||
"policy": "Политика релизов дашборда",
|
||||
"require_approval": "Требовать согласование перед PROD",
|
||||
"require_comment": "Требовать комментарий",
|
||||
"block_drift": "Блокировать публикацию при drift",
|
||||
"approval_roles": "Роли согласования"
|
||||
},
|
||||
"diff_loading": "Загрузка изменений...",
|
||||
"diff_show_more": "Показать ещё ({count} файлов)",
|
||||
|
||||
@@ -197,6 +197,32 @@ interface DeploymentStatus {
|
||||
current_content_hash: string | null;
|
||||
}
|
||||
|
||||
interface DashboardRelease {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
notes: string;
|
||||
commit_hash: string;
|
||||
content_hash: string;
|
||||
status: 'awaiting_approval' | 'ready_to_publish' | 'published' | 'superseded' | string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
approved_at?: string | null;
|
||||
approved_by?: string | null;
|
||||
approval_comment?: string | null;
|
||||
published_at?: string | null;
|
||||
published_by?: string | null;
|
||||
}
|
||||
|
||||
interface ReleasePolicy {
|
||||
require_prod_approval: boolean;
|
||||
approval_roles: string[];
|
||||
require_approval_comment: boolean;
|
||||
approval_expires_hours: number;
|
||||
block_publish_on_drift: boolean;
|
||||
is_override: boolean;
|
||||
}
|
||||
|
||||
interface EnvironmentItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
@@ -341,6 +367,16 @@ export class GitManagerModel {
|
||||
selectedVersionA: string | null = $state(null);
|
||||
selectedVersionB: string | null = $state(null);
|
||||
|
||||
// ── Named Releases ──────────────────────────────────────────
|
||||
releases: DashboardRelease[] = $state([]);
|
||||
releasePolicy: ReleasePolicy | null = $state(null);
|
||||
releasesLoading: boolean = $state(false);
|
||||
releaseActionLoading: boolean = $state(false);
|
||||
releaseName: string = $state('');
|
||||
releaseVersion: string = $state('');
|
||||
releaseNotes: string = $state('');
|
||||
releaseApprovalComment: string = $state('');
|
||||
|
||||
// ── Create Remote Repo Dialog ────────────────────────────────
|
||||
/** True when the create-remote-repo modal is open (replaces native prompt()). */
|
||||
showCreateRepoDialog: boolean = $state(false);
|
||||
@@ -401,6 +437,11 @@ export class GitManagerModel {
|
||||
return Boolean(preprod?.commit_hash && preprod.validation_status === 'validated');
|
||||
});
|
||||
|
||||
/** Latest release that still needs a business action. */
|
||||
activeRelease: DashboardRelease | null = $derived.by(() => (
|
||||
this.releases.find((release) => ['awaiting_approval', 'ready_to_publish'].includes(release.status)) || null
|
||||
));
|
||||
|
||||
/** Lower-case provider label for auto-push checkbox text. */
|
||||
pushProviderLabel: string = $derived(resolvePushProviderLabel(this.configs, this.selectedConfigId, this.repositoryProvider));
|
||||
|
||||
@@ -524,8 +565,10 @@ export class GitManagerModel {
|
||||
this.environmentHistoriesLoading = false;
|
||||
}
|
||||
|
||||
// Also fetch real deployment status (non-blocking)
|
||||
this.loadDeploymentStatus();
|
||||
// Also fetch real deployment status and release ledger (non-blocking).
|
||||
void this.loadDeploymentStatus();
|
||||
void this.loadReleases();
|
||||
void this.loadReleasePolicy();
|
||||
}
|
||||
|
||||
/** Fetch per-environment deployment status from deployment_records. */
|
||||
@@ -540,6 +583,29 @@ export class GitManagerModel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the release ledger shown in the dedicated Release tab. */
|
||||
async loadReleases(): Promise<void> {
|
||||
if (!this.dashboardId || !this.initialized) return;
|
||||
this.releasesLoading = true;
|
||||
try {
|
||||
this.releases = await gitService.getReleases<DashboardRelease[]>(this.dashboardId, this.resolvedEnvId);
|
||||
} catch {
|
||||
this.releases = [];
|
||||
} finally {
|
||||
this.releasesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the resolved per-dashboard release policy; global values are returned as a fallback. */
|
||||
async loadReleasePolicy(): Promise<void> {
|
||||
if (!this.dashboardId || !this.initialized) return;
|
||||
try {
|
||||
this.releasePolicy = await gitService.getReleasePolicy<ReleasePolicy>(this.dashboardId, this.resolvedEnvId);
|
||||
} catch {
|
||||
this.releasePolicy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a version (commit hash) for the details panel. Supports compare via shift. */
|
||||
selectVersion(hash: string | null, isSecondary = false): void {
|
||||
if (isSecondary) {
|
||||
@@ -569,6 +635,7 @@ export class GitManagerModel {
|
||||
this.deploymentStatus = await gitService.validatePreprodDeployment<DeploymentStatus>(
|
||||
this.dashboardId, this.resolvedEnvId,
|
||||
);
|
||||
await this.loadReleases();
|
||||
notifications.success((this._t?.git as Record<string, unknown>)?.pipeline_preprod_validated as string || 'Проверка PREPROD подтверждена');
|
||||
log('GitManagerModel.validatePreprodDeployment', 'REFLECT', 'PREPROD validation persisted', { stage: 'preprod' });
|
||||
} catch (e: unknown) {
|
||||
@@ -579,6 +646,77 @@ export class GitManagerModel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a named immutable release from the validated PREPROD deployment. */
|
||||
async createRelease(): Promise<void> {
|
||||
if (!this.releaseName.trim() || !this.releaseVersion.trim() || !this.releaseNotes.trim()) {
|
||||
notifications.warning('Заполните название, версию и описание релиза');
|
||||
return;
|
||||
}
|
||||
this.clearGitError();
|
||||
this.releaseActionLoading = true;
|
||||
try {
|
||||
await gitService.createRelease(this.dashboardId, {
|
||||
name: this.releaseName.trim(), version: this.releaseVersion.trim(), notes: this.releaseNotes.trim(),
|
||||
}, this.resolvedEnvId);
|
||||
this.releaseName = '';
|
||||
this.releaseVersion = '';
|
||||
this.releaseNotes = '';
|
||||
notifications.success('Релиз создан');
|
||||
await this.loadReleases();
|
||||
} catch (e: unknown) {
|
||||
this._setGitError(e);
|
||||
} finally {
|
||||
this.releaseActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the repository approval gate to the active release. */
|
||||
async approveRelease(): Promise<void> {
|
||||
if (!this.activeRelease) return;
|
||||
this.clearGitError();
|
||||
this.releaseActionLoading = true;
|
||||
try {
|
||||
await gitService.approveRelease(this.dashboardId, this.activeRelease.id, { comment: this.releaseApprovalComment.trim() || undefined }, this.resolvedEnvId);
|
||||
this.releaseApprovalComment = '';
|
||||
notifications.success('Релиз согласован');
|
||||
await this.loadReleases();
|
||||
} catch (e: unknown) {
|
||||
this._setGitError(e);
|
||||
} finally {
|
||||
this.releaseActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the exact commit pinned by the active approved release. */
|
||||
async publishRelease(): Promise<void> {
|
||||
if (!this.activeRelease) return;
|
||||
this.clearGitError();
|
||||
this.releaseActionLoading = true;
|
||||
try {
|
||||
await gitService.publishRelease(this.dashboardId, this.activeRelease.id, this.resolvedEnvId);
|
||||
notifications.success('Релиз опубликован в PROD');
|
||||
await Promise.all([this.loadReleases(), this.loadDeploymentStatus()]);
|
||||
} catch (e: unknown) {
|
||||
this._setGitError(e);
|
||||
} finally {
|
||||
this.releaseActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist an administrator-edited per-dashboard release policy. */
|
||||
async saveReleasePolicy(): Promise<void> {
|
||||
if (!this.releasePolicy) return;
|
||||
this.releaseActionLoading = true;
|
||||
try {
|
||||
this.releasePolicy = await gitService.updateReleasePolicy<ReleasePolicy>(this.dashboardId, this.releasePolicy, this.resolvedEnvId);
|
||||
notifications.success('Политика релизов сохранена');
|
||||
} catch (e: unknown) {
|
||||
this._setGitError(e);
|
||||
} finally {
|
||||
this.releaseActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: fetch diff between two selected versions (or A vs current head). */
|
||||
async getSelectedVersionsDiff(): Promise<{ from: string; to?: string; diff: string } | null> {
|
||||
if (!this.selectedVersionA) return null;
|
||||
|
||||
@@ -639,7 +639,7 @@
|
||||
show={model.showPasswordPrompt}
|
||||
databases={model.passwordPromptDatabases}
|
||||
errorMessage={model.passwordPromptErrorMessage}
|
||||
onresume={(e) => model.resumeMigration(e.detail.passwords)}
|
||||
onresume={({ passwords }) => model.resumeMigration(passwords)}
|
||||
oncancel={() => model.showPasswordPrompt = false}
|
||||
/>
|
||||
<!-- #endregion MigrationModals -->
|
||||
|
||||
@@ -60,6 +60,25 @@ interface PromotePayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ReleaseCreatePayload {
|
||||
name: string;
|
||||
version: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface ReleaseApprovalPayload {
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
interface ReleasePolicyPayload {
|
||||
require_prod_approval: boolean;
|
||||
approval_roles: string[];
|
||||
require_approval_comment: boolean;
|
||||
approval_expires_hours: number;
|
||||
block_publish_on_drift: boolean;
|
||||
is_override?: boolean;
|
||||
}
|
||||
|
||||
// #region gitService [C:3] [TYPE Object]
|
||||
// @BRIEF Exported Git API client with methods for all repository and config operations.
|
||||
// @RELATION CALLS -> [requestApi]
|
||||
@@ -481,6 +500,48 @@ export const gitService = {
|
||||
},
|
||||
// #endregion validatePreprodDeployment
|
||||
|
||||
// #region getReleasePolicy [C:2] [TYPE Function] [SEMANTICS git,release,policy]
|
||||
// @BRIEF Fetch the effective repository-level dashboard release policy.
|
||||
async getReleasePolicy<T = unknown>(dashboardRef: string | number, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, '/release-policy', envId));
|
||||
},
|
||||
// #endregion getReleasePolicy
|
||||
|
||||
// #region updateReleasePolicy [C:3] [TYPE Function] [SEMANTICS git,release,policy]
|
||||
// @BRIEF Save an administrator-managed repository release policy override.
|
||||
async updateReleasePolicy<T = unknown>(dashboardRef: string | number, policy: ReleasePolicyPayload, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, '/release-policy', envId), 'PUT', policy);
|
||||
},
|
||||
// #endregion updateReleasePolicy
|
||||
|
||||
// #region getReleases [C:2] [TYPE Function] [SEMANTICS git,release,history]
|
||||
// @BRIEF Fetch named dashboard releases, newest first.
|
||||
async getReleases<T = unknown>(dashboardRef: string | number, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, '/releases', envId));
|
||||
},
|
||||
// #endregion getReleases
|
||||
|
||||
// #region createRelease [C:3] [TYPE Function] [SEMANTICS git,release,create]
|
||||
// @BRIEF Create a named release from the current validated PREPROD deployment.
|
||||
async createRelease<T = unknown>(dashboardRef: string | number, payload: ReleaseCreatePayload, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, '/releases', envId), 'POST', payload);
|
||||
},
|
||||
// #endregion createRelease
|
||||
|
||||
// #region approveRelease [C:3] [TYPE Function] [SEMANTICS git,release,approval]
|
||||
// @BRIEF Approve a named release under its configured repository policy.
|
||||
async approveRelease<T = unknown>(dashboardRef: string | number, releaseId: string, payload: ReleaseApprovalPayload, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, `/releases/${encodeURIComponent(releaseId)}/approve`, envId), 'POST', payload);
|
||||
},
|
||||
// #endregion approveRelease
|
||||
|
||||
// #region publishRelease [C:3] [TYPE Function] [SEMANTICS git,release,publish]
|
||||
// @BRIEF Publish the exact commit pinned by a ready dashboard release.
|
||||
async publishRelease<T = unknown>(dashboardRef: string | number, releaseId: string, envId: string | number | null = null): Promise<T> {
|
||||
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, `/releases/${encodeURIComponent(releaseId)}/publish`, envId), 'POST');
|
||||
},
|
||||
// #endregion publishRelease
|
||||
|
||||
// #region getBranchProtectionRules [C:2] [TYPE Function] [SEMANTICS git, branch, protection]
|
||||
// @BRIEF Fetch branch protection rules for environment branches.
|
||||
// @POST Returns list of protection rule objects.
|
||||
|
||||
Reference in New Issue
Block a user