fix migration resume and release workflow

This commit is contained in:
2026-07-16 12:31:59 +03:00
parent 45ce585aba
commit 8f0d123ff8
32 changed files with 2046 additions and 163 deletions

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View 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]

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View 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

View 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

View File

@@ -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()

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 -->

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 });

View File

@@ -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",

View File

@@ -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} файлов)",

View File

@@ -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;

View File

@@ -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 -->

View File

@@ -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.