El GET de comprobacion no miraba su propio codigo HTTP. Un 403 devuelve un
objeto Status, que parsea como JSON perfectamente y luego revienta en
dep.spec.replicas ("Cannot read properties of undefined (reading 'replicas')").
El aviso resultante decia "reinicio fallido, intervencion manual necesaria"
cuando el reinicio habia ido BIEN y lo unico roto era la verificacion.
Falso negativo, mucho menos grave que el falso positivo de ayer -- pero te
levanta de la cama para nada, y estando Jose fuera eso importa.
Ahora el nodo marca verificable:false cuando no ha podido comprobar (HTTP no
2xx, cuerpo ilegible o error de red) y el aviso lo dice con esas palabras,
con el codigo de la comprobacion aparte del codigo del PATCH.
Reproducido en produccion: POST legitimo a las 08:43:22 (PATCH ok, restartedAt
cambia), permiso retirado a los 15 s, GET a los 60 s con 403. Ejecucion de
60,394 s, o sea el camino completo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
17 KiB
JSON
350 lines
17 KiB
JSON
{
|
|
"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 // 'verificable: false' = no se pudo COMPROBAR, que no es lo mismo que\n // 'no se reinicio'. El PATCH ya habia dicho que si.\n if (res.statusCode < 200 || res.statusCode >= 300) {\n return resolve([{ json: { serviceName, deployment, kind, namespace,\n statusOk: false, verificable: false, checkCode: res.statusCode,\n error: String(data || '').slice(0, 200) } }]);\n }\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 verificable: true, ready, desired, generation, observed, rolloutOk } }]);\n } catch (e) {\n resolve([{ json: { serviceName, deployment, kind, namespace,\n statusOk: false, verificable: false, checkCode: res.statusCode,\n error: e.message } }]);\n }\n });\n });\n req.on('error', (e) => resolve([{ json: { serviceName, deployment, kind, namespace,\n statusOk: false, verificable: false, checkCode: null, 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": "={{ ($json.verificable === false ? '⚠️ Servicio *' + $json.serviceName + '* caído — el reinicio se lanzó BIEN, pero no he podido comprobar cómo quedó' : '❌ Servicio *' + $json.serviceName + '* caído — reinicio fallido, intervención manual necesaria') + ($json.statusCode ? ' (HTTP ' + $json.statusCode + ')' : '') + ($json.checkCode ? ' (comprobación: HTTP ' + $json.checkCode + ')' : '') + ($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
|
|
]
|
|
}
|
|
],
|
|
"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
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"callerPolicy": "workflowsFromSameOwner",
|
|
"availableInMCP": false,
|
|
"binaryMode": "separate"
|
|
},
|
|
"_activeVersionId": "ebe6d3e6-5cf8-4265-9435-2a63fd555748",
|
|
"_workflowId": "m8fZG86J5JJzVQn9"
|
|
} |