feat: Implement agent heartbeat with mTLS and node status tracking
This commit is contained in:
108
internal/api/node_status_handler.go
Normal file
108
internal/api/node_status_handler.go
Normal file
@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/store"
|
||||
)
|
||||
|
||||
// NodeStatusRequest represents the data sent by an agent in a heartbeat
|
||||
type NodeStatusRequest struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeUID string `json:"nodeUID"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Resources struct {
|
||||
Capacity map[string]string `json:"capacity"`
|
||||
Allocatable map[string]string `json:"allocatable"`
|
||||
} `json:"resources"`
|
||||
WorkloadInstances []struct {
|
||||
WorkloadName string `json:"workloadName"`
|
||||
Namespace string `json:"namespace"`
|
||||
InstanceID string `json:"instanceID"`
|
||||
ContainerID string `json:"containerID"`
|
||||
ImageID string `json:"imageID"`
|
||||
State string `json:"state"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
HealthStatus string `json:"healthStatus"`
|
||||
Restarts int `json:"restarts"`
|
||||
} `json:"workloadInstances,omitempty"`
|
||||
OverlayNetwork struct {
|
||||
Status string `json:"status"`
|
||||
LastPeerSync string `json:"lastPeerSync"`
|
||||
} `json:"overlayNetwork"`
|
||||
}
|
||||
|
||||
// NewNodeStatusHandler creates a handler for node status updates
|
||||
func NewNodeStatusHandler(stateStore store.StateStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract node name from URL path
|
||||
pathParts := strings.Split(r.URL.Path, "/")
|
||||
if len(pathParts) < 4 {
|
||||
http.Error(w, "Invalid URL path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
nodeName := pathParts[len(pathParts)-2] // /v1alpha1/nodes/{nodeName}/status
|
||||
|
||||
log.Printf("Received status update from node: %s", nodeName)
|
||||
|
||||
// Read and parse the request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read request body: %v", err)
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var statusReq NodeStatusRequest
|
||||
if err := json.Unmarshal(body, &statusReq); err != nil {
|
||||
log.Printf("Failed to parse status request: %v", err)
|
||||
http.Error(w, "Failed to parse status request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the node name in the URL matches the one in the request
|
||||
if statusReq.NodeName != nodeName {
|
||||
log.Printf("Node name mismatch: %s (URL) vs %s (body)", nodeName, statusReq.NodeName)
|
||||
http.Error(w, "Node name mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the node status in etcd
|
||||
nodeStatusKey := fmt.Sprintf("/kat/nodes/status/%s", nodeName)
|
||||
nodeStatus := map[string]interface{}{
|
||||
"lastHeartbeat": time.Now().Unix(),
|
||||
"status": "Ready",
|
||||
"resources": statusReq.Resources,
|
||||
"network": statusReq.OverlayNetwork,
|
||||
}
|
||||
|
||||
// Add workload instances if present
|
||||
if len(statusReq.WorkloadInstances) > 0 {
|
||||
nodeStatus["workloadInstances"] = statusReq.WorkloadInstances
|
||||
}
|
||||
|
||||
nodeStatusData, err := json.Marshal(nodeStatus)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal node status: %v", err)
|
||||
http.Error(w, "Failed to marshal node status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Storing node status in etcd at key: %s", nodeStatusKey)
|
||||
if err := stateStore.Put(r.Context(), nodeStatusKey, nodeStatusData); err != nil {
|
||||
log.Printf("Failed to store node status: %v", err)
|
||||
http.Error(w, "Failed to store node status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Successfully stored status update for node: %s", nodeName)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
108
internal/api/node_status_handler_test.go
Normal file
108
internal/api/node_status_handler_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/dubey/kat/internal/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestNodeStatusHandler(t *testing.T) {
|
||||
// Create mock state store
|
||||
mockStore := new(MockStateStore)
|
||||
mockStore.On("Put", mock.Anything, mock.MatchedBy(func(key string) bool {
|
||||
return key == "/kat/nodes/status/test-node"
|
||||
}), mock.Anything).Return(nil)
|
||||
|
||||
// Create node status handler
|
||||
handler := NewNodeStatusHandler(mockStore)
|
||||
|
||||
// Create test request
|
||||
statusReq := NodeStatusRequest{
|
||||
NodeName: "test-node",
|
||||
NodeUID: "test-uid",
|
||||
Timestamp: time.Now(),
|
||||
Resources: struct {
|
||||
Capacity map[string]string `json:"capacity"`
|
||||
Allocatable map[string]string `json:"allocatable"`
|
||||
}{
|
||||
Capacity: map[string]string{
|
||||
"cpu": "2000m",
|
||||
"memory": "4096Mi",
|
||||
},
|
||||
Allocatable: map[string]string{
|
||||
"cpu": "1800m",
|
||||
"memory": "3800Mi",
|
||||
},
|
||||
},
|
||||
OverlayNetwork: struct {
|
||||
Status string `json:"status"`
|
||||
LastPeerSync string `json:"lastPeerSync"`
|
||||
}{
|
||||
Status: "connected",
|
||||
LastPeerSync: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
reqBody, err := json.Marshal(statusReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal status request: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest("POST", "/v1alpha1/nodes/test-node/status", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler
|
||||
handler(w, req)
|
||||
|
||||
// Check response
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify mock was called
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestNodeStatusHandlerNameMismatch(t *testing.T) {
|
||||
// Create mock state store
|
||||
mockStore := new(MockStateStore)
|
||||
|
||||
// Create node status handler
|
||||
handler := NewNodeStatusHandler(mockStore)
|
||||
|
||||
// Create test request with mismatched node name
|
||||
statusReq := NodeStatusRequest{
|
||||
NodeName: "wrong-node", // This doesn't match the URL path
|
||||
NodeUID: "test-uid",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
reqBody, err := json.Marshal(statusReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal status request: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest("POST", "/v1alpha1/nodes/test-node/status", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler
|
||||
handler(w, req)
|
||||
|
||||
// Check response - should be bad request due to name mismatch
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
// Verify mock was not called
|
||||
mockStore.AssertNotCalled(t, "Put", mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
@ -142,4 +142,5 @@ func (s *Server) RegisterJoinHandler(handler http.HandlerFunc) {
|
||||
// RegisterNodeStatusHandler registers the handler for node status updates
|
||||
func (s *Server) RegisterNodeStatusHandler(handler http.HandlerFunc) {
|
||||
s.router.HandleFunc("POST", "/v1alpha1/nodes/{nodeName}/status", handler)
|
||||
log.Printf("Registered node status handler at /v1alpha1/nodes/{nodeName}/status")
|
||||
}
|
||||
|
Reference in New Issue
Block a user