Files

774 lines
31 KiB
Python

import os
import re
import json
import yaml
import glob
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from app.core.config import settings
from app.services.metadb_service import save_connection_to_metadb
from app.services.dbt_service import sync_dbt_models_from_nodes
logger = logging.getLogger(__name__)
def parse_yaml_to_pipeline_dict(cfg: dict, filename: str) -> dict:
"""Parse an Airflow YAML config file into UI Canvas compatible node/edge structure."""
pipeline_id = cfg.get("pipeline_id") or filename.replace(".yaml", "").replace(".yml", "")
description = cfg.get("description", "Airflow Configured Pipeline")
schedule = cfg.get("schedule", "0 */1 * * *")
mode = cfg.get("mode", "medallion")
nodes = []
edges = []
if mode == "flexible" or "tasks" in cfg:
tasks = cfg.get("tasks", [])
for idx, t in enumerate(tasks):
node_id = str(t.get("task_id", f"node_{idx}"))
n_type = t.get("type") or t.get("operator_type") or "source_db"
label = t.get("label") or node_id
nodes.append({
"id": node_id,
"type": "customNode",
"position": {"x": 80 + idx * 260, "y": 160},
"data": {
"label": label,
"type": n_type,
"configured": True,
"config": t.get("config", {})
}
})
for dep_id in t.get("dependencies", []):
edges.append({
"id": f"e_{dep_id}_{node_id}",
"source": str(dep_id),
"target": str(node_id)
})
else:
# Medallion mode parsing
bronze = cfg.get("bronze", {})
silver = cfg.get("silver", {})
gold = cfg.get("gold", {})
src_type = bronze.get("source_type", "database")
node_src_type = "source_db"
if src_type == "gws":
node_src_type = "source_gws"
elif src_type == "api":
node_src_type = "source_api"
nodes.append({
"id": "node-bronze-src",
"type": "customNode",
"position": {"x": 80, "y": 160},
"data": {
"label": f"Bronze {src_type.upper()} Source",
"type": node_src_type,
"configured": True,
"config": bronze
}
})
nodes.append({
"id": "node-bronze-storage",
"type": "customNode",
"position": {"x": 340, "y": 160},
"data": {
"label": "MinIO Bronze Storage",
"type": "minio_storage",
"configured": True,
"config": bronze.get("target", {})
}
})
edges.append({"id": "e1-2", "source": "node-bronze-src", "target": "node-bronze-storage"})
sil_trans = silver.get("transformation", {})
if sil_trans:
nodes.append({
"id": "node-silver-dbt",
"type": "customNode",
"position": {"x": 600, "y": 160},
"data": {
"label": f"dbt Silver Model ({sil_trans.get('select_models', 'silver')})",
"type": "dbt_transform",
"configured": True,
"config": {"dbt_layer": "silver", "dbt_model": sil_trans.get("select_models", "")}
}
})
edges.append({"id": "e2-3", "source": "node-bronze-storage", "target": "node-silver-dbt"})
sil_ge = silver.get("quality_check", {})
if sil_ge:
prev_id = "node-silver-dbt" if sil_trans else "node-bronze-storage"
nodes.append({
"id": "node-silver-ge",
"type": "customNode",
"position": {"x": 860, "y": 160},
"data": {
"label": f"GE Quality ({sil_ge.get('expectation_suite', 'default')})",
"type": "ge_quality",
"configured": True,
"config": sil_ge
}
})
edges.append({"id": "e3-4", "source": prev_id, "target": "node-silver-ge"})
gold_trans = gold.get("transformation", {})
if gold_trans:
prev_id = "node-silver-ge" if sil_ge else ("node-silver-dbt" if sil_trans else "node-bronze-storage")
nodes.append({
"id": "node-gold-dbt",
"type": "customNode",
"position": {"x": 1120, "y": 160},
"data": {
"label": f"dbt Gold Mart ({gold_trans.get('select_models', 'gold')})",
"type": "dbt_transform",
"configured": True,
"config": {"dbt_layer": "gold", "dbt_model": gold_trans.get("select_models", "")}
}
})
edges.append({"id": "e4-5", "source": prev_id, "target": "node-gold-dbt"})
return {
"id": pipeline_id,
"description": description,
"schedule": schedule,
"nodes": nodes,
"edges": edges,
"is_draft": False,
"status": "deployed"
}
def get_metadata_path(filename: str) -> str:
"""Resolve existing metadata file path, checking METADATA_DIR then CONFIGS_DIR."""
meta_dir = getattr(settings, "METADATA_DIR", settings.CONFIGS_DIR)
p = os.path.join(meta_dir, filename)
if os.path.exists(p):
return p
cfg_p = os.path.join(settings.CONFIGS_DIR, filename)
if os.path.exists(cfg_p):
return cfg_p
return p
def get_target_metadata_path(filename: str) -> str:
"""Resolve target path for writing metadata file."""
meta_dir = getattr(settings, "METADATA_DIR", settings.CONFIGS_DIR)
return os.path.join(meta_dir, filename)
def get_pipeline_by_id(pipeline_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve single pipeline dictionary by ID, searching JSON drafts, deployed JSON, or YAML."""
norm_id = pipeline_id.lower().replace(" ", "_")
draft_path = get_metadata_path(f"{norm_id}.draft.json")
if os.path.exists(draft_path):
try:
with open(draft_path, "r", encoding="utf-8") as f:
data = json.load(f)
data["id"] = norm_id
data["status"] = "draft"
return data
except Exception as e:
logger.warning("Error reading draft JSON %s: %s", draft_path, e)
json_path = get_metadata_path(f"{norm_id}.json")
if os.path.exists(json_path):
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
data["id"] = norm_id
data["status"] = data.get("status", "deployed")
return data
except Exception as e:
logger.warning("Error reading metadata JSON %s: %s", json_path, e)
yaml_path = os.path.join(settings.CONFIGS_DIR, f"{norm_id}.yaml")
if os.path.exists(yaml_path):
try:
with open(yaml_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
if isinstance(cfg, dict):
return parse_yaml_to_pipeline_dict(cfg, f"{norm_id}.yaml")
except Exception as e:
logger.warning("Error reading YAML %s: %s", yaml_path, e)
return None
def list_all_pipelines() -> List[Dict[str, Any]]:
"""Retrieve all pipeline configs (JSON drafts/metadata and deployed YAMLs)."""
os.makedirs(settings.CONFIGS_DIR, exist_ok=True)
if hasattr(settings, "METADATA_DIR"):
os.makedirs(settings.METADATA_DIR, exist_ok=True)
pipelines_map: Dict[str, Dict[str, Any]] = {}
# 1. Load JSON files (saved drafts and UI canvas snapshots)
scan_dirs = []
if hasattr(settings, "METADATA_DIR") and os.path.exists(settings.METADATA_DIR):
scan_dirs.append(settings.METADATA_DIR)
container_meta = "/app/merica-pipelines/metadata"
if container_meta not in scan_dirs and os.path.exists(container_meta):
scan_dirs.append(container_meta)
if settings.CONFIGS_DIR not in scan_dirs and os.path.exists(settings.CONFIGS_DIR):
scan_dirs.append(settings.CONFIGS_DIR)
for s_dir in scan_dirs:
for file_name in sorted(os.listdir(s_dir)):
file_path = os.path.join(s_dir, file_name)
if file_name.endswith(".json") and not ".version." in file_name:
if file_name in ["rbac_mappings.json", "package.json", "tsconfig.json"]:
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
continue
if "nodes" not in data and not file_name.endswith(".draft.json") and "tasks" not in data and "pipeline_id" not in data:
continue
p_id = data.get("id") or data.get("pipeline_id") or file_name.replace(".draft.json", "").replace(".json", "")
data["id"] = p_id
if file_name.endswith(".draft.json") or data.get("is_draft"):
data["status"] = "draft"
else:
data["status"] = "deployed"
pipelines_map[p_id] = data
except Exception as e:
logger.warning("Error reading JSON file %s: %s", file_name, e)
# 2. Load YAML files (real Airflow DAG configs)
yaml_scan_dirs = []
if os.path.exists(settings.CONFIGS_DIR):
yaml_scan_dirs.append(settings.CONFIGS_DIR)
container_cfg = "/app/merica-pipelines/configs"
if container_cfg not in yaml_scan_dirs and os.path.exists(container_cfg):
yaml_scan_dirs.append(container_cfg)
for b_dir in sorted(glob.glob("/opt/airflow/dag_bundles/*/tracking_repo/configs")):
if b_dir not in yaml_scan_dirs and os.path.exists(b_dir):
yaml_scan_dirs.append(b_dir)
for y_dir in yaml_scan_dirs:
for file_name in sorted(os.listdir(y_dir)):
file_path = os.path.join(y_dir, file_name)
if file_name.endswith(".yaml") or file_name.endswith(".yml"):
p_id = file_name.replace(".yaml", "").replace(".yml", "")
existing = pipelines_map.get(p_id)
should_load_yaml = False
if existing is None:
should_load_yaml = True
elif existing.get("status") == "deployed":
json_path = get_metadata_path(f"{p_id}.json")
if os.path.exists(json_path) and os.path.getmtime(file_path) > os.path.getmtime(json_path):
should_load_yaml = True
if should_load_yaml:
try:
with open(file_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
if isinstance(cfg, dict):
parsed_data = parse_yaml_to_pipeline_dict(cfg, file_name)
pipelines_map[p_id] = parsed_data
except Exception as e:
logger.warning("Error parsing YAML config %s: %s", file_name, e)
return list(pipelines_map.values())
def save_pipeline_draft(
pipeline_id: str,
description: Optional[str],
schedule: Optional[str],
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
notes: Optional[List[Dict[str, Any]]] = None,
owner_id: Optional[str] = None,
owner_name: Optional[str] = None
) -> Dict[str, Any]:
"""Save an in-progress pipeline design as a .draft.json file."""
norm_id = pipeline_id.lower().replace(" ", "_")
target_meta_dir = getattr(settings, "METADATA_DIR", settings.CONFIGS_DIR)
os.makedirs(target_meta_dir, exist_ok=True)
json_path = os.path.join(target_meta_dir, f"{norm_id}.draft.json")
existing = get_pipeline_by_id(norm_id) if os.path.exists(json_path) else None
resolved_owner_id = owner_id or (existing.get("owner_id") if existing else None)
resolved_owner_name = owner_name or (existing.get("owner_name") if existing else None)
resolved_notes = notes if notes is not None else (existing.get("notes", []) if existing else [])
draft_data = {
"id": norm_id,
"description": description or "Dynamic Lakehouse Pipeline Draft",
"schedule": schedule or "0 */1 * * *",
"owner_id": resolved_owner_id,
"owner_name": resolved_owner_name,
"nodes": nodes,
"edges": edges,
"notes": resolved_notes,
"is_draft": True,
"status": "draft",
"updated_at": datetime.utcnow().isoformat() + "Z"
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(draft_data, f, indent=2)
return {
"status": "success",
"pipeline_id": norm_id,
"filename": f"{norm_id}.draft.json",
"message": f"Draft pipeline '{norm_id}' saved successfully."
}
def duplicate_pipeline(
pipeline_id: str,
new_pipeline_id: str,
owner_id: Optional[str] = None,
owner_name: Optional[str] = None
) -> Dict[str, Any]:
"""
Duplicate a pipeline with a new ID.
Per Decision #4: The cloner becomes the owner, and the status is set to draft.
"""
source = get_pipeline_by_id(pipeline_id)
if not source:
raise ValueError(f"Source pipeline '{pipeline_id}' not found.")
norm_new_id = new_pipeline_id.lower().replace(" ", "_")
return save_pipeline_draft(
pipeline_id=norm_new_id,
description=f"Copy of {source.get('description', '')}".strip(),
schedule=source.get("schedule", "0 */1 * * *"),
nodes=source.get("nodes", []),
edges=source.get("edges", []),
notes=source.get("notes", []),
owner_id=owner_id,
owner_name=owner_name
)
def build_semantic_task_id(node: Dict[str, Any], seen_ids: Optional[set] = None) -> str:
"""Transform random ReactFlow IDs (node_179...) into semantic Airflow task IDs."""
data = node.get("data", {})
cfg = data.get("config", {})
raw_id = str(node.get("id", "task"))
if not raw_id.startswith("node_"):
candidate = re.sub(r'[^a-zA-Z0-9_.-]', '_', raw_id).lower()
if seen_ids is not None:
seen_ids.add(candidate)
return candidate
op_type = (data.get("type") or "task").lower()
label = (data.get("label") or "").lower()
layer = "bronze" if any(k in op_type for k in ["source", "bronze", "sling", "cdc", "gws", "api"]) else (
"silver" if any(k in op_type for k in ["silver", "iceberg", "duckdb", "pyiceberg"]) else (
"gold" if any(k in op_type for k in ["gold", "mart"]) else (
"quality" if any(k in op_type for k in ["ge", "quality"]) else "task"
)
)
)
entity = ""
for f in ["table_name", "target_table", "dbt_model", "dataset_name"]:
if cfg.get(f):
entity = re.sub(r'[^a-zA-Z0-9]', '', str(cfg[f]).split(".")[-1].replace("_clean", "").replace("_raw", "")).lower()
break
if not entity and label:
entity = re.sub(r'[^a-zA-Z0-9]', '_', label).strip('_').lower().replace("source_", "").replace("dbt_", "").replace("_task", "")
if not entity:
entity = op_type.replace("source_", "").replace("_transform", "")
engine = "cdc" if "cdc" in op_type or "sling" in op_type or "db" in op_type else (
"gws" if "gws" in op_type or "sheet" in op_type else (
"api" if "api" in op_type else (
"duckdb" if "duckdb" in op_type else (
"pyiceberg" if "pyiceberg" in op_type else (
"spark" if "spark" in op_type else (
"dbt" if "dbt" in op_type else "op"
)
)
)
)
)
)
s_id = f"{layer}_{entity}_{engine}_task"
s_id = re.sub(r'_+', '_', s_id).strip('_')
if seen_ids is not None:
base = s_id
seq = 2
while s_id in seen_ids:
s_id = f"{base}_{seq}"
seq += 1
seen_ids.add(s_id)
return s_id
def deploy_pipeline_dag(
pipeline_id: str,
description: Optional[str],
schedule: Optional[str],
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
spec_yaml: Optional[str] = "",
notes: Optional[List[Dict[str, Any]]] = None,
owner_id: Optional[str] = None,
owner_name: Optional[str] = None
) -> Dict[str, Any]:
"""Compile and deploy pipeline: sync dbt models, persist custom connections, write JSON and YAML."""
norm_id = pipeline_id.lower().replace(" ", "_")
os.makedirs(settings.CONFIGS_DIR, exist_ok=True)
# Sync dbt SQL model files physically if dbt nodes exist
sync_dbt_models_from_nodes(nodes)
# Automatically persist custom database connections if present in nodes
for node in nodes:
cfg = node.get("data", {}).get("config", {})
if cfg.get("conn_id") == "custom_db" or cfg.get("custom_host"):
conn_id = cfg.get("custom_conn_id") or f"conn_custom_{norm_id}"
save_connection_to_metadb(
conn_id=conn_id,
conn_type=cfg.get("custom_db_type", "postgres"),
host=cfg.get("custom_host", "localhost"),
port=int(cfg.get("custom_port", 5432)),
schema=cfg.get("custom_database", "public"),
login=cfg.get("custom_user", "postgres"),
password=cfg.get("custom_password", "")
)
# Build semantic node ID mapping
seen_ids = set()
node_id_map = {}
for node in nodes:
raw_id = str(node.get("id"))
node_id_map[raw_id] = build_semantic_task_id(node, seen_ids)
# 1. Save JSON metadata for UI load/edit (marking as deployed)
json_path = get_target_metadata_path(f"{norm_id}.json")
existing = get_pipeline_by_id(norm_id) if os.path.exists(json_path) else None
resolved_owner_id = owner_id or (existing.get("owner_id") if existing else None)
resolved_owner_name = owner_name or (existing.get("owner_name") if existing else None)
resolved_notes = notes if notes is not None else (existing.get("notes", []) if existing else [])
pipeline_data = {
"id": norm_id,
"description": description or "Dynamic Lakehouse Pipeline",
"schedule": schedule or "0 */1 * * *",
"owner_id": resolved_owner_id,
"owner_name": resolved_owner_name,
"nodes": nodes,
"edges": edges,
"notes": resolved_notes,
"is_draft": False,
"status": "deployed",
"updated_at": datetime.utcnow().isoformat() + "Z"
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(pipeline_data, f, indent=2)
# Remove draft file if it existed
for draft_d in [getattr(settings, "METADATA_DIR", None), settings.CONFIGS_DIR]:
if draft_d:
d_p = os.path.join(draft_d, f"{norm_id}.draft.json")
if os.path.exists(d_p):
os.remove(d_p)
# 2. Sanitize and Save YAML specification for Airflow DAG Processor
yaml_path = os.path.join(settings.CONFIGS_DIR, f"{norm_id}.yaml")
if spec_yaml:
try:
parsed_spec = yaml.safe_load(spec_yaml)
if isinstance(parsed_spec, dict) and "tasks" in parsed_spec:
for t in parsed_spec["tasks"]:
raw_tid = str(t.get("task_id"))
if raw_tid in node_id_map:
t["task_id"] = node_id_map[raw_tid]
# Remap dependencies to semantic task IDs
t["dependencies"] = [node_id_map.get(str(d), str(d)) for d in t.get("dependencies", [])]
yaml_content = yaml.dump(parsed_spec, sort_keys=False)
else:
yaml_content = spec_yaml
except Exception:
yaml_content = spec_yaml
else:
yaml_content = yaml.dump(pipeline_data, sort_keys=False)
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(yaml_content)
# 3. Auto-Register pipeline & lineage to OpenMetadata 2.0 with Dynamic Governance
try:
from app.services.openmetadata_service import sync_pipeline_to_openmetadata, auto_sync_canvas_lineage
gov_dict = {}
if "parsed_spec" in locals() and isinstance(parsed_spec, dict):
gov_dict = parsed_spec.get("governance", {})
elif isinstance(pipeline_data, dict):
gov_dict = pipeline_data.get("governance", {})
if not gov_dict.get("owner") and resolved_owner_id:
gov_dict["owner"] = resolved_owner_id
if not gov_dict.get("owner_name") and resolved_owner_name:
gov_dict["owner_name"] = resolved_owner_name
sync_pipeline_to_openmetadata(norm_id, description or "", nodes=nodes, governance=gov_dict)
auto_sync_canvas_lineage(norm_id, nodes, edges)
except Exception as e:
logger.warning("OpenMetadata Auto-Registration Note: %s", e)
return {
"status": "success",
"pipeline_id": norm_id,
"filename": f"{norm_id}.yaml",
"message": f"Pipeline '{norm_id}' successfully generated & saved to {yaml_path}"
}
def delete_pipeline_files(pipeline_id: str) -> bool:
"""Delete all artifacts (json, draft.json, yaml, version snapshots) associated with a pipeline."""
norm_id = pipeline_id.lower().replace(" ", "_")
target_dirs = []
if hasattr(settings, "METADATA_DIR") and settings.METADATA_DIR:
target_dirs.append(settings.METADATA_DIR)
if settings.CONFIGS_DIR not in target_dirs:
target_dirs.append(settings.CONFIGS_DIR)
deleted = False
for d in target_dirs:
if not os.path.exists(d):
continue
json_path = os.path.join(d, f"{norm_id}.json")
draft_path = os.path.join(d, f"{norm_id}.draft.json")
yaml_path = os.path.join(d, f"{norm_id}.yaml")
for p in (json_path, draft_path, yaml_path):
if os.path.exists(p):
os.remove(p)
deleted = True
# Delete version snapshots
for file_name in os.listdir(d):
if file_name.startswith(f"{norm_id}.version.") and file_name.endswith(".json"):
v_path = os.path.join(d, file_name)
os.remove(v_path)
deleted = True
return deleted
def publish_pipeline_version(
pipeline_id: str,
version_tag: str,
changelog: Optional[str] = "",
bundle_name: Optional[str] = "dlh-core-ingestion",
nodes: Optional[List[Dict[str, Any]]] = None,
edges: Optional[List[Dict[str, Any]]] = None
) -> Dict[str, Any]:
"""Publish a permanent semantic version snapshot for Airflow 3 DAG Bundle & UI Time Machine."""
norm_id = pipeline_id.lower().replace(" ", "_")
os.makedirs(settings.CONFIGS_DIR, exist_ok=True)
if hasattr(settings, "METADATA_DIR"):
os.makedirs(settings.METADATA_DIR, exist_ok=True)
clean_tag = version_tag.strip().lower()
if not clean_tag.startswith("v"):
clean_tag = f"v{clean_tag}"
json_path = get_metadata_path(f"{norm_id}.json")
draft_path = get_metadata_path(f"{norm_id}.draft.json")
# 1. Resolve pipeline topology
pipeline_data = {}
if nodes is not None and edges is not None:
pipeline_data = {
"id": norm_id,
"description": f"Published version {clean_tag}",
"schedule": "0 */1 * * *",
"nodes": nodes,
"edges": edges,
}
elif os.path.exists(json_path):
with open(json_path, "r", encoding="utf-8") as f:
pipeline_data = json.load(f)
elif os.path.exists(draft_path):
with open(draft_path, "r", encoding="utf-8") as f:
pipeline_data = json.load(f)
else:
pipeline_data = {
"id": norm_id,
"description": f"Published version {clean_tag}",
"schedule": "0 */1 * * *",
"nodes": [],
"edges": [],
}
# Update metadata
pipeline_data["version"] = clean_tag
pipeline_data["bundle_name"] = bundle_name or "dlh-core-ingestion"
pipeline_data["changelog"] = changelog or "Released new version via MERICA Studio"
pipeline_data["published_at"] = datetime.utcnow().isoformat() + "Z"
pipeline_data["is_draft"] = False
pipeline_data["status"] = "deployed"
# Sync dbt models
if "nodes" in pipeline_data and pipeline_data["nodes"]:
sync_dbt_models_from_nodes(pipeline_data["nodes"])
# 2. Save version snapshot file in METADATA_DIR
snapshot_filename = f"{norm_id}.version.{clean_tag}.json"
snapshot_path = get_target_metadata_path(snapshot_filename)
with open(snapshot_path, "w", encoding="utf-8") as f:
json.dump(pipeline_data, f, indent=2)
# 3. Update active JSON metadata in METADATA_DIR
target_json_path = get_target_metadata_path(f"{norm_id}.json")
with open(target_json_path, "w", encoding="utf-8") as f:
json.dump(pipeline_data, f, indent=2)
# Remove draft if exists in either dir
for draft_d in [getattr(settings, "METADATA_DIR", None), settings.CONFIGS_DIR]:
if draft_d:
d_p = os.path.join(draft_d, f"{norm_id}.draft.json")
if os.path.exists(d_p):
os.remove(d_p)
# 4. Generate/Update YAML DAG Specification for Airflow 3 in CONFIGS_DIR
yaml_path = os.path.join(settings.CONFIGS_DIR, f"{norm_id}.yaml")
yaml_header = (
f"# ============================================================\n"
f"# MERICA Studio Data Lakehouse Pipeline\n"
f"# Airflow 3 DAG Bundle: {pipeline_data['bundle_name']}\n"
f"# Pinned Version: {clean_tag} | Published: {pipeline_data['published_at']}\n"
f"# Changelog: {pipeline_data['changelog']}\n"
f"# ============================================================\n"
)
yaml_body = yaml.dump(pipeline_data, sort_keys=False)
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(yaml_header + yaml_body)
return {
"status": "published",
"pipeline_id": norm_id,
"version": clean_tag,
"bundle_name": pipeline_data["bundle_name"],
"changelog": pipeline_data["changelog"],
"published_at": pipeline_data["published_at"],
"snapshot_filename": snapshot_filename,
"nodes_count": len(pipeline_data.get("nodes", [])),
"edges_count": len(pipeline_data.get("edges", [])),
"message": f"Pipeline '{norm_id}' version {clean_tag} published and pinned to bundle '{pipeline_data['bundle_name']}'."
}
def list_pipeline_versions(pipeline_id: str) -> Dict[str, Any]:
"""Retrieve all published historical version snapshots for a pipeline."""
norm_id = pipeline_id.lower().replace(" ", "_")
os.makedirs(settings.CONFIGS_DIR, exist_ok=True)
if hasattr(settings, "METADATA_DIR"):
os.makedirs(settings.METADATA_DIR, exist_ok=True)
current_version = None
current_bundle = "dlh-core-ingestion"
json_path = get_metadata_path(f"{norm_id}.json")
if os.path.exists(json_path):
try:
with open(json_path, "r", encoding="utf-8") as f:
cur_data = json.load(f)
current_version = cur_data.get("version")
current_bundle = cur_data.get("bundle_name", "dlh-core-ingestion")
except Exception:
pass
versions = []
prefix = f"{norm_id}.version."
seen_snapshots = set()
scan_dirs = []
if hasattr(settings, "METADATA_DIR") and settings.METADATA_DIR:
scan_dirs.append(settings.METADATA_DIR)
if settings.CONFIGS_DIR not in scan_dirs:
scan_dirs.append(settings.CONFIGS_DIR)
for s_dir in scan_dirs:
if not os.path.exists(s_dir):
continue
for file_name in sorted(os.listdir(s_dir)):
if file_name.startswith(prefix) and file_name.endswith(".json") and file_name not in seen_snapshots:
seen_snapshots.add(file_name)
file_path = os.path.join(s_dir, file_name)
try:
with open(file_path, "r", encoding="utf-8") as f:
snap = json.load(f)
versions.append({
"version": snap.get("version", "v1.0.0"),
"pipeline_id": norm_id,
"bundle_name": snap.get("bundle_name", current_bundle),
"changelog": snap.get("changelog", ""),
"published_at": snap.get("published_at", ""),
"nodes_count": len(snap.get("nodes", [])),
"edges_count": len(snap.get("edges", [])),
"snapshot_filename": file_name
})
except Exception as e:
logger.warning("Error reading version snapshot %s: %s", file_name, e)
# Sort newest published first
versions.sort(key=lambda x: x.get("published_at", ""), reverse=True)
return {
"pipeline_id": norm_id,
"current_version": current_version,
"bundle_name": current_bundle,
"versions": versions
}
def revert_pipeline_version(pipeline_id: str, version_tag: str) -> Dict[str, Any]:
"""Revert the active canvas pipeline to a specific past historical version snapshot."""
norm_id = pipeline_id.lower().replace(" ", "_")
clean_tag = version_tag.strip().lower()
if not clean_tag.startswith("v"):
clean_tag = f"v{clean_tag}"
snapshot_filename = f"{norm_id}.version.{clean_tag}.json"
snapshot_path = get_metadata_path(snapshot_filename)
if not os.path.exists(snapshot_path):
return {
"status": "error",
"message": f"Version snapshot '{snapshot_filename}' not found for pipeline '{norm_id}'."
}
with open(snapshot_path, "r", encoding="utf-8") as f:
reverted_data = json.load(f)
# Update active JSON file in METADATA_DIR
target_json_path = get_target_metadata_path(f"{norm_id}.json")
with open(target_json_path, "w", encoding="utf-8") as f:
json.dump(reverted_data, f, indent=2)
# Recompile active YAML DAG file in CONFIGS_DIR
yaml_path = os.path.join(settings.CONFIGS_DIR, f"{norm_id}.yaml")
yaml_header = (
f"# ============================================================\n"
f"# MERICA Studio Data Lakehouse Pipeline (Reverted)\n"
f"# Airflow 3 DAG Bundle: {reverted_data.get('bundle_name', 'dlh-core-ingestion')}\n"
f"# Pinned Version: {clean_tag} | Reverted at: {datetime.utcnow().isoformat()}Z\n"
f"# ============================================================\n"
)
yaml_body = yaml.dump(reverted_data, sort_keys=False)
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(yaml_header + yaml_body)
return {
"status": "reverted",
"pipeline_id": norm_id,
"active_version": clean_tag,
"pipeline_data": reverted_data,
"message": f"Successfully reverted pipeline '{norm_id}' to version {clean_tag}."
}