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() }