diff --git a/backend/alembic/versions/f5e6d7c8b9a0_add_dashboard_releases.py b/backend/alembic/versions/f5e6d7c8b9a0_add_dashboard_releases.py new file mode 100644 index 000000000..54f3bfd36 --- /dev/null +++ b/backend/alembic/versions/f5e6d7c8b9a0_add_dashboard_releases.py @@ -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 diff --git a/backend/src/api/routes/git/__init__.py b/backend/src/api/routes/git/__init__.py index 06f71b090..2825f0596 100644 --- a/backend/src/api/routes/git/__init__.py +++ b/backend/src/api/routes/git/__init__.py @@ -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 diff --git a/backend/src/api/routes/git/_release_routes.py b/backend/src/api/routes/git/_release_routes.py new file mode 100644 index 000000000..e0b543342 --- /dev/null +++ b/backend/src/api/routes/git/_release_routes.py @@ -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 diff --git a/backend/src/api/routes/git/_repo_lifecycle_routes.py b/backend/src/api/routes/git/_repo_lifecycle_routes.py index b16a0bb24..892742d15 100644 --- a/backend/src/api/routes/git/_repo_lifecycle_routes.py +++ b/backend/src/api/routes/git/_repo_lifecycle_routes.py @@ -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,7 +630,21 @@ 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: raise diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index c314b4952..f4a78f2bc 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -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 diff --git a/backend/src/core/migration/dry_run_orchestrator.py b/backend/src/core/migration/dry_run_orchestrator.py index 1214f04ff..5aca988ff 100644 --- a/backend/src/core/migration/dry_run_orchestrator.py +++ b/backend/src/core/migration/dry_run_orchestrator.py @@ -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 diff --git a/backend/src/core/migration_engine.py b/backend/src/core/migration_engine.py index b27ebf114..ef355c72a 100644 --- a/backend/src/core/migration_engine.py +++ b/backend/src/core/migration_engine.py @@ -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: - self._transform_database_yaml(db_file, db_mapping) + 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. diff --git a/backend/src/core/task_manager/graph.py b/backend/src/core/task_manager/graph.py index c537f71d0..5c8728550 100644 --- a/backend/src/core/task_manager/graph.py +++ b/backend/src/core/task_manager/graph.py @@ -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] diff --git a/backend/src/core/task_manager/lifecycle.py b/backend/src/core/task_manager/lifecycle.py index f729e1e71..740da57af 100644 --- a/backend/src/core/task_manager/lifecycle.py +++ b/backend/src/core/task_manager/lifecycle.py @@ -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,14 +375,17 @@ 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 = loop.create_future() - self.graph.create_future(task_id, future) + future = self.graph.task_futures.get(task_id) + if future is None: + future = loop.create_future() + self.graph.create_future(task_id, future) try: await asyncio.wait_for(future, timeout=timeout) except asyncio.TimeoutError: @@ -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 diff --git a/backend/src/models/dashboard_release.py b/backend/src/models/dashboard_release.py new file mode 100644 index 000000000..5cf6adc29 --- /dev/null +++ b/backend/src/models/dashboard_release.py @@ -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 diff --git a/backend/src/models/git.py b/backend/src/models/git.py index 7c6e94461..2e45ca27e 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -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 diff --git a/backend/src/plugins/migration.py b/backend/src/plugins/migration.py index 601bef109..ecaa55519 100755 --- a/backend/src/plugins/migration.py +++ b/backend/src/plugins/migration.py @@ -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) diff --git a/backend/tests/api/test_git_repo_lifecycle_routes.py b/backend/tests/api/test_git_repo_lifecycle_routes.py index b0ad9073a..ec4693378 100644 --- a/backend/tests/api/test_git_repo_lifecycle_routes.py +++ b/backend/tests/api/test_git_repo_lifecycle_routes.py @@ -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 diff --git a/backend/tests/core/task_manager/test_graph.py b/backend/tests/core/task_manager/test_graph.py index 1ca0a9be2..a4a456262 100644 --- a/backend/tests/core/task_manager/test_graph.py +++ b/backend/tests/core/task_manager/test_graph.py @@ -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 diff --git a/backend/tests/core/task_manager/test_lifecycle.py b/backend/tests/core/task_manager/test_lifecycle.py index 92ba5a9d9..02a078a2f 100644 --- a/backend/tests/core/task_manager/test_lifecycle.py +++ b/backend/tests/core/task_manager/test_lifecycle.py @@ -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 diff --git a/backend/tests/core/test_migration_engine.py b/backend/tests/core/test_migration_engine.py index de2241d03..e4996e0e4 100644 --- a/backend/tests/core/test_migration_engine.py +++ b/backend/tests/core/test_migration_engine.py @@ -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. diff --git a/backend/tests/models/test_dashboard_release_model.py b/backend/tests/models/test_dashboard_release_model.py new file mode 100644 index 000000000..bd4bd9adf --- /dev/null +++ b/backend/tests/models/test_dashboard_release_model.py @@ -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 diff --git a/backend/tests/plugins/test_migration_plugin_password.py b/backend/tests/plugins/test_migration_plugin_password.py new file mode 100644 index 000000000..8455fd0a2 --- /dev/null +++ b/backend/tests/plugins/test_migration_plugin_password.py @@ -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 diff --git a/backend/tests/test_task_manager.py b/backend/tests/test_task_manager.py index 7bbf5ef9e..842829be7 100644 --- a/backend/tests/test_task_manager.py +++ b/backend/tests/test_task_manager.py @@ -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() diff --git a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte index adc41c1bf..c53bd785c 100644 --- a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte +++ b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte @@ -5,7 +5,7 @@ - + @@ -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(); } diff --git a/frontend/src/lib/components/git/GitManager.svelte b/frontend/src/lib/components/git/GitManager.svelte index b8add2a3c..730b8c88b 100644 --- a/frontend/src/lib/components/git/GitManager.svelte +++ b/frontend/src/lib/components/git/GitManager.svelte @@ -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'} model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onGenerateSummary={() => model.handleGenerateWorkspaceSummary(true)} onCommit={() => model.handleCommit()} /> {:else if model.activeTab === 'release'} - model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} /> + model.createRelease()} + onApprove={() => model.approveRelease()} + onPublish={() => model.publishRelease()} + onSavePolicy={() => model.saveReleasePolicy()} + /> {:else} model.handlePull()} onPush={() => model.handlePush()} /> {/if} diff --git a/frontend/src/lib/components/git/GitReleasePanel.svelte b/frontend/src/lib/components/git/GitReleasePanel.svelte index be0cd842f..24154e0e7 100644 --- a/frontend/src/lib/components/git/GitReleasePanel.svelte +++ b/frontend/src/lib/components/git/GitReleasePanel.svelte @@ -1,135 +1,171 @@ - + - + - - - - + + + + + + + + + +
-
-
{$t.git?.release?.pipeline_status || 'Текущий статус пайплайна'}
-
- DEV (dev) - - PREPROD (preprod) - - PROD (prod) -
-
- - - - - - {#if showAdvancedPromote} -
-
- - +
+
+
+

{$t.git?.release?.title || 'Релиз дашборда'}

+

{$t.git?.release?.intro || 'Релиз фиксирует проверенную версию PREPROD и публикует именно её в PROD.'}

- - {#if directMergeNeedsReason} -
- {$t.git?.release?.reason_required || 'Direct merge requires an audit reason.'} -
- {/if} + {#if preprod?.commit_hash} + + {candidateReady ? ($t.git?.release?.preprod_validated || 'PREPROD проверен') : ($t.git?.release?.preprod_pending || 'PREPROD ожидает проверки')} + {/if}
+
+ + {#if releasesLoading} +
{$t.common?.loading || 'Загрузка…'}
+ {:else if activeRelease} +
+
+
+

{$t.git?.release?.active || 'Активный релиз'}

+

{activeRelease.name} v{activeRelease.version}

+

{activeRelease.notes}

+

{$t.git?.release?.pinned_version || 'Зафиксированная версия'}: {activeRelease.commit_hash.slice(0, 12)}

+
+ {statusLabel(activeRelease.status)} +
+ + {#if activeRelease.status === 'awaiting_approval'} +
+

{$t.git?.release?.approval_hint || 'Согласуйте релиз, прежде чем публиковать его в PROD.'}

+ {#if releasePolicy?.require_approval_comment}{/if} + +
+ {:else} +
+

{$t.git?.release?.publish_hint || 'В PROD будет опубликован ровно этот проверенный состав релиза.'}

+ +
+ {/if} +
+ {:else if candidateReady} +
+

{$t.git?.release?.create_title || 'Создать релиз из проверенного PREPROD'}

+

{$t.git?.release?.create_hint || 'Версия и описание станут частью неизменяемой истории выпуска.'}

+
+ + +
+ + +
+ {:else} +
+

{$t.git?.release?.candidate_required || 'Сначала подготовьте проверенный кандидат'}

+

{$t.git?.release?.candidate_required_hint || 'Разверните версию в PREPROD и подтвердите её проверку в пути публикации выше.'}

+
{/if} - -
-
-
{$t.git?.deployment || 'Deployment'}
- -
- - {#if currentEnvStage === 'PROD'} -
-
- {$t.git?.prod_rollback_hint || 'PROD rollback is available from commit history.'} - +
0}> + {$t.git?.release?.history || 'История релизов'} ({releases.length}) + {#if releases.length === 0} +

{$t.git?.release?.history_empty || 'Релизов пока нет.'}

+ {:else} +
+ {#each releases as release (release.id)} +
+ {release.name} v{release.version} {statusLabel(release.status)} +
+

{release.notes}

{$t.git?.release?.created || 'Создан'}: {release.created_by} · {formatDate(release.created_at)}

+

{$t.git?.release?.pinned_version || 'Зафиксированная версия'}: {release.commit_hash}

+ {#if release.approved_by}

{$t.git?.release?.approved || 'Согласован'}: {release.approved_by} · {formatDate(release.approved_at)}

{/if} + {#if release.published_by}

{$t.git?.release?.published || 'Опубликован'}: {release.published_by} · {formatDate(release.published_at)}

{/if} +
+
+ {/each}
-
+ {/if} + + + {#if canConfigurePolicy && releasePolicy} +
+ {$t.git?.release?.policy || 'Политика релизов дашборда'} +
+ + + + releasePolicy.approval_roles = event.currentTarget.value.split(',').map((role) => role.trim()).filter(Boolean)} /> +
+ +
{/if}
diff --git a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts index 86760d7de..21987f6d7 100644 --- a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts +++ b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts @@ -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', () => { diff --git a/frontend/src/lib/components/git/__tests__/GitReleasePanel.test.ts b/frontend/src/lib/components/git/__tests__/GitReleasePanel.test.ts new file mode 100644 index 000000000..b0c2c7cfc --- /dev/null +++ b/frontend/src/lib/components/git/__tests__/GitReleasePanel.test.ts @@ -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; common: Record }) => 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 diff --git a/frontend/src/lib/components/migration/PasswordPrompt.svelte b/frontend/src/lib/components/migration/PasswordPrompt.svelte index 506544984..d8b8fb684 100644 --- a/frontend/src/lib/components/migration/PasswordPrompt.svelte +++ b/frontend/src/lib/components/migration/PasswordPrompt.svelte @@ -25,7 +25,7 @@ show?: boolean; databases?: string[]; errorMessage?: string; - onresume?: (_payload: { passwords: Record }) => void; + onresume?: (_payload: { passwords: Record }) => void | Promise; 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 diff --git a/frontend/src/lib/components/migration/__tests__/PasswordPrompt.test.ts b/frontend/src/lib/components/migration/__tests__/PasswordPrompt.test.ts new file mode 100644 index 000000000..13c843311 --- /dev/null +++ b/frontend/src/lib/components/migration/__tests__/PasswordPrompt.test.ts @@ -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 diff --git a/frontend/src/lib/components/tasks/TaskRunner.svelte b/frontend/src/lib/components/tasks/TaskRunner.svelte index 87a194bd7..f4e487a70 100755 --- a/frontend/src/lib/components/tasks/TaskRunner.svelte +++ b/frontend/src/lib/components/tasks/TaskRunner.svelte @@ -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 }) { const task = selectedTask.current; - const { passwords } = event.detail; + if (!task) return; try { await api.postApi(`/tasks/${task.id}/resume`, { passwords }); diff --git a/frontend/src/lib/i18n/locales/en/git.json b/frontend/src/lib/i18n/locales/en/git.json index 26704b096..d3811f6a8 100644 --- a/frontend/src/lib/i18n/locales/en/git.json +++ b/frontend/src/lib/i18n/locales/en/git.json @@ -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", diff --git a/frontend/src/lib/i18n/locales/ru/git.json b/frontend/src/lib/i18n/locales/ru/git.json index 321a0bac6..3b9b10f28 100644 --- a/frontend/src/lib/i18n/locales/ru/git.json +++ b/frontend/src/lib/i18n/locales/ru/git.json @@ -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} файлов)", diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts index 5bb560165..d5817f7c4 100644 --- a/frontend/src/lib/models/GitManagerModel.svelte.ts +++ b/frontend/src/lib/models/GitManagerModel.svelte.ts @@ -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 { + if (!this.dashboardId || !this.initialized) return; + this.releasesLoading = true; + try { + this.releases = await gitService.getReleases(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 { + if (!this.dashboardId || !this.initialized) return; + try { + this.releasePolicy = await gitService.getReleasePolicy(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( this.dashboardId, this.resolvedEnvId, ); + await this.loadReleases(); notifications.success((this._t?.git as Record)?.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 { + 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 { + 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 { + 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 { + if (!this.releasePolicy) return; + this.releaseActionLoading = true; + try { + this.releasePolicy = await gitService.updateReleasePolicy(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; diff --git a/frontend/src/routes/migration/+page.svelte b/frontend/src/routes/migration/+page.svelte index a7f387d1d..0a3d6ff6b 100644 --- a/frontend/src/routes/migration/+page.svelte +++ b/frontend/src/routes/migration/+page.svelte @@ -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} /> diff --git a/frontend/src/services/gitService.ts b/frontend/src/services/gitService.ts index ba1a87b51..09900057b 100644 --- a/frontend/src/services/gitService.ts +++ b/frontend/src/services/gitService.ts @@ -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(dashboardRef: string | number, envId: string | number | null = null): Promise { + return gitRequest(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(dashboardRef: string | number, policy: ReleasePolicyPayload, envId: string | number | null = null): Promise { + return gitRequest(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(dashboardRef: string | number, envId: string | number | null = null): Promise { + return gitRequest(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(dashboardRef: string | number, payload: ReleaseCreatePayload, envId: string | number | null = null): Promise { + return gitRequest(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(dashboardRef: string | number, releaseId: string, payload: ReleaseApprovalPayload, envId: string | number | null = null): Promise { + return gitRequest(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(dashboardRef: string | number, releaseId: string, envId: string | number | null = null): Promise { + return gitRequest(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.