feat(backup-monitor): add n8n workflow JSON validator

This commit is contained in:
jared 2026-05-07 11:21:24 -04:00
parent 765ffd2394
commit 69f15d5fd2
1 changed files with 131 additions and 0 deletions

131
scripts/validate-n8n-workflow.py Executable file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Validate an n8n workflow JSON file for structural correctness.
Usage: validate-n8n-workflow.py path/to/workflow.json
Exit codes: 0 = valid, 1 = invalid, 2 = usage error.
"""
import json
import sys
from pathlib import Path
KNOWN_MIN_TYPE_VERSIONS = {
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.scheduleTrigger": 1.2,
}
REQUIRED_TOP = ["name", "nodes", "connections"]
REQUIRED_NODE = ["id", "name", "type", "typeVersion", "position", "parameters"]
def validate(workflow):
errors, warnings = [], []
if not isinstance(workflow, dict):
return ["Root must be a JSON object."], []
for key in REQUIRED_TOP:
if key not in workflow:
errors.append(f"Top-level: missing required field '{key}'.")
if errors:
return errors, warnings
if not isinstance(workflow["nodes"], list):
errors.append("Top-level 'nodes' must be an array.")
if not isinstance(workflow["connections"], dict):
errors.append("Top-level 'connections' must be an object.")
if errors:
return errors, warnings
seen_names, seen_ids = set(), set()
for i, node in enumerate(workflow["nodes"]):
loc = f"nodes[{i}]"
if not isinstance(node, dict):
errors.append(f"{loc}: must be an object.")
continue
for key in REQUIRED_NODE:
if key not in node:
errors.append(f"{loc}: missing required field '{key}'.")
name = node.get("name")
if name:
loc = f"nodes[{i}] '{name}'"
if name in seen_names:
errors.append(f"{loc}: duplicate node name.")
seen_names.add(name)
nid = node.get("id")
if nid:
if nid in seen_ids:
errors.append(f"{loc}: duplicate node id '{nid}'.")
seen_ids.add(nid)
pos = node.get("position")
if pos is not None and not (
isinstance(pos, list) and len(pos) == 2
and all(isinstance(v, (int, float)) for v in pos)
):
errors.append(f"{loc}: 'position' must be [number, number].")
if node.get("parameters") is not None and not isinstance(node["parameters"], dict):
errors.append(f"{loc}: 'parameters' must be an object.")
creds = node.get("credentials")
if isinstance(creds, dict):
for ctype, cred in creds.items():
if not isinstance(cred, dict) or "name" not in cred:
errors.append(f"{loc}: credentials['{ctype}'] must have a 'name' field.")
ntype, tv = node.get("type"), node.get("typeVersion")
if isinstance(tv, (int, float)) and ntype in KNOWN_MIN_TYPE_VERSIONS:
min_tv = KNOWN_MIN_TYPE_VERSIONS[ntype]
if tv < min_tv:
warnings.append(f"{loc}: typeVersion {tv} for '{ntype}' is below recommended {min_tv}.")
for src, outputs in workflow["connections"].items():
if src not in seen_names:
errors.append(f"connections: source '{src}' does not match any node name.")
if not isinstance(outputs, dict):
errors.append(f"connections['{src}']: must be an object.")
continue
for out_type, out_list in outputs.items():
if not isinstance(out_list, list):
continue
for oi, branch in enumerate(out_list):
if branch is None:
continue
if not isinstance(branch, list):
errors.append(f"connections['{src}']['{out_type}'][{oi}]: must be an array.")
continue
for ti, target in enumerate(branch):
if not isinstance(target, dict):
errors.append(f"connections['{src}']['{out_type}'][{oi}][{ti}]: must be an object.")
continue
for k in ("node", "type", "index"):
if k not in target:
errors.append(f"connections['{src}']['{out_type}'][{oi}][{ti}]: missing '{k}'.")
tnode = target.get("node")
if tnode and tnode not in seen_names:
errors.append(f"connections['{src}']['{out_type}'][{oi}][{ti}]: target '{tnode}' not found.")
return errors, warnings
def main():
if len(sys.argv) != 2:
print("Usage: validate-n8n-workflow.py <workflow.json>", file=sys.stderr)
sys.exit(2)
path = Path(sys.argv[1])
try:
workflow = json.loads(path.read_text())
except FileNotFoundError:
print(f"File not found: {path}", file=sys.stderr)
sys.exit(2)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
errors, warnings = validate(workflow)
for w in warnings:
print(f"WARN: {w}")
for e in errors:
print(f"ERROR: {e}", file=sys.stderr)
if errors:
print(f"\n{len(errors)} error(s), {len(warnings)} warning(s).", file=sys.stderr)
sys.exit(1)
print(f"OK — {len(workflow['nodes'])} nodes, {len(workflow['connections'])} connections, {len(warnings)} warning(s).")
if __name__ == "__main__":
main()