# #region MigrationApi [C:5] [TYPE Module] [SEMANTICS fastapi, migration, api, sync, search, mapping] # @BRIEF HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints. # @LAYER Infrastructure # @RELATION DEPENDS_ON -> [AppDependencies] # @RELATION DEPENDS_ON -> [DatabaseModule] # @RELATION DEPENDS_ON -> [DashboardSelection] # @RELATION DEPENDS_ON -> [DashboardMetadata] # @RELATION DEPENDS_ON -> [MigrationDryRunService] # @RELATION DEPENDS_ON -> [IdMappingService] # @RELATION DEPENDS_ON -> [ResourceMapping] # @INVARIANT Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures. # @PRE Backend core services initialized and Database session available. # @POST Migration tasks are enqueued or dry-run results are computed and returned. # @SIDE_EFFECT Enqueues long-running tasks, potentially mutates ResourceMapping table, and performs remote Superset API calls. # @DATA_CONTRACT [DashboardSelection | QueryParams] -> [TaskResponse | DryRunResult | MappingSummary] # @TEST_CONTRACT [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary] # @TEST_SCENARIO [invalid_environment] -> [HTTP_400_or_404] # @TEST_SCENARIO [valid_execution] -> [success_payload_with_required_fields] # @TEST_EDGE [missing_field] ->[HTTP_400] # @TEST_EDGE [invalid_type] ->[validation_error] # @TEST_EDGE [external_fail] ->[HTTP_500] # @TEST_INVARIANT [EnvironmentValidationBeforeAction] -> VERIFIED_BY: [invalid_environment, valid_execution] from typing import Any, cast from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ...core.database import get_db from ...core.logger import belief_scope, logger from ...core.mapping_service import IdMappingService from ...core.migration.dry_run_orchestrator import MigrationDryRunService from ...core.async_superset_client import AsyncSupersetClient from ...dependencies import get_config_manager, get_task_manager, has_permission from ...models.dashboard import DashboardMetadata, DashboardSelection from ...models.mapping import ResourceMapping logger = cast(Any, logger) router = APIRouter(prefix="/api", tags=["migration"]) # #region get_dashboards [C:3] [TYPE Function] # @BRIEF Fetch dashboard metadata from a requested environment for migration selection UI. # @PRE env_id is provided and exists in configured environments. # @POST Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent. # @SIDE_EFFECT Reads environment configuration and performs remote Superset metadata retrieval over network. # @DATA_CONTRACT Input[str env_id] -> Output[List[DashboardMetadata]] # @RELATION CALLS -> [EXT:method:SupersetClient.get_dashboards_summary] @router.get("/environments/{env_id}/dashboards", response_model=list[DashboardMetadata]) async def get_dashboards( env_id: str, config_manager=Depends(get_config_manager), _=Depends(has_permission("plugin:migration", "EXECUTE")), ): with belief_scope("get_dashboards", f"env_id={env_id}"): logger.reason(f"Fetching dashboards for environment: {env_id}") environments = config_manager.get_environments() env = next((e for e in environments if e.id == env_id), None) if not env: logger.explore(f"Environment {env_id} not found in configuration") raise HTTPException(status_code=404, detail="Environment not found") client = AsyncSupersetClient(env) dashboards = await client.get_dashboards_summary() logger.reflect(f"Retrieved {len(dashboards)} dashboards from {env_id}") return dashboards # #endregion get_dashboards # #region execute_migration [C:5] [TYPE Function] # @BRIEF Validate migration selection and enqueue asynchronous migration task execution. # @PRE DashboardSelection payload is valid and both source/target environments exist. # @POST Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure. # @SIDE_EFFECT Reads configuration, writes task record through task manager, and writes operational logs. # @DATA_CONTRACT Input[DashboardSelection] -> Output[Dict[str, str]] # @RELATION CALLS -> [create_task] # @RELATION DEPENDS_ON -> [DashboardSelection] # @INVARIANT Migration task dispatch never occurs before source and target environment ids pass guard validation. @router.post("/migration/execute") async def execute_migration( selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager), _=Depends(has_permission("plugin:migration", "EXECUTE")), ): with belief_scope("execute_migration"): logger.reason( f"Initiating migration from {selection.source_env_id} to {selection.target_env_id}" ) # Validate environments exist environments = config_manager.get_environments() env_ids = {e.id for e in environments} if ( selection.source_env_id not in env_ids or selection.target_env_id not in env_ids ): logger.explore( "Invalid environment selection", extra={ "source": selection.source_env_id, "target": selection.target_env_id, }, ) raise HTTPException( status_code=400, detail="Invalid source or target environment" ) # Include replace_db_config and fix_cross_filters in the task parameters task_params = selection.model_dump() task_params["replace_db_config"] = selection.replace_db_config task_params["fix_cross_filters"] = selection.fix_cross_filters logger.reason( f"Creating migration task with {len(selection.selected_ids)} dashboards" ) try: task = await task_manager.create_task("superset-migration", task_params) logger.reflect(f"Migration task created: {task.id}") return {"task_id": task.id, "message": "Migration initiated"} except Exception as e: logger.explore(f"Task creation failed: {e}") raise HTTPException( status_code=500, detail=f"Failed to create migration task: {e!s}" ) # #endregion execute_migration # #region dry_run_migration [C:5] [TYPE Function] # @BRIEF Build pre-flight migration diff and risk summary without mutating target systems. # @PRE DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty. # @POST Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors. # @SIDE_EFFECT Reads local mappings from DB and fetches source/target metadata via Superset API. # @DATA_CONTRACT Input[DashboardSelection] -> Output[Dict[str, Any]] # @RELATION DEPENDS_ON -> [DashboardSelection] # @RELATION DEPENDS_ON -> [MigrationDryRunService] # @INVARIANT Dry-run flow remains read-only and rejects identical source/target environments before service execution. @router.post("/migration/dry-run", response_model=dict[str, Any]) async def dry_run_migration( selection: DashboardSelection, config_manager=Depends(get_config_manager), db: Session = Depends(get_db), _=Depends(has_permission("plugin:migration", "EXECUTE")), ): with belief_scope("dry_run_migration"): logger.reason( f"Starting dry run: {selection.source_env_id} -> {selection.target_env_id}" ) environments = config_manager.get_environments() env_map = {env.id: env for env in environments} source_env = env_map.get(selection.source_env_id) target_env = env_map.get(selection.target_env_id) if not source_env or not target_env: logger.explore("Invalid environment selection for dry run") raise HTTPException( status_code=400, detail="Invalid source or target environment" ) if selection.source_env_id == selection.target_env_id: logger.explore("Source and target environments are identical") raise HTTPException( status_code=400, detail="Source and target environments must be different", ) if not selection.selected_ids: logger.explore("No dashboards selected for dry run") raise HTTPException( status_code=400, detail="No dashboards selected for dry run" ) service = MigrationDryRunService() source_client = AsyncSupersetClient(source_env) target_client = AsyncSupersetClient(target_env) try: result = await service.run( selection=selection, source_client=source_client, target_client=target_client, db=db, ) logger.reflect("Dry run analysis complete") return result except ValueError as exc: logger.explore(f"Dry run orchestrator failed: {exc}") raise HTTPException(status_code=500, detail=str(exc)) from exc # #endregion dry_run_migration # #region get_migration_settings [C:3] [TYPE Function] # @BRIEF Read and return configured migration synchronization cron expression. # @PRE Configuration store is available and requester has READ permission. # @POST Returns {"cron": str} reflecting current persisted settings value. # @SIDE_EFFECT Reads configuration from config manager. # @DATA_CONTRACT Input[None] -> Output[Dict[str, str]] # @RELATION DEPENDS_ON -> [AppDependencies] @router.get("/migration/settings", response_model=dict[str, str]) async def get_migration_settings( config_manager=Depends(get_config_manager), _=Depends(has_permission("plugin:migration", "READ")), ): with belief_scope("get_migration_settings"): config = config_manager.get_config() cron = config.settings.migration_sync_cron return {"cron": cron} # #endregion get_migration_settings # #region update_migration_settings [C:3] [TYPE Function] # @BRIEF Validate and persist migration synchronization cron expression update. # @PRE Payload includes "cron" key and requester has WRITE permission. # @POST Returns {"cron": str, "status": "updated"} and persists updated cron value. # @SIDE_EFFECT Mutates configuration and writes persisted config through config manager. # @DATA_CONTRACT Input[Dict[str, str]] -> Output[Dict[str, str]] # @RELATION DEPENDS_ON -> [AppDependencies] @router.put("/migration/settings", response_model=dict[str, str]) async def update_migration_settings( payload: dict[str, str], config_manager=Depends(get_config_manager), _=Depends(has_permission("plugin:migration", "WRITE")), ): with belief_scope("update_migration_settings"): if "cron" not in payload: raise HTTPException( status_code=400, detail="Missing 'cron' field in payload" ) cron_expr = payload["cron"] config = config_manager.get_config() config.settings.migration_sync_cron = cron_expr config_manager.save_config(config) return {"cron": cron_expr, "status": "updated"} # #endregion update_migration_settings # #region get_resource_mappings [C:3] [TYPE Function] # @BRIEF Fetch synchronized resource mappings with optional filters and pagination for migration mappings view. # @PRE skip>=0, 1<=limit<=500, DB session is active, requester has READ permission. # @POST Returns {"items": [...], "total": int} where items reflect applied filters and pagination. # @SIDE_EFFECT Executes database read queries against ResourceMapping table. # @DATA_CONTRACT Input[QueryParams] -> Output[Dict[str, Any]] # @RELATION DEPENDS_ON -> [ResourceMapping] @router.get("/migration/mappings-data", response_model=dict[str, Any]) async def get_resource_mappings( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), search: str | None = Query(None, description="Search by resource name or UUID"), env_id: str | None = Query(None, description="Filter by environment ID"), resource_type: str | None = Query(None, description="Filter by resource type"), db: Session = Depends(get_db), _=Depends(has_permission("plugin:migration", "READ")), ): with belief_scope("get_resource_mappings"): query = db.query(ResourceMapping) if env_id: query = query.filter(ResourceMapping.environment_id == env_id) if resource_type: query = query.filter(ResourceMapping.resource_type == resource_type.upper()) if search: search_term = f"%{search}%" query = query.filter( (ResourceMapping.resource_name.ilike(search_term)) | (ResourceMapping.uuid.ilike(search_term)) ) total = query.count() mappings = ( query.order_by(ResourceMapping.resource_type, ResourceMapping.resource_name) .offset(skip) .limit(limit) .all() ) items = [] for m in mappings: mapping = cast(Any, m) resource_type_value = ( mapping.resource_type.value if mapping.resource_type is not None else None ) last_synced_at = ( mapping.last_synced_at.isoformat() if mapping.last_synced_at is not None else None ) items.append( { "id": mapping.id, "environment_id": mapping.environment_id, "resource_type": resource_type_value, "uuid": mapping.uuid, "remote_id": mapping.remote_integer_id, "resource_name": mapping.resource_name, "last_synced_at": last_synced_at, } ) return {"items": items, "total": total} # #endregion get_resource_mappings # #region trigger_sync_now [C:3] [TYPE Function] # @BRIEF Trigger immediate ID synchronization for every configured environment. # @PRE At least one environment is configured and requester has EXECUTE permission. # @POST Returns sync summary with synced/failed counts after attempting all environments. # @SIDE_EFFECT Upserts Environment rows, commits DB transaction, performs network sync calls, and writes logs. # @DATA_CONTRACT Input[None] -> Output[Dict[str, Any]] # @RELATION DEPENDS_ON -> [IdMappingService] # @RELATION CALLS -> [sync_environment] @router.post("/migration/sync-now", response_model=dict[str, Any]) async def trigger_sync_now( config_manager=Depends(get_config_manager), db: Session = Depends(get_db), _=Depends(has_permission("plugin:migration", "EXECUTE")), ): with belief_scope("trigger_sync_now"): from ...core.logger import logger from ...models.mapping import Environment as EnvironmentModel config = config_manager.get_config() environments = config.environments if not environments: raise HTTPException(status_code=400, detail="No environments configured") # Ensure each environment exists in DB (upsert) to satisfy FK constraints for env in environments: existing = db.query(EnvironmentModel).filter_by(id=env.id).first() if not existing: db_env = EnvironmentModel( id=env.id, name=env.name, url=env.url, credentials_id=env.id, # Use env.id as credentials reference ) db.add(db_env) logger.reason( f"Created environment row for {env.id}", extra={"src": "trigger_sync_now"}, ) else: existing.name = env.name existing.url = env.url db.commit() service = IdMappingService(db) results = {"synced": [], "failed": []} for env in environments: try: client = AsyncSupersetClient(env) service.sync_environment(env.id, client) results["synced"].append(env.id) logger.reason(f"Synced environment {env.id}", extra={"src": "trigger_sync_now"}) except Exception as e: results["failed"].append({"env_id": env.id, "error": str(e)}) logger.error(f"[trigger_sync_now][Error] Failed to sync {env.id}: {e}") return { "status": "completed", "synced_count": len(results["synced"]), "failed_count": len(results["failed"]), "details": results, } # #endregion trigger_sync_now # #endregion MigrationApi