feat: mock gateway

This commit is contained in:
Patrick Balsiger
2025-10-24 14:24:14 +02:00
parent fba1f162b3
commit 3c3fb886a3
9 changed files with 2851 additions and 0 deletions

449
internal/mock/data.go Normal file
View File

@@ -0,0 +1,449 @@
package mock
import (
"fmt"
"math/rand"
"time"
"spore-gateway/pkg/client"
"spore-gateway/pkg/registry"
)
// GenerateMockClusterMembers generates mock cluster member data
func GenerateMockClusterMembers(nodes map[string]*NodeInfo) []client.ClusterMember {
members := make([]client.ClusterMember, 0, len(nodes))
for _, node := range nodes {
member := client.ClusterMember{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen.Unix(),
Labels: node.Labels,
Resources: map[string]interface{}{
"freeHeap": 32768 + rand.Intn(32768),
"cpuFreqMHz": 80 + rand.Intn(160),
"flashChipSize": 4194304,
},
}
members = append(members, member)
}
return members
}
// GenerateMockTaskStatus generates mock task status data
func GenerateMockTaskStatus() *client.TaskStatusResponse {
tasks := []client.TaskInfo{
{
Name: "HeartbeatTask",
Interval: 5000,
Enabled: true,
Running: true,
AutoStart: true,
},
{
Name: "SensorReadTask",
Interval: 10000,
Enabled: true,
Running: true,
AutoStart: true,
},
{
Name: "StatusUpdateTask",
Interval: 30000,
Enabled: true,
Running: false,
AutoStart: false,
},
{
Name: "CleanupTask",
Interval: 60000,
Enabled: false,
Running: false,
AutoStart: false,
},
}
activeTasks := 0
for _, task := range tasks {
if task.Running {
activeTasks++
}
}
return &client.TaskStatusResponse{
Summary: client.TaskSummary{
TotalTasks: len(tasks),
ActiveTasks: activeTasks,
},
Tasks: tasks,
System: client.SystemInfo{
FreeHeap: 32768 + int64(rand.Intn(32768)),
Uptime: int64(time.Now().Unix() - 3600*24), // 24 hours uptime
},
}
}
// GenerateMockSystemStatus generates mock system status data
func GenerateMockSystemStatus(labels map[string]string) *client.SystemStatusResponse {
return &client.SystemStatusResponse{
FreeHeap: 32768 + int64(rand.Intn(32768)),
ChipID: int64(rand.Int31()),
SDKVersion: "3.1.0",
CPUFreqMHz: 80 + rand.Intn(160),
FlashChipSize: 4194304,
Labels: labels,
}
}
// GenerateMockCapabilities generates mock API endpoint capabilities
func GenerateMockCapabilities() *client.CapabilitiesResponse {
return &client.CapabilitiesResponse{
Endpoints: []client.EndpointInfo{
{
URI: "/api/node/status",
Method: "GET",
Parameters: []client.ParameterInfo{
{
Name: "detailed",
Type: "boolean",
Required: false,
Description: "Include detailed system information",
Location: "query",
Default: "false",
},
},
},
{
URI: "/api/cluster/members",
Method: "GET",
Parameters: []client.ParameterInfo{},
},
{
URI: "/api/tasks/status",
Method: "GET",
Parameters: []client.ParameterInfo{},
},
{
URI: "/api/node/update",
Method: "POST",
Parameters: []client.ParameterInfo{
{
Name: "firmware",
Type: "file",
Required: true,
Description: "Firmware binary file",
Location: "body",
},
},
},
{
URI: "/api/node/config",
Method: "POST",
Parameters: []client.ParameterInfo{
{
Name: "labels",
Type: "json",
Required: true,
Description: "Node labels in JSON format",
Location: "body",
},
},
},
{
URI: "/api/sensors/read",
Method: "GET",
Parameters: []client.ParameterInfo{
{
Name: "sensor",
Type: "string",
Required: false,
Description: "Specific sensor to read",
Location: "query",
Values: []string{"temperature", "humidity", "pressure"},
},
},
},
},
}
}
// GenerateMockFirmwareList generates mock firmware registry data
func GenerateMockFirmwareList() []registry.GroupedFirmware {
return []registry.GroupedFirmware{
{
Name: "spore-firmware",
Firmware: []registry.FirmwareRecord{
{
Name: "spore-firmware",
Version: "1.0.0",
Size: 524288,
Labels: map[string]string{
"stable": "true",
"env": "production",
},
Path: "/firmware/spore-firmware/1.0.0",
},
{
Name: "spore-firmware",
Version: "1.1.0",
Size: 548864,
Labels: map[string]string{
"stable": "true",
"env": "production",
},
Path: "/firmware/spore-firmware/1.1.0",
},
{
Name: "spore-firmware",
Version: "1.2.0",
Size: 573440,
Labels: map[string]string{
"stable": "false",
"env": "beta",
},
Path: "/firmware/spore-firmware/1.2.0",
},
},
},
{
Name: "sensor-firmware",
Firmware: []registry.FirmwareRecord{
{
Name: "sensor-firmware",
Version: "2.0.0",
Size: 262144,
Labels: map[string]string{
"stable": "true",
"type": "sensor",
},
Path: "/firmware/sensor-firmware/2.0.0",
},
{
Name: "sensor-firmware",
Version: "2.1.0",
Size: 286720,
Labels: map[string]string{
"stable": "true",
"type": "sensor",
},
Path: "/firmware/sensor-firmware/2.1.0",
},
},
},
}
}
// GenerateMockFirmwareBinary generates mock firmware binary data
func GenerateMockFirmwareBinary(size int) []byte {
// Generate some pseudo-random but deterministic binary data
data := make([]byte, size)
for i := range data {
data[i] = byte(i % 256)
}
return data
}
// NodeInfo is an alias to avoid import cycle
type NodeInfo struct {
IP string
Hostname string
Status string
Latency int64
LastSeen time.Time
Labels map[string]string
}
// ResourceMetrics represents resource usage metrics for a node
type ResourceMetrics struct {
Timestamp int64 `json:"timestamp"`
NodeIP string `json:"node_ip"`
Hostname string `json:"hostname"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Network NetworkMetrics `json:"network"`
Flash FlashMetrics `json:"flash"`
Labels map[string]string `json:"labels"`
}
// CPUMetrics represents CPU usage metrics
type CPUMetrics struct {
Frequency int `json:"frequency_mhz"`
UsagePercent float64 `json:"usage_percent"`
Temperature float64 `json:"temperature_c,omitempty"`
}
// MemoryMetrics represents memory usage metrics
type MemoryMetrics struct {
Total int64 `json:"total_bytes"`
Free int64 `json:"free_bytes"`
Used int64 `json:"used_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
// NetworkMetrics represents network usage metrics
type NetworkMetrics struct {
BytesSent int64 `json:"bytes_sent"`
BytesReceived int64 `json:"bytes_received"`
PacketsSent int64 `json:"packets_sent"`
PacketsRecv int64 `json:"packets_received"`
RSSI int `json:"rssi_dbm,omitempty"`
SignalQuality float64 `json:"signal_quality_percent,omitempty"`
}
// FlashMetrics represents flash storage metrics
type FlashMetrics struct {
Total int64 `json:"total_bytes"`
Used int64 `json:"used_bytes"`
Free int64 `json:"free_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
// MonitoringResourcesResponse represents the monitoring resources endpoint response
type MonitoringResourcesResponse struct {
Timestamp string `json:"timestamp"`
Nodes []ResourceMetrics `json:"nodes"`
Summary ResourceSummary `json:"summary"`
}
// ResourceSummary provides aggregate statistics across all nodes
type ResourceSummary struct {
TotalNodes int `json:"total_nodes"`
AvgCPUUsage float64 `json:"avg_cpu_usage_percent"`
AvgMemoryUsage float64 `json:"avg_memory_usage_percent"`
AvgFlashUsage float64 `json:"avg_flash_usage_percent"`
TotalBytesSent int64 `json:"total_bytes_sent"`
TotalBytesRecv int64 `json:"total_bytes_received"`
}
// GenerateMockMonitoringResources generates meaningful mock monitoring data for all nodes
func GenerateMockMonitoringResources(nodes map[string]*NodeInfo) *MonitoringResourcesResponse {
now := time.Now()
metrics := make([]ResourceMetrics, 0, len(nodes))
var totalCPU, totalMemoryPercent, totalFlash float64
var totalBytesSent, totalBytesRecv int64
for _, node := range nodes {
// Generate realistic resource usage based on node characteristics
cpuFreq := 80 + rand.Intn(160)
cpuUsage := 15.0 + rand.Float64()*45.0 // 15-60% usage
// Memory metrics
nodeMemoryTotal := int64(65536 + rand.Intn(65536)) // 64-128KB
freeMemory := int64(32768 + rand.Intn(32768)) // 32-64KB free
usedMemory := nodeMemoryTotal - freeMemory
memoryUsagePercent := float64(usedMemory) / float64(nodeMemoryTotal) * 100
// Flash metrics
flashTotal := int64(4194304) // 4MB
flashUsed := int64(1048576 + rand.Intn(2097152)) // 1-3MB used
flashFree := flashTotal - flashUsed
flashUsagePercent := float64(flashUsed) / float64(flashTotal) * 100
// Network metrics (simulating accumulated traffic)
bytesSent := int64(1000000 + rand.Intn(5000000)) // 1-6MB
bytesRecv := int64(2000000 + rand.Intn(8000000)) // 2-10MB
packetsSent := int64(10000 + rand.Intn(50000))
packetsRecv := int64(15000 + rand.Intn(75000))
// WiFi signal metrics
rssi := -30 - rand.Intn(60) // -30 to -90 dBm
signalQuality := float64(100+rssi+90) / 60.0 * 100 // Convert RSSI to quality percentage
if signalQuality < 0 {
signalQuality = 0
} else if signalQuality > 100 {
signalQuality = 100
}
metric := ResourceMetrics{
Timestamp: now.Unix(),
NodeIP: node.IP,
Hostname: node.Hostname,
CPU: CPUMetrics{
Frequency: cpuFreq,
UsagePercent: cpuUsage,
Temperature: 45.0 + rand.Float64()*20.0, // 45-65°C
},
Memory: MemoryMetrics{
Total: nodeMemoryTotal,
Free: freeMemory,
Used: usedMemory,
UsagePercent: memoryUsagePercent,
},
Network: NetworkMetrics{
BytesSent: bytesSent,
BytesReceived: bytesRecv,
PacketsSent: packetsSent,
PacketsRecv: packetsRecv,
RSSI: rssi,
SignalQuality: signalQuality,
},
Flash: FlashMetrics{
Total: flashTotal,
Used: flashUsed,
Free: flashFree,
UsagePercent: flashUsagePercent,
},
Labels: node.Labels,
}
metrics = append(metrics, metric)
// Accumulate for summary
totalCPU += cpuUsage
totalMemoryPercent += memoryUsagePercent
totalFlash += flashUsagePercent
totalBytesSent += bytesSent
totalBytesRecv += bytesRecv
}
// Calculate averages
nodeCount := len(nodes)
var avgCPU, avgMemory, avgFlash float64
if nodeCount > 0 {
avgCPU = totalCPU / float64(nodeCount)
avgMemory = totalMemoryPercent / float64(nodeCount)
avgFlash = totalFlash / float64(nodeCount)
}
return &MonitoringResourcesResponse{
Timestamp: now.Format(time.RFC3339),
Nodes: metrics,
Summary: ResourceSummary{
TotalNodes: nodeCount,
AvgCPUUsage: avgCPU,
AvgMemoryUsage: avgMemory,
AvgFlashUsage: avgFlash,
TotalBytesSent: totalBytesSent,
TotalBytesRecv: totalBytesRecv,
},
}
}
// GenerateMockProxyResponse generates a mock response for proxy calls
func GenerateMockProxyResponse(method, uri string) map[string]interface{} {
switch uri {
case "/api/sensors/read":
return map[string]interface{}{
"temperature": 22.5 + rand.Float64()*5,
"humidity": 45.0 + rand.Float64()*20,
"pressure": 1013.0 + rand.Float64()*10,
"timestamp": time.Now().Unix(),
}
case "/api/led/control":
return map[string]interface{}{
"status": "success",
"message": "LED state updated",
"state": "on",
}
default:
return map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Mock response for %s %s", method, uri),
"data": map[string]interface{}{"mock": true},
}
}
}

274
internal/mock/discovery.go Normal file
View File

@@ -0,0 +1,274 @@
package mock
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
"spore-gateway/internal/discovery"
log "github.com/sirupsen/logrus"
)
// MockNodeDiscovery simulates node discovery with mock nodes
type MockNodeDiscovery struct {
mockNodes map[string]*discovery.NodeInfo
primaryNode string
mutex sync.RWMutex
callbacks []discovery.NodeUpdateCallback
heartbeatRate time.Duration
shutdownChan chan struct{}
shutdownOnce sync.Once
logger *log.Logger
}
// NewMockNodeDiscovery creates a new mock node discovery instance
func NewMockNodeDiscovery(numNodes int, heartbeatRate time.Duration) *MockNodeDiscovery {
mnd := &MockNodeDiscovery{
mockNodes: make(map[string]*discovery.NodeInfo),
heartbeatRate: heartbeatRate,
shutdownChan: make(chan struct{}),
logger: log.New(),
}
// Generate mock nodes with realistic firmware versions
// These versions should match the firmware available in the registry
now := time.Now()
for i := 0; i < numNodes; i++ {
ip := fmt.Sprintf("192.168.1.%d", 100+i)
hostname := fmt.Sprintf("spore-node-%d", i+1)
// Distribute nodes across different firmware versions
// Most nodes on stable versions, some on beta
var version string
switch i % 5 {
case 0, 1:
version = "1.0.0" // 40% on oldest stable
case 2, 3:
version = "1.1.0" // 40% on newer stable
case 4:
version = "1.2.0" // 20% on beta
}
// Determine stability based on version
stable := "true"
env := "production"
if version == "1.2.0" {
stable = "false"
env = "beta"
}
nodeInfo := &discovery.NodeInfo{
IP: ip,
Port: 80,
Hostname: hostname,
Status: discovery.NodeStatusActive,
DiscoveredAt: now.Add(-time.Duration(i*5) * time.Minute),
LastSeen: now,
Uptime: fmt.Sprintf("%dh%dm", 10+i, rand.Intn(60)),
Labels: map[string]string{
"version": version,
"stable": stable,
"env": env,
"zone": fmt.Sprintf("zone-%d", (i%3)+1),
"type": "spore-node",
},
Latency: int64(10 + rand.Intn(50)),
Resources: map[string]interface{}{
"freeHeap": 32768 + rand.Intn(32768),
"cpuFreqMHz": 80 + rand.Intn(160),
"flashChipSize": 4194304,
},
}
mnd.mockNodes[ip] = nodeInfo
}
// Set first node as primary
if numNodes > 0 {
mnd.primaryNode = fmt.Sprintf("192.168.1.%d", 100)
}
mnd.logger.WithField("nodes", numNodes).Info("Mock discovery initialized with nodes")
return mnd
}
// Start starts the mock discovery (simulates periodic heartbeats)
func (mnd *MockNodeDiscovery) Start() error {
mnd.logger.Info("Starting mock node discovery")
// Simulate periodic node updates
go mnd.simulateHeartbeats()
return nil
}
// Shutdown gracefully shuts down the mock discovery
func (mnd *MockNodeDiscovery) Shutdown(ctx context.Context) error {
mnd.shutdownOnce.Do(func() {
mnd.logger.Info("Shutting down mock node discovery")
close(mnd.shutdownChan)
})
return nil
}
// simulateHeartbeats simulates periodic node heartbeats and updates
func (mnd *MockNodeDiscovery) simulateHeartbeats() {
ticker := time.NewTicker(mnd.heartbeatRate)
defer ticker.Stop()
for {
select {
case <-mnd.shutdownChan:
return
case <-ticker.C:
mnd.updateMockNodes()
}
}
}
// updateMockNodes simulates node updates (version changes, status changes, etc.)
func (mnd *MockNodeDiscovery) updateMockNodes() {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
now := time.Now()
for ip, node := range mnd.mockNodes {
// Update last seen time
node.LastSeen = now
// Randomly update some metrics
if rand.Float32() < 0.3 { // 30% chance
node.Latency = int64(10 + rand.Intn(50))
// Update resources map
node.Resources["freeHeap"] = 32768 + rand.Intn(32768)
}
// Occasionally notify about updates
if rand.Float32() < 0.1 { // 10% chance
mnd.notifyCallbacks(ip, "heartbeat")
}
}
}
// GetNodes returns a copy of all mock nodes
func (mnd *MockNodeDiscovery) GetNodes() map[string]*discovery.NodeInfo {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
nodes := make(map[string]*discovery.NodeInfo)
for ip, node := range mnd.mockNodes {
// Create a copy
nodeCopy := *node
nodes[ip] = &nodeCopy
}
return nodes
}
// GetPrimaryNode returns the current primary node IP
func (mnd *MockNodeDiscovery) GetPrimaryNode() string {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
return mnd.primaryNode
}
// SetPrimaryNode manually sets the primary node
func (mnd *MockNodeDiscovery) SetPrimaryNode(ip string) error {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if _, exists := mnd.mockNodes[ip]; !exists {
return fmt.Errorf("node %s not found", ip)
}
oldPrimary := mnd.primaryNode
mnd.primaryNode = ip
mnd.logger.WithFields(log.Fields{
"old_primary": oldPrimary,
"new_primary": ip,
}).Info("Primary node changed")
mnd.notifyCallbacks(ip, "primary_changed")
return nil
}
// SelectRandomPrimaryNode selects a random active node as primary
func (mnd *MockNodeDiscovery) SelectRandomPrimaryNode() string {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if len(mnd.mockNodes) == 0 {
return ""
}
// Get active nodes
var activeNodes []string
for ip, node := range mnd.mockNodes {
if node.Status == discovery.NodeStatusActive {
activeNodes = append(activeNodes, ip)
}
}
if len(activeNodes) == 0 {
return mnd.primaryNode
}
// Select random node
randomIndex := rand.Intn(len(activeNodes))
randomNode := activeNodes[randomIndex]
oldPrimary := mnd.primaryNode
mnd.primaryNode = randomNode
mnd.logger.WithFields(log.Fields{
"old_primary": oldPrimary,
"new_primary": randomNode,
}).Info("Randomly selected new primary node")
mnd.notifyCallbacks(randomNode, "primary_changed")
return randomNode
}
// AddCallback registers a callback for node updates
func (mnd *MockNodeDiscovery) AddCallback(callback discovery.NodeUpdateCallback) {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
mnd.callbacks = append(mnd.callbacks, callback)
}
// GetClusterStatus returns current cluster status
func (mnd *MockNodeDiscovery) GetClusterStatus() discovery.ClusterStatus {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
return discovery.ClusterStatus{
PrimaryNode: mnd.primaryNode,
TotalNodes: len(mnd.mockNodes),
UDPPort: "4210",
ServerRunning: true,
}
}
// notifyCallbacks notifies all registered callbacks about node changes
func (mnd *MockNodeDiscovery) notifyCallbacks(nodeIP, action string) {
for _, callback := range mnd.callbacks {
go callback(nodeIP, action)
}
}
// UpdateNodeVersion simulates updating a node's version (for testing rollouts)
func (mnd *MockNodeDiscovery) UpdateNodeVersion(ip, version string) {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if node, exists := mnd.mockNodes[ip]; exists {
node.Labels["version"] = version
mnd.logger.WithFields(log.Fields{
"ip": ip,
"version": version,
}).Info("Updated node version")
mnd.notifyCallbacks(ip, "version_updated")
}
}

752
internal/mock/server.go Normal file
View File

@@ -0,0 +1,752 @@
package mock
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"time"
"spore-gateway/internal/discovery"
"spore-gateway/pkg/client"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
// MockHTTPServer represents the mock HTTP server
type MockHTTPServer struct {
port string
router *mux.Router
discovery *MockNodeDiscovery
wsServer *MockWebSocketServer
server *http.Server
enableWS bool
firmwareStore map[string][]byte // Simple in-memory firmware storage
}
// NewMockHTTPServer creates a new mock HTTP server instance
func NewMockHTTPServer(port string, discovery *MockNodeDiscovery, enableWS bool) *MockHTTPServer {
ms := &MockHTTPServer{
port: port,
router: mux.NewRouter(),
discovery: discovery,
enableWS: enableWS,
firmwareStore: make(map[string][]byte),
}
// Initialize WebSocket server if enabled
if enableWS {
ms.wsServer = NewMockWebSocketServer(discovery)
}
ms.setupRoutes()
ms.setupMiddleware()
ms.server = &http.Server{
Addr: ":" + port,
Handler: ms.router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return ms
}
// setupMiddleware configures middleware for the server
func (ms *MockHTTPServer) setupMiddleware() {
ms.router.Use(ms.corsMiddleware)
ms.router.Use(ms.jsonMiddleware)
ms.router.Use(ms.loggingMiddleware)
}
// corsMiddleware handles CORS headers
func (ms *MockHTTPServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept")
w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// jsonMiddleware sets JSON content type
func (ms *MockHTTPServer) jsonMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
// loggingMiddleware logs HTTP requests
func (ms *MockHTTPServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.WithFields(log.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote_addr": r.RemoteAddr,
"duration": time.Since(start),
}).Debug("HTTP request")
})
}
// setupRoutes configures all the API routes
func (ms *MockHTTPServer) setupRoutes() {
// API routes
api := ms.router.PathPrefix("/api").Subrouter()
api.Use(ms.corsMiddleware)
// Discovery endpoints
api.HandleFunc("/discovery/nodes", ms.getDiscoveryNodes).Methods("GET")
api.HandleFunc("/discovery/refresh", ms.refreshDiscovery).Methods("POST", "OPTIONS")
api.HandleFunc("/discovery/random-primary", ms.selectRandomPrimary).Methods("POST", "OPTIONS")
api.HandleFunc("/discovery/primary/{ip}", ms.setPrimaryNode).Methods("POST", "OPTIONS")
// Cluster endpoints
api.HandleFunc("/cluster/members", ms.getClusterMembers).Methods("GET")
api.HandleFunc("/cluster/refresh", ms.refreshCluster).Methods("POST", "OPTIONS")
api.HandleFunc("/cluster/node/versions", ms.getClusterNodeVersions).Methods("GET")
api.HandleFunc("/rollout", ms.startRollout).Methods("POST", "OPTIONS")
// Task endpoints
api.HandleFunc("/tasks/status", ms.getTaskStatus).Methods("GET")
// Node endpoints
api.HandleFunc("/node/status", ms.getNodeStatus).Methods("GET")
api.HandleFunc("/node/status/{ip}", ms.getNodeStatusByIP).Methods("GET")
api.HandleFunc("/node/endpoints", ms.getNodeEndpoints).Methods("GET")
api.HandleFunc("/node/update", ms.updateNodeFirmware).Methods("POST", "OPTIONS")
// Proxy endpoints
api.HandleFunc("/proxy-call", ms.proxyCall).Methods("POST", "OPTIONS")
// Registry proxy endpoints
api.HandleFunc("/registry/health", ms.getRegistryHealth).Methods("GET")
api.HandleFunc("/registry/firmware", ms.listRegistryFirmware).Methods("GET")
api.HandleFunc("/registry/firmware", ms.uploadRegistryFirmware).Methods("POST", "OPTIONS")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.downloadRegistryFirmware).Methods("GET")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.updateRegistryFirmware).Methods("PUT", "OPTIONS")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.deleteRegistryFirmware).Methods("DELETE", "OPTIONS")
// Monitoring endpoints
api.HandleFunc("/monitoring/resources", ms.getMonitoringResources).Methods("GET")
// Test endpoints
api.HandleFunc("/test/websocket", ms.testWebSocket).Methods("POST", "OPTIONS")
// Health check
api.HandleFunc("/health", ms.healthCheck).Methods("GET")
// WebSocket endpoint
if ms.enableWS {
ms.router.HandleFunc("/ws", ms.corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := ms.wsServer.HandleWebSocket(w, r); err != nil {
log.WithError(err).Error("WebSocket connection failed")
http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest)
}
})).ServeHTTP)
}
}
// Start starts the HTTP server
func (ms *MockHTTPServer) Start() error {
log.WithField("port", ms.port).Info("Starting mock HTTP server")
return ms.server.ListenAndServe()
}
// Shutdown gracefully shuts down the HTTP server
func (ms *MockHTTPServer) Shutdown(ctx context.Context) error {
log.Info("Shutting down mock HTTP server")
// Shutdown WebSocket server if enabled
if ms.enableWS && ms.wsServer != nil {
if err := ms.wsServer.Shutdown(ctx); err != nil {
log.WithError(err).Error("WebSocket server shutdown error")
}
}
return ms.server.Shutdown(ctx)
}
// API endpoint handlers
// GET /api/discovery/nodes
func (ms *MockHTTPServer) getDiscoveryNodes(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
primaryNode := ms.discovery.GetPrimaryNode()
clusterStatus := ms.discovery.GetClusterStatus()
type NodeResponse struct {
*discovery.NodeInfo
IsPrimary bool `json:"isPrimary"`
}
response := struct {
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Nodes []NodeResponse `json:"nodes"`
ClientInitialized bool `json:"clientInitialized"`
ClientBaseURL string `json:"clientBaseUrl"`
ClusterStatus discovery.ClusterStatus `json:"clusterStatus"`
}{
PrimaryNode: primaryNode,
TotalNodes: len(nodes),
Nodes: make([]NodeResponse, 0, len(nodes)),
ClientInitialized: primaryNode != "",
ClientBaseURL: fmt.Sprintf("http://%s", primaryNode),
ClusterStatus: clusterStatus,
}
for _, node := range nodes {
nodeResponse := NodeResponse{
NodeInfo: node,
IsPrimary: node.IP == primaryNode,
}
response.Nodes = append(response.Nodes, nodeResponse)
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/refresh
func (ms *MockHTTPServer) refreshDiscovery(w http.ResponseWriter, r *http.Request) {
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
ClientInitialized bool `json:"clientInitialized"`
}{
Success: true,
Message: "Mock cluster refresh completed",
PrimaryNode: ms.discovery.GetPrimaryNode(),
TotalNodes: len(ms.discovery.GetNodes()),
ClientInitialized: ms.discovery.GetPrimaryNode() != "",
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/random-primary
func (ms *MockHTTPServer) selectRandomPrimary(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
if len(nodes) == 0 {
http.Error(w, `{"error": "No nodes available"}`, http.StatusNotFound)
return
}
newPrimary := ms.discovery.SelectRandomPrimaryNode()
if newPrimary == "" {
http.Error(w, `{"error": "Selection failed"}`, http.StatusInternalServerError)
return
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
ClientInitialized bool `json:"clientInitialized"`
Timestamp string `json:"timestamp"`
}{
Success: true,
Message: fmt.Sprintf("Randomly selected new primary node: %s", newPrimary),
PrimaryNode: newPrimary,
TotalNodes: len(nodes),
ClientInitialized: true,
Timestamp: time.Now().Format(time.RFC3339),
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/primary/{ip}
func (ms *MockHTTPServer) setPrimaryNode(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedIP := vars["ip"]
if err := ms.discovery.SetPrimaryNode(requestedIP); err != nil {
http.Error(w, fmt.Sprintf(`{"error": "Node not found", "message": "Node with IP %s not found"}`, requestedIP), http.StatusNotFound)
return
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
ClientInitialized bool `json:"clientInitialized"`
}{
Success: true,
Message: fmt.Sprintf("Primary node set to %s", requestedIP),
PrimaryNode: requestedIP,
ClientInitialized: true,
}
json.NewEncoder(w).Encode(response)
}
// GET /api/cluster/members
func (ms *MockHTTPServer) getClusterMembers(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
response := &client.ClusterStatusResponse{
Members: members,
}
json.NewEncoder(w).Encode(response)
}
// POST /api/cluster/refresh
func (ms *MockHTTPServer) refreshCluster(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil && err.Error() != "EOF" {
requestBody.Reason = "manual_refresh"
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
Reason string `json:"reason"`
WSclients int `json:"wsClients"`
}{
Success: true,
Message: "Mock cluster refresh triggered",
Reason: requestBody.Reason,
}
if ms.enableWS && ms.wsServer != nil {
response.WSclients = ms.wsServer.GetClientCount()
}
json.NewEncoder(w).Encode(response)
}
// GET /api/cluster/node/versions
func (ms *MockHTTPServer) getClusterNodeVersions(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
type NodeVersionInfo struct {
IP string `json:"ip"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
var nodeVersions []NodeVersionInfo
for _, node := range nodes {
version := "unknown"
if v, exists := node.Labels["version"]; exists {
version = v
}
nodeVersions = append(nodeVersions, NodeVersionInfo{
IP: node.IP,
Version: version,
Labels: node.Labels,
})
}
response := struct {
Nodes []NodeVersionInfo `json:"nodes"`
}{
Nodes: nodeVersions,
}
json.NewEncoder(w).Encode(response)
}
// RolloutNode represents a node in a rollout request
type RolloutNode struct {
IP string `json:"ip"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
// POST /api/rollout
func (ms *MockHTTPServer) startRollout(w http.ResponseWriter, r *http.Request) {
var request struct {
Firmware struct {
Name string `json:"name"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
} `json:"firmware"`
Nodes []RolloutNode `json:"nodes"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
rolloutID := fmt.Sprintf("rollout_%d", time.Now().Unix())
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
RolloutID string `json:"rolloutId"`
TotalNodes int `json:"totalNodes"`
FirmwareURL string `json:"firmwareUrl"`
}{
Success: true,
Message: fmt.Sprintf("Mock rollout started for %d nodes", len(request.Nodes)),
RolloutID: rolloutID,
TotalNodes: len(request.Nodes),
FirmwareURL: fmt.Sprintf("http://localhost:3002/firmware/%s/%s", request.Firmware.Name, request.Firmware.Version),
}
json.NewEncoder(w).Encode(response)
// Simulate rollout progress in background
if ms.enableWS && ms.wsServer != nil {
go ms.simulateRollout(rolloutID, request.Nodes, request.Firmware.Version)
}
}
// simulateRollout simulates a rollout process with progress updates
func (ms *MockHTTPServer) simulateRollout(rolloutID string, nodes []RolloutNode, newVersion string) {
for i, node := range nodes {
// Simulate updating labels
time.Sleep(500 * time.Millisecond)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "updating_labels", i+1, len(nodes))
}
// Simulate uploading
time.Sleep(1 * time.Second)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "uploading", i+1, len(nodes))
}
// Update node version in discovery
ms.discovery.UpdateNodeVersion(node.IP, strings.TrimPrefix(newVersion, "v"))
// Simulate completion
time.Sleep(500 * time.Millisecond)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "completed", i+1, len(nodes))
}
}
log.WithField("rollout_id", rolloutID).Info("Mock rollout completed")
}
// GET /api/tasks/status
func (ms *MockHTTPServer) getTaskStatus(w http.ResponseWriter, r *http.Request) {
response := GenerateMockTaskStatus()
json.NewEncoder(w).Encode(response)
}
// GET /api/node/status
func (ms *MockHTTPServer) getNodeStatus(w http.ResponseWriter, r *http.Request) {
primaryNode := ms.discovery.GetPrimaryNode()
nodes := ms.discovery.GetNodes()
var labels map[string]string
if primaryNode != "" && nodes[primaryNode] != nil {
labels = nodes[primaryNode].Labels
} else {
labels = make(map[string]string)
}
response := GenerateMockSystemStatus(labels)
json.NewEncoder(w).Encode(response)
}
// GET /api/node/status/{ip}
func (ms *MockHTTPServer) getNodeStatusByIP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
nodeIP := vars["ip"]
nodes := ms.discovery.GetNodes()
node, exists := nodes[nodeIP]
if !exists {
http.Error(w, fmt.Sprintf(`{"error": "Node not found", "message": "Node with IP %s not found"}`, nodeIP), http.StatusNotFound)
return
}
response := GenerateMockSystemStatus(node.Labels)
json.NewEncoder(w).Encode(response)
}
// GET /api/node/endpoints
func (ms *MockHTTPServer) getNodeEndpoints(w http.ResponseWriter, r *http.Request) {
response := GenerateMockCapabilities()
json.NewEncoder(w).Encode(response)
}
// POST /api/node/update
func (ms *MockHTTPServer) updateNodeFirmware(w http.ResponseWriter, r *http.Request) {
nodeIP := r.URL.Query().Get("ip")
if nodeIP == "" {
nodeIP = r.Header.Get("X-Node-IP")
}
if nodeIP == "" {
http.Error(w, `{"error": "Node IP required"}`, http.StatusBadRequest)
return
}
// Parse multipart form
err := r.ParseMultipartForm(50 << 20) // 50MB limit
if err != nil {
http.Error(w, `{"error": "Failed to parse form"}`, http.StatusBadRequest)
return
}
file, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, `{"error": "No file received"}`, http.StatusBadRequest)
return
}
defer file.Close()
filename := fileHeader.Filename
if filename == "" {
filename = "firmware.bin"
}
// Read file data
fileData, err := io.ReadAll(file)
if err != nil {
http.Error(w, `{"error": "Failed to read file"}`, http.StatusInternalServerError)
return
}
log.WithFields(log.Fields{
"node_ip": nodeIP,
"file_size": len(fileData),
"filename": filename,
}).Info("Mock firmware upload received")
// Store firmware in memory
ms.firmwareStore[nodeIP] = fileData
// Broadcast status if WebSocket enabled
if ms.enableWS && ms.wsServer != nil {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "uploading", filename, len(fileData))
}
// Send immediate response
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
NodeIP string `json:"nodeIp"`
FileSize int `json:"fileSize"`
Filename string `json:"filename"`
Status string `json:"status"`
}{
Success: true,
Message: "Mock firmware upload received",
NodeIP: nodeIP,
FileSize: len(fileData),
Filename: filename,
Status: "processing",
}
json.NewEncoder(w).Encode(response)
// Simulate firmware update in background
go func() {
time.Sleep(2 * time.Second)
if ms.enableWS && ms.wsServer != nil {
// Randomly succeed or fail (90% success rate)
if rand.Float32() < 0.9 {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "completed", filename, len(fileData))
} else {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "failed", filename, len(fileData))
}
}
}()
}
// POST /api/proxy-call
func (ms *MockHTTPServer) proxyCall(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
IP string `json:"ip"`
Method string `json:"method"`
URI string `json:"uri"`
Params []map[string]interface{} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
// Generate mock response based on URI
mockResponse := GenerateMockProxyResponse(requestBody.Method, requestBody.URI)
// Wrap in data field for consistency
response := map[string]interface{}{
"data": mockResponse,
"status": http.StatusOK,
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/health
func (ms *MockHTTPServer) getRegistryHealth(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
"service": "mock-registry",
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/firmware
func (ms *MockHTTPServer) listRegistryFirmware(w http.ResponseWriter, r *http.Request) {
firmwareList := GenerateMockFirmwareList()
json.NewEncoder(w).Encode(firmwareList)
}
// POST /api/registry/firmware
func (ms *MockHTTPServer) uploadRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware uploaded successfully",
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) downloadRegistryFirmware(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
version := vars["version"]
// Generate mock firmware binary
firmwareData := GenerateMockFirmwareBinary(524288) // 512KB
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.bin\"", name, version))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(firmwareData)))
w.Write(firmwareData)
}
// PUT /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) updateRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware metadata updated",
}
json.NewEncoder(w).Encode(response)
}
// DELETE /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) deleteRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware deleted",
}
json.NewEncoder(w).Encode(response)
}
// POST /api/test/websocket
func (ms *MockHTTPServer) testWebSocket(w http.ResponseWriter, r *http.Request) {
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
WSclients int `json:"websocketClients"`
TotalNodes int `json:"totalNodes"`
}{
Success: true,
Message: "Mock WebSocket test broadcast sent",
TotalNodes: len(ms.discovery.GetNodes()),
}
if ms.enableWS && ms.wsServer != nil {
response.WSclients = ms.wsServer.GetClientCount()
// Trigger a test broadcast
ms.wsServer.BroadcastClusterUpdate()
}
json.NewEncoder(w).Encode(response)
}
// GET /api/monitoring/resources
func (ms *MockHTTPServer) getMonitoringResources(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
response := GenerateMockMonitoringResources(mockNodes)
json.NewEncoder(w).Encode(response)
}
// GET /api/health
func (ms *MockHTTPServer) healthCheck(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
primaryNode := ms.discovery.GetPrimaryNode()
health := struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Services map[string]bool `json:"services"`
Cluster map[string]interface{} `json:"cluster"`
Mock bool `json:"mock"`
}{
Status: "healthy",
Timestamp: time.Now().Format(time.RFC3339),
Services: map[string]bool{
"http": true,
"websocket": ms.enableWS,
"discovery": true,
"mockClient": primaryNode != "",
},
Cluster: map[string]interface{}{
"totalNodes": len(nodes),
"primaryNode": primaryNode,
},
Mock: true,
}
json.NewEncoder(w).Encode(health)
}

475
internal/mock/websocket.go Normal file
View File

@@ -0,0 +1,475 @@
package mock
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow connections from any origin
},
}
// MockWebSocketServer manages WebSocket connections and mock broadcasts
type MockWebSocketServer struct {
discovery *MockNodeDiscovery
clients map[*websocket.Conn]bool
mutex sync.RWMutex
writeMutex sync.Mutex
shutdownChan chan struct{}
shutdownOnce sync.Once
logger *log.Logger
}
// NewMockWebSocketServer creates a new mock WebSocket server
func NewMockWebSocketServer(discovery *MockNodeDiscovery) *MockWebSocketServer {
mws := &MockWebSocketServer{
discovery: discovery,
clients: make(map[*websocket.Conn]bool),
shutdownChan: make(chan struct{}),
logger: log.New(),
}
// Register callback for node updates
discovery.AddCallback(mws.handleNodeUpdate)
// Start periodic broadcasts
go mws.startPeriodicBroadcasts()
return mws
}
// HandleWebSocket handles WebSocket upgrade and connection
func (mws *MockWebSocketServer) HandleWebSocket(w http.ResponseWriter, r *http.Request) error {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
mws.logger.WithError(err).Error("Failed to upgrade WebSocket connection")
return err
}
mws.mutex.Lock()
mws.clients[conn] = true
mws.mutex.Unlock()
mws.logger.Debug("Mock WebSocket client connected")
// Send current cluster state to newly connected client
go mws.sendCurrentClusterState(conn)
// Handle client messages and disconnection
go mws.handleClient(conn)
return nil
}
// handleClient handles messages from a WebSocket client
func (mws *MockWebSocketServer) handleClient(conn *websocket.Conn) {
defer func() {
mws.mutex.Lock()
delete(mws.clients, conn)
mws.mutex.Unlock()
conn.Close()
mws.logger.Debug("Mock WebSocket client disconnected")
}()
// Set read deadline and pong handler
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Start ping routine
go func() {
ticker := time.NewTicker(54 * time.Second)
defer ticker.Stop()
for {
select {
case <-mws.shutdownChan:
return
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}()
// Read messages
for {
_, _, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
mws.logger.WithError(err).Error("WebSocket error")
}
break
}
}
}
// sendCurrentClusterState sends the current cluster state to a newly connected client
func (mws *MockWebSocketServer) sendCurrentClusterState(conn *websocket.Conn) {
nodes := mws.discovery.GetNodes()
if len(nodes) == 0 {
return
}
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
message := struct {
Type string `json:"type"`
Members interface{} `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Type: "cluster_update",
Members: members,
PrimaryNode: mws.discovery.GetPrimaryNode(),
TotalNodes: len(nodes),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal cluster data")
return
}
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send initial cluster state")
}
}
// handleNodeUpdate is called when node information changes
func (mws *MockWebSocketServer) handleNodeUpdate(nodeIP, action string) {
mws.logger.WithFields(log.Fields{
"node_ip": nodeIP,
"action": action,
}).Debug("Mock node update received, broadcasting to WebSocket clients")
// Broadcast cluster update
mws.BroadcastClusterUpdate()
// Also broadcast node discovery event
mws.broadcastNodeDiscovery(nodeIP, action)
}
// startPeriodicBroadcasts sends periodic updates to keep clients informed
func (mws *MockWebSocketServer) startPeriodicBroadcasts() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-mws.shutdownChan:
return
case <-ticker.C:
mws.BroadcastClusterUpdate()
}
}
}
// BroadcastClusterUpdate sends cluster updates to all connected clients
func (mws *MockWebSocketServer) BroadcastClusterUpdate() {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
nodes := mws.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
message := struct {
Type string `json:"type"`
Members interface{} `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Type: "cluster_update",
Members: members,
PrimaryNode: mws.discovery.GetPrimaryNode(),
TotalNodes: len(nodes),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal cluster update")
return
}
mws.logger.WithField("clients", len(clients)).Debug("Broadcasting mock cluster update")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send cluster update to client")
}
}
}
// broadcastNodeDiscovery sends node discovery events to all clients
func (mws *MockWebSocketServer) broadcastNodeDiscovery(nodeIP, action string) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
Action string `json:"action"`
NodeIP string `json:"nodeIp"`
Timestamp string `json:"timestamp"`
}{
Type: "node_discovery",
Action: action,
NodeIP: nodeIP,
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal node discovery event")
return
}
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send node discovery event to client")
}
}
}
// BroadcastFirmwareUploadStatus sends firmware upload status updates to all clients
func (mws *MockWebSocketServer) BroadcastFirmwareUploadStatus(nodeIP, status, filename string, fileSize int) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
Filename string `json:"filename"`
FileSize int `json:"fileSize"`
Timestamp string `json:"timestamp"`
}{
Type: "firmware_upload_status",
NodeIP: nodeIP,
Status: status,
Filename: filename,
FileSize: fileSize,
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal firmware upload status")
return
}
mws.logger.WithFields(log.Fields{
"node_ip": nodeIP,
"status": status,
"clients": len(clients),
}).Debug("Broadcasting mock firmware upload status")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send firmware upload status to client")
}
}
}
// BroadcastRolloutProgress sends rollout progress updates to all clients
func (mws *MockWebSocketServer) BroadcastRolloutProgress(rolloutID, nodeIP, status string, current, total int) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
RolloutID string `json:"rolloutId"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
Current int `json:"current"`
Total int `json:"total"`
Progress int `json:"progress"`
Timestamp string `json:"timestamp"`
}{
Type: "rollout_progress",
RolloutID: rolloutID,
NodeIP: nodeIP,
Status: status,
Current: current,
Total: total,
Progress: calculateProgress(current, total, status),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal rollout progress")
return
}
mws.logger.WithFields(log.Fields{
"rollout_id": rolloutID,
"node_ip": nodeIP,
"status": status,
"progress": fmt.Sprintf("%d/%d", current, total),
}).Debug("Broadcasting mock rollout progress")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send rollout progress to client")
}
}
}
// calculateProgress calculates the correct progress percentage based on current status
func calculateProgress(current, total int, status string) int {
if total == 0 {
return 0
}
// Base progress is based on completed nodes
completedNodes := current - 1
if status == "completed" {
completedNodes = current
}
// Calculate base progress (completed nodes / total nodes)
baseProgress := float64(completedNodes) / float64(total) * 100
// If currently updating labels or uploading, add partial progress for the current node
if status == "updating_labels" {
nodeProgress := 100.0 / float64(total) * 0.25
baseProgress += nodeProgress
} else if status == "uploading" {
nodeProgress := 100.0 / float64(total) * 0.5
baseProgress += nodeProgress
}
// Ensure we don't exceed 100%
if baseProgress > 100 {
baseProgress = 100
}
return int(baseProgress)
}
// GetClientCount returns the number of connected WebSocket clients
func (mws *MockWebSocketServer) GetClientCount() int {
mws.mutex.RLock()
defer mws.mutex.RUnlock()
return len(mws.clients)
}
// Shutdown gracefully shuts down the WebSocket server
func (mws *MockWebSocketServer) Shutdown(ctx context.Context) error {
mws.shutdownOnce.Do(func() {
mws.logger.Info("Shutting down mock WebSocket server")
close(mws.shutdownChan)
mws.mutex.Lock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.Unlock()
// Close all client connections
for _, client := range clients {
client.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, "Server shutting down"))
client.Close()
}
})
return nil
}