mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36: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:
@@ -2,7 +2,9 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"atsflare-agent/internal/config"
|
"atsflare-agent/internal/config"
|
||||||
@@ -11,8 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HeartbeatService interface {
|
type HeartbeatService interface {
|
||||||
Register(ctx context.Context, payload protocol.NodePayload) error
|
Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
|
||||||
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
|
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
|
||||||
|
SetToken(token string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncService interface {
|
type SyncService interface {
|
||||||
@@ -33,21 +36,20 @@ func (r *Runner) Run(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("agent runner started: node_id=%s node=%s ip=%s", nodeID, r.Config.NodeName, r.Config.NodeIP)
|
log.Printf("agent runner started: node_id=%s node=%s ip=%s", nodeID, r.Config.NodeName, r.Config.NodeIP)
|
||||||
if err = r.HeartbeatService.Register(ctx, r.nodePayload(nodeID)); err != nil {
|
if r.hasAgentToken() {
|
||||||
log.Printf("agent register failed: %v", err)
|
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
|
||||||
} else {
|
r.recordSyncError(err)
|
||||||
log.Printf("agent register succeeded: node_id=%s", nodeID)
|
log.Printf("agent startup sync failed: %v", err)
|
||||||
}
|
} else {
|
||||||
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
|
log.Printf("agent startup sync completed")
|
||||||
r.recordSyncError(err)
|
}
|
||||||
log.Printf("agent startup sync failed: %v", err)
|
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
|
||||||
} else {
|
log.Printf("agent startup heartbeat failed: %v", err)
|
||||||
log.Printf("agent startup sync completed")
|
} else {
|
||||||
}
|
log.Printf("agent startup heartbeat succeeded: node_id=%s", nodeID)
|
||||||
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
|
}
|
||||||
log.Printf("agent startup heartbeat failed: %v", err)
|
} else if err = r.tryRegister(ctx, &nodeID); err != nil {
|
||||||
} else {
|
log.Printf("agent initial discovery register failed: %v", err)
|
||||||
log.Printf("agent startup heartbeat succeeded: node_id=%s", nodeID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval)
|
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval)
|
||||||
@@ -61,10 +63,19 @@ func (r *Runner) Run(ctx context.Context) error {
|
|||||||
log.Printf("agent runner shutting down: %v", ctx.Err())
|
log.Printf("agent runner shutting down: %v", ctx.Err())
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-heartbeatTicker.C:
|
case <-heartbeatTicker.C:
|
||||||
|
if !r.hasAgentToken() {
|
||||||
|
if err = r.tryRegister(ctx, &nodeID); err != nil {
|
||||||
|
log.Printf("agent discovery register failed: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
|
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
|
||||||
log.Printf("agent heartbeat failed: %v", err)
|
log.Printf("agent heartbeat failed: %v", err)
|
||||||
}
|
}
|
||||||
case <-syncTicker.C:
|
case <-syncTicker.C:
|
||||||
|
if !r.hasAgentToken() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Printf("agent sync tick: node_id=%s", nodeID)
|
log.Printf("agent sync tick: node_id=%s", nodeID)
|
||||||
if err = r.SyncService.SyncOnce(ctx); err != nil {
|
if err = r.SyncService.SyncOnce(ctx); err != nil {
|
||||||
r.recordSyncError(err)
|
r.recordSyncError(err)
|
||||||
@@ -76,6 +87,47 @@ func (r *Runner) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) hasAgentToken() bool {
|
||||||
|
return strings.TrimSpace(r.Config.AgentToken) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||||
|
if strings.TrimSpace(r.Config.DiscoveryToken) == "" {
|
||||||
|
return errors.New("agent_token 为空且未配置 discovery_token")
|
||||||
|
}
|
||||||
|
log.Printf("agent discovery registration started")
|
||||||
|
response, err := r.HeartbeatService.Register(ctx, r.nodePayload(*nodeID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response == nil || strings.TrimSpace(response.AgentToken) == "" || strings.TrimSpace(response.NodeID) == "" {
|
||||||
|
return errors.New("discovery register response 缺少 node_id 或 agent_token")
|
||||||
|
}
|
||||||
|
snapshot, err := r.StateStore.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot.NodeID = response.NodeID
|
||||||
|
if err = r.StateStore.Save(snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Config.AgentToken = response.AgentToken
|
||||||
|
r.Config.DiscoveryToken = ""
|
||||||
|
if err = r.Config.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.HeartbeatService.SetToken(response.AgentToken)
|
||||||
|
*nodeID = response.NodeID
|
||||||
|
log.Printf("agent discovery registration succeeded: node_id=%s", response.NodeID)
|
||||||
|
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
|
||||||
|
r.recordSyncError(err)
|
||||||
|
log.Printf("agent post-register startup sync failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("agent post-register startup sync completed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) recordSyncError(err error) {
|
func (r *Runner) recordSyncError(err error) {
|
||||||
if err == nil || r.StateStore == nil {
|
if err == nil || r.StateStore == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,15 +19,17 @@ type fakeHeartbeatService struct {
|
|||||||
registerCalls int
|
registerCalls int
|
||||||
heartbeatCalls int
|
heartbeatCalls int
|
||||||
registerErr error
|
registerErr error
|
||||||
|
registerResp *protocol.RegisterNodeResponse
|
||||||
heartbeatErrs []error
|
heartbeatErrs []error
|
||||||
onHeartbeat func(int)
|
onHeartbeat func(int)
|
||||||
|
lastToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) error {
|
func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.registerCalls++
|
f.registerCalls++
|
||||||
return f.registerErr
|
return f.registerResp, f.registerErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
||||||
@@ -45,6 +48,12 @@ func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.N
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeHeartbeatService) SetToken(token string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.lastToken = token
|
||||||
|
}
|
||||||
|
|
||||||
type fakeSyncService struct {
|
type fakeSyncService struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
startupErr error
|
startupErr error
|
||||||
@@ -90,6 +99,7 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner := &Runner{
|
runner := &Runner{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
|
AgentToken: "agent-token",
|
||||||
NodeName: "edge-01",
|
NodeName: "edge-01",
|
||||||
NodeIP: "10.0.0.8",
|
NodeIP: "10.0.0.8",
|
||||||
AgentVersion: "0.1.0",
|
AgentVersion: "0.1.0",
|
||||||
@@ -106,8 +116,8 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
|
|||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("expected context cancellation, got %v", err)
|
t.Fatalf("expected context cancellation, got %v", err)
|
||||||
}
|
}
|
||||||
if heartbeatService.registerCalls != 1 {
|
if heartbeatService.registerCalls != 0 {
|
||||||
t.Fatalf("expected 1 register call, got %d", heartbeatService.registerCalls)
|
t.Fatalf("expected no discovery register call, got %d", heartbeatService.registerCalls)
|
||||||
}
|
}
|
||||||
if heartbeatService.heartbeatCalls < 2 {
|
if heartbeatService.heartbeatCalls < 2 {
|
||||||
t.Fatalf("expected heartbeat loop to continue, got %d heartbeat calls", heartbeatService.heartbeatCalls)
|
t.Fatalf("expected heartbeat loop to continue, got %d heartbeat calls", heartbeatService.heartbeatCalls)
|
||||||
@@ -140,6 +150,7 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner := &Runner{
|
runner := &Runner{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
|
AgentToken: "agent-token",
|
||||||
NodeName: "edge-01",
|
NodeName: "edge-01",
|
||||||
NodeIP: "10.0.0.8",
|
NodeIP: "10.0.0.8",
|
||||||
AgentVersion: "0.1.0",
|
AgentVersion: "0.1.0",
|
||||||
@@ -156,8 +167,8 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
|
|||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("expected context cancellation, got %v", err)
|
t.Fatalf("expected context cancellation, got %v", err)
|
||||||
}
|
}
|
||||||
if heartbeatService.registerCalls != 1 {
|
if heartbeatService.registerCalls != 0 {
|
||||||
t.Fatalf("expected register attempt, got %d", heartbeatService.registerCalls)
|
t.Fatalf("expected no register attempt, got %d", heartbeatService.registerCalls)
|
||||||
}
|
}
|
||||||
if syncService.syncOnceCalls == 0 {
|
if syncService.syncOnceCalls == 0 {
|
||||||
t.Fatal("expected sync loop to continue after heartbeat/register errors")
|
t.Fatal("expected sync loop to continue after heartbeat/register errors")
|
||||||
@@ -170,3 +181,72 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
|
|||||||
t.Fatalf("expected sync error to be recorded, got %q", snapshot.LastError)
|
t.Fatalf("expected sync error to be recorded, got %q", snapshot.LastError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
heartbeatService := &fakeHeartbeatService{
|
||||||
|
registerResp: &protocol.RegisterNodeResponse{
|
||||||
|
NodeID: "node-server-assigned",
|
||||||
|
AgentToken: "agent-token-issued",
|
||||||
|
Name: "edge-01",
|
||||||
|
},
|
||||||
|
onHeartbeat: func(callCount int) {
|
||||||
|
if callCount >= 1 {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
syncService := &fakeSyncService{}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "agent.json")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","discovery_token":"discovery-token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to seed config file: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
runner := &Runner{
|
||||||
|
Config: &config.Config{
|
||||||
|
ServerURL: cfg.ServerURL,
|
||||||
|
DiscoveryToken: cfg.DiscoveryToken,
|
||||||
|
NodeName: cfg.NodeName,
|
||||||
|
NodeIP: cfg.NodeIP,
|
||||||
|
AgentVersion: "0.1.0",
|
||||||
|
NginxVersion: "1.25.5",
|
||||||
|
HeartbeatInterval: 10 * time.Millisecond,
|
||||||
|
SyncInterval: 20 * time.Millisecond,
|
||||||
|
},
|
||||||
|
StateStore: stateStore,
|
||||||
|
HeartbeatService: heartbeatService,
|
||||||
|
SyncService: syncService,
|
||||||
|
}
|
||||||
|
runner.Config = cfg
|
||||||
|
runner.Config.AgentVersion = "0.1.0"
|
||||||
|
runner.Config.NginxVersion = "1.25.5"
|
||||||
|
runner.Config.HeartbeatInterval = 10 * time.Millisecond
|
||||||
|
runner.Config.SyncInterval = 20 * time.Millisecond
|
||||||
|
|
||||||
|
err = runner.Run(ctx)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
if heartbeatService.registerCalls == 0 {
|
||||||
|
t.Fatal("expected discovery register to be attempted")
|
||||||
|
}
|
||||||
|
if heartbeatService.lastToken != "agent-token-issued" {
|
||||||
|
t.Fatalf("expected client token to be updated, got %q", heartbeatService.lastToken)
|
||||||
|
}
|
||||||
|
snapshot, loadErr := stateStore.Load()
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("failed to load state: %v", loadErr)
|
||||||
|
}
|
||||||
|
if snapshot.NodeID != "node-server-assigned" {
|
||||||
|
t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID)
|
||||||
|
}
|
||||||
|
if runner.Config.AgentToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
|
||||||
|
t.Fatal("expected config token rotation to complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
pathpkg "path"
|
pathpkg "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,6 +21,7 @@ const (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
AgentToken string `json:"agent_token"`
|
AgentToken string `json:"agent_token"`
|
||||||
|
DiscoveryToken string `json:"discovery_token"`
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
NodeIP string `json:"node_ip"`
|
NodeIP string `json:"node_ip"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
@@ -36,6 +38,7 @@ type Config struct {
|
|||||||
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
||||||
SyncInterval time.Duration `json:"sync_interval"`
|
SyncInterval time.Duration `json:"sync_interval"`
|
||||||
RequestTimeout time.Duration `json:"request_timeout"`
|
RequestTimeout time.Duration `json:"request_timeout"`
|
||||||
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
@@ -47,6 +50,7 @@ func Load(path string) (*Config, error) {
|
|||||||
if err = json.Unmarshal(data, cfg); err != nil {
|
if err = json.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.configPath = path
|
||||||
applyDefaults(cfg, filepath.Dir(path))
|
applyDefaults(cfg, filepath.Dir(path))
|
||||||
if err = validate(cfg); err != nil {
|
if err = validate(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -71,6 +75,12 @@ func applyDefaults(cfg *Config, baseDir string) {
|
|||||||
if cfg.DataDir == "" {
|
if cfg.DataDir == "" {
|
||||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||||
}
|
}
|
||||||
|
if cfg.NodeName == "" {
|
||||||
|
cfg.NodeName = detectHostname()
|
||||||
|
}
|
||||||
|
if cfg.NodeIP == "" {
|
||||||
|
cfg.NodeIP = detectNodeIP()
|
||||||
|
}
|
||||||
if cfg.NginxPath == "" {
|
if cfg.NginxPath == "" {
|
||||||
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
|
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
|
||||||
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
|
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
|
||||||
@@ -137,8 +147,8 @@ func validate(cfg *Config) error {
|
|||||||
if cfg.ServerURL == "" {
|
if cfg.ServerURL == "" {
|
||||||
return errors.New("server_url 不能为空")
|
return errors.New("server_url 不能为空")
|
||||||
}
|
}
|
||||||
if cfg.AgentToken == "" {
|
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
|
||||||
return errors.New("agent_token 不能为空")
|
return errors.New("agent_token 和 discovery_token 不能同时为空")
|
||||||
}
|
}
|
||||||
if cfg.NodeName == "" {
|
if cfg.NodeName == "" {
|
||||||
return errors.New("node_name 不能为空")
|
return errors.New("node_name 不能为空")
|
||||||
@@ -148,3 +158,52 @@ func validate(cfg *Config) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Save() error {
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("config 不能为空")
|
||||||
|
}
|
||||||
|
if cfg.configPath == "" {
|
||||||
|
return errors.New("config path 未初始化")
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(cfg.configPath, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectHostname() string {
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectNodeIP() string {
|
||||||
|
interfaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipv4 := ipNet.IP.To4()
|
||||||
|
if ipv4 != nil {
|
||||||
|
return ipv4.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
RegisterNode(ctx context.Context, payload protocol.NodePayload) error
|
RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
|
||||||
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
|
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
|
||||||
|
SetToken(token string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -19,10 +20,14 @@ func New(client Client) *Service {
|
|||||||
return &Service{client: client}
|
return &Service{client: client}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) error {
|
func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||||
return s.client.RegisterNode(ctx, payload)
|
return s.client.RegisterNode(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
||||||
return s.client.Heartbeat(ctx, payload)
|
return s.client.Heartbeat(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetToken(token string) {
|
||||||
|
s.client.SetToken(token)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,9 +29,17 @@ func New(baseURL string, token string, timeout time.Duration) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) error {
|
func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||||
log.Printf("http register node request: node_id=%s current_version=%s", payload.NodeID, payload.CurrentVersion)
|
log.Printf("http register node request: node_id=%s current_version=%s", payload.NodeID, payload.CurrentVersion)
|
||||||
return c.postJSON(ctx, "/api/agent/nodes/register", payload, nil)
|
resp := protocol.APIResponse[protocol.RegisterNodeResponse]{}
|
||||||
|
if err := c.postJSON(ctx, "/api/agent/nodes/register", payload, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
return nil, errors.New(resp.Message)
|
||||||
|
}
|
||||||
|
log.Printf("http register node response: node_id=%s", resp.Data.NodeID)
|
||||||
|
return &resp.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
|
||||||
@@ -56,6 +64,11 @@ func (c *Client) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPa
|
|||||||
return c.postJSON(ctx, "/api/agent/apply-logs", payload, nil)
|
return c.postJSON(ctx, "/api/agent/apply-logs", payload, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetToken(token string) {
|
||||||
|
c.token = strings.TrimSpace(token)
|
||||||
|
log.Printf("http client token updated")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type NodePayload struct {
|
|||||||
LastError string `json:"last_error"`
|
LastError string `json:"last_error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RegisterNodeResponse struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
AgentToken string `json:"agent_token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type ApplyLogPayload struct {
|
type ApplyLogPayload struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"gin-template/model"
|
||||||
"gin-template/service"
|
"gin-template/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,7 +17,15 @@ func AgentRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node, err := service.RegisterNode(payload)
|
discoveryNode, ok := c.Get("discovery_node")
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无权进行此操作,Discovery Token 无效",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.RegisterNode(discoveryNode.(*model.Node), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -27,7 +36,7 @@ func AgentRegister(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": node,
|
"data": result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +49,15 @@ func AgentHeartbeat(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node, err := service.HeartbeatNode(payload)
|
authNode, ok := c.Get("agent_node")
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无权进行此操作,Agent Token 无效",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := service.HeartbeatNode(authNode.(*model.Node), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -80,6 +97,10 @@ func AgentReportApplyLog(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authNode, ok := c.Get("agent_node")
|
||||||
|
if ok {
|
||||||
|
payload.NodeID = authNode.(*model.Node).NodeID
|
||||||
|
}
|
||||||
log, err := service.ReportApplyLog(payload)
|
log, err := service.ReportApplyLog(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"gin-template/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateNode(c *gin.Context) {
|
||||||
|
var input service.NodeInput
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := service.CreateNode(input)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": node,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateNode(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.NodeInput
|
||||||
|
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := service.UpdateNode(uint(id), input)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": node,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteNode(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = service.DeleteNode(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gin-template/common"
|
"gin-template/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -9,15 +9,8 @@ import (
|
|||||||
func AgentAuth() func(c *gin.Context) {
|
func AgentAuth() func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token := c.GetHeader("X-Agent-Token")
|
token := c.GetHeader("X-Agent-Token")
|
||||||
if common.AgentToken == "" {
|
node, err := service.AuthenticateAgentToken(token)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
if err != nil {
|
||||||
"success": false,
|
|
||||||
"message": "Agent Token 未配置",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if token == "" || token != common.AgentToken {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "无权进行此操作,Agent Token 无效",
|
"message": "无权进行此操作,Agent Token 无效",
|
||||||
@@ -25,6 +18,24 @@ func AgentAuth() func(c *gin.Context) {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Set("agent_node", node)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentDiscoveryAuth() func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := c.GetHeader("X-Agent-Token")
|
||||||
|
node, err := service.AuthenticateDiscoveryToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无权进行此操作,Discovery Token 无效",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("discovery_node", node)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ type Node struct {
|
|||||||
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
|
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
|
||||||
Name string `json:"name" gorm:"size:128;not null"`
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
IP string `json:"ip" gorm:"size:64;not null"`
|
IP string `json:"ip" gorm:"size:64;not null"`
|
||||||
AgentToken string `json:"-" gorm:"size:128;uniqueIndex"`
|
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||||
DiscoveryToken string `json:"-" gorm:"size:128;uniqueIndex"`
|
DiscoveryToken string `json:"-" gorm:"size:128;index"`
|
||||||
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
nodeRoute.Use(middleware.AdminAuth())
|
nodeRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
nodeRoute.GET("/", controller.GetNodes)
|
nodeRoute.GET("/", controller.GetNodes)
|
||||||
|
nodeRoute.POST("/", controller.CreateNode)
|
||||||
|
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||||
|
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
||||||
}
|
}
|
||||||
applyLogRoute := apiRouter.Group("/apply-logs")
|
applyLogRoute := apiRouter.Group("/apply-logs")
|
||||||
applyLogRoute.Use(middleware.AdminAuth())
|
applyLogRoute.Use(middleware.AdminAuth())
|
||||||
@@ -106,12 +109,19 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||||
}
|
}
|
||||||
agentRoute := apiRouter.Group("/agent")
|
agentRoute := apiRouter.Group("/agent")
|
||||||
agentRoute.Use(middleware.AgentAuth())
|
|
||||||
{
|
{
|
||||||
agentRoute.POST("/nodes/register", controller.AgentRegister)
|
discoveryRoute := agentRoute.Group("/")
|
||||||
agentRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
discoveryRoute.Use(middleware.AgentDiscoveryAuth())
|
||||||
agentRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
{
|
||||||
agentRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
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/common"
|
||||||
"gin-template/model"
|
"gin-template/model"
|
||||||
"gin-template/router"
|
"gin-template/router"
|
||||||
|
"gin-template/service"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"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") {
|
if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") {
|
||||||
t.Fatal("expected support files json to contain certificate artifacts")
|
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
|
var activeConfig map[string]any
|
||||||
decodeResponseData(t, agentResp, &activeConfig)
|
decodeResponseData(t, agentResp, &activeConfig)
|
||||||
supportFiles, ok := activeConfig["support_files"].([]any)
|
supportFiles, ok := activeConfig["support_files"].([]any)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
func TestPhase2AgentLifecycle(t *testing.T) {
|
func TestPhase2AgentLifecycle(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
common.RedisEnabled = false
|
common.RedisEnabled = false
|
||||||
common.AgentToken = "phase2-agent-token"
|
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
@@ -34,11 +33,20 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
unauthorizedRecorder := httptest.NewRecorder()
|
unauthorizedRecorder := httptest.NewRecorder()
|
||||||
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
||||||
if unauthorizedRecorder.Code != http.StatusUnauthorized {
|
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{
|
nodePayload := map[string]any{
|
||||||
"node_id": "node-001",
|
"node_id": "local-node-id",
|
||||||
"name": "shanghai-edge-1",
|
"name": "shanghai-edge-1",
|
||||||
"ip": "10.0.0.8",
|
"ip": "10.0.0.8",
|
||||||
"agent_version": "0.1.0",
|
"agent_version": "0.1.0",
|
||||||
@@ -46,15 +54,15 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
"current_version": "",
|
"current_version": "",
|
||||||
"last_error": "",
|
"last_error": "",
|
||||||
}
|
}
|
||||||
resp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/register", nodePayload)
|
resp := performAgentJSONRequestWithToken(t, engine, createdNode.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", nodePayload)
|
||||||
var registeredNode model.Node
|
var registration service.AgentRegistrationResponse
|
||||||
decodeResponseData(t, resp, ®isteredNode)
|
decodeResponseData(t, resp, ®istration)
|
||||||
if registeredNode.NodeID != "node-001" || registeredNode.Status != service.NodeStatusOnline {
|
if registration.NodeID != createdNode.NodeID || registration.AgentToken == "" {
|
||||||
t.Fatal("expected node registration to persist online node state")
|
t.Fatal("expected discovery registration to return assigned node_id and agent token")
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatPayload := map[string]any{
|
heartbeatPayload := map[string]any{
|
||||||
"node_id": "node-001",
|
"node_id": "spoofed-node-id",
|
||||||
"name": "shanghai-edge-1",
|
"name": "shanghai-edge-1",
|
||||||
"ip": "10.0.0.9",
|
"ip": "10.0.0.9",
|
||||||
"agent_version": "0.1.1",
|
"agent_version": "0.1.1",
|
||||||
@@ -62,21 +70,22 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
"current_version": "",
|
"current_version": "",
|
||||||
"last_error": "",
|
"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)
|
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")
|
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
|
var activeConfig service.AgentConfigResponse
|
||||||
decodeResponseData(t, activeConfigResp, &activeConfig)
|
decodeResponseData(t, activeConfigResp, &activeConfig)
|
||||||
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
||||||
t.Fatal("expected active config response to contain version payload")
|
t.Fatal("expected active config response to contain version payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
successApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
successApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||||
"node_id": "node-001",
|
"node_id": "spoofed-node-id",
|
||||||
"version": activeConfig.Version,
|
"version": activeConfig.Version,
|
||||||
"result": service.ApplyResultOK,
|
"result": service.ApplyResultOK,
|
||||||
"message": "apply ok",
|
"message": "apply ok",
|
||||||
@@ -87,8 +96,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
t.Fatal("expected apply log success to be recorded")
|
t.Fatal("expected apply log success to be recorded")
|
||||||
}
|
}
|
||||||
|
|
||||||
failedApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
failedApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||||
"node_id": "node-001",
|
"node_id": "spoofed-node-id",
|
||||||
"version": activeConfig.Version,
|
"version": activeConfig.Version,
|
||||||
"result": service.ApplyResultFailed,
|
"result": service.ApplyResultFailed,
|
||||||
"message": "nginx reload failed",
|
"message": "nginx reload failed",
|
||||||
@@ -105,6 +114,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
if len(nodes) != 1 {
|
if len(nodes) != 1 {
|
||||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
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" {
|
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
|
||||||
t.Fatal("expected node list to expose latest apply status")
|
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")
|
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
|
var logs []model.ApplyLog
|
||||||
decodeResponseData(t, logsResp, &logs)
|
decodeResponseData(t, logsResp, &logs)
|
||||||
if len(logs) != 2 {
|
if len(logs) != 2 {
|
||||||
t.Fatalf("expected 2 apply logs, got %d", len(logs))
|
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)
|
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)
|
t.Fatalf("failed to update node last_seen_at: %v", err)
|
||||||
}
|
}
|
||||||
nodesResp = performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
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 {
|
if nodes[0].Status != service.NodeStatusOffline {
|
||||||
t.Fatal("expected node to be shown as offline after timeout")
|
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()
|
t.Helper()
|
||||||
var payload []byte
|
var payload []byte
|
||||||
var err error
|
var err error
|
||||||
@@ -147,7 +181,7 @@ func performAgentJSONRequest(t *testing.T, engine http.Handler, method string, p
|
|||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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()
|
recorder := httptest.NewRecorder()
|
||||||
engine.ServeHTTP(recorder, req)
|
engine.ServeHTTP(recorder, req)
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
NodeStatusOnline = "online"
|
NodeStatusOnline = "online"
|
||||||
NodeStatusOffline = "offline"
|
NodeStatusOffline = "offline"
|
||||||
|
NodeStatusPending = "pending"
|
||||||
ApplyResultOK = "success"
|
ApplyResultOK = "success"
|
||||||
ApplyResultFailed = "failed"
|
ApplyResultFailed = "failed"
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,8 @@ type NodeView struct {
|
|||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
DiscoveryToken string `json:"discovery_token,omitempty"`
|
||||||
|
Pending bool `json:"pending"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
NginxVersion string `json:"nginx_version"`
|
NginxVersion string `json:"nginx_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -61,14 +64,61 @@ type NodeView struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterNode(payload AgentNodePayload) (*model.Node, error) {
|
func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
|
||||||
common.SysLog("agent register request received: node_id=" + strings.TrimSpace(payload.NodeID) + " name=" + strings.TrimSpace(payload.Name) + " ip=" + strings.TrimSpace(payload.IP))
|
common.SysLog("agent discovery register request received: name=" + strings.TrimSpace(payload.Name) + " ip=" + strings.TrimSpace(payload.IP))
|
||||||
return upsertNode(payload)
|
payload.Name = strings.TrimSpace(payload.Name)
|
||||||
|
payload.IP = strings.TrimSpace(payload.IP)
|
||||||
|
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||||
|
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||||
|
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||||
|
payload.LastError = strings.TrimSpace(payload.LastError)
|
||||||
|
if node == nil {
|
||||||
|
return nil, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
if payload.IP == "" {
|
||||||
|
return nil, errors.New("ip 不能为空")
|
||||||
|
}
|
||||||
|
if payload.AgentVersion == "" {
|
||||||
|
return nil, errors.New("agent_version 不能为空")
|
||||||
|
}
|
||||||
|
agentToken, err := newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyNodeRuntime(node, payload, true)
|
||||||
|
node.AgentToken = agentToken
|
||||||
|
node.DiscoveryToken = ""
|
||||||
|
if err = node.Update(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("agent discovery register succeeded: node_id=" + node.NodeID + " name=" + node.Name)
|
||||||
|
return &AgentRegistrationResponse{
|
||||||
|
NodeID: node.NodeID,
|
||||||
|
AgentToken: node.AgentToken,
|
||||||
|
Name: node.Name,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HeartbeatNode(payload AgentNodePayload) (*model.Node, error) {
|
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, error) {
|
||||||
common.SysLog("agent heartbeat received: node_id=" + strings.TrimSpace(payload.NodeID) + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
|
common.SysLog("agent heartbeat received: node_id=" + node.NodeID + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
|
||||||
return upsertNode(payload)
|
payload.NodeID = node.NodeID
|
||||||
|
payload.Name = strings.TrimSpace(payload.Name)
|
||||||
|
payload.IP = strings.TrimSpace(payload.IP)
|
||||||
|
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||||
|
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||||
|
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||||
|
payload.LastError = strings.TrimSpace(payload.LastError)
|
||||||
|
if payload.IP == "" {
|
||||||
|
return nil, errors.New("ip 不能为空")
|
||||||
|
}
|
||||||
|
if payload.AgentVersion == "" {
|
||||||
|
return nil, errors.New("agent_version 不能为空")
|
||||||
|
}
|
||||||
|
applyNodeRuntime(node, payload, true)
|
||||||
|
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
||||||
@@ -153,30 +203,18 @@ func ListNodeViews() ([]*NodeView, error) {
|
|||||||
}
|
}
|
||||||
views := make([]*NodeView, 0, len(nodes))
|
views := make([]*NodeView, 0, len(nodes))
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
computedStatus := computeNodeStatus(node.LastSeenAt)
|
computedStatus := computeNodeStatus(node)
|
||||||
if node.Status != computedStatus {
|
if node.Status != computedStatus {
|
||||||
if computedStatus == NodeStatusOffline {
|
if computedStatus == NodeStatusOffline {
|
||||||
common.SysError("node offline: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " last_seen_at=" + node.LastSeenAt.Format(time.RFC3339))
|
common.SysError("node offline: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " last_seen_at=" + node.LastSeenAt.Format(time.RFC3339))
|
||||||
} else {
|
} else if computedStatus == NodeStatusOnline {
|
||||||
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP)
|
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP)
|
||||||
}
|
}
|
||||||
_ = model.DB.Model(node).Update("status", computedStatus).Error
|
_ = model.DB.Model(node).Update("status", computedStatus).Error
|
||||||
node.Status = computedStatus
|
node.Status = computedStatus
|
||||||
}
|
}
|
||||||
view := &NodeView{
|
view := buildNodeView(node)
|
||||||
ID: node.ID,
|
view.Status = computedStatus
|
||||||
NodeID: node.NodeID,
|
|
||||||
Name: node.Name,
|
|
||||||
IP: node.IP,
|
|
||||||
AgentVersion: node.AgentVersion,
|
|
||||||
NginxVersion: node.NginxVersion,
|
|
||||||
Status: computedStatus,
|
|
||||||
CurrentVersion: node.CurrentVersion,
|
|
||||||
LastSeenAt: node.LastSeenAt,
|
|
||||||
LastError: node.LastError,
|
|
||||||
CreatedAt: node.CreatedAt,
|
|
||||||
UpdatedAt: node.UpdatedAt,
|
|
||||||
}
|
|
||||||
if log, err := model.GetLatestApplyLog(node.NodeID); err == nil {
|
if log, err := model.GetLatestApplyLog(node.NodeID); err == nil {
|
||||||
view.LatestApplyResult = log.Result
|
view.LatestApplyResult = log.Result
|
||||||
view.LatestApplyMessage = log.Message
|
view.LatestApplyMessage = log.Message
|
||||||
@@ -192,72 +230,20 @@ func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upsertNode(payload AgentNodePayload) (*model.Node, error) {
|
func upsertNode(payload AgentNodePayload) (*model.Node, error) {
|
||||||
now := time.Now()
|
return nil, errors.New("不再支持匿名自动注册")
|
||||||
payload.NodeID = strings.TrimSpace(payload.NodeID)
|
|
||||||
payload.Name = strings.TrimSpace(payload.Name)
|
|
||||||
payload.IP = strings.TrimSpace(payload.IP)
|
|
||||||
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
|
||||||
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
|
||||||
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
|
||||||
payload.LastError = strings.TrimSpace(payload.LastError)
|
|
||||||
if payload.NodeID == "" {
|
|
||||||
return nil, errors.New("node_id 不能为空")
|
|
||||||
}
|
|
||||||
if payload.Name == "" {
|
|
||||||
return nil, errors.New("name 不能为空")
|
|
||||||
}
|
|
||||||
if payload.IP == "" {
|
|
||||||
return nil, errors.New("ip 不能为空")
|
|
||||||
}
|
|
||||||
if payload.AgentVersion == "" {
|
|
||||||
return nil, errors.New("agent_version 不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
node := &model.Node{}
|
|
||||||
err := model.DB.Where("node_id = ?", payload.NodeID).First(node).Error
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
node = &model.Node{
|
|
||||||
NodeID: payload.NodeID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previousStatus := node.Status
|
|
||||||
previousIP := node.IP
|
|
||||||
previousVersion := node.CurrentVersion
|
|
||||||
previousAgentVersion := node.AgentVersion
|
|
||||||
node.Name = payload.Name
|
|
||||||
node.IP = payload.IP
|
|
||||||
node.AgentVersion = payload.AgentVersion
|
|
||||||
node.NginxVersion = payload.NginxVersion
|
|
||||||
node.Status = NodeStatusOnline
|
|
||||||
node.CurrentVersion = payload.CurrentVersion
|
|
||||||
node.LastSeenAt = now
|
|
||||||
node.LastError = payload.LastError
|
|
||||||
if node.ID == 0 {
|
|
||||||
if err = model.DB.Create(node).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " agent_version=" + node.AgentVersion)
|
|
||||||
return node, nil
|
|
||||||
}
|
|
||||||
if err = model.DB.Model(node).Select("name", "ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if previousStatus != NodeStatusOnline {
|
|
||||||
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " agent_version=" + node.AgentVersion)
|
|
||||||
} else if previousIP != node.IP || previousVersion != node.CurrentVersion || previousAgentVersion != node.AgentVersion {
|
|
||||||
common.SysLog("node metadata updated: node_id=" + node.NodeID + " ip=" + previousIP + "->" + node.IP + " agent_version=" + previousAgentVersion + "->" + node.AgentVersion + " current_version=" + previousVersion + "->" + node.CurrentVersion)
|
|
||||||
}
|
|
||||||
return node, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeNodeStatus(lastSeenAt time.Time) string {
|
func computeNodeStatus(node *model.Node) string {
|
||||||
if lastSeenAt.IsZero() {
|
if node == nil {
|
||||||
return NodeStatusOffline
|
return NodeStatusOffline
|
||||||
}
|
}
|
||||||
if time.Since(lastSeenAt) > common.NodeOfflineThreshold {
|
if strings.TrimSpace(node.AgentToken) == "" && strings.TrimSpace(node.DiscoveryToken) != "" {
|
||||||
|
return NodeStatusPending
|
||||||
|
}
|
||||||
|
if node.LastSeenAt.IsZero() {
|
||||||
|
return NodeStatusOffline
|
||||||
|
}
|
||||||
|
if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold {
|
||||||
return NodeStatusOffline
|
return NodeStatusOffline
|
||||||
}
|
}
|
||||||
return NodeStatusOnline
|
return NodeStatusOnline
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"gin-template/common"
|
||||||
|
"gin-template/model"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentRegistrationResponse struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
AgentToken string `json:"agent_token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateNode(input NodeInput) (*NodeView, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("节点名不能为空")
|
||||||
|
}
|
||||||
|
node := &model.Node{
|
||||||
|
Name: name,
|
||||||
|
IP: "",
|
||||||
|
AgentVersion: "",
|
||||||
|
NginxVersion: "",
|
||||||
|
Status: NodeStatusPending,
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
node.NodeID, err = newServerNodeID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.DiscoveryToken, err = newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := node.Insert(); err != nil {
|
||||||
|
if isUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("节点标识生成冲突,请重试")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("node created: name=" + node.Name + " node_id=" + node.NodeID)
|
||||||
|
return buildNodeView(node), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("节点名不能为空")
|
||||||
|
}
|
||||||
|
node, err := model.GetNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.Name = name
|
||||||
|
if err = node.Update(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("node updated: name=" + node.Name + " node_id=" + node.NodeID)
|
||||||
|
return buildNodeView(node), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteNode(id uint) error {
|
||||||
|
node, err := model.GetNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
common.SysLog("node deleted: name=" + node.Name + " node_id=" + node.NodeID)
|
||||||
|
return node.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthenticateAgentToken(token string) (*model.Node, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New("缺少 Agent Token")
|
||||||
|
}
|
||||||
|
return model.GetNodeByAgentToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthenticateDiscoveryToken(token string) (*model.Node, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New("缺少 Discovery Token")
|
||||||
|
}
|
||||||
|
return model.GetNodeByDiscoveryToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNodeView(node *model.Node) *NodeView {
|
||||||
|
status := computeNodeStatus(node)
|
||||||
|
view := &NodeView{
|
||||||
|
ID: node.ID,
|
||||||
|
NodeID: node.NodeID,
|
||||||
|
Name: node.Name,
|
||||||
|
IP: node.IP,
|
||||||
|
AgentVersion: node.AgentVersion,
|
||||||
|
NginxVersion: node.NginxVersion,
|
||||||
|
Status: status,
|
||||||
|
CurrentVersion: node.CurrentVersion,
|
||||||
|
LastSeenAt: node.LastSeenAt,
|
||||||
|
LastError: node.LastError,
|
||||||
|
CreatedAt: node.CreatedAt,
|
||||||
|
UpdatedAt: node.UpdatedAt,
|
||||||
|
Pending: status == NodeStatusPending,
|
||||||
|
}
|
||||||
|
if status == NodeStatusPending {
|
||||||
|
view.DiscoveryToken = node.DiscoveryToken
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) {
|
||||||
|
if !preserveName || strings.TrimSpace(node.Name) == "" {
|
||||||
|
if strings.TrimSpace(payload.Name) != "" {
|
||||||
|
node.Name = strings.TrimSpace(payload.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.IP = strings.TrimSpace(payload.IP)
|
||||||
|
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||||
|
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||||
|
node.Status = NodeStatusOnline
|
||||||
|
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||||
|
node.LastSeenAt = time.Now()
|
||||||
|
node.LastError = strings.TrimSpace(payload.LastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRandomToken() (string, error) {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerNodeID() (string, error) {
|
||||||
|
token, err := newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "node-" + token, nil
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Header, Label, Segment, Table } from 'semantic-ui-react';
|
import { Button, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
|
||||||
import { API, formatDateTime, showError } from '../../helpers';
|
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
name: '',
|
||||||
|
};
|
||||||
|
|
||||||
const renderStatus = (status) => {
|
const renderStatus = (status) => {
|
||||||
if (status === 'online') {
|
if (status === 'online') {
|
||||||
return <Label color='green'>在线</Label>;
|
return <Label color='green'>在线</Label>;
|
||||||
}
|
}
|
||||||
|
if (status === 'pending') {
|
||||||
|
return <Label color='orange'>待接入</Label>;
|
||||||
|
}
|
||||||
return <Label color='grey'>离线</Label>;
|
return <Label color='grey'>离线</Label>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +29,9 @@ const renderApply = (result) => {
|
|||||||
const Node = () => {
|
const Node = () => {
|
||||||
const [nodes, setNodes] = useState([]);
|
const [nodes, setNodes] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
|
||||||
const loadNodes = async () => {
|
const loadNodes = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -39,16 +49,84 @@ const Node = () => {
|
|||||||
loadNodes().then();
|
loadNodes().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(initialForm);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitNode = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
};
|
||||||
|
const res = editingId
|
||||||
|
? await API.put(`/api/nodes/${editingId}`, payload)
|
||||||
|
: await API.post('/api/nodes/', payload);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(editingId ? '节点已更新' : '节点已创建');
|
||||||
|
resetForm();
|
||||||
|
await loadNodes();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (node) => {
|
||||||
|
setEditingId(node.id);
|
||||||
|
setForm({
|
||||||
|
name: node.name || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNode = async (node) => {
|
||||||
|
if (!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await API.delete(`/api/nodes/${node.id}`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('节点已删除');
|
||||||
|
if (editingId === node.id) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
await loadNodes();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment loading={loading}>
|
<Segment loading={loading}>
|
||||||
<Header as='h3'>节点状态</Header>
|
<Header as='h3'>节点管理</Header>
|
||||||
<p className='page-subtitle'>查看节点在线状态、当前版本和最近一次应用结果。</p>
|
<p className='page-subtitle'>创建节点、查看接入令牌,并跟踪节点在线状态、版本与最近一次应用结果。</p>
|
||||||
|
|
||||||
|
<Form onSubmit={submitNode}>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='节点名'
|
||||||
|
placeholder='例如 shanghai-edge-1'
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e, { value }) => setForm({ ...form, name: value })}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Button primary type='submit' loading={submitting}>
|
||||||
|
{editingId ? '保存修改' : '新增节点'}
|
||||||
|
</Button>
|
||||||
|
{editingId ? (
|
||||||
|
<Button type='button' onClick={resetForm}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
|
||||||
<Table celled stackable className='atsf-table'>
|
<Table celled stackable className='atsf-table'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell>节点名</Table.HeaderCell>
|
<Table.HeaderCell>节点名</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Node ID</Table.HeaderCell>
|
<Table.HeaderCell>Node ID</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Discovery Token</Table.HeaderCell>
|
||||||
<Table.HeaderCell>IP</Table.HeaderCell>
|
<Table.HeaderCell>IP</Table.HeaderCell>
|
||||||
<Table.HeaderCell>状态</Table.HeaderCell>
|
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
||||||
@@ -56,6 +134,7 @@ const Node = () => {
|
|||||||
<Table.HeaderCell>最近应用</Table.HeaderCell>
|
<Table.HeaderCell>最近应用</Table.HeaderCell>
|
||||||
<Table.HeaderCell>最近心跳</Table.HeaderCell>
|
<Table.HeaderCell>最近心跳</Table.HeaderCell>
|
||||||
<Table.HeaderCell>错误</Table.HeaderCell>
|
<Table.HeaderCell>错误</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -63,6 +142,14 @@ const Node = () => {
|
|||||||
<Table.Row key={node.id}>
|
<Table.Row key={node.id}>
|
||||||
<Table.Cell>{node.name}</Table.Cell>
|
<Table.Cell>{node.name}</Table.Cell>
|
||||||
<Table.Cell>{node.node_id}</Table.Cell>
|
<Table.Cell>{node.node_id}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{node.pending && node.discovery_token ? (
|
||||||
|
<>
|
||||||
|
<Label color='orange'>待接入</Label>
|
||||||
|
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.discovery_token}</div>
|
||||||
|
</>
|
||||||
|
) : '已激活'}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>{node.ip}</Table.Cell>
|
<Table.Cell>{node.ip}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
||||||
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
|
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
|
||||||
@@ -71,8 +158,16 @@ const Node = () => {
|
|||||||
{renderApply(node.latest_apply_result)}
|
{renderApply(node.latest_apply_result)}
|
||||||
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
|
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDateTime(node.last_seen_at)}</Table.Cell>
|
<Table.Cell>{node.last_seen_at ? formatDateTime(node.last_seen_at) : '暂无'}</Table.Cell>
|
||||||
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button size='small' onClick={() => beginEdit(node)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size='small' negative onClick={() => deleteNode(node)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
Reference in New Issue
Block a user