diff --git a/n8n/deployment-n8n.yaml b/n8n/deployment-n8n.yaml index 64fe925..2cc1798 100644 --- a/n8n/deployment-n8n.yaml +++ b/n8n/deployment-n8n.yaml @@ -67,14 +67,22 @@ spec: # webhook /webhook/uptime-kuma-restart es público y desde hoy sí # reinicia servicios de verdad, así que no puede quedar abierto. # ⚠️ FUERA DE GIT a propósito (secret n8n-kuma-webhook, creado con - # kubectl): si algún día falta, el workflow NO deja de avisar — se - # salta la comprobación y sigue, que es como se comportaba antes. + # kubectl). + # + # SIN "optional: true" desde el 2026-07-31: si el secreto falta, el + # pod NO arranca. Antes arrancaba y el workflow se saltaba la + # comprobación, o sea que la ausencia de credencial equivalía a + # autorización en un endpoint expuesto a internet. El argumento para + # dejarlo abierto era "que un despiste no te deje sin avisos", y era + # falso: el aviso de caída lo manda Kuma por su notificación 1 + # (Telegram, directa). Fallar en cerrado no cuesta ni un aviso — + # solo el reinicio automático. Un pod que no arranca se ve; un + # webhook abierto, no. - name: KUMA_WEBHOOK_TOKEN valueFrom: secretKeyRef: name: n8n-kuma-webhook key: KUMA_WEBHOOK_TOKEN - optional: true # n8n ≥1.x bloquea $env en expresiones por defecto; sin esto el nodo # HTTP del autopost no puede leer GETXAPI_TOKEN. Instancia single-user. - name: N8N_BLOCK_ENV_ACCESS_IN_NODE diff --git a/n8n/rbac-restarter.yaml b/n8n/rbac-restarter.yaml index 5653cc7..30fc155 100644 --- a/n8n/rbac-restarter.yaml +++ b/n8n/rbac-restarter.yaml @@ -1,4 +1,5 @@ -# Permisos para el workflow "Uptime Kuma -> K8s Auto-Restart" (2026-07-30). +# Permisos para el workflow "Uptime Kuma -> K8s Auto-Restart" (2026-07-30, +# acotado el 2026-07-31 tras una review adversarial de Codex). # # El nodo Code lee el token proyectado de la ServiceAccount y hace un PATCH # contra kubernetes.default.svc. Nunca habia existido ningun Role ni RoleBinding @@ -8,28 +9,34 @@ # # SA propia y no la "default" del namespace: cualquier pod que se despliegue en # n8n sin pedir SA hereda la default, y estos permisos cruzan namespaces. +# +# ⚠️ Esta SA va montada en el pod ENTERO de n8n, no solo en este workflow: los +# nodos Code leen el token de /var/run/secrets/..., asi que CUALQUIER workflow +# con un nodo Code hereda lo que aqui se conceda. Por eso el permiso no es +# "deployments del namespace" sino resourceNames con los 12 workloads EXACTOS +# del SERVICE_MAP. Si se anade un servicio al mapa, hay que anadirlo aqui o +# dara 403 -- deliberado: que el mapa y el permiso se muevan juntos. +# +# Antes habia un ClusterRole sin resourceNames + un RoleBinding por namespace. +# Eso permitia reiniciar CUALQUIER deployment de esos 10 namespaces. Ahora son +# Roles por namespace, cada uno con su lista cerrada. apiVersion: v1 kind: ServiceAccount metadata: name: n8n-restarter namespace: n8n --- -# get ademas de patch: tras el PATCH el workflow espera 60 s y RELEE el -# deployment para comprobar readyReplicas -- sin get, ese chequeo posterior -# devolvia "Cannot read properties of undefined (reading 'replicas')". apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: name: n8n-restarter + namespace: n8n rules: - apiGroups: ["apps"] - # statefulsets porque Gitea lo es (no un Deployment) y esta en el mapa. - resources: ["deployments", "statefulsets"] + resources: ["deployments"] + resourceNames: ["n8n"] verbs: ["get", "patch"] --- -# Un RoleBinding por namespace y no un ClusterRoleBinding: el webhook que -# dispara esto es PUBLICO y sin autenticar, asi que el alcance de lo que -# puede reiniciar un desconocido debe ser exactamente el SERVICE_MAP. apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: @@ -37,7 +44,7 @@ metadata: namespace: n8n roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -45,13 +52,24 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: monitoring +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["kube-prometheus-stack-grafana", "uptime-kuma"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: monitoring roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -59,13 +77,24 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: homarr +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["homarr"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: homarr roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -73,13 +102,24 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: gitea +rules: + - apiGroups: ["apps"] + resources: ["statefulsets"] + resourceNames: ["gitea"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: gitea roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -87,13 +127,24 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: authentik +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["authentik-server"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: authentik roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -101,13 +152,24 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: vaultwarden +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["vaultwarden"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: vaultwarden roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -115,22 +177,41 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: ollama +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["ollama"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: ollama roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount name: n8n-restarter namespace: n8n --- -# Los dos blogs (2026-07-30). Son lo unico de aqui que da la cara al publico, y -# justo lo que peor se lleva con quince dias sin nadie mirando: si Ghost se -# atasca, que al menos se intente un reinicio antes de que llegue el aviso. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: ghost-en +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["ghost-en"] + verbs: ["get", "patch"] +--- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: @@ -138,7 +219,7 @@ metadata: namespace: ghost-en roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount @@ -146,21 +227,41 @@ subjects: namespace: n8n --- apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: zona-exclusion +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["zona-exclusion"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: n8n-restarter namespace: zona-exclusion roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount name: n8n-restarter namespace: n8n --- -# argocd el ultimo a proposito: reiniciar argocd-server o argocd-repo-server -# es lo mas agresivo del mapa (deja el cluster sin reconciliar un rato). +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: n8n-restarter + namespace: argocd +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["argocd-repo-server", "argocd-server"] + verbs: ["get", "patch"] +--- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: @@ -168,7 +269,7 @@ metadata: namespace: argocd roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: n8n-restarter subjects: - kind: ServiceAccount diff --git a/n8n/workflow-uptime-kuma-auto-restart.json b/n8n/workflow-uptime-kuma-auto-restart.json index b1a65c3..37fda2c 100644 --- a/n8n/workflow-uptime-kuma-auto-restart.json +++ b/n8n/workflow-uptime-kuma-auto-restart.json @@ -1,312 +1,350 @@ { - "name": "Uptime Kuma → K8s Auto-Restart", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "uptime-kuma-restart", - "options": {} - }, - "id": "d1a2b3c4-1111-1111-1111-000000000001", - "name": "Uptime Kuma Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 1, - "position": [ - 256, - 304 - ], - "webhookId": "uptime-kuma-restart" - }, - { - "parameters": { - "jsCode": "// Puerta de entrada (2026-07-30). Este webhook es PUBLICO y sin autenticar, y\n// desde hoy reinicia servicios de verdad, los dos blogs incluidos. Uptime Kuma\n// manda el secreto compartido en la cabecera X-Kuma-Token\n// (notificacion \"n8n Auto-Restart\" -> webhookAdditionalHeaders).\n//\n// Si el secreto NO esta configurado en n8n, se sigue adelante sin comprobar\n// nada: un despiste de configuracion debe degradar al comportamiento de antes,\n// nunca dejarte sin avisos en silencio. Si esta configurado y no coincide, se\n// devuelve vacio: ni reinicio, ni Telegram, ni fila de error que llenar a base\n// de peticiones.\nlet secreto = null;\ntry { secreto = $env.KUMA_WEBHOOK_TOKEN; } catch (e) { /* $env capado */ }\nif (!secreto && typeof process !== 'undefined') secreto = process.env.KUMA_WEBHOOK_TOKEN;\n\nconst cabeceras = $input.first().json.headers || {};\nif (secreto && cabeceras['x-kuma-token'] !== secreto) {\n return [];\n}\n\nconst body = $input.first().json.body || $input.first().json;\nconst monitor = body.monitor || {};\nconst heartbeat = body.heartbeat || {};\n\n// Solo lo que existe de verdad en el cluster (revisado 2026-07-30): fuera\n// OpenClaw, los tres polymarket-* y open-webui, decomisados en julio.\n// 'kind' es el plural que va en la ruta del API: Gitea es un StatefulSet,\n// no un Deployment, y con 'deployments' fijo daba 404 aunque hubiera permisos.\nconst SERVICE_MAP = {\n 'n8n': { kind: 'deployments', name: 'n8n', namespace: 'n8n' },\n 'Vaultwarden': { kind: 'deployments', name: 'vaultwarden', namespace: 'vaultwarden' },\n 'Grafana': { kind: 'deployments', name: 'kube-prometheus-stack-grafana', namespace: 'monitoring' },\n 'Uptime Kuma': { kind: 'deployments', name: 'uptime-kuma', namespace: 'monitoring' },\n 'Authentik': { kind: 'deployments', name: 'authentik-server', namespace: 'authentik' },\n 'Homarr': { kind: 'deployments', name: 'homarr', namespace: 'homarr' },\n 'ArgoCD': { kind: 'deployments', name: 'argocd-server', namespace: 'argocd' },\n 'ArgoCD repo-server': { kind: 'deployments', name: 'argocd-repo-server', namespace: 'argocd' },\n 'Ollama': { kind: 'deployments', name: 'ollama', namespace: 'ollama' },\n 'Gitea': { kind: 'statefulsets', name: 'gitea', namespace: 'gitea' },\n // Los blogs (2026-07-30). La clave es el NOMBRE EXACTO del monitor en Kuma,\n // que se busca sin distinguir mayusculas -- si se renombra alli, aqui deja de\n // encontrarse y el workflow se va por la rama de \"sin mapeo\".\n // El monitor \"www.zonadeexclusion.com (301->apex)\" queda FUERA a proposito:\n // que ese 301 falle es cosa de Traefik o del DNS, no de Ghost, y reiniciar el\n // blog no arreglaria nada.\n 'theexclusionzone.com (Ghost EN)': { kind: 'deployments', name: 'ghost-en', namespace: 'ghost-en' },\n 'zonadeexclusion.com (Ghost ES)': { kind: 'deployments', name: 'zona-exclusion', namespace: 'zona-exclusion' },\n};\n\nconst serviceName = monitor.name || 'desconocido';\nconst status = heartbeat.status;\n\nif (status !== 0) {\n return [];\n}\n\n// Busqueda case-insensitive\nconst k8s = SERVICE_MAP[serviceName] ||\n Object.entries(SERVICE_MAP).find(([k]) => k.toLowerCase() === serviceName.toLowerCase())?.[1];\n\nif (!k8s) {\n return [{ json: {\n serviceName,\n canRestart: false,\n error: 'Sin mapeo K8s para: ' + serviceName\n }}];\n}\n\n// Se sigue llamando 'deployment' aunque a veces sea un StatefulSet: el nodo IF\n// \"Tiene mapeo K8s\" comprueba ese campo, y renombrarlo lo romperia en silencio.\nreturn [{ json: {\n serviceName,\n canRestart: true,\n deployment: k8s.name,\n kind: k8s.kind,\n namespace: k8s.namespace\n}}];\n" - }, - "id": "d1a2b3c4-2222-2222-2222-000000000002", - "name": "Parse & Map Service", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 480, - 304 - ] - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{ $json.deployment || '' }}", - "operation": "isNotEmpty" - } - ] + "name": "Uptime Kuma → K8s Auto-Restart", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "uptime-kuma-restart", + "options": {} + }, + "id": "d1a2b3c4-1111-1111-1111-000000000001", + "name": "Uptime Kuma Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 256, + 304 + ], + "webhookId": "uptime-kuma-restart" + }, + { + "parameters": { + "jsCode": "// Puerta de entrada (2026-07-30, cerrada de verdad el 2026-07-31). Este webhook\n// es PUBLICO y reinicia servicios, los dos blogs incluidos. Uptime Kuma manda el\n// secreto en la cabecera X-Kuma-Token (notificacion \"n8n Auto-Restart\" ->\n// webhookAdditionalHeaders).\n//\n// FAIL-CLOSED. Hasta el 2026-07-31 esto seguia adelante SIN comprobar nada si el\n// secreto no estaba configurado, con el argumento de \"que un despiste no te deje\n// sin avisos\". El argumento era falso: el aviso de caida lo manda Kuma por su\n// notificacion 1 (Telegram, directa), no por aqui. Cerrar no cuesta ni un aviso,\n// solo el reinicio automatico; dejarlo abierto hacia que la ausencia de\n// credencial valiera como autorizacion en un endpoint expuesto a internet.\n//\n// Las dos negativas NO son iguales, a proposito:\n// - secreto ausente -> error RUIDOSO. Es un fallo de configuracion propio\n// y tiene que verse en la lista de ejecuciones, no morir en silencio.\n// - cabecera que no casa -> vacio y a callar. Es un desconocido llamando al\n// endpoint: ni respuesta, ni filas de error que engordar a base de POSTs.\nlet secreto = null;\ntry { secreto = $env.KUMA_WEBHOOK_TOKEN; } catch (e) { /* $env capado */ }\nif (!secreto && typeof process !== 'undefined') secreto = process.env.KUMA_WEBHOOK_TOKEN;\n\nif (!secreto) {\n throw new Error('KUMA_WEBHOOK_TOKEN no configurado: rechazo el webhook (fail-closed).');\n}\n\nconst cabeceras = $input.first().json.headers || {};\nif (cabeceras['x-kuma-token'] !== secreto) {\n return [];\n}\n\nconst body = $input.first().json.body || $input.first().json;\nconst monitor = body.monitor || {};\nconst heartbeat = body.heartbeat || {};\n\n// Solo lo que existe de verdad en el cluster (revisado 2026-07-30): fuera\n// OpenClaw, los tres polymarket-* y open-webui, decomisados en julio.\n// 'kind' es el plural que va en la ruta del API: Gitea es un StatefulSet,\n// no un Deployment, y con 'deployments' fijo daba 404 aunque hubiera permisos.\nconst SERVICE_MAP = {\n 'n8n': { kind: 'deployments', name: 'n8n', namespace: 'n8n' },\n 'Vaultwarden': { kind: 'deployments', name: 'vaultwarden', namespace: 'vaultwarden' },\n 'Grafana': { kind: 'deployments', name: 'kube-prometheus-stack-grafana', namespace: 'monitoring' },\n 'Uptime Kuma': { kind: 'deployments', name: 'uptime-kuma', namespace: 'monitoring' },\n 'Authentik': { kind: 'deployments', name: 'authentik-server', namespace: 'authentik' },\n 'Homarr': { kind: 'deployments', name: 'homarr', namespace: 'homarr' },\n 'ArgoCD': { kind: 'deployments', name: 'argocd-server', namespace: 'argocd' },\n 'ArgoCD repo-server': { kind: 'deployments', name: 'argocd-repo-server', namespace: 'argocd' },\n 'Ollama': { kind: 'deployments', name: 'ollama', namespace: 'ollama' },\n 'Gitea': { kind: 'statefulsets', name: 'gitea', namespace: 'gitea' },\n // Los blogs (2026-07-30). La clave es el NOMBRE EXACTO del monitor en Kuma,\n // que se busca sin distinguir mayusculas -- si se renombra alli, aqui deja de\n // encontrarse y el workflow se va por la rama de \"sin mapeo\".\n // El monitor \"www.zonadeexclusion.com (301->apex)\" queda FUERA a proposito:\n // que ese 301 falle es cosa de Traefik o del DNS, no de Ghost, y reiniciar el\n // blog no arreglaria nada.\n 'theexclusionzone.com (Ghost EN)': { kind: 'deployments', name: 'ghost-en', namespace: 'ghost-en' },\n 'zonadeexclusion.com (Ghost ES)': { kind: 'deployments', name: 'zona-exclusion', namespace: 'zona-exclusion' },\n};\n\nconst serviceName = monitor.name || 'desconocido';\nconst status = heartbeat.status;\n\nif (status !== 0) {\n return [];\n}\n\n// Busqueda case-insensitive\nconst k8s = SERVICE_MAP[serviceName] ||\n Object.entries(SERVICE_MAP).find(([k]) => k.toLowerCase() === serviceName.toLowerCase())?.[1];\n\nif (!k8s) {\n return [{ json: {\n serviceName,\n canRestart: false,\n error: 'Sin mapeo K8s para: ' + serviceName\n }}];\n}\n\n// Se sigue llamando 'deployment' aunque a veces sea un StatefulSet: el nodo IF\n// \"Tiene mapeo K8s\" comprueba ese campo, y renombrarlo lo romperia en silencio.\nreturn [{ json: {\n serviceName,\n canRestart: true,\n deployment: k8s.name,\n kind: k8s.kind,\n namespace: k8s.namespace\n}}];\n" + }, + "id": "d1a2b3c4-2222-2222-2222-000000000002", + "name": "Parse & Map Service", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 480, + 304 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.deployment || '' }}", + "operation": "isNotEmpty" + } + ] + } + }, + "id": "d1a2b3c4-3333-3333-3333-000000000003", + "name": "Tiene mapeo K8s", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 704, + 304 + ] + }, + { + "parameters": { + "jsCode": "const https = require('https');\nconst fs = require('fs');\n\nconst token = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim();\nconst ca = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');\n\nconst deployment = $input.first().json.deployment;\nconst namespace = $input.first().json.namespace;\nconst serviceName = $input.first().json.serviceName;\n// Sin kind, deployments: asi un workflow viejo en vuelo no se rompe.\nconst kind = $input.first().json.kind || 'deployments';\n\nconst now = new Date().toISOString();\nconst patchBody = JSON.stringify({\n spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': now } } } }\n});\n\nreturn new Promise((resolve) => {\n const req = https.request({\n hostname: 'kubernetes.default.svc',\n port: 443,\n path: `/apis/apps/v1/namespaces/${namespace}/${kind}/${deployment}`,\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/strategic-merge-patch+json',\n 'Content-Length': Buffer.byteLength(patchBody)\n },\n ca\n }, (res) => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n const ok = res.statusCode >= 200 && res.statusCode < 300;\n // La generation que devuelve el PATCH es la NUEVA. Guardarla permite\n // comprobar despues que el rollout de ESTE patch se completo, en vez\n // de conformarse con que el servicio este sano (podia estarlo ya).\n let generation = null;\n try { generation = JSON.parse(data).metadata.generation; } catch (e) { /* cuerpo no JSON */ }\n resolve([{ json: { serviceName, deployment, kind, namespace,\n restartOk: ok, statusCode: res.statusCode, generation,\n error: ok ? null : String(data || '').slice(0, 200) } }]);\n });\n });\n req.on('error', (e) => resolve([{ json: { serviceName, deployment, kind, namespace,\n restartOk: false, statusCode: null, generation: null, error: e.message } }]));\n req.write(patchBody);\n req.end();\n});" + }, + "id": "d1a2b3c4-4444-4444-4444-000000000004", + "name": "K8s API Restart", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 912, + 192 + ] + }, + { + "parameters": { + "amount": 60, + "unit": "seconds" + }, + "id": "d1a2b3c4-5555-5555-5555-000000000005", + "name": "Esperar 60s", + "type": "n8n-nodes-base.wait", + "typeVersion": 1, + "position": [ + 1344, + 192 + ], + "webhookId": "wait-resume-uptime-001" + }, + { + "parameters": { + "jsCode": "const https = require('https');\nconst fs = require('fs');\n\nconst token = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim();\nconst ca = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');\n\nconst deployment = $input.first().json.deployment;\nconst namespace = $input.first().json.namespace;\nconst serviceName = $input.first().json.serviceName;\n// Sin kind, deployments: asi un workflow viejo en vuelo no se rompe.\nconst kind = $input.first().json.kind || 'deployments';\nconst generation = $input.first().json.generation;\n\nreturn new Promise((resolve) => {\n const req = https.request({\n hostname: 'kubernetes.default.svc',\n port: 443,\n path: `/apis/apps/v1/namespaces/${namespace}/${kind}/${deployment}`,\n method: 'GET',\n headers: { 'Authorization': `Bearer ${token}` },\n ca\n }, (res) => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const dep = JSON.parse(data);\n const desired = dep.spec.replicas || 1;\n const ready = dep.status.readyReplicas || 0;\n const updated = dep.status.updatedReplicas || 0;\n // Que este sano no prueba que se haya reiniciado: si el PATCH no\n // hubiera surtido efecto, un servicio que ya estaba bien daria\n // 'exito' igual. observedGeneration >= la generation que devolvio\n // el PATCH significa que el controlador ya proceso ESE cambio.\n // Se compara con >= y no con == a proposito: ArgoCD (selfHeal)\n // revierte la anotacion restartedAt poco despues, lo que sube la\n // generation otra vez. Con == eso daria un fallo falso.\n const observed = dep.status.observedGeneration || 0;\n const rolloutOk = generation == null ? true : observed >= generation;\n const statusOk = ready >= desired && updated >= desired && rolloutOk;\n resolve([{ json: { serviceName, deployment, kind, namespace, statusOk,\n ready, desired, generation, observed, rolloutOk } }]);\n } catch (e) {\n resolve([{ json: { serviceName, deployment, kind, namespace, statusOk: false, error: e.message } }]);\n }\n });\n });\n req.on('error', (e) => resolve([{ json: { serviceName, deployment, kind, namespace, statusOk: false, error: e.message } }]));\n req.end();\n});" + }, + "id": "d1a2b3c4-6666-6666-6666-000000000006", + "name": "K8s API Check Status", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1568, + 192 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.statusOk ? 'yes' : 'no' }}", + "value2": "yes" + } + ] + } + }, + "id": "d1a2b3c4-7777-7777-7777-000000000007", + "name": "Reinicio Exitoso", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1792, + 192 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "chat_id", + "value": "5138407666" + }, + { + "name": "text", + "value": "={{ '✅ Servicio *' + $json.serviceName + '* caído detectado — reiniciado correctamente' }}" + }, + { + "name": "parse_mode", + "value": "Markdown" + } + ] + }, + "options": {} + }, + "id": "d1a2b3c4-8888-8888-8888-000000000008", + "name": "Telegram Exito", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2000, + 80 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "chat_id", + "value": "5138407666" + }, + { + "name": "text", + "value": "={{ '❌ Servicio *' + $json.serviceName + '* caído — reinicio fallido, intervención manual necesaria' + ($json.statusCode ? ' (HTTP ' + $json.statusCode + ')' : '') + ($json.error ? ': ' + String($json.error).replace(/[*_`\\[\\]]/g, '').slice(0, 200) : '') + ($json.rolloutOk === false ? ' — el PATCH pasó pero el rollout no llegó a completarse' : '') }}" + }, + { + "name": "parse_mode", + "value": "Markdown" + } + ] + }, + "options": {} + }, + "id": "d1a2b3c4-9999-9999-9999-000000000009", + "name": "Telegram Fallo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2000, + 304 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "chat_id", + "value": "5138407666" + }, + { + "name": "text", + "value": "={{ '⚠️ Alerta Uptime Kuma: Servicio *' + $json.serviceName + '* caído pero sin mapeo K8s configurado. No se puede reiniciar automáticamente.' }}" + }, + { + "name": "parse_mode", + "value": "Markdown" + } + ] + }, + "options": {} + }, + "id": "d1a2b3c4-aaaa-aaaa-aaaa-00000000000a", + "name": "Telegram Sin Mapeo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 912, + 432 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.restartOk ? 'yes' : 'no' }}", + "value2": "yes" + } + ] + } + }, + "id": "d1a2b3c4-bbbb-bbbb-bbbb-00000000000b", + "name": "PATCH aceptado", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 1120, + 192 + ] } - }, - "id": "d1a2b3c4-3333-3333-3333-000000000003", - "name": "Tiene mapeo K8s", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 704, - 304 - ] - }, - { - "parameters": { - "jsCode": "const https = require('https');\nconst fs = require('fs');\n\nconst token = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim();\nconst ca = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');\n\nconst deployment = $input.first().json.deployment;\nconst namespace = $input.first().json.namespace;\nconst serviceName = $input.first().json.serviceName;\n// Sin kind, deployments: asi un workflow viejo en vuelo no se rompe.\nconst kind = $input.first().json.kind || 'deployments';\n\nconst now = new Date().toISOString();\nconst patchBody = JSON.stringify({\n spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': now } } } }\n});\n\nreturn new Promise((resolve) => {\n const req = https.request({\n hostname: 'kubernetes.default.svc',\n port: 443,\n path: `/apis/apps/v1/namespaces/${namespace}/${kind}/${deployment}`,\n method: 'PATCH',\n headers: {\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/strategic-merge-patch+json',\n 'Content-Length': Buffer.byteLength(patchBody)\n },\n ca\n }, (res) => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n const ok = res.statusCode >= 200 && res.statusCode < 300;\n resolve([{ json: { serviceName, deployment, kind, namespace, restartOk: ok, statusCode: res.statusCode } }]);\n });\n });\n req.on('error', (e) => resolve([{ json: { serviceName, deployment, kind, namespace, restartOk: false, error: e.message } }]));\n req.write(patchBody);\n req.end();\n});" - }, - "id": "d1a2b3c4-4444-4444-4444-000000000004", - "name": "K8s API Restart", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 912, - 192 - ] - }, - { - "parameters": { - "amount": 60, - "unit": "seconds" - }, - "id": "d1a2b3c4-5555-5555-5555-000000000005", - "name": "Esperar 60s", - "type": "n8n-nodes-base.wait", - "typeVersion": 1, - "position": [ - 1136, - 192 - ], - "webhookId": "wait-resume-uptime-001" - }, - { - "parameters": { - "jsCode": "const https = require('https');\nconst fs = require('fs');\n\nconst token = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim();\nconst ca = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt');\n\nconst deployment = $input.first().json.deployment;\nconst namespace = $input.first().json.namespace;\nconst serviceName = $input.first().json.serviceName;\n// Sin kind, deployments: asi un workflow viejo en vuelo no se rompe.\nconst kind = $input.first().json.kind || 'deployments';\n\nreturn new Promise((resolve) => {\n const req = https.request({\n hostname: 'kubernetes.default.svc',\n port: 443,\n path: `/apis/apps/v1/namespaces/${namespace}/${kind}/${deployment}`,\n method: 'GET',\n headers: { 'Authorization': `Bearer ${token}` },\n ca\n }, (res) => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const dep = JSON.parse(data);\n const desired = dep.spec.replicas || 1;\n const ready = dep.status.readyReplicas || 0;\n const updated = dep.status.updatedReplicas || 0;\n const statusOk = ready >= desired && updated >= desired;\n resolve([{ json: { serviceName, deployment, kind, namespace, statusOk, ready, desired } }]);\n } catch (e) {\n resolve([{ json: { serviceName, deployment, kind, namespace, statusOk: false, error: e.message } }]);\n }\n });\n });\n req.on('error', (e) => resolve([{ json: { serviceName, deployment, kind, namespace, statusOk: false, error: e.message } }]));\n req.end();\n});" - }, - "id": "d1a2b3c4-6666-6666-6666-000000000006", - "name": "K8s API Check Status", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1360, - 192 - ] - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{ $json.statusOk ? 'yes' : 'no' }}", - "value2": "yes" - } - ] + ], + "connections": { + "Uptime Kuma Webhook": { + "main": [ + [ + { + "node": "Parse & Map Service", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse & Map Service": { + "main": [ + [ + { + "node": "Tiene mapeo K8s", + "type": "main", + "index": 0 + } + ] + ] + }, + "Tiene mapeo K8s": { + "main": [ + [ + { + "node": "K8s API Restart", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Telegram Sin Mapeo", + "type": "main", + "index": 0 + } + ] + ] + }, + "K8s API Restart": { + "main": [ + [ + { + "node": "PATCH aceptado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar 60s": { + "main": [ + [ + { + "node": "K8s API Check Status", + "type": "main", + "index": 0 + } + ] + ] + }, + "K8s API Check Status": { + "main": [ + [ + { + "node": "Reinicio Exitoso", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reinicio Exitoso": { + "main": [ + [ + { + "node": "Telegram Exito", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Telegram Fallo", + "type": "main", + "index": 0 + } + ] + ] + }, + "PATCH aceptado": { + "main": [ + [ + { + "node": "Esperar 60s", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Telegram Fallo", + "type": "main", + "index": 0 + } + ] + ] } - }, - "id": "d1a2b3c4-7777-7777-7777-000000000007", - "name": "Reinicio Exitoso", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1584, - 192 - ] }, - { - "parameters": { - "method": "POST", - "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", - "sendBody": true, - "bodyParameters": { - "parameters": [ - { - "name": "chat_id", - "value": "5138407666" - }, - { - "name": "text", - "value": "={{ '✅ Servicio *' + $json.serviceName + '* caído detectado — reiniciado correctamente' }}" - }, - { - "name": "parse_mode", - "value": "Markdown" - } - ] - }, - "options": {} - }, - "id": "d1a2b3c4-8888-8888-8888-000000000008", - "name": "Telegram Exito", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, - "position": [ - 1792, - 80 - ] + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "binaryMode": "separate" }, - { - "parameters": { - "method": "POST", - "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", - "sendBody": true, - "bodyParameters": { - "parameters": [ - { - "name": "chat_id", - "value": "5138407666" - }, - { - "name": "text", - "value": "={{ '❌ Servicio *' + $json.serviceName + '* caído — reinicio fallido, intervención manual necesaria' }}" - }, - { - "name": "parse_mode", - "value": "Markdown" - } - ] - }, - "options": {} - }, - "id": "d1a2b3c4-9999-9999-9999-000000000009", - "name": "Telegram Fallo", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, - "position": [ - 1792, - 304 - ] - }, - { - "parameters": { - "method": "POST", - "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", - "sendBody": true, - "bodyParameters": { - "parameters": [ - { - "name": "chat_id", - "value": "5138407666" - }, - { - "name": "text", - "value": "={{ '⚠️ Alerta Uptime Kuma: Servicio *' + $json.serviceName + '* caído pero sin mapeo K8s configurado. No se puede reiniciar automáticamente.' }}" - }, - { - "name": "parse_mode", - "value": "Markdown" - } - ] - }, - "options": {} - }, - "id": "d1a2b3c4-aaaa-aaaa-aaaa-00000000000a", - "name": "Telegram Sin Mapeo", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, - "position": [ - 912, - 432 - ] - } - ], - "connections": { - "Uptime Kuma Webhook": { - "main": [ - [ - { - "node": "Parse & Map Service", - "type": "main", - "index": 0 - } - ] - ] - }, - "Parse & Map Service": { - "main": [ - [ - { - "node": "Tiene mapeo K8s", - "type": "main", - "index": 0 - } - ] - ] - }, - "Tiene mapeo K8s": { - "main": [ - [ - { - "node": "K8s API Restart", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Telegram Sin Mapeo", - "type": "main", - "index": 0 - } - ] - ] - }, - "K8s API Restart": { - "main": [ - [ - { - "node": "Esperar 60s", - "type": "main", - "index": 0 - } - ] - ] - }, - "Esperar 60s": { - "main": [ - [ - { - "node": "K8s API Check Status", - "type": "main", - "index": 0 - } - ] - ] - }, - "K8s API Check Status": { - "main": [ - [ - { - "node": "Reinicio Exitoso", - "type": "main", - "index": 0 - } - ] - ] - }, - "Reinicio Exitoso": { - "main": [ - [ - { - "node": "Telegram Exito", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Telegram Fallo", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "settings": { - "executionOrder": "v1", - "callerPolicy": "workflowsFromSameOwner", - "availableInMCP": false, - "binaryMode": "separate" - }, - "_activeVersionId": "ebe6d3e6-5cf8-4265-9435-2a63fd555748", - "_workflowId": "m8fZG86J5JJzVQn9" -} + "_activeVersionId": "ebe6d3e6-5cf8-4265-9435-2a63fd555748", + "_workflowId": "m8fZG86J5JJzVQn9" +} \ No newline at end of file