150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Report drift between the repo's cleaned workflow definitions and the live n8n instance.
|
|
|
|
Read-only: this script only issues GET requests against n8n's Public API. It never writes,
|
|
imports, activates, or otherwise modifies anything in n8n -- fixing drift is a deliberate,
|
|
reviewed action a human takes in the n8n UI (or via a separate, explicit import step), not
|
|
something this script does automatically.
|
|
|
|
Usage:
|
|
N8N_BASE_URL=https://n8n.itworx.tech N8N_API_KEY=... python n8n/workflows/check_drift.py
|
|
|
|
N8N_API_KEY must be an n8n Public API key (n8n UI -> Settings -> API), not a session cookie
|
|
and not a workflow credential. It is read from the environment only and is never printed.
|
|
|
|
Exit code is 0 when every checked workflow matches the live instance, 1 when any drift (or
|
|
a fetch error) is found, so this is safe to wire into CI as a non-blocking check.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
WORKFLOWS_DIR = Path(__file__).parent
|
|
|
|
# (repo file name, live workflow ID) -- kept in sync with MANIFEST.md by hand, since the
|
|
# manifest is the human-readable source of truth and this is just its machine-checkable echo.
|
|
KNOWN_WORKFLOWS = [
|
|
("fleet-ops-vehicle-return.json", "mobilityops-return-processing"),
|
|
("fleet-ops-data-quality-scan.json", "mobilityops-scheduled-quality-scan"),
|
|
("fleet-ops-error-handler.json", "Xppn2rAEqUuyiCJF"),
|
|
("fleet-ops-ragcore-procedure-sync.json", "6wbkc4d1AouGpmWT"),
|
|
("fleet-ops-alert-receiver.json", "mobilityops-alert-receiver"),
|
|
]
|
|
|
|
# Fields that legitimately differ between a committed definition and the live instance
|
|
# (instance-assigned identifiers, timestamps, UI-only cosmetics) and must not be reported
|
|
# as drift.
|
|
_VOLATILE_TOP_LEVEL_KEYS = {
|
|
"versionId",
|
|
"createdAt",
|
|
"updatedAt",
|
|
"pinData",
|
|
"staticData",
|
|
"shared",
|
|
"triggerCount",
|
|
"isArchived",
|
|
}
|
|
_VOLATILE_NODE_KEYS = {"position", "webhookId"}
|
|
|
|
|
|
def _strip_credential_ids(value: Any) -> Any:
|
|
"""Live workflows carry instance-specific credential IDs alongside the credential name
|
|
(e.g. {"id": "3", "name": "Fleet Ops Service Token"}). The repo definitions intentionally
|
|
omit the id, since it's meaningless outside the instance that issued it. Drop it from both
|
|
sides so credential *references* are compared by name only."""
|
|
if isinstance(value, dict):
|
|
if set(value.keys()) <= {"id", "name"} and "name" in value:
|
|
return {"name": value["name"]}
|
|
return {k: _strip_credential_ids(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_strip_credential_ids(v) for v in value]
|
|
return value
|
|
|
|
|
|
def _normalize_node(node: dict[str, Any]) -> dict[str, Any]:
|
|
cleaned = {k: v for k, v in node.items() if k not in _VOLATILE_NODE_KEYS}
|
|
return _strip_credential_ids(cleaned)
|
|
|
|
|
|
def _normalize_workflow(doc: dict[str, Any]) -> dict[str, Any]:
|
|
nodes_by_name = {n["name"]: _normalize_node(n) for n in doc.get("nodes", [])}
|
|
return {
|
|
"name": doc.get("name"),
|
|
"active": doc.get("active"),
|
|
"nodes": nodes_by_name,
|
|
"connections": doc.get("connections", {}),
|
|
"settings": doc.get("settings", {}),
|
|
}
|
|
|
|
|
|
def _diff(path: str, local: Any, live: Any, out: list[str]) -> None:
|
|
if isinstance(local, dict) and isinstance(live, dict):
|
|
for key in sorted(set(local) | set(live)):
|
|
if key in _VOLATILE_TOP_LEVEL_KEYS:
|
|
continue
|
|
if key not in live:
|
|
out.append(f"{path}.{key}: present in repo, missing live")
|
|
elif key not in local:
|
|
out.append(f"{path}.{key}: present live, missing in repo")
|
|
else:
|
|
_diff(f"{path}.{key}", local[key], live[key], out)
|
|
elif local != live:
|
|
out.append(f"{path}: repo={local!r} live={live!r}")
|
|
|
|
|
|
def fetch_live_workflow(base_url: str, api_key: str, workflow_id: str) -> dict[str, Any]:
|
|
url = f"{base_url.rstrip('/')}/api/v1/workflows/{workflow_id}"
|
|
request = urllib.request.Request(url, headers={"X-N8N-API-KEY": api_key, "Accept": "application/json"})
|
|
with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 (fixed https base url from env)
|
|
return json.load(response)
|
|
|
|
|
|
def main() -> int:
|
|
base_url = os.environ.get("N8N_BASE_URL")
|
|
api_key = os.environ.get("N8N_API_KEY")
|
|
if not base_url or not api_key:
|
|
print(
|
|
"Set N8N_BASE_URL and N8N_API_KEY (an n8n Public API key) in the environment.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
any_drift = False
|
|
for filename, workflow_id in KNOWN_WORKFLOWS:
|
|
repo_path = WORKFLOWS_DIR / filename
|
|
local_doc = json.loads(repo_path.read_text(encoding="utf-8"))
|
|
|
|
try:
|
|
live_doc = fetch_live_workflow(base_url, api_key, workflow_id)
|
|
except urllib.error.HTTPError as exc:
|
|
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: HTTP {exc.code}")
|
|
any_drift = True
|
|
continue
|
|
except urllib.error.URLError as exc:
|
|
print(f"[{filename}] FAILED to fetch live workflow {workflow_id}: {exc.reason}")
|
|
any_drift = True
|
|
continue
|
|
|
|
differences: list[str] = []
|
|
_diff(filename, _normalize_workflow(local_doc), _normalize_workflow(live_doc), differences)
|
|
|
|
if differences:
|
|
any_drift = True
|
|
print(f"[{filename}] DRIFT from live workflow {workflow_id}:")
|
|
for line in differences:
|
|
print(f" - {line}")
|
|
else:
|
|
print(f"[{filename}] matches live workflow {workflow_id}")
|
|
|
|
return 1 if any_drift else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|