Skip to content

Commit b0cd098

Browse files
committed
feat(core): add confirmed Xray restart tracking for remote nodes
- Replace fire-and-forget restart with request/confirm lifecycle - Track pending restart state via nodeRestart map with timeout handling - Confirm bounce only when node reports a changed Xray start time - Expose restart state in NodeView for UI feedback (pending/done/timeout) - Add NodeXrayConfig for read-only config viewer on any node - Include comprehensive tests for confirmation, timeout, and idempotency
1 parent 0e81928 commit b0cd098

42 files changed

Lines changed: 2330 additions & 1711 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/core/manager.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import (
1010
"sync/atomic"
1111
"time"
1212

13+
"github.com/AppsGanin/rospanel/internal/abuse"
1314
"github.com/AppsGanin/rospanel/internal/geo"
1415
"github.com/AppsGanin/rospanel/internal/logbuf"
1516
"github.com/AppsGanin/rospanel/internal/model"
1617
"github.com/AppsGanin/rospanel/internal/nodeapi"
1718
"github.com/AppsGanin/rospanel/internal/opera"
18-
"github.com/AppsGanin/rospanel/internal/abuse"
1919
"github.com/AppsGanin/rospanel/internal/store"
2020
"github.com/AppsGanin/rospanel/internal/sysstat"
2121
"github.com/AppsGanin/rospanel/internal/xray"
@@ -176,6 +176,16 @@ type Manager struct {
176176
nodeUpdateMu sync.Mutex
177177
nodeUpdateWanted map[int64]bool
178178
nodeGeoWanted map[int64]bool
179+
// nodeRestart holds Xray-restart requests that have not been confirmed yet. Unlike
180+
// the two flags above, a restart is not done when it is sent: the operator needs to
181+
// know it actually happened, so the request outlives its delivery and is only
182+
// dropped when the node reports a bounced Xray (or the wait times out).
183+
nodeRestart map[int64]*nodeRestartReq
184+
185+
// nodeHostStats is each node's last-reported machine state (disk/RAM/guards) for
186+
// its diagnostics page, under nodeGeoMu with the other "last reported" caches.
187+
// Bounded by the node count; a deleted node's entry is dead weight of one struct.
188+
nodeHostStats map[int64]nodeapi.HostStats
179189

180190
// nodeLogs holds the most recent log tail reported by each node, plus which
181191
// nodes an operator is currently viewing (so the panel asks them for logs).
@@ -217,8 +227,10 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
217227
nodes: newNodeRegistry(),
218228
nodeUpdateWanted: map[int64]bool{},
219229
nodeGeoWanted: map[int64]bool{},
230+
nodeRestart: map[int64]*nodeRestartReq{},
220231
nodeLogs: map[int64]nodeLogEntry{},
221232
nodeGeoFiles: map[int64][]nodeapi.GeoFile{},
233+
nodeHostStats: map[int64]nodeapi.HostStats{},
222234
nodeLogsWanted: map[int64]int64{},
223235
}
224236
if set, err := st.GetSettings(); err == nil {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package core
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
8+
"github.com/AppsGanin/rospanel/internal/store"
9+
)
10+
11+
// restartTestManager returns a manager with a node registered, ready to take
12+
// restart requests.
13+
func restartTestManager(t *testing.T) (*Manager, int64) {
14+
t.Helper()
15+
st, err := store.Open(filepath.Join(t.TempDir(), "restart.db"))
16+
if err != nil {
17+
t.Fatalf("open: %v", err)
18+
}
19+
t.Cleanup(func() { _ = st.Close() })
20+
n, err := st.CreateNode("edge", "203.0.113.9", "")
21+
if err != nil {
22+
t.Fatalf("create node: %v", err)
23+
}
24+
m := &Manager{
25+
store: st,
26+
nodes: newNodeRegistry(),
27+
nodeRestart: map[int64]*nodeRestartReq{},
28+
}
29+
return m, n.ID
30+
}
31+
32+
// The restart button used to report success the moment it was clicked, which is how
33+
// a node with a dead supervisor could swallow three restarts in a row and still look
34+
// fine. A request must therefore survive being SENT and only clear when the node
35+
// reports an Xray that actually came back.
36+
func TestNodeRestartConfirmsOnlyOnAFreshXray(t *testing.T) {
37+
m, id := restartTestManager(t)
38+
39+
if got := m.NodeRestartState(id); got != "" {
40+
t.Fatalf("a node nobody touched reads as %q", got)
41+
}
42+
if err := m.RequestNodeXrayRestart(id); err != nil {
43+
t.Fatalf("request: %v", err)
44+
}
45+
if got := m.NodeRestartState(id); got != RestartPending {
46+
t.Fatalf("right after the operator asked: %q, want %q", got, RestartPending)
47+
}
48+
49+
// The node's poll returns and carries the command away. Xray was up since 1000.
50+
if !m.TakeNodeXrayRestart(id, 1000) {
51+
t.Fatal("the command was not handed to the node")
52+
}
53+
// Sending is not doing: the operator is still waiting to hear it happened.
54+
if got := m.NodeRestartState(id); got != RestartPending {
55+
t.Errorf("state on delivery = %q — reporting done when it was merely sent is "+
56+
"the lie this exists to stop", got)
57+
}
58+
// And it is handed over exactly once: re-sending on every poll would restart the
59+
// node again and again while we wait.
60+
if m.TakeNodeXrayRestart(id, 1000) {
61+
t.Error("the command was handed over twice")
62+
}
63+
64+
// A sync that still shows the SAME Xray proves nothing — it is the report that
65+
// was already in flight, or a node that never acted.
66+
m.ConfirmNodeXrayRestart(id, 1000)
67+
if got := m.NodeRestartState(id); got != RestartPending {
68+
t.Errorf("state = %q — an unchanged Xray start time was taken as proof", got)
69+
}
70+
71+
// A different start time is the node's own proof that the process bounced.
72+
m.ConfirmNodeXrayRestart(id, 1042)
73+
if got := m.NodeRestartState(id); got != RestartDone {
74+
t.Errorf("state after the node proved the bounce = %q, want %q", got, RestartDone)
75+
}
76+
77+
// The answer is held briefly and then stops being news: confirmation lands about
78+
// a second after the click, so without this window the operator sees nothing
79+
// change at all and clicks again.
80+
m.nodeUpdateMu.Lock()
81+
m.nodeRestart[id].outcomeAt = time.Now().Add(-nodeRestartShow - time.Second)
82+
m.nodeUpdateMu.Unlock()
83+
if got := m.NodeRestartState(id); got != "" {
84+
t.Errorf("state = %q long after it resolved, want it gone", got)
85+
}
86+
}
87+
88+
// An agent too old to report its Xray start time can never prove anything, so its
89+
// request must not hang the button forever — nor be mistaken for a success.
90+
func TestNodeRestartGivesUpWaiting(t *testing.T) {
91+
m, id := restartTestManager(t)
92+
93+
if err := m.RequestNodeXrayRestart(id); err != nil {
94+
t.Fatalf("request: %v", err)
95+
}
96+
if !m.TakeNodeXrayRestart(id, 0) {
97+
t.Fatal("the command was not handed to the node")
98+
}
99+
// A node reporting no start time at all (old agent) never confirms.
100+
m.ConfirmNodeXrayRestart(id, 0)
101+
if got := m.NodeRestartState(id); got != RestartPending {
102+
t.Errorf("state = %q — a zero start time was accepted as proof", got)
103+
}
104+
105+
// Age the request past the wait: the UI must fall back to the server's real
106+
// status rather than keep claiming a restart is on its way.
107+
m.nodeUpdateMu.Lock()
108+
m.nodeRestart[id].at = time.Now().Add(-nodeRestartWait - time.Second)
109+
m.nodeUpdateMu.Unlock()
110+
111+
if got := m.NodeRestartState(id); got != RestartTimeout {
112+
t.Errorf("state after the wait = %q, want %q — giving up has to be said out "+
113+
"loud, not shown as the badge quietly vanishing", got, RestartTimeout)
114+
}
115+
// Expiry also stops the command from being delivered late — a node that was
116+
// offline must not bounce Xray minutes after the operator gave up.
117+
if err := m.RequestNodeXrayRestart(id); err != nil {
118+
t.Fatalf("request: %v", err)
119+
}
120+
m.nodeUpdateMu.Lock()
121+
m.nodeRestart[id].at = time.Now().Add(-nodeRestartWait - time.Second)
122+
m.nodeUpdateMu.Unlock()
123+
if m.TakeNodeXrayRestart(id, 500) {
124+
t.Error("an expired request was still handed to the node")
125+
}
126+
}

0 commit comments

Comments
 (0)