inventory_n8n_workflows.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import os
  5. import re
  6. import sys
  7. from collections import Counter
  8. from datetime import datetime, timedelta, timezone
  9. from pathlib import Path
  10. from typing import Any, Mapping
  11. ROOT = Path(__file__).resolve().parents[1]
  12. if str(ROOT) not in sys.path:
  13. sys.path.insert(0, str(ROOT))
  14. from app.core.data_factory.n8n_client import N8nClient, N8nClientError
  15. from app.core.data_flow.workflow_repository import workflow_hash
  16. SCHEMA_VERSION = "1.0"
  17. KNOWN_NODE_CATEGORIES = {
  18. "trigger",
  19. "sql",
  20. "python_or_script",
  21. "http",
  22. "condition",
  23. "notify",
  24. "ai",
  25. "subflow",
  26. }
  27. def _parse_timestamp(value: Any) -> datetime | None:
  28. if not value:
  29. return None
  30. try:
  31. parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
  32. except ValueError:
  33. return None
  34. if parsed.tzinfo is None:
  35. parsed = parsed.replace(tzinfo=timezone.utc)
  36. return parsed.astimezone(timezone.utc)
  37. def _safe_error(value: Any) -> str:
  38. message = re.sub(
  39. r"(?i)((?:api[-_ ]?key|authorization|password|token)\s*[=:]\s*)\S+",
  40. r"\1[redacted]",
  41. str(value),
  42. )
  43. message = re.sub(
  44. r"(?i)(authorization\s+bearer\s+)\S+",
  45. r"\1[redacted]",
  46. message,
  47. )
  48. return message[:500]
  49. def _node_category(node_type: str) -> str:
  50. normalized = node_type.lower()
  51. if "trigger" in normalized or normalized.endswith(".webhook"):
  52. return "trigger"
  53. if any(
  54. name in normalized
  55. for name in ("postgres", "mysql", "mssql", "mariadb", "oracle", "snowflake")
  56. ):
  57. return "sql"
  58. if any(
  59. name in normalized
  60. for name in ("python", ".code", "executecommand", ".ssh")
  61. ):
  62. return "python_or_script"
  63. if "httprequest" in normalized:
  64. return "http"
  65. if normalized.endswith((".if", ".switch", ".filter", ".merge")):
  66. return "condition"
  67. if any(
  68. name in normalized
  69. for name in ("email", "slack", "teams", "telegram", "mattermost", "discord")
  70. ):
  71. return "notify"
  72. if "langchain" in normalized or ".ai" in normalized:
  73. return "ai"
  74. if "executeworkflow" in normalized:
  75. return "subflow"
  76. return "unknown"
  77. def _credential_references(nodes: list[dict[str, Any]]) -> list[dict[str, str]]:
  78. references = []
  79. for node in nodes:
  80. credentials = node.get("credentials")
  81. if not isinstance(credentials, dict):
  82. continue
  83. for credential_type, credential in credentials.items():
  84. if not isinstance(credential, dict):
  85. continue
  86. name = str(credential.get("name") or "").strip()
  87. if not name:
  88. continue
  89. references.append(
  90. {
  91. "node_name": str(node.get("name") or ""),
  92. "credential_type": str(credential_type),
  93. "credential_name": name,
  94. }
  95. )
  96. return sorted(
  97. references,
  98. key=lambda item: (
  99. item["node_name"],
  100. item["credential_type"],
  101. item["credential_name"],
  102. ),
  103. )
  104. def _trigger_summary(nodes: list[dict[str, Any]]) -> list[dict[str, str]]:
  105. triggers = []
  106. for node in nodes:
  107. node_type = str(node.get("type") or "")
  108. if _node_category(node_type) != "trigger":
  109. continue
  110. trigger = {
  111. "node_name": str(node.get("name") or ""),
  112. "node_type": node_type,
  113. }
  114. parameters = node.get("parameters")
  115. if isinstance(parameters, dict) and node_type.lower().endswith(".webhook"):
  116. path = parameters.get("path")
  117. method = parameters.get("httpMethod")
  118. if path:
  119. trigger["webhook_path"] = str(path)
  120. if method:
  121. trigger["http_method"] = str(method)
  122. triggers.append(trigger)
  123. return triggers
  124. def _execution_summary(
  125. executions: list[dict[str, Any]], *, generated_at: datetime
  126. ) -> dict[str, Any]:
  127. cutoff = generated_at - timedelta(days=90)
  128. recent = [
  129. execution
  130. for execution in executions
  131. if (started_at := _parse_timestamp(execution.get("startedAt")))
  132. and started_at >= cutoff
  133. ]
  134. recent.sort(
  135. key=lambda item: _parse_timestamp(item.get("startedAt"))
  136. or datetime.min.replace(tzinfo=timezone.utc),
  137. reverse=True,
  138. )
  139. statuses = Counter(str(item.get("status") or "unknown") for item in recent)
  140. latest = recent[0] if recent else {}
  141. return {
  142. "count_90d": len(recent),
  143. "status_counts": dict(sorted(statuses.items())),
  144. "last_status": latest.get("status"),
  145. "last_started_at": latest.get("startedAt"),
  146. "last_stopped_at": latest.get("stoppedAt"),
  147. }
  148. def _collect_pages(client, method_name: str, **kwargs) -> list[dict[str, Any]]:
  149. items: list[dict[str, Any]] = []
  150. cursor = None
  151. seen_cursors = set()
  152. for _ in range(1000):
  153. result = getattr(client, method_name)(
  154. **kwargs,
  155. limit=100,
  156. cursor=cursor,
  157. )
  158. page = result.get("data", [])
  159. if isinstance(page, list):
  160. items.extend(item for item in page if isinstance(item, dict))
  161. next_cursor = result.get("nextCursor")
  162. if not next_cursor:
  163. return items
  164. if next_cursor in seen_cursors:
  165. raise ValueError(f"n8n {method_name} returned a repeated cursor")
  166. seen_cursors.add(next_cursor)
  167. cursor = next_cursor
  168. raise ValueError(f"n8n {method_name} exceeded the page safety limit")
  169. def empty_inventory(*, reason: str, generated_at: str | None = None) -> dict[str, Any]:
  170. timestamp = generated_at or datetime.now(timezone.utc).isoformat()
  171. return {
  172. "schema_version": SCHEMA_VERSION,
  173. "generated_at": timestamp,
  174. "source": "n8n",
  175. "collection_status": "pending_live_n8n_access",
  176. "collection_error": _safe_error(reason),
  177. "coverage": {
  178. "workflow_count": 0,
  179. "active_count": 0,
  180. "recently_executed_count": 0,
  181. "blocked_count": 0,
  182. "unbound_count": 0,
  183. },
  184. "workflows": [],
  185. }
  186. def collect_inventory(
  187. client,
  188. *,
  189. bindings: Mapping[str, str] | None = None,
  190. generated_at: str | None = None,
  191. ) -> dict[str, Any]:
  192. timestamp = generated_at or datetime.now(timezone.utc).isoformat()
  193. generated = _parse_timestamp(timestamp)
  194. if generated is None:
  195. raise ValueError("generated_at must be an ISO 8601 timestamp")
  196. workflow_bindings = {str(key): str(value) for key, value in (bindings or {}).items()}
  197. workflows = _collect_pages(client, "list_workflows")
  198. inventory_workflows = []
  199. for workflow in workflows:
  200. workflow_id = str(workflow.get("id") or "").strip()
  201. if not workflow_id:
  202. continue
  203. executions = _collect_pages(
  204. client,
  205. "list_executions",
  206. workflow_id=workflow_id,
  207. include_data=False,
  208. )
  209. nodes = [
  210. node for node in workflow.get("nodes", []) if isinstance(node, dict)
  211. ]
  212. node_types = sorted(
  213. {str(node.get("type") or "").strip() for node in nodes if node.get("type")}
  214. )
  215. categories = sorted({_node_category(node_type) for node_type in node_types})
  216. dataflow_uid = workflow_bindings.get(workflow_id)
  217. blockers = [
  218. f"unsupported_node:{node_type}"
  219. for node_type in node_types
  220. if _node_category(node_type) == "unknown"
  221. ]
  222. if not dataflow_uid:
  223. blockers.append("missing_dataflow_binding")
  224. recent_execution = _execution_summary(executions, generated_at=generated)
  225. active = bool(workflow.get("active"))
  226. inventory_workflows.append(
  227. {
  228. "workflow_id": workflow_id,
  229. "name": str(workflow.get("name") or ""),
  230. "active": active,
  231. "updated_at": workflow.get("updatedAt"),
  232. "tags": sorted(
  233. str(tag.get("name"))
  234. for tag in workflow.get("tags", [])
  235. if isinstance(tag, dict) and tag.get("name")
  236. ),
  237. "dataflow_uid": dataflow_uid,
  238. "definition_hash": workflow_hash(workflow),
  239. "node_types": node_types,
  240. "node_categories": categories,
  241. "triggers": _trigger_summary(nodes),
  242. "credential_references": _credential_references(nodes),
  243. "recent_execution": recent_execution,
  244. "migration_assessment": {
  245. "status": "blocked" if blockers else "review_required",
  246. "priority": "blocked"
  247. if blockers
  248. else ("high" if active else "normal"),
  249. "production_write": "unknown",
  250. "idempotent": "unknown",
  251. "shadow_mode": "review_required",
  252. "blockers": sorted(blockers),
  253. },
  254. }
  255. )
  256. inventory_workflows.sort(key=lambda item: item["workflow_id"])
  257. return {
  258. "schema_version": SCHEMA_VERSION,
  259. "generated_at": timestamp,
  260. "source": "n8n",
  261. "collection_status": "complete",
  262. "collection_error": None,
  263. "coverage": {
  264. "workflow_count": len(inventory_workflows),
  265. "active_count": sum(item["active"] for item in inventory_workflows),
  266. "recently_executed_count": sum(
  267. item["recent_execution"]["count_90d"] > 0
  268. for item in inventory_workflows
  269. ),
  270. "blocked_count": sum(
  271. item["migration_assessment"]["status"] == "blocked"
  272. for item in inventory_workflows
  273. ),
  274. "unbound_count": sum(
  275. item["dataflow_uid"] is None for item in inventory_workflows
  276. ),
  277. },
  278. "workflows": inventory_workflows,
  279. }
  280. def _load_bindings(path: str | None) -> dict[str, str]:
  281. if not path:
  282. return {}
  283. payload = json.loads(Path(path).read_text(encoding="utf-8"))
  284. if not isinstance(payload, dict):
  285. raise ValueError("bindings file must contain a workflow-id to DataFlow-UID object")
  286. return {str(key): str(value) for key, value in payload.items()}
  287. def main() -> int:
  288. parser = argparse.ArgumentParser(
  289. description="Export a credential-safe n8n migration inventory."
  290. )
  291. parser.add_argument(
  292. "--output",
  293. default="docs/generated/n8n_migration_inventory.json",
  294. )
  295. parser.add_argument("--bindings-json")
  296. args = parser.parse_args()
  297. output = Path(args.output)
  298. output.parent.mkdir(parents=True, exist_ok=True)
  299. api_url = os.environ.get("N8N_API_URL", "")
  300. api_key = os.environ.get("N8N_API_KEY", "")
  301. if not api_url or not api_key:
  302. result = empty_inventory(reason="N8N_API_URL or N8N_API_KEY is not configured")
  303. output.write_text(
  304. json.dumps(result, ensure_ascii=False, indent=2) + "\n",
  305. encoding="utf-8",
  306. )
  307. return 2
  308. try:
  309. api_timeout = int(os.environ.get("N8N_API_TIMEOUT", "30"))
  310. if api_timeout <= 0:
  311. raise ValueError
  312. except ValueError:
  313. result = empty_inventory(reason="N8N_API_TIMEOUT must be a positive integer")
  314. output.write_text(
  315. json.dumps(result, ensure_ascii=False, indent=2) + "\n",
  316. encoding="utf-8",
  317. )
  318. return 2
  319. try:
  320. result = collect_inventory(
  321. N8nClient(api_url=api_url, api_key=api_key, timeout=api_timeout),
  322. bindings=_load_bindings(args.bindings_json),
  323. )
  324. except (N8nClientError, ValueError) as exc:
  325. result = empty_inventory(reason=str(exc))
  326. output.write_text(
  327. json.dumps(result, ensure_ascii=False, indent=2) + "\n",
  328. encoding="utf-8",
  329. )
  330. return 2
  331. output.write_text(
  332. json.dumps(result, ensure_ascii=False, indent=2) + "\n",
  333. encoding="utf-8",
  334. )
  335. return 0
  336. if __name__ == "__main__":
  337. raise SystemExit(main())