Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/modules/netpulse_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
52 changes: 52 additions & 0 deletions internal/modules/netpulse_report.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
59 changes: 59 additions & 0 deletions internal/modules/netpulse_report_test.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
32 changes: 32 additions & 0 deletions internal/modules/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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",
"🔴 <b>NetGrip</b>\nSelf-update failed: "+htmlEsc(msg),
"🔴 NetGrip\nSelf-update failed: "+msg,
Expand Down Expand Up @@ -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()
}

Expand Down
69 changes: 69 additions & 0 deletions internal/modules/selfupdate_outcome_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading