377 lines
11 KiB
Go
377 lines
11 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)
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get cluster status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to decode cluster status response: %w", err)
|
|
}
|
|
|
|
return &clusterStatus, nil
|
|
}
|
|
|
|
// GetTaskStatus retrieves task status information
|
|
func (c *SporeClient) GetTaskStatus() (*TaskStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/api/tasks/status", c.BaseURL)
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get task status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to decode task status response: %w", err)
|
|
}
|
|
|
|
return &taskStatus, nil
|
|
}
|
|
|
|
// GetSystemStatus retrieves system status information
|
|
func (c *SporeClient) GetSystemStatus() (*SystemStatusResponse, error) {
|
|
url := fmt.Sprintf("%s/api/node/status", c.BaseURL)
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get system status: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to decode system status response: %w", err)
|
|
}
|
|
|
|
return &systemStatus, nil
|
|
}
|
|
|
|
// GetCapabilities retrieves available API endpoints
|
|
func (c *SporeClient) GetCapabilities() (*CapabilitiesResponse, error) {
|
|
url := fmt.Sprintf("%s/api/node/endpoints", c.BaseURL)
|
|
|
|
resp, err := c.HTTPClient.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get capabilities: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to decode capabilities response: %w", err)
|
|
}
|
|
|
|
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)
|
|
|
|
// Create multipart form
|
|
var requestBody bytes.Buffer
|
|
contentType := createMultipartForm(&requestBody, firmwareData, filename)
|
|
|
|
if contentType == "" {
|
|
return nil, fmt.Errorf("failed to create multipart form")
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, &requestBody)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
resp, err := firmwareClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to upload firmware: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("firmware update failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var updateResponse FirmwareUpdateResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&updateResponse); err != nil {
|
|
return nil, fmt.Errorf("failed to decode firmware update response: %w", err)
|
|
}
|
|
|
|
return &updateResponse, 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)
|
|
|
|
// Parse parameters and build request
|
|
req, err := c.buildProxyRequest(method, targetURL, params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build proxy request: %w", err)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("proxy call failed: %w", err)
|
|
}
|
|
|
|
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()
|
|
}
|