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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 11:17:07 -05:00
co-authored by Claude Sonnet 4.6
parent fd72eec281
commit 66088ce691
4 changed files with 83 additions and 17 deletions
+13 -4
View File
@@ -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"
+53 -11
View File
@@ -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",
+8
View File
@@ -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("🔧 <b>Renice automático aplicado</b>\nServidor: <b>%s</b>\n%s\n\n<i>CPU sostenida por encima del umbral.</i>",
escapeTelegramHTML(servidorNombre),
escapeTelegramHTML(accion))
sendTelegramAdmin(msg)
}
func sendTelegramAdmin(mensaje string) {
configs, err := models.GetAllTelegramConfigs()
if err != nil {
+9 -2
View File
@@ -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})
}