600 lines
18 KiB
Go
600 lines
18 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// SporeClient represents a client for communicating with SPORE nodes
|
|
type SporeClient struct {
|
|
BaseURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewSporeClient creates a new SPORE API client
|
|
func NewSporeClient(baseURL string) *SporeClient {
|
|
return &SporeClient{
|
|
BaseURL: baseURL,
|
|
HTTPClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ClusterStatusResponse represents the response from /api/cluster/members
|
|
type ClusterStatusResponse struct {
|
|
Members []ClusterMember `json:"members"`
|
|
}
|
|
|
|
// ClusterMember represents a member in the cluster
|
|
type ClusterMember struct {
|
|
IP string `json:"ip"`
|
|
Hostname string `json:"hostname"`
|
|
Status string `json:"status"`
|
|
Latency int64 `json:"latency"`
|
|
LastSeen int64 `json:"lastSeen"`
|
|
Labels map[string]string `json:"labels"`
|
|
Resources map[string]interface{} `json:"resources"`
|
|
}
|
|
|
|
// TaskStatusResponse represents the response from /api/tasks/status
|
|
type TaskStatusResponse struct {
|
|
Summary TaskSummary `json:"summary"`
|
|
Tasks []TaskInfo `json:"tasks"`
|
|
System SystemInfo `json:"system"`
|
|
}
|
|
|
|
// TaskSummary represents task summary information
|
|
type TaskSummary struct {
|
|
TotalTasks int `json:"totalTasks"`
|
|
ActiveTasks int `json:"activeTasks"`
|
|
}
|
|
|
|
// TaskInfo represents information about a task
|
|
type TaskInfo struct {
|
|
Name string `json:"name"`
|
|
Interval int `json:"interval"`
|
|
Enabled bool `json:"enabled"`
|
|
Running bool `json:"running"`
|
|
AutoStart bool `json:"autoStart"`
|
|
}
|
|
|
|
// SystemInfo represents system information
|
|
type SystemInfo struct {
|
|
FreeHeap int64 `json:"freeHeap"`
|
|
Uptime int64 `json:"uptime"`
|
|
}
|
|
|
|
// SystemStatusResponse represents the response from /api/node/status
|
|
type SystemStatusResponse struct {
|
|
FreeHeap int64 `json:"freeHeap"`
|
|
ChipID int64 `json:"chipId"`
|
|
SDKVersion string `json:"sdkVersion"`
|
|
CPUFreqMHz int `json:"cpuFreqMHz"`
|
|
FlashChipSize int64 `json:"flashChipSize"`
|
|
Labels map[string]string `json:"labels"`
|
|
}
|
|
|
|
// CapabilitiesResponse represents the response from /api/node/endpoints
|
|
type CapabilitiesResponse struct {
|
|
Endpoints []EndpointInfo `json:"endpoints"`
|
|
}
|
|
|
|
// EndpointInfo represents information about an API endpoint
|
|
type EndpointInfo struct {
|
|
URI string `json:"uri"`
|
|
Method string `json:"method"`
|
|
Parameters []ParameterInfo `json:"params"`
|
|
}
|
|
|
|
// ParameterInfo represents information about a parameter
|
|
type ParameterInfo struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Required bool `json:"required"`
|
|
Description string `json:"description"`
|
|
Location string `json:"location"` // query, path, body
|
|
Default string `json:"default,omitempty"`
|
|
Values []string `json:"values,omitempty"`
|
|
}
|
|
|
|
// FirmwareUpdateResponse represents the response from firmware update
|
|
type FirmwareUpdateResponse struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// GetClusterStatus retrieves cluster member information
|
|
func (c *SporeClient) GetClusterStatus() (*ClusterStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/api/cluster/members", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/cluster/members",
|
|
}).Debug("Fetching cluster status from SPORE node")
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to fetch cluster status from SPORE node")
|
|
return nil, fmt.Errorf("failed to get cluster status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
}).Debug("Cluster status request returned non-OK status")
|
|
return nil, fmt.Errorf("cluster status request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
var clusterStatus ClusterStatusResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&clusterStatus); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to decode cluster status response")
|
|
return nil, fmt.Errorf("failed to decode cluster status response: %w", err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"member_count": len(clusterStatus.Members),
|
|
}).Debug("Successfully fetched cluster status from SPORE node")
|
|
|
|
return &clusterStatus, nil
|
|
}
|
|
|
|
// GetTaskStatus retrieves task status information
|
|
func (c *SporeClient) GetTaskStatus() (*TaskStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/api/tasks/status", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/tasks/status",
|
|
}).Debug("Fetching task status from SPORE node")
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to fetch task status from SPORE node")
|
|
return nil, fmt.Errorf("failed to get task status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
}).Debug("Task status request returned non-OK status")
|
|
return nil, fmt.Errorf("task status request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
var taskStatus TaskStatusResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&taskStatus); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to decode task status response")
|
|
return nil, fmt.Errorf("failed to decode task status response: %w", err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"total_tasks": taskStatus.Summary.TotalTasks,
|
|
"active_tasks": taskStatus.Summary.ActiveTasks,
|
|
}).Debug("Successfully fetched task status from SPORE node")
|
|
|
|
return &taskStatus, nil
|
|
}
|
|
|
|
// GetSystemStatus retrieves system status information
|
|
func (c *SporeClient) GetSystemStatus() (*SystemStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/api/node/status", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/node/status",
|
|
}).Debug("Fetching system status from SPORE node")
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to fetch system status from SPORE node")
|
|
return nil, fmt.Errorf("failed to get system status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
}).Debug("System status request returned non-OK status")
|
|
return nil, fmt.Errorf("system status request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
var systemStatus SystemStatusResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&systemStatus); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to decode system status response")
|
|
return nil, fmt.Errorf("failed to decode system status response: %w", err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"free_heap": systemStatus.FreeHeap,
|
|
"chip_id": systemStatus.ChipID,
|
|
}).Debug("Successfully fetched system status from SPORE node")
|
|
|
|
return &systemStatus, nil
|
|
}
|
|
|
|
// GetCapabilities retrieves available API endpoints
|
|
func (c *SporeClient) GetCapabilities() (*CapabilitiesResponse, error) {
|
|
url := fmt.Sprintf("%s/api/node/endpoints", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/node/endpoints",
|
|
}).Debug("Fetching capabilities from SPORE node")
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to fetch capabilities from SPORE node")
|
|
return nil, fmt.Errorf("failed to get capabilities: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
}).Debug("Capabilities request returned non-OK status")
|
|
return nil, fmt.Errorf("capabilities request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
var capabilities CapabilitiesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to decode capabilities response")
|
|
return nil, fmt.Errorf("failed to decode capabilities response: %w", err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint_count": len(capabilities.Endpoints),
|
|
}).Debug("Successfully fetched capabilities from SPORE node")
|
|
|
|
return &capabilities, nil
|
|
}
|
|
|
|
// UpdateFirmware uploads firmware to a SPORE node
|
|
func (c *SporeClient) UpdateFirmware(firmwareData []byte, filename string) (*FirmwareUpdateResponse, error) {
|
|
url := fmt.Sprintf("%s/api/node/update", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/node/update",
|
|
"filename": filename,
|
|
"data_size": len(firmwareData),
|
|
}).Debug("Preparing firmware upload to SPORE node")
|
|
|
|
// Create multipart form
|
|
var requestBody bytes.Buffer
|
|
contentType := createMultipartForm(&requestBody, firmwareData, filename)
|
|
|
|
if contentType == "" {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
}).Debug("Failed to create multipart form for firmware upload")
|
|
return nil, fmt.Errorf("failed to create multipart form")
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, &requestBody)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to create firmware update request")
|
|
return nil, fmt.Errorf("failed to create firmware update request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", contentType)
|
|
|
|
// Create a client with extended timeout for firmware uploads
|
|
firmwareClient := &http.Client{
|
|
Timeout: 5 * time.Minute, // 5 minutes for firmware uploads
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"filename": filename,
|
|
"data_size": len(firmwareData),
|
|
}).Debug("Uploading firmware to SPORE node")
|
|
|
|
resp, err := firmwareClient.Do(req)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_ip": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Error("Failed to send firmware request to SPORE device")
|
|
return nil, fmt.Errorf("failed to upload firmware: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_ip": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
"headers": resp.Header,
|
|
}).Debug("Received response from SPORE device")
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Only try to read body for error cases
|
|
body, _ := io.ReadAll(resp.Body)
|
|
log.WithFields(log.Fields{
|
|
"node_ip": c.BaseURL,
|
|
"status": resp.StatusCode,
|
|
"error_body": string(body),
|
|
}).Error("SPORE device reported firmware upload failure")
|
|
return nil, fmt.Errorf("firmware update failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// For successful firmware uploads, don't try to read the response body
|
|
// The SPORE device restarts immediately after sending the response, so reading the body
|
|
// would cause the connection to hang or timeout
|
|
log.WithFields(log.Fields{
|
|
"node_ip": c.BaseURL,
|
|
"status": "success_no_body",
|
|
}).Info("Firmware upload completed successfully (device restarting)")
|
|
|
|
updateResponse := FirmwareUpdateResponse{
|
|
Status: "OK",
|
|
Message: "Firmware update completed successfully",
|
|
}
|
|
|
|
return &updateResponse, nil
|
|
}
|
|
|
|
// UpdateNodeLabels updates the labels on a SPORE node
|
|
func (c *SporeClient) UpdateNodeLabels(labels map[string]string) error {
|
|
targetURL := fmt.Sprintf("%s/api/node/config", c.BaseURL)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"endpoint": "/api/node/config",
|
|
"labels": labels,
|
|
}).Debug("Updating node labels on SPORE node")
|
|
|
|
// Convert labels to JSON
|
|
labelsJSON, err := json.Marshal(labels)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to marshal labels")
|
|
return fmt.Errorf("failed to marshal labels: %w", err)
|
|
}
|
|
|
|
// Create form data
|
|
data := url.Values{}
|
|
data.Set("labels", string(labelsJSON))
|
|
|
|
req, err := http.NewRequest("POST", targetURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to create labels update request")
|
|
return fmt.Errorf("failed to create labels update request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to update node labels")
|
|
return fmt.Errorf("failed to update node labels: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"status_code": resp.StatusCode,
|
|
"error_body": string(body),
|
|
}).Debug("Node labels update returned non-OK status")
|
|
return fmt.Errorf("node labels update failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"labels": labels,
|
|
}).Debug("Successfully updated node labels on SPORE node")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProxyCall makes a generic HTTP request to a SPORE node endpoint
|
|
func (c *SporeClient) ProxyCall(method, uri string, params map[string]interface{}) (*http.Response, error) {
|
|
// Build target URL
|
|
targetURL := fmt.Sprintf("%s%s", c.BaseURL, uri)
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"method": method,
|
|
"endpoint": uri,
|
|
"param_count": len(params),
|
|
}).Debug("Making proxy call to SPORE node")
|
|
|
|
// Parse parameters and build request
|
|
req, err := c.buildProxyRequest(method, targetURL, params)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"method": method,
|
|
"endpoint": uri,
|
|
"error": err.Error(),
|
|
}).Debug("Failed to build proxy request")
|
|
return nil, fmt.Errorf("failed to build proxy request: %w", err)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"method": method,
|
|
"endpoint": uri,
|
|
"error": err.Error(),
|
|
}).Debug("Proxy call failed")
|
|
return nil, fmt.Errorf("proxy call failed: %w", err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"node_url": c.BaseURL,
|
|
"method": method,
|
|
"endpoint": uri,
|
|
"status_code": resp.StatusCode,
|
|
}).Debug("Proxy call completed successfully")
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// buildProxyRequest builds an HTTP request for proxy calls
|
|
func (c *SporeClient) buildProxyRequest(method, targetURL string, params map[string]interface{}) (*http.Request, error) {
|
|
var body io.Reader
|
|
var contentType string
|
|
|
|
if method != "GET" && params != nil {
|
|
// Use form-encoded data for all body parameters (matching index-standalone.js behavior)
|
|
form := make(map[string][]string)
|
|
query := make(map[string][]string)
|
|
|
|
for name, value := range params {
|
|
location := "body"
|
|
|
|
// Check if value is a parameter object with location info
|
|
if paramObj, ok := value.(map[string]interface{}); ok {
|
|
if loc, exists := paramObj["location"].(string); exists {
|
|
location = loc
|
|
}
|
|
// Extract the actual value
|
|
if val, exists := paramObj["value"]; exists {
|
|
value = val
|
|
}
|
|
}
|
|
|
|
switch location {
|
|
case "query":
|
|
query[name] = append(query[name], fmt.Sprintf("%v", value))
|
|
case "path":
|
|
// Replace {name} or :name in path
|
|
placeholder := fmt.Sprintf("{%s}", name)
|
|
if strings.Contains(targetURL, placeholder) {
|
|
targetURL = strings.ReplaceAll(targetURL, placeholder, fmt.Sprintf("%v", value))
|
|
}
|
|
placeholder = fmt.Sprintf(":%s", name)
|
|
if strings.Contains(targetURL, placeholder) {
|
|
targetURL = strings.ReplaceAll(targetURL, placeholder, fmt.Sprintf("%v", value))
|
|
}
|
|
default:
|
|
// Use form-encoded data for all body parameters (matching index-standalone.js behavior)
|
|
// This is simpler and more reliable than trying to detect JSON automatically
|
|
form[name] = append(form[name], fmt.Sprintf("%v", value))
|
|
}
|
|
}
|
|
|
|
// Add query parameters to URL
|
|
if len(query) > 0 {
|
|
urlObj, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid target URL: %w", err)
|
|
}
|
|
|
|
q := urlObj.Query()
|
|
for key, values := range query {
|
|
for _, value := range values {
|
|
q.Add(key, value)
|
|
}
|
|
}
|
|
urlObj.RawQuery = q.Encode()
|
|
targetURL = urlObj.String()
|
|
}
|
|
|
|
// Create request body - always use form-encoded data for consistency
|
|
if len(form) > 0 {
|
|
data := url.Values{}
|
|
for key, values := range form {
|
|
for _, value := range values {
|
|
data.Add(key, value)
|
|
}
|
|
}
|
|
body = strings.NewReader(data.Encode())
|
|
contentType = "application/x-www-form-urlencoded"
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest(method, targetURL, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
if method != "GET" && contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// Helper function to create multipart form for file uploads
|
|
func createMultipartForm(requestBody *bytes.Buffer, firmwareData []byte, filename string) string {
|
|
writer := multipart.NewWriter(requestBody)
|
|
|
|
// Add file field
|
|
fileWriter, err := writer.CreateFormFile("firmware", filename)
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to create form file")
|
|
return ""
|
|
}
|
|
|
|
_, err = fileWriter.Write(firmwareData)
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to write file data")
|
|
return ""
|
|
}
|
|
|
|
err = writer.Close()
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to close multipart writer")
|
|
return ""
|
|
}
|
|
|
|
return writer.FormDataContentType()
|
|
}
|