From e5912bfc7e9c73f489453c8a920231056cae6527 Mon Sep 17 00:00:00 2001 From: JohannVelazquez Date: Wed, 30 Sep 2026 00:01:37 -0600 Subject: [PATCH] =?UTF-8?q?Registro=20#71,=20adenda:=20piloto=20del=20webh?= =?UTF-8?q?ook=20Jira=20FAC=20validado=20(webhook=20id=201,=20FAC-169=20de?= =?UTF-8?q?tectado=20en=2014.4=20s=20con=20HMAC=20y=20borrado)=20y=20scrip?= =?UTF-8?q?t=20de=20operaci=C3=B3n;=20pendiente=20mover=20el=20webhook=20a?= =?UTF-8?q?=20paputec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bitacora/PENDIENTES.md | 12 ++- bitacora/REGISTRO.md | 8 ++ planeacion/jira-webhook-prueba.py | 164 ++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 planeacion/jira-webhook-prueba.py diff --git a/bitacora/PENDIENTES.md b/bitacora/PENDIENTES.md index 902eec1..9d40ddb 100644 --- a/bitacora/PENDIENTES.md +++ b/bitacora/PENDIENTES.md @@ -8,11 +8,13 @@ _Última actualización: **2026-09-29** — plan del webhook Jira FAC → vista - [ ] 📄 **Archivar en `fuentes/` la autorización escrita de Pedro** para crear el webhook/regla en Jira; anotar su alcance (¿incluye crear un ticket de prueba en FAC?). - [x] ~~🔎 **Fase 1 (solo GET):** `myself` + `mypermissions` (ADMINISTER / ADMINISTER_PROJECTS en FAC) + inventario de webhooks → decide Vía A (webhook de sistema) o Vía B (regla de Automation).~~ → **HECHO (29-sep):** `ADMINISTER`=true, 0 webhooks de sitio, API de Automation accesible (50+ reglas). Vía A y Vía B disponibles. -- [ ] 🔴 **Decidir el evento:** creación del ticket, transición a *En proceso de facturación* (dura ~3 min), o ambos. -- [ ] 🔧 **Fase 2:** `POST /api/jira/webhook` con secreto, relectura del ticket, idempotencia y solo dry-run; pruebas con `FakeHandler`. Poller de respaldo. -- [ ] 🌐 **Fase 3:** ngrok con traffic policy de una sola ruta; API en ReadOnly mientras el túnel esté abierto. -- [ ] ✍️ **Fase 4:** alta del webhook/regla como operación numerada (id, URL, fecha, autorización). -- [ ] 🧪 **Fase 5:** ticket de prueba → "lista para revisar" → verificar que BIND no recibió escritura. Reversa documentada. +- [x] ~~🔴 **Decidir el evento:** creación del ticket, transición a *En proceso de facturación* (dura ~3 min), o ambos.~~ → **Creación del ticket** (`jira:issue_created`) para el piloto; la transición a *En proceso de facturación* queda para después. +- [x] ~~🔧 **Fase 2:** `POST /api/jira/webhook` con secreto, relectura del ticket, idempotencia y solo dry-run; pruebas con `FakeHandler`. Poller de respaldo.~~ → **HECHO (29-sep, `d7238da`).** Falta el poller de respaldo. +- [x] ~~🌐 **Fase 3:** ngrok con traffic policy de una sola ruta; API en ReadOnly mientras el túnel esté abierto.~~ → **HECHO (29-sep)**; túnel apagado al cerrar la sesión. +- [x] ~~✍️ **Fase 4:** alta del webhook/regla como operación numerada (id, URL, fecha, autorización).~~ → **HECHO: webhook de sitio id 1** (19:05:53). +- [x] ~~🧪 **Fase 5:** ticket de prueba → "lista para revisar" → verificar que BIND no recibió escritura. Reversa documentada.~~ → **HECHO: FAC-169** creado → detectado en 14.4 s (HMAC, verificado) → borrado. Ver adenda del [#71](REGISTRO.md). +- [ ] 🔥 **Mover el webhook id 1 a paputec** (`https://paputec.mx/balam/api/jira/webhook`): publicar `d7238da` en paputec, poner `Jira__Webhook__Secret` en `/opt/balam/.env`, `PUT` del webhook con la URL nueva y ese secreto (solo HMAC, sin token en la URL). **Hoy Jira entrega a una URL caída** (el túnel ngrok se apagó). +- [ ] 🔧 **Siguiente paso de la meta:** con cada `JiraTicketEvent` verificado, armar la **vista previa (dry-run) de la cotización** y marcar el ticket "lista para revisar" en la bandeja — la confirmación sigue siendo humana (ADR-001). - [ ] ☁️ **Destino productivo del webhook:** ngrok con dominio fijo (de pago) vs Azure (sigue bloqueado por el cliente). - [ ] ⚠️ **Una sola sesión de Claude Code dueña de la API** en sesiones de escritura (el 29-sep otra sesión la relanzó en ReadOnly). - [ ] 🔥 **API público de BIND en 500 en todos los endpoints** (key de Arturo) — sync detenido desde el 10-sep. Pedir a Balam la API key del usuario `CTO` o revisar el acceso de Arturo. diff --git a/bitacora/REGISTRO.md b/bitacora/REGISTRO.md index bc883d5..e0664ae 100644 --- a/bitacora/REGISTRO.md +++ b/bitacora/REGISTRO.md @@ -1643,6 +1643,14 @@ Se aplicó el protocolo del guion §6: parar, anunciar el diagnóstico, pivotear **Repo de la plataforma:** se sube el puente de prefactura por la UI de BIND (`BindUiClient`, `PrefacturaUiEndpoints`, tarjeta del wizard y pruebas) sobre 4 commits remotos de la otra máquina (26-ago: experimento de folio compartido; 15-sep: publicación en paputec.mx, catálogo de servicios, cotización en lote). Tres conflictos resueltos: **se conservó el candado 409 de `POST /api/emision/prefactura`** (el commit remoto del 26-ago noche lo había retirado para el experimento de `Number`, que el 27-ago BIND ignoró), se combinaron los campos de `NewInvoice` (`Number`/`ShowIEPS` + `PrevInvoice`/`IsCFDI`/`QuoteID`) y los usings de `Program.cs`. Verificado después del rebase: **108 pruebas en verde** y frontend compilando; escaneo del diff sin secretos. Commit `4d6b3d8`. +**ADENDA (29-sep, noche) — PILOTO DEL WEBHOOK VALIDADO DE PUNTA A PUNTA** (autorización expresa de Johann para las tres escrituras de prueba en Jira; Pedro autorizó el uso de su token): +- **Plataforma** (commit `d7238da` en el repo del cliente): `POST /api/jira/webhook` con secreto (HMAC `X-Hub-Signature` o token), relectura del ticket por API (solo GET), tabla `JiraTicketEvents` con RLS e idempotencia, `GET /api/jira/eventos`; sin secreto responde 404. 8 pruebas nuevas, suite de la API en 38/38. Instancia expuesta en el puerto 5079 con `Bind:Mode=ReadOnly` y el puente UI apagado; ngrok con *traffic policy* que solo deja pasar el webhook (verificado desde internet: todo lo demás 404). +- **Antes de escribir, inventario de Automation (solo GET):** 64 reglas (42 activas); de las 17 que se disparan al crear un ticket, **solo "Confirmación ticket creado - FACT" aplica a FAC** (proyecto 10034) y manda correo al reporter. Notificaciones de FAC al crear/borrar: reporter + watchers → con el ticket de prueba todo le llegó solo a Pedro. +- **Jira-op 1 (19:05:53):** webhook de sitio **id 1** registrado — `jira:issue_created`, filtro `project = FAC`, con secreto HMAC, apuntando a `https://gregoria-disjoined-marcelino.ngrok-free.dev/api/jira/webhook`. +- **Jira-op 2:** ticket de prueba **FAC-169** creado por API (asignado a Pedro, marcado como prueba). → Jira entregó **una sola vez**, **firmado HMAC**, desde "Atlassian Webhook HTTP Client"; la plataforma lo registró **verificado** (estatus Open) **14.4 s** después del timestamp del evento (la creación por API tardó ~11 s en responder; el aviso llegó ~3 s después). +- **Jira-op 3 (19:07:44):** FAC-169 **borrado** (204; GET posterior → 404). En la BD local queda el evento id 1 como evidencia. +- **Estado al cierre:** el webhook id 1 sigue registrado y activo, pero el túnel y la instancia 5079 se apagaron al cerrar la sesión → **Jira entrega a una URL caída** hasta moverlo. Destino estable: **`https://paputec.mx/balam/api/jira/webhook`** (Apache ya proxya `/balam/api/`). Script de operación: [`../planeacion/jira-webhook-prueba.py`](../planeacion/jira-webhook-prueba.py) (subcomandos `estado`, `mover`, `borrar-webhook`, `crear-ticket`, `borrar-ticket`). + > **Lectura estratégica:** el webhook no cambia el protocolo de escritura: lo que acelera es la *captura*, no la *decisión*. Y el caso más valioso no es la creación del ticket sino el cruce de 3 minutos por *En proceso de facturación*, que el polling de 15 min pierde. --- diff --git a/planeacion/jira-webhook-prueba.py b/planeacion/jira-webhook-prueba.py new file mode 100644 index 0000000..e2854fe --- /dev/null +++ b/planeacion/jira-webhook-prueba.py @@ -0,0 +1,164 @@ +# Piloto del webhook Jira FAC → plataforma (29-sep-2026). Autorizado por Pedro +# (vía Johann). Cada escritura en Jira es explícita, una por subcomando: +# +# url → lee la URL pública del túnel ngrok local (solo lectura) +# registrar → POST /rest/webhooks/1.0/webhook (ESCRIBE: 1 webhook de sitio) +# crear-ticket → POST /rest/api/3/issue (ESCRIBE: 1 ticket de prueba en FAC) +# esperar CLAVE → espera la detección en la BD local (solo lectura) +# borrar-ticket CLAVE → DELETE /rest/api/3/issue/CLAVE (ESCRIBE: borra el ticket de prueba) +# borrar-webhook → DELETE del webhook registrado (reversa total) +# mover URL_BASE → PUT del webhook a otra URL base (ESCRIBE), p. ej. +# https://paputec.mx/balam (el receptor vive en /api/jira/webhook) +# estado → webhooks de sitio actuales (solo lectura) +# +# Estado del piloto: webhook de sitio id 1 registrado el 29-sep apuntando al +# túnel ngrok de Johann; prueba FAC-169 creada, detectada y borrada. +# +# El token de Jira y el secreto del webhook nunca se imprimen ni se versionan: +# token → propuesta/token-jira.txt (gitignoreado) o $JIRA_TOKEN_FILE +# secreto → $JIRA_WEBHOOK_SECRET_FILE (default: ~/jira-webhook-secret.txt) +import base64, json, os, subprocess, sys, time, urllib.request, urllib.error + +SITE = "https://balam-jsm-temp.atlassian.net" +EMAIL = "pedro.ayala@balamtalentoestrategico.com" +PEDRO = "712020:0cde8558-7794-468c-9e2c-73dfe63bd57b" +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOME = os.path.expanduser("~") +TOKEN = open(os.environ.get("JIRA_TOKEN_FILE", os.path.join(REPO, "propuesta", "token-jira.txt")), encoding="utf-8").read().strip() +SECRETO = open(os.environ.get("JIRA_WEBHOOK_SECRET_FILE", os.path.join(HOME, "jira-webhook-secret.txt")), encoding="utf-8").read().strip() +AUTH = "Basic " + base64.b64encode(f"{EMAIL}:{TOKEN}".encode()).decode() +ESTADO = os.environ.get("JIRA_WEBHOOK_ESTADO", os.path.join(HOME, "jira-webhook-estado.json")) + + +def jira(method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(SITE + path, data=data, method=method, headers={ + "Authorization": AUTH, "Accept": "application/json", "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read().decode("utf-8") + return r.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace")[:500] + + +def estado(**cambios): + s = json.load(open(ESTADO, encoding="utf-8")) if os.path.exists(ESTADO) else {} + s.update(cambios) + json.dump(s, open(ESTADO, "w", encoding="utf-8"), indent=1) + return s + + +def url_publica(): + for puerto in (4040, 4041, 4042): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/api/tunnels", timeout=3) as r: + for t in json.loads(r.read().decode())["tunnels"]: + if t["public_url"].startswith("https://") and "5079" in t["config"]["addr"]: + return t["public_url"] + except Exception: + pass + return None + + +def sql(consulta): + # Superusuario local del docker-compose: ve la tabla pese a RLS. + out = subprocess.run(["docker", "exec", "balam-postgres-dev", "psql", "-U", "balam", "-d", "balam", + "-At", "-F", " | ", "-c", consulta], capture_output=True, text=True, encoding="utf-8") + return out.stdout.strip() or out.stderr.strip() + + +cmd = sys.argv[1] if len(sys.argv) > 1 else "estado" + +if cmd == "url": + print(url_publica() or "Sin túnel ngrok hacia 5079.") + +elif cmd == "registrar": + base = url_publica() + if not base: + sys.exit("Sin túnel ngrok hacia 5079: no se registra nada.") + cuerpo = { + "name": "Balam plataforma · FAC ticket creado (piloto 29-sep)", + "url": f"{base}/api/jira/webhook?token={SECRETO}", + "events": ["jira:issue_created"], + "filters": {"issue-related-events-section": "project = FAC"}, + "excludeBody": False, + "secret": SECRETO, + } + st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo) + if st == 400 and "secret" in str(r).lower(): + cuerpo.pop("secret") + st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo) + if st in (200, 201) and isinstance(r, dict): + wid = r.get("self", "").rstrip("/").split("/")[-1] + estado(webhook_id=wid, webhook_url_base=base, registrado=time.strftime("%Y-%m-%d %H:%M:%S"), + con_secreto_hmac="secret" in cuerpo) + print(f"[{st}] webhook registrado · id={wid} · eventos={r.get('events')} · filtro={r.get('filters')} · " + f"secreto HMAC enviado={'secret' in cuerpo}") + else: + print(f"[{st}] {r}") + +elif cmd == "crear-ticket": + adf = {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": + "Ticket de PRUEBA de la plataforma Balam (29-sep-2026), autorizado por Pedro Ayala, para validar " + "que la plataforma detecta la creación de tickets por webhook. Se elimina en cuanto se confirma."}]}]} + st, r = jira("POST", "/rest/api/3/issue", {"fields": { + "project": {"key": "FAC"}, "issuetype": {"id": "10073"}, + "summary": "[PRUEBA PLATAFORMA BALAM] Detección por webhook — se borra en minutos", + "description": adf, "assignee": {"accountId": PEDRO}}}) + if st == 201: + estado(ticket=r["key"], ticket_id=r["id"], creado=time.strftime("%Y-%m-%d %H:%M:%S")) + print(f"[201] ticket de prueba creado: {r['key']} (id {r['id']})") + else: + print(f"[{st}] {r}") + +elif cmd == "esperar": + clave = sys.argv[2] + for i in range(60): + fila = sql(f"""SELECT "Id","IssueKey","Verified","AuthMethod","JiraStatus", + to_char("JiraTimestampUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'), + to_char("ReceivedAtUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'), + round(extract(epoch from ("ReceivedAtUtc"-"JiraTimestampUtc"))::numeric,2), + coalesce("Error",'') FROM "JiraTicketEvents" WHERE "IssueKey"='{clave}'""") + if fila and "|" in fila: + print(f"DETECTADO tras ~{i}s de espera: id | llave | verificado | auth | estatus Jira | emitido (MX) | recibido (MX) | retraso s | error") + print(" " + fila) + break + time.sleep(1) + else: + print(f"Sin detección de {clave} en 60 s.") + +elif cmd == "borrar-ticket": + clave = sys.argv[2] + st, r = jira("DELETE", f"/rest/api/3/issue/{clave}") + st2, _ = jira("GET", f"/rest/api/3/issue/{clave}") + estado(ticket_borrado=clave if st == 204 else None, borrado=time.strftime("%Y-%m-%d %H:%M:%S")) + print(f"[{st}] DELETE {clave} · verificación GET → {st2} (404 = ya no existe)") + +elif cmd == "borrar-webhook": + wid = estado().get("webhook_id") + st, r = jira("DELETE", f"/rest/webhooks/1.0/webhook/{wid}") + print(f"[{st}] DELETE webhook {wid}") + +elif cmd == "mover": + # Destino estable (paputec): solo HMAC, sin el secreto en la URL. + base = sys.argv[2].rstrip("/") + wid = estado().get("webhook_id") or "1" + st, r = jira("PUT", f"/rest/webhooks/1.0/webhook/{wid}", { + "name": "Balam plataforma · FAC ticket creado", + "url": f"{base}/api/jira/webhook", + "events": ["jira:issue_created"], + "filters": {"issue-related-events-section": "project = FAC"}, + "excludeBody": False, + "secret": SECRETO, + }) + if st in (200, 201): + estado(webhook_id=wid, webhook_url_base=base, movido=time.strftime("%Y-%m-%d %H:%M:%S")) + print(f"[{st}] webhook {wid} → {base}/api/jira/webhook" + ("" if st in (200, 201) else f" · {r}")) + +elif cmd == "estado": + st, r = jira("GET", "/rest/webhooks/1.0/webhook") + print(f"[{st}] webhooks de sitio: {len(r) if isinstance(r, list) else r}") + for w in r if isinstance(r, list) else []: + print(f" - {w.get('name')} · habilitado={w.get('enabled')} · eventos={w.get('events')} · filtro={w.get('filters')}") + print("estado local:", json.dumps(estado(), ensure_ascii=False))