[优化] 添加节点 IP 手动覆盖功能,更新相关文档和测试用例

This commit is contained in:
ryan
2026-05-31 14:09:53 +08:00
parent 8894620b92
commit 4ae6c2718f
20 changed files with 351 additions and 7 deletions
+1
View File
@@ -115,6 +115,7 @@ type NodeView struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
IPManualOverride bool `json:"ip_manual_override"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
+25 -1
View File
@@ -18,6 +18,7 @@ import (
type NodeInput struct {
Name string `json:"name"`
IP string `json:"ip"`
IPManualOverride *bool `json:"ip_manual_override"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
GeoName string `json:"geo_name"`
GeoLatitude *float64 `json:"geo_latitude"`
@@ -59,9 +60,11 @@ func CreateNode(input NodeInput) (*NodeView, error) {
if name == "" {
return nil, errors.New("节点名不能为空")
}
ipManualOverride := resolveNodeIPManualOverride(input, nil, ip)
node := &model.Node{
Name: name,
IP: ip,
IPManualOverride: ipManualOverride,
GeoName: geoName,
GeoLatitude: geoLatitude,
GeoLongitude: geoLongitude,
@@ -102,8 +105,10 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
if err != nil {
return nil, err
}
ipManualOverride := resolveNodeIPManualOverride(input, node, ip)
node.Name = name
node.IP = ip
node.IPManualOverride = ipManualOverride
node.GeoName = geoName
node.GeoLatitude = geoLatitude
node.GeoLongitude = geoLongitude
@@ -283,6 +288,7 @@ func buildNodeView(node *model.Node) *NodeView {
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
IPManualOverride: node.IPManualOverride,
GeoName: strings.TrimSpace(node.GeoName),
GeoLatitude: node.GeoLatitude,
GeoLongitude: node.GeoLongitude,
@@ -331,6 +337,9 @@ func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *flo
if ip != "" && net.ParseIP(ip) == nil {
return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效")
}
if input.IPManualOverride != nil && *input.IPManualOverride && ip == "" {
return "", "", "", nil, nil, false, errors.New("锁定节点 IP 时必须填写节点 IP")
}
if len(geoName) > 128 {
return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符")
}
@@ -357,6 +366,19 @@ func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *flo
return name, ip, geoName, geoLatitude, geoLongitude, true, nil
}
func resolveNodeIPManualOverride(input NodeInput, existing *model.Node, normalizedIP string) bool {
if input.IPManualOverride != nil {
return *input.IPManualOverride
}
if existing == nil {
return strings.TrimSpace(normalizedIP) != ""
}
if existing.IPManualOverride {
return true
}
return strings.TrimSpace(normalizedIP) != "" && strings.TrimSpace(normalizedIP) != strings.TrimSpace(existing.IP)
}
func cloneCoordinate(value *float64) *float64 {
if value == nil {
return nil
@@ -499,7 +521,9 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
node.Name = strings.TrimSpace(payload.Name)
}
}
node.IP = strings.TrimSpace(payload.IP)
if !node.IPManualOverride {
node.IP = strings.TrimSpace(payload.IP)
}
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
@@ -84,6 +84,10 @@ func geoipFloat(value float64) *float64 {
return &value
}
func boolValue(value bool) *bool {
return &value
}
func TestRequestNodeAgentPreviewUpdate(t *testing.T) {
setupServiceTestDB(t)
@@ -453,6 +457,192 @@ func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) {
}
}
func TestHeartbeatNodePreservesManualIPOverride(t *testing.T) {
setupServiceTestDB(t)
node := &model.Node{
NodeID: "node-ip-manual",
Name: "ip-manual",
IP: "203.0.113.10",
IPManualOverride: true,
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
resp, err := HeartbeatNode(node, AgentNodePayload{
NodeID: node.NodeID,
Name: node.Name,
IP: "10.0.0.8",
AgentVersion: "v0.5.0",
NginxVersion: "1.27.1.3",
OpenrestyStatus: OpenrestyStatusHealthy,
})
if err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
}
if resp.Node.IP != "203.0.113.10" {
t.Fatalf("expected manual ip to be preserved, got %s", resp.Node.IP)
}
if resp.Node.AgentVersion != "v0.5.0" || resp.Node.NginxVersion != "1.27.1.3" {
t.Fatalf("expected runtime metadata to update despite locked ip, got %+v", resp.Node)
}
stored, err := model.GetNodeByNodeID(node.NodeID)
if err != nil {
t.Fatalf("failed to reload node: %v", err)
}
if stored.IP != "203.0.113.10" {
t.Fatalf("expected stored manual ip to be preserved, got %s", stored.IP)
}
}
func TestHeartbeatNodeUpdatesIPWhenManualOverrideDisabled(t *testing.T) {
setupServiceTestDB(t)
node := &model.Node{
NodeID: "node-ip-auto",
Name: "ip-auto",
IP: "10.0.0.8",
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
resp, err := HeartbeatNode(node, AgentNodePayload{
NodeID: node.NodeID,
Name: node.Name,
IP: "8.8.8.8",
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
})
if err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
}
if resp.Node.IP != "8.8.8.8" {
t.Fatalf("expected heartbeat ip to update unlocked node, got %s", resp.Node.IP)
}
}
func TestUpdateNodeCanLockAndUnlockManualIP(t *testing.T) {
setupServiceTestDB(t)
node, err := CreateNode(NodeInput{Name: "manual-ip-edge"})
if err != nil {
t.Fatalf("failed to create node: %v", err)
}
locked, err := UpdateNode(node.ID, NodeInput{
Name: "manual-ip-edge",
IP: "203.0.113.10",
IPManualOverride: boolValue(true),
})
if err != nil {
t.Fatalf("expected lock update to succeed: %v", err)
}
if !locked.IPManualOverride || locked.IP != "203.0.113.10" {
t.Fatalf("expected node ip to be locked, got %+v", locked)
}
stored, err := model.GetNodeByID(node.ID)
if err != nil {
t.Fatalf("failed to reload node: %v", err)
}
if _, err = HeartbeatNode(stored, AgentNodePayload{
NodeID: stored.NodeID,
Name: stored.Name,
IP: "8.8.8.8",
AgentVersion: "v0.5.0",
NginxVersion: "1.27.1.3",
}); err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
}
lockedStored, err := model.GetNodeByID(node.ID)
if err != nil {
t.Fatalf("failed to reload locked node: %v", err)
}
if lockedStored.IP != "203.0.113.10" {
t.Fatalf("expected heartbeat to preserve locked ip, got %s", lockedStored.IP)
}
unlocked, err := UpdateNode(node.ID, NodeInput{
Name: "manual-ip-edge",
IP: "203.0.113.10",
IPManualOverride: boolValue(false),
})
if err != nil {
t.Fatalf("expected unlock update to succeed: %v", err)
}
if unlocked.IPManualOverride {
t.Fatalf("expected node ip lock to be disabled, got %+v", unlocked)
}
unlockedStored, err := model.GetNodeByID(node.ID)
if err != nil {
t.Fatalf("failed to reload unlocked node: %v", err)
}
if _, err = HeartbeatNode(unlockedStored, AgentNodePayload{
NodeID: unlockedStored.NodeID,
Name: unlockedStored.Name,
IP: "8.8.4.4",
AgentVersion: "v0.5.1",
NginxVersion: "1.27.1.4",
}); err != nil {
t.Fatalf("expected heartbeat to succeed after unlock: %v", err)
}
reloaded, err := model.GetNodeByID(node.ID)
if err != nil {
t.Fatalf("failed to reload updated node: %v", err)
}
if reloaded.IP != "8.8.4.4" {
t.Fatalf("expected heartbeat to update unlocked ip, got %s", reloaded.IP)
}
}
func TestNodeManualIPOverrideDefaultsForManualInput(t *testing.T) {
setupServiceTestDB(t)
created, err := CreateNode(NodeInput{
Name: "precreated-edge",
IP: "203.0.113.20",
})
if err != nil {
t.Fatalf("expected create to succeed: %v", err)
}
if !created.IPManualOverride {
t.Fatalf("expected precreated node with explicit ip to default to manual override, got %+v", created)
}
empty, err := CreateNode(NodeInput{Name: "auto-edge"})
if err != nil {
t.Fatalf("expected empty-ip create to succeed: %v", err)
}
if empty.IPManualOverride {
t.Fatalf("expected empty-ip node to stay unlocked, got %+v", empty)
}
updated, err := UpdateNode(empty.ID, NodeInput{
Name: "auto-edge",
IP: "203.0.113.21",
})
if err != nil {
t.Fatalf("expected update to succeed: %v", err)
}
if !updated.IPManualOverride {
t.Fatalf("expected manual ip edit to default to locked, got %+v", updated)
}
}
func TestRequestNodeOpenrestyRestart(t *testing.T) {
setupServiceTestDB(t)