Cerrar el auto-reinicio: fail-closed, permisos por nombre y fallo visible
Tres agujeros que encontro una review adversarial de Codex sobre el trabajo de ayer. Los tres verificados contra el fichero antes de tocar nada. 1. El webhook publico fallaba en ABIERTO. Con `optional: true` y un `if (secreto && ...)`, la ausencia del secreto equivalia a autorizacion: cualquier POST con heartbeat.status=0 y un monitor.name del mapa reiniciaba n8n, Gitea, ArgoCD, Vaultwarden o los blogs. El argumento para dejarlo asi era "que un despiste no te deje sin avisos", y era falso: el aviso de caida lo manda Kuma por su notificacion 1, directa, no por este webhook. Ahora el secreto es obligatorio (sin `optional`) y la puerta rechaza si falta. Secreto ausente da error ruidoso; cabecera que no casa, vacio. 2. El ClusterRole permitia patch sobre CUALQUIER deployment de 10 namespaces, y la SA va montada en el pod entero de n8n: cualquier workflow con un nodo Code heredaba eso. Ahora son Roles por namespace con resourceNames sobre los 12 workloads exactos del SERVICE_MAP. 3. El flujo daba exito aunque el PATCH fallara. `restartOk` se calculaba y se tiraba: `K8s API Check Status` construia un objeto nuevo sin el, y el IF final solo miraba `statusOk`. Un 403 sobre un servicio ya sano mandaba "reiniciado correctamente". Ahora hay rama de fallo tras el PATCH y el exito exige observedGeneration >= la generation que devolvio el PATCH. Se compara con >= porque selfHeal de ArgoCD revierte la anotacion y vuelve a subir la generation; con == daria un fallo falso. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -67,14 +67,22 @@ spec:
|
|||||||
# webhook /webhook/uptime-kuma-restart es público y desde hoy sí
|
# webhook /webhook/uptime-kuma-restart es público y desde hoy sí
|
||||||
# reinicia servicios de verdad, así que no puede quedar abierto.
|
# reinicia servicios de verdad, así que no puede quedar abierto.
|
||||||
# ⚠️ FUERA DE GIT a propósito (secret n8n-kuma-webhook, creado con
|
# ⚠️ 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
|
# kubectl).
|
||||||
# salta la comprobación y sigue, que es como se comportaba antes.
|
#
|
||||||
|
# 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
|
- name: KUMA_WEBHOOK_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: n8n-kuma-webhook
|
name: n8n-kuma-webhook
|
||||||
key: KUMA_WEBHOOK_TOKEN
|
key: KUMA_WEBHOOK_TOKEN
|
||||||
optional: true
|
|
||||||
# n8n ≥1.x bloquea $env en expresiones por defecto; sin esto el nodo
|
# n8n ≥1.x bloquea $env en expresiones por defecto; sin esto el nodo
|
||||||
# HTTP del autopost no puede leer GETXAPI_TOKEN. Instancia single-user.
|
# HTTP del autopost no puede leer GETXAPI_TOKEN. Instancia single-user.
|
||||||
- name: N8N_BLOCK_ENV_ACCESS_IN_NODE
|
- name: N8N_BLOCK_ENV_ACCESS_IN_NODE
|
||||||
|
|||||||
+126
-25
@@ -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
|
# 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
|
# 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
|
# 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.
|
# 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
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: n8n
|
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
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
|
namespace: n8n
|
||||||
rules:
|
rules:
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
# statefulsets porque Gitea lo es (no un Deployment) y esta en el mapa.
|
resources: ["deployments"]
|
||||||
resources: ["deployments", "statefulsets"]
|
resourceNames: ["n8n"]
|
||||||
verbs: ["get", "patch"]
|
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
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
@@ -37,7 +44,7 @@ metadata:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -45,13 +52,24 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: monitoring
|
namespace: monitoring
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -59,13 +77,24 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: homarr
|
namespace: homarr
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -73,13 +102,24 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: gitea
|
namespace: gitea
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -87,13 +127,24 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: authentik
|
namespace: authentik
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -101,13 +152,24 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: vaultwarden
|
namespace: vaultwarden
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -115,22 +177,41 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: ollama
|
namespace: ollama
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
# Los dos blogs (2026-07-30). Son lo unico de aqui que da la cara al publico, y
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
# justo lo que peor se lleva con quince dias sin nadie mirando: si Ghost se
|
kind: Role
|
||||||
# atasca, que al menos se intente un reinicio antes de que llegue el aviso.
|
metadata:
|
||||||
|
name: n8n-restarter
|
||||||
|
namespace: ghost-en
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments"]
|
||||||
|
resourceNames: ["ghost-en"]
|
||||||
|
verbs: ["get", "patch"]
|
||||||
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
@@ -138,7 +219,7 @@ metadata:
|
|||||||
namespace: ghost-en
|
namespace: ghost-en
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
@@ -146,21 +227,41 @@ subjects:
|
|||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
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
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: zona-exclusion
|
namespace: zona-exclusion
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
namespace: n8n
|
namespace: n8n
|
||||||
---
|
---
|
||||||
# argocd el ultimo a proposito: reiniciar argocd-server o argocd-repo-server
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
# es lo mas agresivo del mapa (deja el cluster sin reconciliar un rato).
|
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
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
@@ -168,7 +269,7 @@ metadata:
|
|||||||
namespace: argocd
|
namespace: argocd
|
||||||
roleRef:
|
roleRef:
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
kind: ClusterRole
|
kind: Role
|
||||||
name: n8n-restarter
|
name: n8n-restarter
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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"
|
"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",
|
"id": "d1a2b3c4-2222-2222-2222-000000000002",
|
||||||
"name": "Parse & Map Service",
|
"name": "Parse & Map Service",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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});"
|
"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",
|
"id": "d1a2b3c4-4444-4444-4444-000000000004",
|
||||||
"name": "K8s API Restart",
|
"name": "K8s API Restart",
|
||||||
@@ -73,21 +73,21 @@
|
|||||||
"type": "n8n-nodes-base.wait",
|
"type": "n8n-nodes-base.wait",
|
||||||
"typeVersion": 1,
|
"typeVersion": 1,
|
||||||
"position": [
|
"position": [
|
||||||
1136,
|
1344,
|
||||||
192
|
192
|
||||||
],
|
],
|
||||||
"webhookId": "wait-resume-uptime-001"
|
"webhookId": "wait-resume-uptime-001"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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});"
|
"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",
|
"id": "d1a2b3c4-6666-6666-6666-000000000006",
|
||||||
"name": "K8s API Check Status",
|
"name": "K8s API Check Status",
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [
|
"position": [
|
||||||
1360,
|
1568,
|
||||||
192
|
192
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"type": "n8n-nodes-base.if",
|
"type": "n8n-nodes-base.if",
|
||||||
"typeVersion": 1,
|
"typeVersion": 1,
|
||||||
"position": [
|
"position": [
|
||||||
1584,
|
1792,
|
||||||
192
|
192
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4,
|
"typeVersion": 4,
|
||||||
"position": [
|
"position": [
|
||||||
1792,
|
2000,
|
||||||
80
|
80
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "text",
|
"name": "text",
|
||||||
"value": "={{ '❌ Servicio *' + $json.serviceName + '* caído — reinicio fallido, intervención manual necesaria' }}"
|
"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",
|
"name": "parse_mode",
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4,
|
"typeVersion": 4,
|
||||||
"position": [
|
"position": [
|
||||||
1792,
|
2000,
|
||||||
304
|
304
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -206,6 +206,26 @@
|
|||||||
912,
|
912,
|
||||||
432
|
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": {
|
"connections": {
|
||||||
@@ -253,7 +273,7 @@
|
|||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Esperar 60s",
|
"node": "PATCH aceptado",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
@@ -299,6 +319,24 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"PATCH aceptado": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Esperar 60s",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Telegram Fallo",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
Reference in New Issue
Block a user