merge: item 2 node-deletion cascade
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -512,6 +513,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteProbeDataForAgent drops one agent's probe rows; a variable so
|
||||||
|
// tests can point the cascade at a scratch probe store.
|
||||||
|
var deleteProbeDataForAgent = probe.DeleteAgentData
|
||||||
|
|
||||||
func (s *NodeService) Delete(id int) error {
|
func (s *NodeService) Delete(id int) error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
// Refuse to delete a node that still owns inbounds: dropping the node row
|
// Refuse to delete a node that still owns inbounds: dropping the node row
|
||||||
@@ -534,11 +539,20 @@ func (s *NodeService) Delete(id int) error {
|
|||||||
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
|
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
|
||||||
guid = n.Guid
|
guid = n.Guid
|
||||||
}
|
}
|
||||||
|
// Bound agents link by agent_nodes.node_id = panel node id, never agent_nodes.id (Oracle-JP: node 33, agent row 20).
|
||||||
|
// Collected before the transaction so a failed commit never touches probe data.
|
||||||
|
var agentIDs []int
|
||||||
|
if err := db.Model(&model.AgentNode{}).Where("node_id = ?", id).Pluck("id", &agentIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Delete the node row and its per-node child rows atomically. Remove the
|
// Delete the node row and its per-node child rows atomically. Remove the
|
||||||
// children (traffic baselines, IP attribution) before the parent node row so
|
// children (agent bindings, baselines, IP attribution) before the parent
|
||||||
// the ordering already matches a future ON DELETE constraint. Delete stays
|
// row; ordering already matches a future ON DELETE constraint, and Delete
|
||||||
// tolerant of a missing node row so it can still clean up orphaned baselines.
|
// stays tolerant of a missing node row so it can still clean up orphans.
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("node_id = ?", id).Delete(&model.AgentNode{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -553,6 +567,14 @@ func (s *NodeService) Delete(id int) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// probe.db is a separate file, so its rows go in their own transaction inside DeleteAgentData, keyed by agent row id.
|
||||||
|
// Best-effort like agent deletion; the public-page snapshot is dropped so the node vanishes at once.
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
deleteProbeDataForAgent(int64(agentID))
|
||||||
|
}
|
||||||
|
if len(agentIDs) > 0 {
|
||||||
|
InvalidateProbeSnapshot()
|
||||||
|
}
|
||||||
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
||||||
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node deletion must cascade to the node's own agent bindings and to
|
||||||
|
// every probe row of those agents, and to nothing else. The key trap:
|
||||||
|
// agent rows link by node_id (the panel node id), while probe rows
|
||||||
|
// link by agent row id — matching probe rows on the node id would
|
||||||
|
// delete a different node's probe data (Oracle-JP: node 33's agent
|
||||||
|
// row id was 20, so node-id matching would have struck agent 33).
|
||||||
|
func TestNodeDelete_CascadesAgentAndProbeRows(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open probe store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
var probeErr error
|
||||||
|
oldProbeDelete := deleteProbeDataForAgent
|
||||||
|
deleteProbeDataForAgent = func(id int64) {
|
||||||
|
if err := st.DeleteAgentData(id); err != nil && probeErr == nil {
|
||||||
|
probeErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete })
|
||||||
|
|
||||||
|
nodeA := &model.Node{Name: "cascade-a"}
|
||||||
|
nodeB := &model.Node{Name: "cascade-b"}
|
||||||
|
if err := db.Create(nodeA).Error; err != nil {
|
||||||
|
t.Fatalf("create node A: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(nodeB).Error; err != nil {
|
||||||
|
t.Fatalf("create node B: %v", err)
|
||||||
|
}
|
||||||
|
mkAgent := func(name string, nodeID *int) *model.AgentNode {
|
||||||
|
ag := &model.AgentNode{TokenHash: "hash-" + name, Name: name, NodeID: nodeID}
|
||||||
|
if err := db.Create(ag).Error; err != nil {
|
||||||
|
t.Fatalf("create agent %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return ag
|
||||||
|
}
|
||||||
|
a1 := mkAgent("cascade-a1", &nodeA.Id)
|
||||||
|
a2 := mkAgent("cascade-a2", &nodeA.Id)
|
||||||
|
b1 := mkAgent("cascade-b1", &nodeB.Id)
|
||||||
|
u1 := mkAgent("cascade-unbound", nil)
|
||||||
|
if err := db.Create(&model.NodeClientTraffic{NodeId: nodeA.Id, Email: "a@example.com"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed baseline A: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.NodeClientTraffic{NodeId: nodeB.Id, Email: "b@example.com"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed baseline B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedProbe := func(ag *model.AgentNode, price float64) {
|
||||||
|
t.Helper()
|
||||||
|
exec := func(q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := st.DB().Exec(q, args...); err != nil {
|
||||||
|
t.Fatalf("seed probe agent %d: %v", ag.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, ?)`, ag.Id, price)
|
||||||
|
exec(`INSERT INTO probe_traffic (agent_id, total_rx) VALUES (?, 100)`, ag.Id)
|
||||||
|
exec(`INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx, tcp, udp, procs) VALUES (?, 1000, 1, 1, 1, 1, 1, 1, 1, 1, 1)`, ag.Id)
|
||||||
|
exec(`INSERT INTO probe_ping_node (task_id, agent_id) VALUES (7001, ?)`, ag.Id)
|
||||||
|
exec(`INSERT INTO probe_ping_record (agent_id, ts, task_id, latency) VALUES (?, 1000, 7001, 5)`, ag.Id)
|
||||||
|
}
|
||||||
|
if _, err := st.DB().Exec(`INSERT INTO probe_ping_task (id, name, target) VALUES (7001, 'cascade-task', 'example.com')`); err != nil {
|
||||||
|
t.Fatalf("seed ping task: %v", err)
|
||||||
|
}
|
||||||
|
seedProbe(a1, 11)
|
||||||
|
seedProbe(a2, 22)
|
||||||
|
seedProbe(b1, 33)
|
||||||
|
|
||||||
|
svc := &NodeService{}
|
||||||
|
if err := svc.Delete(nodeA.Id); err != nil {
|
||||||
|
t.Fatalf("Delete node A: %v", err)
|
||||||
|
}
|
||||||
|
if probeErr != nil {
|
||||||
|
t.Fatalf("probe cascade: %v", probeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
countPanel := func(table, where string, args ...any) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := db.Table(table).Where(where, args...).Count(&n).Error; err != nil {
|
||||||
|
t.Fatalf("count %s: %v", table, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
countProbe := func(table string, agentID int) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE agent_id = ?`, agentID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count probe %s agent %d: %v", table, agentID, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if got := countPanel("nodes", "id = ?", nodeA.Id); got != 0 {
|
||||||
|
t.Fatalf("node A rows = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("nodes", "id = ?", nodeB.Id); got != 1 {
|
||||||
|
t.Fatalf("node B rows = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("agent_nodes", "node_id = ?", nodeA.Id); got != 0 {
|
||||||
|
t.Fatalf("node A agent rows = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("agent_nodes", "id = ?", b1.Id); got != 1 {
|
||||||
|
t.Fatalf("node B agent row = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("agent_nodes", "id = ?", u1.Id); got != 1 {
|
||||||
|
t.Fatalf("unbound agent row = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("node_client_traffics", "node_id = ?", nodeA.Id); got != 0 {
|
||||||
|
t.Fatalf("node A baselines = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := countPanel("node_client_traffics", "node_id = ?", nodeB.Id); got != 1 {
|
||||||
|
t.Fatalf("node B baselines = %d, want 1", got)
|
||||||
|
}
|
||||||
|
// Deleted agents: every probe table empty. Surviving agent: every
|
||||||
|
// probe row intact, with its own values (not just a nonzero count).
|
||||||
|
for _, table := range []string{"probe_meta", "probe_traffic", "probe_metric", "probe_ping_node", "probe_ping_record"} {
|
||||||
|
for _, ag := range []*model.AgentNode{a1, a2} {
|
||||||
|
if got := countProbe(table, ag.Id); got != 0 {
|
||||||
|
t.Fatalf("%s rows for deleted agent %d = %d, want 0", table, ag.Id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := countProbe(table, b1.Id); got != 1 {
|
||||||
|
t.Fatalf("%s rows for surviving agent %d = %d, want 1", table, b1.Id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var price float64
|
||||||
|
if err := st.DB().QueryRow(`SELECT price FROM probe_meta WHERE agent_id = ?`, b1.Id).Scan(&price); err != nil || price != 33 {
|
||||||
|
t.Fatalf("surviving agent meta price = %v err=%v, want 33", price, err)
|
||||||
|
}
|
||||||
|
var taskCnt int
|
||||||
|
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_ping_task WHERE id = 7001`).Scan(&taskCnt); err != nil || taskCnt != 1 {
|
||||||
|
t.Fatalf("ping task rows = %d err=%v, want 1 (tasks are global, only assignments cascade)", taskCnt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blocked delete (inbounds still attached) must touch neither panel
|
||||||
|
// agent rows nor probe rows: the probe cascade runs only after the
|
||||||
|
// panel transaction commits.
|
||||||
|
func TestNodeDelete_BlockedKeepsAgentAndProbeRows(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open probe store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
var probeCalls []int64
|
||||||
|
oldProbeDelete := deleteProbeDataForAgent
|
||||||
|
deleteProbeDataForAgent = func(id int64) { probeCalls = append(probeCalls, id) }
|
||||||
|
t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete })
|
||||||
|
|
||||||
|
node := &model.Node{Name: "blocked-node"}
|
||||||
|
if err := db.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
ag := &model.AgentNode{TokenHash: "hash-blocked", Name: "blocked-agent", NodeID: &node.Id}
|
||||||
|
if err := db.Create(ag).Error; err != nil {
|
||||||
|
t.Fatalf("create agent: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.DB().Exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, 44)`, ag.Id); err != nil {
|
||||||
|
t.Fatalf("seed probe meta: %v", err)
|
||||||
|
}
|
||||||
|
createNodeInbound(t, db, node.Id, "blocked-in-443", 443)
|
||||||
|
|
||||||
|
svc := &NodeService{}
|
||||||
|
if err := svc.Delete(node.Id); err == nil {
|
||||||
|
t.Fatal("Delete should fail while an inbound is still attached")
|
||||||
|
}
|
||||||
|
if len(probeCalls) != 0 {
|
||||||
|
t.Fatalf("probe cascade ran on blocked delete: %v", probeCalls)
|
||||||
|
}
|
||||||
|
var nodeCnt, agCnt int64
|
||||||
|
db.Model(&model.Node{}).Where("id = ?", node.Id).Count(&nodeCnt)
|
||||||
|
db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Count(&agCnt)
|
||||||
|
var metaCnt int
|
||||||
|
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_meta WHERE agent_id = ?`, ag.Id).Scan(&metaCnt); err != nil {
|
||||||
|
t.Fatalf("count probe meta: %v", err)
|
||||||
|
}
|
||||||
|
if nodeCnt != 1 || agCnt != 1 || metaCnt != 1 {
|
||||||
|
t.Fatalf("after blocked delete: node=%d agent=%d probeMeta=%d, want 1/1/1", nodeCnt, agCnt, metaCnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user