368 lines
14 KiB
Python
368 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Helper para mutaciones seguras de workflows n8n via API REST.
|
|
|
|
Reusable. Patrón base extraído de `n8n/_add_automap_node.py` y de la memoria
|
|
acumulada sobre la API de n8n (la API publica acepta PUT con
|
|
`{name,nodes,connections,settings}`; el campo `settings` debe filtrarse a un
|
|
whitelist; tras un PUT estructural el workflow puede quedar inactivo; el
|
|
endpoint de activación/desactivación es POST /activate, POST /deactivate).
|
|
|
|
Ejemplo:
|
|
from scripts.n8n_workflow_lib import load_credentials, N8NClient
|
|
client = N8NClient(*load_credentials())
|
|
wf, backup_path = client.backup_workflow(WID, label='fase_1')
|
|
new_node = {...}
|
|
client.add_node(wf, new_node)
|
|
client.insert_between(wf, 'Datos API Cuenta objetivo - MARCA',
|
|
new_node['name'], 'Buscar Contacto Objetivo - MARCA (phone)')
|
|
client.put_workflow(WID, wf, dry_run=True) # dump a n8n/dryrun_*.json
|
|
client.put_workflow(WID, wf, dry_run=False) # aplica real
|
|
client.verify_post(WID, expected_node_names=[new_node['name']], prev_version_id=wf['versionId'])
|
|
client.activate(WID)
|
|
"""
|
|
|
|
import argparse
|
|
import copy
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
|
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
N8N_DIR = os.path.join(ROOT_DIR, "n8n")
|
|
|
|
# Whitelist confirmada en `_add_automap_node.py`. Si n8n agrega settings nuevos
|
|
# útiles, actualizar aquí (una sola fuente de verdad).
|
|
ALLOWED_SETTINGS = {
|
|
"saveExecutionProgress",
|
|
"saveManualExecutions",
|
|
"saveDataErrorExecution",
|
|
"saveDataSuccessExecution",
|
|
"executionTimeout",
|
|
"errorWorkflow",
|
|
"timezone",
|
|
"executionOrder",
|
|
}
|
|
|
|
|
|
class AlreadyExistsError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class PutFailedError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class VerificationFailedError(RuntimeError):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_credentials(path=None):
|
|
"""Lee credenciales n8n del archivo plano `API:` / `URL:`.
|
|
Devuelve (api_key, base_url_sin_slash_final).
|
|
"""
|
|
if path is None:
|
|
path = os.path.join(N8N_DIR, "n8n credencials.txt")
|
|
api_key = base_url = None
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
if line.startswith("API:"):
|
|
api_key = line.split("API:", 1)[1].strip()
|
|
elif line.startswith("URL:"):
|
|
base_url = line.split("URL:", 1)[1].strip().rstrip("/")
|
|
if not api_key or not base_url:
|
|
raise RuntimeError(f"Credenciales incompletas en {path}")
|
|
return api_key, base_url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class N8NClient:
|
|
def __init__(self, api_key, base_url, timeout=60):
|
|
self.api_key = api_key
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
self.headers = {
|
|
"X-N8N-API-KEY": api_key,
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
# ---- HTTP base ----
|
|
|
|
def _request(self, method, path, body=None):
|
|
url = f"{self.base_url}{path}"
|
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
req = urllib.request.Request(url, method=method, data=data, headers=self.headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
txt = resp.read().decode("utf-8", errors="replace")
|
|
return resp.status, (json.loads(txt) if txt else None)
|
|
except urllib.error.HTTPError as e:
|
|
txt = e.read().decode("utf-8", errors="replace")
|
|
try:
|
|
payload = json.loads(txt)
|
|
except Exception:
|
|
payload = txt[:500]
|
|
raise PutFailedError(f"HTTP {e.code} {method} {path}: {payload}")
|
|
|
|
# ---- Workflow CRUD ----
|
|
|
|
def get_workflow(self, wid):
|
|
_, wf = self._request("GET", f"/api/v1/workflows/{wid}")
|
|
return wf
|
|
|
|
def backup_workflow(self, wid, label="generic"):
|
|
wf = self.get_workflow(wid)
|
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_label = re.sub(r"[^A-Za-z0-9_-]", "_", label)
|
|
os.makedirs(N8N_DIR, exist_ok=True)
|
|
path = os.path.join(N8N_DIR, f"backup_{safe_label}_{wid}_{ts}.json")
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(wf, fh, ensure_ascii=False, indent=2)
|
|
return wf, path
|
|
|
|
def filter_settings(self, wf):
|
|
s = wf.get("settings") or {}
|
|
return {k: v for k, v in s.items() if k in ALLOWED_SETTINGS}
|
|
|
|
def put_workflow(self, wid, wf, dry_run=True):
|
|
"""Si dry_run=True dumpea el payload a n8n/dryrun_*.json y no llama API.
|
|
Si dry_run=False hace PUT real y retorna el workflow actualizado."""
|
|
payload = {
|
|
"name": wf["name"],
|
|
"nodes": wf["nodes"],
|
|
"connections": wf["connections"],
|
|
"settings": self.filter_settings(wf),
|
|
}
|
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
if dry_run:
|
|
os.makedirs(N8N_DIR, exist_ok=True)
|
|
path = os.path.join(N8N_DIR, f"dryrun_{wid}_{ts}.json")
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
|
return {"dry_run": True, "path": path, "node_count": len(payload["nodes"])}
|
|
status, body = self._request("PUT", f"/api/v1/workflows/{wid}", body=payload)
|
|
if status != 200:
|
|
raise PutFailedError(f"PUT status {status}: {body}")
|
|
return body
|
|
|
|
def activate(self, wid):
|
|
status, body = self._request("POST", f"/api/v1/workflows/{wid}/activate")
|
|
return body
|
|
|
|
def deactivate(self, wid):
|
|
status, body = self._request("POST", f"/api/v1/workflows/{wid}/deactivate")
|
|
return body
|
|
|
|
def restore_from_backup(self, path):
|
|
"""Lee el JSON de backup y hace PUT. Devuelve el nuevo workflow."""
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
wf = json.load(fh)
|
|
wid = wf["id"]
|
|
was_active = bool(wf.get("active"))
|
|
# Desactivar primero si está activo (necesario para PUT estructural en algunos casos).
|
|
try:
|
|
self.deactivate(wid)
|
|
except Exception:
|
|
pass
|
|
result = self.put_workflow(wid, wf, dry_run=False)
|
|
if was_active:
|
|
self.activate(wid)
|
|
return result
|
|
|
|
# ---- Verificación ----
|
|
|
|
def verify_post(self, wid, expected_node_names=None, prev_version_id=None):
|
|
wf = self.get_workflow(wid)
|
|
errors = []
|
|
if prev_version_id and wf.get("versionId") == prev_version_id:
|
|
errors.append(f"versionId no cambió (sigue siendo {prev_version_id})")
|
|
nodes_by_name = {n["name"] for n in wf.get("nodes") or []}
|
|
for name in (expected_node_names or []):
|
|
if name not in nodes_by_name:
|
|
errors.append(f"nodo esperado ausente: {name!r}")
|
|
if errors:
|
|
raise VerificationFailedError("; ".join(errors))
|
|
return wf
|
|
|
|
# ---- Mutaciones in-memory (operan sobre wf dict, no llaman API) ----
|
|
|
|
@staticmethod
|
|
def find_node(wf, name):
|
|
for n in wf.get("nodes") or []:
|
|
if n["name"] == name:
|
|
return n
|
|
return None
|
|
|
|
@staticmethod
|
|
def assert_idempotent(wf, new_name):
|
|
if N8NClient.find_node(wf, new_name) is not None:
|
|
raise AlreadyExistsError(f"El nodo {new_name!r} ya existe en el workflow.")
|
|
|
|
@staticmethod
|
|
def add_node(wf, node_dict):
|
|
"""Append a `nodes`. Si no trae `id`, se genera uno UUIDv4."""
|
|
if "id" not in node_dict:
|
|
node_dict["id"] = str(uuid.uuid4())
|
|
wf.setdefault("nodes", []).append(node_dict)
|
|
return node_dict
|
|
|
|
@staticmethod
|
|
def get_connection_targets(wf, src, output_kind="main", output_index=0):
|
|
"""Devuelve la lista de {node, type, index} que salen de `src` por output `output_index`."""
|
|
conns = wf.get("connections") or {}
|
|
node = conns.get(src) or {}
|
|
branches = node.get(output_kind) or []
|
|
if output_index >= len(branches):
|
|
return []
|
|
return branches[output_index] or []
|
|
|
|
@staticmethod
|
|
def set_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
|
|
"""Sobrescribe el sink de src→[output_index] dejando UN solo destino dst."""
|
|
conns = wf.setdefault("connections", {})
|
|
node = conns.setdefault(src, {})
|
|
branches = node.setdefault(output_kind, [])
|
|
while len(branches) <= output_index:
|
|
branches.append([])
|
|
branches[output_index] = [{"node": dst, "type": output_kind, "index": dst_input_index}]
|
|
|
|
@staticmethod
|
|
def append_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
|
|
"""Agrega dst al sink de src sin borrar los existentes."""
|
|
conns = wf.setdefault("connections", {})
|
|
node = conns.setdefault(src, {})
|
|
branches = node.setdefault(output_kind, [])
|
|
while len(branches) <= output_index:
|
|
branches.append([])
|
|
branches[output_index].append({"node": dst, "type": output_kind, "index": dst_input_index})
|
|
|
|
@staticmethod
|
|
def insert_between(wf, src, mid, dst, output_kind="main", output_index=0):
|
|
"""Reemplaza src→dst por src→mid→dst. Preserva el resto de salidas de src."""
|
|
N8NClient.set_connection(wf, src, mid, output_kind, output_index)
|
|
N8NClient.set_connection(wf, mid, dst, output_kind, 0)
|
|
|
|
@staticmethod
|
|
def branch_if(wf, if_node_name, true_target, false_target):
|
|
"""Para nodos IF: configura output 0 (true) y output 1 (false)."""
|
|
N8NClient.set_connection(wf, if_node_name, true_target, "main", 0)
|
|
N8NClient.set_connection(wf, if_node_name, false_target, "main", 1)
|
|
|
|
@staticmethod
|
|
def rename_node(wf, old_name, new_name):
|
|
"""Renombra un nodo y actualiza TODAS las referencias $('OLD') y connections."""
|
|
node = N8NClient.find_node(wf, old_name)
|
|
if not node:
|
|
raise KeyError(f"Nodo {old_name!r} no existe.")
|
|
if N8NClient.find_node(wf, new_name) is not None:
|
|
raise AlreadyExistsError(f"Ya existe un nodo con nombre {new_name!r}.")
|
|
node["name"] = new_name
|
|
|
|
# Referencias $('OLD') dentro de parameters (jsonBody, url, jsCode, value, expression).
|
|
old_re = re.compile(r"\$\(\s*['\"]" + re.escape(old_name) + r"['\"]\s*\)")
|
|
|
|
def walk(obj):
|
|
if isinstance(obj, dict):
|
|
return {k: walk(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [walk(v) for v in obj]
|
|
if isinstance(obj, str):
|
|
return old_re.sub(f"$('{new_name}')", obj)
|
|
return obj
|
|
|
|
for n in wf.get("nodes") or []:
|
|
n["parameters"] = walk(n.get("parameters") or {})
|
|
|
|
# connections: claves (src) y values (dst entries con .node).
|
|
conns = wf.get("connections") or {}
|
|
if old_name in conns:
|
|
conns[new_name] = conns.pop(old_name)
|
|
for src, outs in conns.items():
|
|
for kind, branches in (outs or {}).items():
|
|
for branch in branches or []:
|
|
for entry in branch or []:
|
|
if entry.get("node") == old_name:
|
|
entry["node"] = new_name
|
|
wf["connections"] = conns
|
|
return node
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Self-test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def self_test(wid):
|
|
client = N8NClient(*load_credentials())
|
|
print(f"[self-test] GET workflow {wid}...")
|
|
wf = client.get_workflow(wid)
|
|
print(f" name: {wf.get('name')} active: {wf.get('active')} nodes: {len(wf.get('nodes') or [])}")
|
|
print(f" versionId: {wf.get('versionId')}")
|
|
|
|
print(f"[self-test] backup...")
|
|
_, path = client.backup_workflow(wid, label="self_test")
|
|
print(f" -> {path}")
|
|
print(f" size: {os.path.getsize(path)} bytes")
|
|
|
|
# In-memory: insertar un nodo dummy y hacer dry-run, sin tocar n8n.
|
|
wf2 = copy.deepcopy(wf)
|
|
dummy_name = "self_test_dummy_node_DELETE_ME"
|
|
try:
|
|
client.assert_idempotent(wf2, dummy_name)
|
|
except AlreadyExistsError as e:
|
|
print(f" WARN: {e} (saltando inserción)")
|
|
return
|
|
dummy = {
|
|
"parameters": {},
|
|
"type": "n8n-nodes-base.set",
|
|
"typeVersion": 1,
|
|
"position": [0, 0],
|
|
"name": dummy_name,
|
|
"notes": "self-test dummy. Si esto llega a n8n, alguien aplicó el dry-run sin querer.",
|
|
}
|
|
client.add_node(wf2, dummy)
|
|
|
|
print(f"[self-test] dry-run PUT...")
|
|
res = client.put_workflow(wid, wf2, dry_run=True)
|
|
print(f" dry_run path: {res['path']} nodes en payload: {res['node_count']}")
|
|
|
|
# Verificar que el dryrun JSON tiene exactamente 1 nodo más que el original.
|
|
with open(res["path"], "r", encoding="utf-8") as fh:
|
|
dryrun = json.load(fh)
|
|
diff_count = len(dryrun["nodes"]) - len(wf["nodes"])
|
|
if diff_count != 1:
|
|
raise SystemExit(f"FAIL: dryrun tiene delta de nodos = {diff_count} (esperado 1)")
|
|
print(f" [OK] delta nodos = +1 (correcto)")
|
|
|
|
# Confirmar que el dummy NO está en n8n live.
|
|
wf_live = client.get_workflow(wid)
|
|
has_dummy = client.find_node(wf_live, dummy_name) is not None
|
|
if has_dummy:
|
|
raise SystemExit("FAIL: dummy llegó a n8n live (dry-run no fue dry).")
|
|
print(" [OK] workflow live intacto (dry-run respetado)")
|
|
|
|
print(f"[self-test] OK")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="n8n workflow lib helper.")
|
|
parser.add_argument("--self-test", metavar="WID", help="Correr self-test contra un workflow id.")
|
|
args = parser.parse_args()
|
|
if args.self_test:
|
|
self_test(args.self_test)
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|