feat(appliance): initial release of MERICA standalone appliance v1.0.0

This commit is contained in:
2026-10-21 17:16:46 +07:00
commit 851be0cda7
144 changed files with 31135 additions and 0 deletions
+773
View File
@@ -0,0 +1,773 @@
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}."
}