mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
feat: Implement node management features including creation, updating, and deletion
- Added CreateNode, UpdateNode, and DeleteNode functions in the controller for managing nodes. - Introduced NodeInput struct for input validation during node creation and updates. - Enhanced the Node model to include DiscoveryToken and Pending status. - Updated the AgentRegister and AgentHeartbeat functions to utilize the new node management logic. - Refactored the API router to include new routes for node management. - Improved the frontend Node component to support node creation, editing, and deletion with appropriate UI feedback. - Added tests to ensure the new functionality works as expected.
This commit is contained in:
@@ -99,6 +99,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
nodeRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
nodeRoute.GET("/", controller.GetNodes)
|
||||
nodeRoute.POST("/", controller.CreateNode)
|
||||
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
||||
}
|
||||
applyLogRoute := apiRouter.Group("/apply-logs")
|
||||
applyLogRoute.Use(middleware.AdminAuth())
|
||||
@@ -106,12 +109,19 @@ func SetApiRouter(router *gin.Engine) {
|
||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||
}
|
||||
agentRoute := apiRouter.Group("/agent")
|
||||
agentRoute.Use(middleware.AgentAuth())
|
||||
{
|
||||
agentRoute.POST("/nodes/register", controller.AgentRegister)
|
||||
agentRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||
agentRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||
agentRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||
discoveryRoute := agentRoute.Group("/")
|
||||
discoveryRoute.Use(middleware.AgentDiscoveryAuth())
|
||||
{
|
||||
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
|
||||
}
|
||||
authorizedRoute := agentRoute.Group("/")
|
||||
authorizedRoute.Use(middleware.AgentAuth())
|
||||
{
|
||||
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gin-template/common"
|
||||
"gin-template/model"
|
||||
"gin-template/router"
|
||||
"gin-template/service"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -199,8 +200,20 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") {
|
||||
t.Fatal("expected support files json to contain certificate artifacts")
|
||||
}
|
||||
if err := (&model.Node{
|
||||
NodeID: "phase1-node",
|
||||
Name: "phase1-node",
|
||||
IP: "10.0.0.8",
|
||||
AgentToken: common.AgentToken,
|
||||
AgentVersion: "0.1.0",
|
||||
NginxVersion: "1.25.5",
|
||||
Status: service.NodeStatusOnline,
|
||||
LastSeenAt: time.Now(),
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("failed to seed phase1 node: %v", err)
|
||||
}
|
||||
|
||||
agentResp := performAgentJSONRequest(t, engine, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig map[string]any
|
||||
decodeResponseData(t, agentResp, &activeConfig)
|
||||
supportFiles, ok := activeConfig["support_files"].([]any)
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
common.AgentToken = "phase2-agent-token"
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
@@ -34,11 +33,20 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
unauthorizedRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
||||
if unauthorizedRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status for missing agent token, got %d", unauthorizedRecorder.Code)
|
||||
t.Fatalf("expected unauthorized status for missing discovery token, got %d", unauthorizedRecorder.Code)
|
||||
}
|
||||
|
||||
createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
|
||||
"name": "shanghai-edge-1",
|
||||
})
|
||||
var createdNode service.NodeView
|
||||
decodeResponseData(t, createdNodeResp, &createdNode)
|
||||
if createdNode.DiscoveryToken == "" || !createdNode.Pending {
|
||||
t.Fatal("expected created node to expose discovery token while pending")
|
||||
}
|
||||
|
||||
nodePayload := map[string]any{
|
||||
"node_id": "node-001",
|
||||
"node_id": "local-node-id",
|
||||
"name": "shanghai-edge-1",
|
||||
"ip": "10.0.0.8",
|
||||
"agent_version": "0.1.0",
|
||||
@@ -46,15 +54,15 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/register", nodePayload)
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.NodeID != "node-001" || registeredNode.Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected node registration to persist online node state")
|
||||
resp := performAgentJSONRequestWithToken(t, engine, createdNode.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", nodePayload)
|
||||
var registration service.AgentRegistrationResponse
|
||||
decodeResponseData(t, resp, ®istration)
|
||||
if registration.NodeID != createdNode.NodeID || registration.AgentToken == "" {
|
||||
t.Fatal("expected discovery registration to return assigned node_id and agent token")
|
||||
}
|
||||
|
||||
heartbeatPayload := map[string]any{
|
||||
"node_id": "node-001",
|
||||
"node_id": "spoofed-node-id",
|
||||
"name": "shanghai-edge-1",
|
||||
"ip": "10.0.0.9",
|
||||
"agent_version": "0.1.1",
|
||||
@@ -62,21 +70,22 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp = performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||
resp = performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" {
|
||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
t.Fatal("expected heartbeat to update node metadata")
|
||||
}
|
||||
|
||||
activeConfigResp := performAgentJSONRequest(t, engine, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
activeConfigResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig service.AgentConfigResponse
|
||||
decodeResponseData(t, activeConfigResp, &activeConfig)
|
||||
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
||||
t.Fatal("expected active config response to contain version payload")
|
||||
}
|
||||
|
||||
successApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "node-001",
|
||||
successApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultOK,
|
||||
"message": "apply ok",
|
||||
@@ -87,8 +96,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
t.Fatal("expected apply log success to be recorded")
|
||||
}
|
||||
|
||||
failedApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "node-001",
|
||||
failedApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultFailed,
|
||||
"message": "nginx reload failed",
|
||||
@@ -105,6 +114,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Pending || nodes[0].DiscoveryToken != "" {
|
||||
t.Fatal("expected registered node to clear pending discovery state")
|
||||
}
|
||||
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
|
||||
t.Fatal("expected node list to expose latest apply status")
|
||||
}
|
||||
@@ -115,15 +127,23 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
t.Fatal("expected node last_error to reflect failed apply")
|
||||
}
|
||||
|
||||
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id=node-001", nil)
|
||||
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
|
||||
var logs []model.ApplyLog
|
||||
decodeResponseData(t, logsResp, &logs)
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("expected 2 apply logs, got %d", len(logs))
|
||||
}
|
||||
|
||||
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
|
||||
"name": "shanghai-edge-1-renamed",
|
||||
})
|
||||
decodeResponseData(t, updatedNodeResp, &createdNode)
|
||||
if createdNode.Name != "shanghai-edge-1-renamed" {
|
||||
t.Fatal("expected node name to be editable")
|
||||
}
|
||||
|
||||
oldTime := time.Now().Add(-common.NodeOfflineThreshold - time.Minute)
|
||||
if err := model.DB.Model(&model.Node{}).Where("node_id = ?", "node-001").Update("last_seen_at", oldTime).Error; err != nil {
|
||||
if err := model.DB.Model(&model.Node{}).Where("node_id = ?", createdNode.NodeID).Update("last_seen_at", oldTime).Error; err != nil {
|
||||
t.Fatalf("failed to update node last_seen_at: %v", err)
|
||||
}
|
||||
nodesResp = performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
||||
@@ -131,9 +151,23 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
if nodes[0].Status != service.NodeStatusOffline {
|
||||
t.Fatal("expected node to be shown as offline after timeout")
|
||||
}
|
||||
|
||||
deleteResp := performJSONRequest(t, engine, adminToken, http.MethodDelete, "/api/nodes/"+toString(createdNode.ID), nil)
|
||||
if !deleteResp.Success {
|
||||
t.Fatalf("expected delete node success, got %s", deleteResp.Message)
|
||||
}
|
||||
|
||||
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
|
||||
deniedReq.Header.Set("Content-Type", "application/json")
|
||||
deniedReq.Header.Set("X-Agent-Token", registration.AgentToken)
|
||||
deniedRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(deniedRecorder, deniedReq)
|
||||
if deniedRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected deleted node token to be rejected, got %d", deniedRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func performAgentJSONRequest(t *testing.T, engine http.Handler, method string, path string, body any) apiResponse {
|
||||
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
@@ -147,7 +181,7 @@ func performAgentJSONRequest(t *testing.T, engine http.Handler, method string, p
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", common.AgentToken)
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
|
||||
Reference in New Issue
Block a user