mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
improved edge cases for versioning system and implemented tests
This commit is contained in:
@@ -26,7 +26,7 @@ func (h *ComposeHandler) GetNodeCompose(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
hostFallback := "https://" + r.Host
|
||||
yamlData, err := h.service.GetNodeCompose(token, hostFallback)
|
||||
yamlData, err := h.service.GetNodeCompose(token, hostFallback, HubAPIVersion)
|
||||
if err != nil {
|
||||
if err.Error() == "unauthorized" {
|
||||
RespondError(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
@@ -2,10 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
@@ -108,8 +108,11 @@ func AgentAuthMiddleware(auth NodeAuthenticator, rateLimiter *RateLimiter) func(
|
||||
// Version handshake
|
||||
wizardMinHubAPIStr := r.Header.Get("X-Wizard-Min-Hub-Api")
|
||||
if wizardMinHubAPIStr != "" {
|
||||
var wizardMinHubAPI int
|
||||
fmt.Sscanf(wizardMinHubAPIStr, "%d", &wizardMinHubAPI)
|
||||
wizardMinHubAPI, err := strconv.Atoi(strings.TrimSpace(wizardMinHubAPIStr))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid X-Wizard-Min-Hub-Api format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if HubAPIVersion < wizardMinHubAPI {
|
||||
http.Error(w, "This Wizard requires Hub v"+wizardMinHubAPIStr+" or later. Please update your Hub.", http.StatusUpgradeRequired)
|
||||
return
|
||||
|
||||
@@ -121,6 +121,17 @@ func TestAgentAuthMiddleware(t *testing.T) {
|
||||
freshHandler.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("Wizard Version Mismatch", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/", nil)
|
||||
req.Header.Set("X-Api-Key", "agent-key")
|
||||
// Simulate a Wizard requesting a highly futuristic Hub API version
|
||||
req.Header.Set("X-Wizard-Min-Hub-Api", "99")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
// Should return HTTP 426 Upgrade Required
|
||||
assert.Equal(t, http.StatusUpgradeRequired, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDualAuthMiddleware(t *testing.T) {
|
||||
|
||||
@@ -107,7 +107,7 @@ func (h *SensorHandler) ToggleSilence(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GetManifests fetches the sensor manifest JSON.
|
||||
func (h *SensorHandler) GetManifests(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := h.composeService.FetchManifestBytes()
|
||||
body, err := h.composeService.FetchManifestBytes(HubAPIVersion)
|
||||
if err != nil {
|
||||
RespondError(w, "Failed to reach manifest registry", http.StatusBadGateway)
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
|
||||
@@ -23,10 +24,22 @@ type Store interface {
|
||||
SetNodeDesiredRevision(nodeID, rev string) error
|
||||
}
|
||||
|
||||
type RegistryIndex struct {
|
||||
Sensors []struct {
|
||||
ID string `json:"id"`
|
||||
Latest string `json:"latest"`
|
||||
Versions []struct {
|
||||
V string `json:"v"`
|
||||
MinHubAPI string `json:"min_hub_api"`
|
||||
} `json:"versions"`
|
||||
} `json:"sensors"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
cache map[string]models.SensorManifest
|
||||
mu sync.RWMutex
|
||||
store Store
|
||||
cache map[string]models.SensorManifest
|
||||
indexCache *RegistryIndex
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
@@ -54,45 +67,67 @@ type PreviewRequest struct {
|
||||
|
||||
// --- MANIFEST FETCHING ---
|
||||
|
||||
func (s *Service) FetchManifestBytes() ([]byte, error) {
|
||||
manifests, err := s.fetchStrictCatalogManifests()
|
||||
func (s *Service) FetchManifestBytes(currentHubAPI int) ([]byte, error) {
|
||||
manifests, err := s.fetchStrictCatalogManifests(currentHubAPI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(manifests)
|
||||
}
|
||||
|
||||
func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error) {
|
||||
func (s *Service) fetchStrictCatalogManifests(currentHubAPI int) ([]models.SensorManifest, error) {
|
||||
registryURL, err := s.store.GetConfigValue("registry_url")
|
||||
if err != nil || registryURL == "" {
|
||||
registryURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages"
|
||||
registryURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages" // TODO implement the default in the store level. and check that url actually works...
|
||||
}
|
||||
|
||||
indexURL := strings.TrimRight(registryURL, "/") + "/index.json"
|
||||
var idx RegistryIndex
|
||||
|
||||
resp, err := http.Get(indexURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("registry returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var idx struct {
|
||||
Sensors []struct {
|
||||
ID string `json:"id"`
|
||||
Latest string `json:"latest"`
|
||||
} `json:"sensors"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
|
||||
return nil, err
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[WARNING] Registry fetch failed (err: %v), falling back to index cache.", err)
|
||||
s.mu.RLock()
|
||||
cachedIdx := s.indexCache
|
||||
s.mu.RUnlock()
|
||||
if cachedIdx == nil {
|
||||
return nil, fmt.Errorf("registry unreachable and no local index cache available")
|
||||
}
|
||||
idx = *cachedIdx
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update index cache
|
||||
s.mu.Lock()
|
||||
s.indexCache = &idx
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
var result []models.SensorManifest
|
||||
|
||||
for _, sensor := range idx.Sensors {
|
||||
cacheKey := sensor.ID + "-v" + sensor.Latest
|
||||
targetVersion := ""
|
||||
|
||||
// Find the highest version where min_hub_api <= currentHubAPI
|
||||
// Assuming versions are ordered [lowest -> highest] by the build script
|
||||
for i := len(sensor.Versions) - 1; i >= 0; i-- {
|
||||
if reqAPI, err := strconv.Atoi(strings.TrimSpace(sensor.Versions[i].MinHubAPI)); err == nil {
|
||||
if currentHubAPI >= reqAPI {
|
||||
targetVersion = sensor.Versions[i].V
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetVersion == "" {
|
||||
log.Printf("[WARNING] No compatible version found for sensor %s", sensor.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
cacheKey := sensor.ID + "-v" + targetVersion
|
||||
s.mu.RLock()
|
||||
cached, ok := s.cache[cacheKey]
|
||||
s.mu.RUnlock()
|
||||
@@ -103,7 +138,7 @@ func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error)
|
||||
}
|
||||
|
||||
sensorName := strings.TrimPrefix(sensor.ID, "hw-sensor-")
|
||||
manifestURL := fmt.Sprintf("%s/%s-v%s.json", strings.TrimRight(registryURL, "/"), sensorName, sensor.Latest)
|
||||
manifestURL := fmt.Sprintf("%s/%s-v%s.json", strings.TrimRight(registryURL, "/"), sensorName, targetVersion)
|
||||
|
||||
mResp, fetchErr := http.Get(manifestURL)
|
||||
if fetchErr != nil {
|
||||
@@ -135,7 +170,7 @@ func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error)
|
||||
|
||||
// --- GENERATION LOGIC ---
|
||||
|
||||
func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
|
||||
func (s *Service) GetNodeCompose(token, hostFallback string, currentHubAPI int) ([]byte, error) {
|
||||
nodeID, err := s.store.GetNodeByKey(token)
|
||||
if err != nil || nodeID == "" {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
@@ -165,7 +200,7 @@ func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
|
||||
effectiveRevision = node.GenerateRevisionHash(nodeDetails.InstalledSensors)
|
||||
}
|
||||
|
||||
manifests, fetchErr := s.fetchStrictCatalogManifests()
|
||||
manifests, fetchErr := s.fetchStrictCatalogManifests(currentHubAPI)
|
||||
if fetchErr != nil {
|
||||
log.Printf("[ERROR] fetchStrictCatalogManifests failed: %v", fetchErr)
|
||||
return nil, fmt.Errorf("failed_to_fetch")
|
||||
@@ -189,6 +224,15 @@ func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("invalid_manifest: %w", valErr)
|
||||
}
|
||||
|
||||
if manifest.MinHubAPI != "" {
|
||||
if minAPI, err := strconv.Atoi(strings.TrimSpace(manifest.MinHubAPI)); err == nil {
|
||||
if currentHubAPI < minAPI {
|
||||
log.Printf("[ERROR] Sensor %s requires Hub API %d, but Hub is running API %d", sensor.ID, minAPI, currentHubAPI)
|
||||
return nil, fmt.Errorf("incompatible_sensor: %s requires Hub API %d", sensor.ID, minAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userVars := make(map[string]string)
|
||||
for k, v := range sensor.EnvVars {
|
||||
if str, ok := v.(string); ok {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package composesvc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/honeywire/hub/internal/models"
|
||||
composesvc "github.com/honeywire/hub/internal/services/compose"
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
RegistryURL string
|
||||
}
|
||||
|
||||
func (m *MockStore) GetConfigValue(key string) (string, error) {
|
||||
if key == "registry_url" {
|
||||
return m.RegistryURL, nil
|
||||
}
|
||||
if key == "hub_endpoint" {
|
||||
return "http://localhost:8080", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockStore) GetNodeByKey(token string) (string, error) {
|
||||
return "node-1", nil
|
||||
}
|
||||
|
||||
func (m *MockStore) GetNodeDetails(nodeID string) (*models.Node, error) {
|
||||
return &models.Node{
|
||||
ID: "node-1",
|
||||
InstalledSensors: []models.NodeSensor{
|
||||
{ID: "hw-sensor-test"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) SetNodeDesiredRevision(nodeID, rev string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestComposeSmartVersionSelection(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/index.json" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"sensors": []map[string]interface{}{
|
||||
{
|
||||
"id": "hw-sensor-test",
|
||||
"latest": "2.0.0",
|
||||
"versions": []map[string]string{
|
||||
{"v": "1.0.0", "min_hub_api": "1"},
|
||||
{"v": "1.5.0", "min_hub_api": " 2 "}, // Injecting malicious whitespace
|
||||
{"v": "2.0.0", "min_hub_api": " 3"}, // Injecting malicious whitespace
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mock responses for the requested versions
|
||||
version := r.URL.Path[len("/test-v") : len(r.URL.Path)-5] // Extract version from path
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "hw-sensor-test",
|
||||
"version": version,
|
||||
"schema_version": "1.0",
|
||||
"min_hub_api": "1", // Mock doesn't need to match perfectly, just needs to parse
|
||||
"deployment": map[string]interface{}{
|
||||
"image_repository": "test",
|
||||
"image_tag": version,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
store := &MockStore{RegistryURL: ts.URL}
|
||||
svc := composesvc.NewService(store)
|
||||
|
||||
t.Run("Perfect Match Resolution (Hub API 2)", func(t *testing.T) {
|
||||
// Hub API 2 should select v1.5.0, ignoring v2.0.0
|
||||
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
if !contains(yamlData, "image: test:1.5.0") {
|
||||
t.Errorf("Expected to deploy v1.5.0, got yaml:\n%s", string(yamlData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Legacy Backward Compat (Hub API 4)", func(t *testing.T) {
|
||||
// Hub API 4 should select v2.0.0 because 4 >= 3
|
||||
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
if !contains(yamlData, "image: test:2.0.0") {
|
||||
t.Errorf("Expected to deploy v2.0.0, got yaml:\n%s", string(yamlData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Compatible Version Found", func(t *testing.T) {
|
||||
// Hub API 0 is too old for everything (minimum is 1)
|
||||
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success (generates empty compose, logs warning), got err: %v", err)
|
||||
}
|
||||
|
||||
if contains(yamlData, "image: test:") {
|
||||
t.Errorf("Expected NO sensor to be deployed, but found one in yaml:\n%s", string(yamlData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Whitespace Robust Parsing", func(t *testing.T) {
|
||||
// Even if min_hub_api has spaces like " 3 ", it should parse cleanly and fail on Hub API 2
|
||||
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success, got error: %v", err)
|
||||
}
|
||||
// Because min_hub_api=" 3 " for v2.0.0 parses successfully, Hub API 2 will correctly reject it and fallback to v1.5.0
|
||||
if !contains(yamlData, "image: test:1.5.0") {
|
||||
t.Errorf("Expected fallback to v1.5.0, got yaml:\n%s", string(yamlData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Network Cache Fallback on 502", func(t *testing.T) {
|
||||
// First, do a successful fetch to populate the cache
|
||||
_, _ = svc.GetNodeCompose("dummy", "http://localhost", 2)
|
||||
|
||||
// Now break the registry URL so the next fetch fails completely
|
||||
store.RegistryURL = "http://localhost:1" // guaranteed connection refused
|
||||
|
||||
// Attempt to fetch again. The network will fail, but the cache should save the day!
|
||||
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected cache fallback success, got error: %v", err)
|
||||
}
|
||||
|
||||
if !contains(yamlData, "image: test:1.5.0") {
|
||||
t.Errorf("Expected cached fallback to v1.5.0, got yaml:\n%s", string(yamlData))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(b []byte, s string) bool {
|
||||
return strings.Contains(string(b), s)
|
||||
}
|
||||
Reference in New Issue
Block a user