From 66088ce691a616bea590e3824a40ceb3a61012a6 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:17:07 -0500 Subject: [PATCH] feat(agent): auto-renice top CPU process after 2 consecutive high-CPU reports - Agent tracks consecutive reports above cpu_threshold (default 90%) - After renice_consecutive reports (default 2 = 60s sustained), runs renice -n 19 on the top CPU process - Reports the action back in the heartbeat payload - Backend sends Telegram alert when renice is applied - All params configurable in agent.yml and install.sh flags Co-Authored-By: Claude Sonnet 4.6 --- agent/install.sh | 17 ++++++-- agent/main.go | 64 +++++++++++++++++++++++----- pkg/services/notif_dispatch.go | 8 ++++ rest/controllers/agent_controller.go | 11 ++++- 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/agent/install.sh b/agent/install.sh index 2e46afb..5abded1 100644 --- a/agent/install.sh +++ b/agent/install.sh @@ -18,13 +18,19 @@ TOKEN="" API_URL="" INTERVAL=30 INSTALL_DIR="" +CPU_THRESHOLD=90 +RENICE_ENABLED=true +RENICE_CONSECUTIVE=2 for arg in "$@"; do case $arg in - --token=*) TOKEN="${arg#*=}" ;; - --url=*) API_URL="${arg#*=}" ;; - --interval=*) INTERVAL="${arg#*=}" ;; - --dir=*) INSTALL_DIR="${arg#*=}" ;; + --token=*) TOKEN="${arg#*=}" ;; + --url=*) API_URL="${arg#*=}" ;; + --interval=*) INTERVAL="${arg#*=}" ;; + --dir=*) INSTALL_DIR="${arg#*=}" ;; + --cpu-threshold=*) CPU_THRESHOLD="${arg#*=}" ;; + --renice=*) RENICE_ENABLED="${arg#*=}" ;; + --renice-consecutive=*) RENICE_CONSECUTIVE="${arg#*=}" ;; esac done @@ -113,6 +119,9 @@ api_url: ${API_URL} token: ${TOKEN} interval: ${INTERVAL} debug: false +cpu_threshold: ${CPU_THRESHOLD} +renice_enabled: ${RENICE_ENABLED} +renice_consecutive: ${RENICE_CONSECUTIVE} EOF ok "Configuración creada en ${INSTALL_DIR}/agent.yml" diff --git a/agent/main.go b/agent/main.go index 6ccf5d5..ec431a3 100644 --- a/agent/main.go +++ b/agent/main.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "os" + "os/exec" "runtime" "sort" "time" @@ -25,10 +26,13 @@ import ( // ── Config ──────────────────────────────────────────────────────────────────── type Config struct { - APIURL string `yaml:"api_url"` - Token string `yaml:"token"` - Interval int `yaml:"interval"` - Debug bool `yaml:"debug"` + APIURL string `yaml:"api_url"` + Token string `yaml:"token"` + Interval int `yaml:"interval"` + Debug bool `yaml:"debug"` + CPUThreshold float64 `yaml:"cpu_threshold"` // % para activar renice (default 90) + ReniceEnabled bool `yaml:"renice_enabled"` // activar renice automático + ReniceConsecutive int `yaml:"renice_consecutive"` // reportes consecutivos requeridos (default 2) } func loadConfig(path string) (*Config, error) { @@ -43,6 +47,12 @@ func loadConfig(path string) (*Config, error) { if cfg.Interval <= 0 { cfg.Interval = 30 } + if cfg.CPUThreshold <= 0 { + cfg.CPUThreshold = 90 + } + if cfg.ReniceConsecutive <= 0 { + cfg.ReniceConsecutive = 2 + } return &cfg, nil } @@ -286,19 +296,21 @@ func collectMetrics() (*Metricas, error) { // ── Reporte ─────────────────────────────────────────────────────────────────── type HeartbeatRequest struct { - Token string `json:"token"` - Metricas string `json:"metricas"` + Token string `json:"token"` + Metricas string `json:"metricas"` + ReniceAction string `json:"renice_action,omitempty"` // descripción si se reniceó algo } -func sendHeartbeat(cfg *Config, metricas *Metricas) error { +func sendHeartbeat(cfg *Config, metricas *Metricas, reniceAction string) error { metJSON, err := json.Marshal(metricas) if err != nil { return err } payload := HeartbeatRequest{ - Token: cfg.Token, - Metricas: string(metJSON), + Token: cfg.Token, + Metricas: string(metJSON), + ReniceAction: reniceAction, } body, _ := json.Marshal(payload) @@ -350,11 +362,14 @@ func main() { log.Fatal("api_url y token son requeridos") } - log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds", cfg.APIURL, cfg.Interval) + log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds | Renice: %v (umbral %.0f%%, %d reportes)", + cfg.APIURL, cfg.Interval, cfg.ReniceEnabled, cfg.CPUThreshold, cfg.ReniceConsecutive) ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) defer ticker.Stop() + consecAlto := 0 // contador de reportes consecutivos con CPU alta + reportar := func() { m, err := collectMetrics() if err != nil { @@ -365,7 +380,34 @@ func main() { b, _ := json.MarshalIndent(m, "", " ") log.Printf("📊 Métricas:\n%s", string(b)) } - if err := sendHeartbeat(cfg, m); err != nil { + + // ── Lógica de renice ────────────────────────────────────────────────── + var reniceAction string + if cfg.ReniceEnabled && m.CPU.Porcentaje >= cfg.CPUThreshold { + consecAlto++ + log.Printf("⚠️ CPU alta: %.1f%% (reporte %d/%d)", m.CPU.Porcentaje, consecAlto, cfg.ReniceConsecutive) + if consecAlto >= cfg.ReniceConsecutive && len(m.TopProcs) > 0 { + // Ordenar top_procs por CPU para encontrar el culpable real + procs := make([]ProcesoInfo, len(m.TopProcs)) + copy(procs, m.TopProcs) + sort.Slice(procs, func(i, j int) bool { return procs[i].CPU > procs[j].CPU }) + culpable := procs[0] + if culpable.CPU > 0 && culpable.PID > 0 { + cmd := exec.Command("renice", "-n", "19", "-p", fmt.Sprintf("%d", culpable.PID)) + if rerr := cmd.Run(); rerr != nil { + log.Printf("❌ renice falló para PID %d (%s): %v", culpable.PID, culpable.Nombre, rerr) + } else { + reniceAction = fmt.Sprintf("renice +19 aplicado a %s (PID %d, CPU %.1f%%)", culpable.Nombre, culpable.PID, culpable.CPU) + log.Printf("🔧 %s", reniceAction) + consecAlto = 0 // reiniciar tras actuar + } + } + } + } else { + consecAlto = 0 + } + + if err := sendHeartbeat(cfg, m, reniceAction); err != nil { log.Printf("⚠️ Error enviando heartbeat: %v", err) } else { log.Printf("✅ Heartbeat | RAM: %.1f%% | CPU: %.1f%% | TCP: %d | Procs: %d", diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go index 3e4cdd7..f9d3cea 100644 --- a/pkg/services/notif_dispatch.go +++ b/pkg/services/notif_dispatch.go @@ -246,6 +246,14 @@ func sendTelegramPortalUser(chatID string, mensaje string) error { return fmt.Errorf("no hay bots de Telegram activos para enviar") } +// NotificarReniceAction envía Telegram cuando el agente aplica un renice automático. +func NotificarReniceAction(servidorNombre, accion string) { + msg := fmt.Sprintf("🔧 Renice automático aplicado\nServidor: %s\n%s\n\nCPU sostenida por encima del umbral.", + escapeTelegramHTML(servidorNombre), + escapeTelegramHTML(accion)) + sendTelegramAdmin(msg) +} + func sendTelegramAdmin(mensaje string) { configs, err := models.GetAllTelegramConfigs() if err != nil { diff --git a/rest/controllers/agent_controller.go b/rest/controllers/agent_controller.go index 3cbc573..fc3d296 100644 --- a/rest/controllers/agent_controller.go +++ b/rest/controllers/agent_controller.go @@ -10,14 +10,16 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // AgentHeartbeat recibe métricas del agente instalado en un servidor. // Ruta pública: POST /agent/heartbeat — autenticada por token en JSON body. func AgentHeartbeat(c *fiber.Ctx) error { type HeartbeatRequest struct { - Token string `json:"token"` - Metricas string `json:"metricas"` // JSON string ya serializado por el agente + Token string `json:"token"` + Metricas string `json:"metricas"` + ReniceAction string `json:"renice_action"` } var req HeartbeatRequest @@ -136,6 +138,11 @@ func AgentHeartbeat(c *fiber.Ctx) error { } } + // Notificar por Telegram si el agente aplicó un renice + if req.ReniceAction != "" { + go services.NotificarReniceAction(servidor.Nombre, req.ReniceAction) + } + return c.JSON(fiber.Map{"ok": true, "servidor_id": servidor.ID}) }