Files
MobilityOps/n8n/workflows/check_drift.py
T
NuklearRabbitandClaude Sonnet 5 e39c0a1dd6 n8n: build and live-validate the Workflow Error Handler (WF4)
New central "Fleet Ops — Workflow Error Handler" workflow (Error
Trigger -> safe-report Code node -> POST to the new /workflow-error
endpoint), wired as the Error Workflow on both existing workflows with
no recursive loop on itself. Live-validated end-to-end against the
real Fleet Ops server (register + idempotent re-register), and via a
genuine induced failure on the scheduled-scan workflow (broken URL,
confirmed failure, reverted, confirmed healthy).

Fixed two real bugs found during live testing: Code node needed
"Run Once for Each Item" (not "All Items") for $json binding, and
every HTTP body field had a stray trailing space from the n8n
code-editor's bracket auto-close that broke datetime/enum validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:52:17 +02:00

148 lines
5.6 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"),
]
# 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())