diff --git a/internal/modules/netpulse_agent.go b/internal/modules/netpulse_agent.go index 03c3124..e4ae321 100644 --- a/internal/modules/netpulse_agent.go +++ b/internal/modules/netpulse_agent.go @@ -622,14 +622,30 @@ func statusCallbackGen(gen int) func(runtime.Status) { // NetGrip se actualiza a sí mismo con su propio updater (sus releases de // GitHub, sha verificado); el servidor solo dispara. Si ya está en la última, // no hace nada (el guard de downgrades vive en el self-update). +// #549: el resultado se reporta de vuelta a NetPulse (upgrade-progress / +// upgrade-result con el token del agente): el self-update corre detached y +// sin este reporte la acción parecía un no-op desde la UI de NetPulse, que +// nunca recibía ni pasos ni el motivo real de un fallo (p. ej. sin espacio). func netPulseUpgradeTrigger(data string) { check := CheckSelfUpdate(npVersion) if check == nil || !check.Available { log.Printf("netpulse: upgrade solicitado por el servidor: ya en la última (%s)", npVersion) return } + cfg, err := ReadNetPulseConfig(prodNetPulsePaths().env) + if err != nil { + cfg = NetPulseConfig{} + } + setSelfUpdateOutcomeHook(func(ok bool, errMsg string) { + npReportUpgradeResult(cfg, ok, errMsg) + }) + npReportUpgradeProgress(cfg, "downloading", 0) log.Printf("netpulse: upgrade solicitado por el servidor → %s", check.Latest) if err := StartSelfUpdate(npVersion); err != nil { + // Consume el hook: el arranque no llegó a producir un resultado + // terminal (p. ej. "update already in progress") y NetPulse debe + // ver el fallo en vez de quedarse esperando. + reportSelfUpdateOutcome(false, err.Error()) log.Printf("netpulse: self-update falló: %v", err) } } diff --git a/internal/modules/netpulse_report.go b/internal/modules/netpulse_report.go new file mode 100644 index 0000000..64a74ee --- /dev/null +++ b/internal/modules/netpulse_report.go @@ -0,0 +1,52 @@ +package modules + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "strings" + "time" +) + +// npReportUpgradeProgress y npReportUpgradeResult (#549) informan a NetPulse +// de los pasos y del resultado terminal del self-update disparado remotamente +// (evento SSE "upgrade"). Usan el mismo contrato que el agente standalone +// (POST /api/agents/{slug}/upgrade-progress|upgrade-result con el token del +// agente en Bearer). Fail-silent con log, al estilo de registerExecutorToken: +// un fallo de red al reportar nunca debe impedir el self-update en sí. +func npReportUpgradeProgress(cfg NetPulseConfig, step string, pct int) { + npPostUpgradeReport(cfg, "/api/agents/"+cfg.Slug+"/upgrade-progress", + map[string]any{"slug": cfg.Slug, "step": step, "pct": pct}) +} + +func npReportUpgradeResult(cfg NetPulseConfig, ok bool, errMsg string) { + npPostUpgradeReport(cfg, "/api/agents/"+cfg.Slug+"/upgrade-result", + map[string]any{"slug": cfg.Slug, "ok": ok, "error": errMsg}) +} + +func npPostUpgradeReport(cfg NetPulseConfig, path string, body map[string]any) { + if cfg.Server == "" || cfg.Slug == "" || cfg.Token == "" { + return + } + raw, err := json.Marshal(body) + if err != nil { + return + } + req, err := http.NewRequest("POST", strings.TrimRight(cfg.Server, "/")+path, bytes.NewReader(raw)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+cfg.Token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("netpulse: reporte de upgrade no entregado: %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("netpulse: reporte de upgrade respondió %d", resp.StatusCode) + } +} diff --git a/internal/modules/netpulse_report_test.go b/internal/modules/netpulse_report_test.go new file mode 100644 index 0000000..60b7b68 --- /dev/null +++ b/internal/modules/netpulse_report_test.go @@ -0,0 +1,59 @@ +package modules + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Los reportes de upgrade usan el contrato del agente standalone: POST a +// /api/agents/{slug}/upgrade-progress|upgrade-result con el token del agente +// en Bearer (#549). +func TestNpUpgradeReportsPostToNetPulse(t *testing.T) { + type req struct { + path string + auth string + body map[string]any + } + reqs := []req{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("body: %v", err) + } + reqs = append(reqs, req{path: r.URL.Path, auth: r.Header.Get("Authorization"), body: body}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := NetPulseConfig{Server: srv.URL + "/", Slug: "flint2", Token: "tok-abc"} + npReportUpgradeProgress(cfg, "downloading", 0) + npReportUpgradeResult(cfg, false, "no space") + + if len(reqs) != 2 { + t.Fatalf("%d peticiones, want 2", len(reqs)) + } + if reqs[0].path != "/api/agents/flint2/upgrade-progress" { + t.Fatalf("path 1: %s", reqs[0].path) + } + if reqs[0].auth != "Bearer tok-abc" { + t.Fatalf("auth 1: %s", reqs[0].auth) + } + if reqs[0].body["step"] != "downloading" { + t.Fatalf("body 1: %v", reqs[0].body) + } + if reqs[1].path != "/api/agents/flint2/upgrade-result" { + t.Fatalf("path 2: %s", reqs[1].path) + } + if reqs[1].body["ok"] != false || reqs[1].body["error"] != "no space" { + t.Fatalf("body 2: %v", reqs[1].body) + } +} + +// Config incompleta (sin server/slug/token): el reporte se omite en silencio +// en vez de petar. +func TestNpUpgradeReportSkipsIncompleteConfig(t *testing.T) { + npReportUpgradeProgress(NetPulseConfig{}, "downloading", 0) + npReportUpgradeResult(NetPulseConfig{Server: "http://x", Slug: "s"}, true, "") +} diff --git a/internal/modules/selfupdate.go b/internal/modules/selfupdate.go index ab850c9..5ba13ac 100644 --- a/internal/modules/selfupdate.go +++ b/internal/modules/selfupdate.go @@ -97,6 +97,36 @@ var ( updateCheck *SelfUpdateCheck ) +// selfUpdateOutcomeHook, when armed, receives the terminal outcome of the +// next self-update so a remote trigger (NetPulse's SSE "upgrade" command, +// #549) can show real progress instead of a silent hang. One-shot: the +// hook is consumed by the first terminal state (success before the +// restart, or any failure) and self-updates started from the panel or the +// scheduler never arm it, so they never report anywhere. +var ( + outcomeHookMu sync.Mutex + selfUpdateOutcomeHook func(ok bool, errMsg string) +) + +func setSelfUpdateOutcomeHook(f func(ok bool, errMsg string)) { + outcomeHookMu.Lock() + defer outcomeHookMu.Unlock() + selfUpdateOutcomeHook = f +} + +// reportSelfUpdateOutcome entrega el resultado terminal al hook armado (si +// hay) y lo consume. Se llama desde el único punto de cada final: el path +// de éxito de runSelfUpdate (antes del restart) y failUpdate. +func reportSelfUpdateOutcome(ok bool, errMsg string) { + outcomeHookMu.Lock() + f := selfUpdateOutcomeHook + selfUpdateOutcomeHook = nil + outcomeHookMu.Unlock() + if f != nil { + f(ok, errMsg) + } +} + func GetSelfUpdateStatus() SelfUpdateStatus { updateMu.Lock() defer updateMu.Unlock() @@ -110,6 +140,7 @@ func failUpdate(format string, args ...any) { msg := fmt.Sprintf(format, args...) log.Printf("netgrip: self-update failed: %s", msg) setUpdateStatus("error", 0, msg) + reportSelfUpdateOutcome(false, msg) notifyAll("NetGrip", "🔴 NetGrip\nSelf-update failed: "+htmlEsc(msg), "🔴 NetGrip\nSelf-update failed: "+msg, @@ -301,6 +332,7 @@ func runSelfUpdate(assetURL string, assetSize int64, currentVersion string) { syncInitScript(currentVersion) setUpdateStatus("restarting", 100, "") + reportSelfUpdateOutcome(true, "") exec.Command("/etc/init.d/netgrip", "restart").Start() } diff --git a/internal/modules/selfupdate_outcome_test.go b/internal/modules/selfupdate_outcome_test.go new file mode 100644 index 0000000..5668b66 --- /dev/null +++ b/internal/modules/selfupdate_outcome_test.go @@ -0,0 +1,69 @@ +package modules + +import ( + "testing" +) + +// El hook de resultado es one-shot: el primer estado terminal lo consume y +// los siguientes no llegan a ningún sitio (#549). Así un self-update +// posterior (panel o scheduler) nunca reporta a NetPulse un resultado que +// NetPulse no pidió. +func TestReportSelfUpdateOutcomeOneShot(t *testing.T) { + calls := 0 + var lastOk bool + var lastMsg string + setSelfUpdateOutcomeHook(func(ok bool, errMsg string) { + calls++ + lastOk, lastMsg = ok, errMsg + }) + t.Cleanup(func() { setSelfUpdateOutcomeHook(nil) }) + + reportSelfUpdateOutcome(false, "no space") + reportSelfUpdateOutcome(true, "") + reportSelfUpdateOutcome(false, "otro") + + if calls != 1 { + t.Fatalf("hook llamado %d veces, want 1", calls) + } + if lastOk { + t.Fatal("primer resultado debía ser ok=false") + } + if lastMsg != "no space" { + t.Fatalf("mensaje: %q", lastMsg) + } +} + +// Sin hook armado, reportSelfUpdateOutcome no hace nada (nil-safe): los +// self-update iniciados desde el panel no tienen por qué llevar hook. +func TestReportSelfUpdateOutcomeNoHook(t *testing.T) { + reportSelfUpdateOutcome(true, "") +} + +// failUpdate (el camino de todos los fallos de runSelfUpdate) entrega el +// motivo real al hook: es lo que convierte "el botón no hace nada" en el +// error visible en NetPulse (#549). +func TestFailUpdateReportsOutcome(t *testing.T) { + var got bool + var msg string + setSelfUpdateOutcomeHook(func(ok bool, errMsg string) { + got = true + msg = errMsg + }) + t.Cleanup(func() { + setSelfUpdateOutcomeHook(nil) + setUpdateStatus("idle", 0, "") + }) + + failUpdate("install: not enough space (need ~%d bytes)", 1234) + + if !got { + t.Fatal("failUpdate no reportó el outcome al hook") + } + want := "install: not enough space (need ~1234 bytes)" + if msg != want { + t.Fatalf("mensaje %q, want %q", msg, want) + } + if st := GetSelfUpdateStatus(); st.Phase != "error" { + t.Fatalf("fase %q, want error", st.Phase) + } +}