Compare commits

1 Commits

Author SHA1 Message Date
Patrick Balsiger
3c3fb886a3 feat: mock gateway 2025-10-24 14:24:14 +02:00
29 changed files with 2930 additions and 1814 deletions

View File

@@ -1,6 +0,0 @@
.git
.gitignore
.cursor
*.md
spore-gateway

1
.gitignore vendored
View File

@@ -1 +1,2 @@
spore-gateway
mock-gateway

View File

@@ -1,31 +0,0 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o spore-gateway ./main.go
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/spore-gateway .
# Expose ports
EXPOSE 3001 4210
# Run the application
CMD ["./spore-gateway"]

View File

@@ -1,59 +0,0 @@
.PHONY: build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
# Build the application
build:
go build -o spore-gateway main.go
# Run the application
run:
go run main.go
# Clean build artifacts
clean:
rm -f spore-gateway
# Format code
fmt:
go fmt ./...
# Lint code (requires golangci-lint)
lint:
golangci-lint run
# Install dependencies
deps:
go mod download
go mod tidy
# Docker variables
DOCKER_REGISTRY ?=
IMAGE_NAME = wirelos/spore-gateway
IMAGE_TAG ?= latest
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
# Build Docker image
docker-build:
docker build -t $(FULL_IMAGE_NAME) .
# Run Docker container
docker-run:
docker run --network host --rm $(FULL_IMAGE_NAME)
# Push Docker image
docker-push:
docker push $(FULL_IMAGE_NAME)
# Build multiarch Docker image
docker-build-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.
# Push multiarch Docker image (if not pushed during build)
docker-push-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.

View File

@@ -28,42 +28,10 @@ Options:
HTTP server port (default "3001")
-udp-port string
UDP discovery port (default "4210")
-mqtt string
Enable MQTT integration with server URL (e.g., tcp://localhost:1883)
-log-level string
Log level (debug, info, warn, error) (default "info")
```
### MQTT Integration
The gateway can integrate with an MQTT broker to subscribe to all MQTT topics and forward messages to connected WebSocket clients.
To enable MQTT integration:
```bash
# Basic usage
./spore-gateway -mqtt tcp://localhost:1883
# With authentication (using environment variables)
MQTT_USER=username MQTT_PASSWORD=password ./spore-gateway -mqtt tcp://broker.example.com:1883
```
When enabled, the gateway will:
- Connect to the specified MQTT broker
- Subscribe to all topics (`#`)
- Forward all received messages to connected WebSocket clients with the format:
```json
{
"topic": "sensor/temperature",
"data": "{\"value\": 23.5}",
"timestamp": "2024-01-15T10:30:00Z"
}
```
Environment variables:
- `MQTT_USER`: Username for MQTT broker authentication (optional)
- `MQTT_PASSWORD`: Password for MQTT broker authentication (optional)
## Integration
The spore-gateway works together with the SPORE UI frontend:
@@ -126,19 +94,11 @@ The application follows the same patterns as the original Node.js spore-ui serve
- HTTP middleware for CORS and logging
- WebSocket support for real-time updates
## Documentation
See the `docs/` directory for detailed documentation:
- [MQTT Integration](./docs/MQTT.md) - MQTT message forwarding and integration
- [Rollout Process](./docs/Rollout.md) - Firmware rollout orchestration
- [Testing Tools](./hack/README.md) - Local MQTT broker and testing scripts
## Architecture
- `main.go` - Application entry point
- `internal/discovery/` - UDP-based node discovery
- `internal/server/` - HTTP API server
- `internal/websocket/` - WebSocket server for real-time updates
- `internal/mqtt/` - MQTT client and message forwarding
- `pkg/client/` - SPORE API client
- `pkg/config/` - Configuration management

425
docs/MOCK_GATEWAY.md Normal file
View File

@@ -0,0 +1,425 @@
# Mock Gateway Implementation
This document describes the mock gateway implementation for the SPORE Gateway project.
## Overview
The mock gateway is a fully-featured simulation of the real SPORE Gateway that implements all functionality (WebSocket, REST API) without requiring actual SPORE nodes. It reuses all types defined for the real gateway, ensuring complete API compatibility.
## Project Structure
```
spore-gateway/
├── cmd/
│ └── mock-gateway/
│ ├── main.go # Mock gateway entry point
│ └── README.md # Detailed usage documentation
├── internal/
│ ├── mock/
│ │ ├── discovery.go # Mock node discovery implementation
│ │ ├── server.go # Mock HTTP server with all endpoints
│ │ ├── websocket.go # Mock WebSocket server
│ │ └── data.go # Mock data generators
│ ├── discovery/ # (Reused types)
│ └── websocket/ # (Reused for upgrader)
├── pkg/
│ ├── client/ # (Reused types)
│ ├── config/ # (Reused types)
│ └── registry/ # (Reused types)
└── scripts/
└── run-mock-gateway.sh # Convenient runner script
```
## Key Features
### 1. Complete REST API Implementation
All endpoints from the real gateway are implemented with realistic mock data:
**Discovery Endpoints**:
- `GET /api/discovery/nodes` - Returns all mock nodes
- `POST /api/discovery/refresh` - Mock refresh operation
- `POST /api/discovery/random-primary` - Selects random primary
- `POST /api/discovery/primary/{ip}` - Sets specific primary
**Cluster Endpoints**:
- `GET /api/cluster/members` - Returns mock cluster members
- `POST /api/cluster/refresh` - Mock cluster refresh
- `GET /api/cluster/node/versions` - Returns node versions
- `POST /api/rollout` - Simulates firmware rollout with progress
**Node Endpoints**:
- `GET /api/node/status` - Mock system status
- `GET /api/node/status/{ip}` - Specific node status
- `GET /api/node/endpoints` - Mock API capabilities
- `POST /api/node/update` - Simulated firmware upload
**Task Endpoints**:
- `GET /api/tasks/status` - Mock task scheduler status
**Monitoring Endpoints**:
- `GET /api/monitoring/resources` - Real-time resource monitoring for all nodes
**Proxy Endpoints**:
- `POST /api/proxy-call` - Mock proxied calls
**Registry Endpoints**:
- `GET /api/registry/health` - Mock registry health
- `GET /api/registry/firmware` - Mock firmware list
- `POST /api/registry/firmware` - Mock firmware upload
- `GET /api/registry/firmware/{name}/{version}` - Mock firmware download
- `PUT /api/registry/firmware/{name}/{version}` - Mock metadata update
- `DELETE /api/registry/firmware/{name}/{version}` - Mock deletion
**WebSocket**:
- `GET /ws` - WebSocket endpoint for real-time updates
**Health**:
- `GET /api/health` - Gateway health check
### 2. WebSocket Implementation
The mock gateway implements full WebSocket functionality with real-time broadcasts:
**Broadcast Types**:
- **Cluster Updates**: Periodic and event-driven cluster state updates
- **Node Discovery**: Node join/leave/update events
- **Firmware Upload Status**: Upload progress notifications
- **Rollout Progress**: Step-by-step rollout progress with percentage
**Message Format**: Identical to real gateway for compatibility
### 3. Mock Node Discovery
Simulates node discovery without UDP:
- Configurable number of nodes (default: 5)
- Realistic node data (IP, hostname, labels, metrics)
- Automatic heartbeat simulation
- Primary node selection
- Node status management (active/inactive)
**Generated Nodes**:
- IPs: `192.168.1.100`, `192.168.1.101`, etc.
- Hostnames: `spore-node-1`, `spore-node-2`, etc.
- Labels: version (matches firmware registry), stable, env, zone, type
- Resources: freeHeap, cpuFreqMHz, flashChipSize
- Latency: Random realistic values
- Firmware Distribution: 40% on v1.0.0, 40% on v1.1.0, 20% on v1.2.0 (beta)
### 4. Type Reuse
The mock gateway reuses all types from the real gateway:
**From `internal/discovery`**:
- `NodeInfo` - Node information structure
- `ClusterStatus` - Cluster state
- `NodeStatus` - Node status enum
- `NodeUpdateCallback` - Callback function type
**From `pkg/client`**:
- `ClusterMember` - Cluster member info
- `ClusterStatusResponse` - Cluster status response
- `TaskStatusResponse` - Task status
- `SystemStatusResponse` - System status
- `CapabilitiesResponse` - API capabilities
- `EndpointInfo` - Endpoint information
- `ParameterInfo` - Parameter details
- `FirmwareUpdateResponse` - Firmware update result
**From `pkg/registry`**:
- `FirmwareRecord` - Firmware metadata
- `GroupedFirmware` - Grouped firmware list
- `FirmwareMetadata` - Firmware metadata for uploads
**From `pkg/config`**:
- `Config` - Configuration structure
This ensures complete API compatibility and type safety.
## Usage
### Quick Start
```bash
# Build and run
go build -o mock-gateway cmd/mock-gateway/main.go
./mock-gateway
# Or use the convenience script
./scripts/run-mock-gateway.sh
```
### Configuration Options
```bash
./mock-gateway [options]
-port string
HTTP server port (default "3001")
-mock-nodes int
Number of mock nodes to simulate (default 5)
-heartbeat-rate int
Heartbeat interval in seconds (default 5)
-log-level string
Log level: debug, info, warn, error (default "info")
-enable-ws bool
Enable WebSocket broadcasts (default true)
-config string
Path to configuration file
```
### Example Usage
```bash
# Development with debug logging
./mock-gateway -log-level debug -mock-nodes 3
# Production-like setup
./mock-gateway -port 3001 -mock-nodes 10 -heartbeat-rate 5
# Testing without WebSocket
./mock-gateway -enable-ws=false
# Large cluster simulation
./mock-gateway -mock-nodes 20 -heartbeat-rate 2
```
### Using the Convenience Script
```bash
# Basic usage
./scripts/run-mock-gateway.sh
# With options
./scripts/run-mock-gateway.sh -p 8080 -n 10 -l debug
# Show help
./scripts/run-mock-gateway.sh --help
```
## Mock Data
### Pre-configured Firmware
**spore-firmware**:
- v1.0.0 - Stable, production (40% of nodes)
- v1.1.0 - Stable, production (40% of nodes)
- v1.2.0 - Beta (20% of nodes)
**sensor-firmware**:
- v2.0.0 - Stable, sensor type
- v2.1.0 - Stable, sensor type
Node labels automatically match the firmware versions in the registry, ensuring consistency between running firmware and available versions.
### Simulated Behaviors
**Firmware Uploads**:
- 2-second simulated processing time
- 90% success rate
- WebSocket status broadcasts
- In-memory storage
**Rollouts**:
- Step-by-step simulation:
1. Label update (500ms)
2. Firmware upload (1s)
3. Completion (500ms)
- WebSocket progress broadcasts
- Node version updates
**Node Updates**:
- Periodic heartbeat simulation
- Random metric updates (latency, heap)
- Automatic status management
**Cluster Changes**:
- Automatic primary selection
- Node discovery events
- WebSocket broadcasts
## API Compatibility
The mock gateway is fully compatible with:
- Frontend applications expecting the real gateway API
- Client libraries using the gateway
- Testing frameworks
- CI/CD pipelines
All responses match the real gateway's format and structure.
## Testing Use Cases
### Frontend Development
```bash
# Run mock gateway for UI development
./mock-gateway -port 3001 -mock-nodes 5 -log-level info
```
### Load Testing
```bash
# Simulate large cluster
./mock-gateway -mock-nodes 50 -heartbeat-rate 1
```
### Integration Testing
```bash
# Headless mode for automated tests
./mock-gateway -log-level error -enable-ws=false
```
### Demonstrations
```bash
# Clean output for demos
./mock-gateway -log-level warn -mock-nodes 8
```
## Implementation Details
### Mock Discovery (`internal/mock/discovery.go`)
**Features**:
- In-memory node storage
- Configurable node count
- Automatic heartbeat simulation
- Primary node management
- Callback system for updates
- Thread-safe operations
**Key Functions**:
- `NewMockNodeDiscovery()` - Creates discovery with N nodes
- `Start()` - Begins heartbeat simulation
- `GetNodes()` - Returns all nodes
- `SetPrimaryNode()` - Sets specific primary
- `SelectRandomPrimaryNode()` - Random selection
- `UpdateNodeVersion()` - Updates node version (for rollouts)
### Mock Server (`internal/mock/server.go`)
**Features**:
- All REST endpoints implemented
- CORS middleware
- JSON middleware
- Logging middleware
- In-memory firmware storage
- Rollout simulation
**Key Functions**:
- `NewMockHTTPServer()` - Creates server
- `setupRoutes()` - Configures all endpoints
- `simulateRollout()` - Simulates rollout process
- All endpoint handlers matching real gateway
### Mock WebSocket (`internal/mock/websocket.go`)
**Features**:
- WebSocket connection management
- Periodic broadcasts
- Event-driven broadcasts
- Client heartbeat (ping/pong)
- Graceful shutdown
**Key Functions**:
- `NewMockWebSocketServer()` - Creates WS server
- `HandleWebSocket()` - Handles connections
- `BroadcastClusterUpdate()` - Cluster updates
- `BroadcastFirmwareUploadStatus()` - Upload status
- `BroadcastRolloutProgress()` - Rollout progress
### Mock Data Generators (`internal/mock/data.go`)
**Functions**:
- `GenerateMockClusterMembers()` - Cluster member data
- `GenerateMockTaskStatus()` - Task scheduler status
- `GenerateMockSystemStatus()` - System status
- `GenerateMockCapabilities()` - API endpoints
- `GenerateMockFirmwareList()` - Firmware registry
- `GenerateMockFirmwareBinary()` - Firmware binary
- `GenerateMockProxyResponse()` - Proxy call responses
- `GenerateMockMonitoringResources()` - Comprehensive resource monitoring data
**Monitoring Data Includes**:
- **CPU Metrics**: Frequency, usage percentage, temperature
- **Memory Metrics**: Total, free, used bytes, usage percentage
- **Network Metrics**: Bytes sent/received, packets sent/received, RSSI, signal quality
- **Flash Metrics**: Total, used, free bytes, usage percentage
- **Summary Statistics**: Aggregate averages across all nodes
## Comparison: Real vs Mock Gateway
| Feature | Real Gateway | Mock Gateway |
|---------|-------------|--------------|
| REST API | ✅ All endpoints | ✅ All endpoints |
| WebSocket | ✅ Real-time | ✅ Real-time |
| Type Safety | ✅ Shared types | ✅ Shared types |
| UDP Discovery | ✅ Real UDP | ❌ Simulated |
| SPORE Node Calls | ✅ Real HTTP | ❌ Mocked |
| Registry Service | ✅ External | ❌ In-memory |
| Firmware Storage | ✅ Disk/Registry | ❌ Memory |
| Node Count | Dynamic (real) | Configurable |
| Setup Required | Hardware + Network | None |
| Dependencies | SPORE nodes + Registry | None |
## Advantages of Mock Gateway
1. **No Hardware Required**: Runs without any SPORE nodes
2. **No Network Setup**: No UDP discovery configuration needed
3. **Fast Iteration**: Instant startup and teardown
4. **Predictable**: Consistent, reproducible behavior
5. **Scalable**: Easily simulate hundreds of nodes
6. **Safe**: No risk to actual hardware
7. **Portable**: Runs anywhere Go runs
8. **Complete**: All features implemented
## When to Use Which
**Use Real Gateway**:
- Production deployment
- Real hardware testing
- Actual firmware updates
- Network topology testing
- Performance benchmarking
**Use Mock Gateway**:
- Frontend development
- API client development
- Automated testing
- Demonstrations
- CI/CD pipelines
- Local development
- Load testing
## Maintenance
The mock gateway is designed to stay in sync with the real gateway:
1. **Types**: All types are shared, so changes propagate automatically
2. **Endpoints**: New endpoints should be added to both implementations
3. **Responses**: Mock responses should mirror real responses
4. **Behaviors**: Simulate realistic timing and error rates
## Future Enhancements
Potential improvements:
- [ ] Configuration file support (currently CLI only)
- [ ] Persistent storage option
- [ ] Configurable error injection
- [ ] Network latency simulation
- [ ] Node failure scenarios
- [ ] Custom firmware metadata
- [ ] API recording/playback
- [ ] Performance metrics
## Contributing
When adding new endpoints to the real gateway:
1. Add corresponding mock implementation in `internal/mock/server.go`
2. Add mock data generators in `internal/mock/data.go` if needed
3. Ensure type reuse from shared packages
4. Update this documentation
5. Test both real and mock implementations
## License
Same as main SPORE Gateway project.

View File

@@ -1,370 +0,0 @@
# MQTT Integration
The SPORE Gateway includes optional MQTT integration that allows subscribing to MQTT brokers and forwarding messages to connected WebSocket clients. This enables integration with IoT devices, sensor networks, and other MQTT-based systems.
## Overview
When enabled, the gateway acts as an MQTT subscriber that:
- Connects to an MQTT broker
- Subscribes to all topics (`#`)
- Forwards received messages to WebSocket clients in real-time
This allows the SPORE UI to display MQTT events alongside SPORE cluster events.
## Features
- **Universal Topic Subscription**: Subscribes to `#` (all topics) to capture all messages
- **WebSocket Forwarding**: All MQTT messages are forwarded to connected WebSocket clients
- **Authentication Support**: Optional username/password authentication
- **Automatic Reconnection**: Handles connection failures and automatically reconnects
- **Structured Message Format**: Messages are formatted with topic, data, and timestamp
## Usage
### Basic Usage
Start the gateway with MQTT integration enabled:
```bash
./spore-gateway -mqtt tcp://localhost:1883
```
### With Authentication
If your MQTT broker requires authentication, use environment variables:
```bash
MQTT_USER=username MQTT_PASSWORD=password ./spore-gateway -mqtt tcp://broker.example.com:1883
```
### Complete Example
```bash
# Terminal 1: Start MQTT broker (optional, for testing)
cd hack
./mosquitto.sh
# Terminal 2: Start SPORE gateway with MQTT integration
cd ..
MQTT_USER=admin MQTT_PASSWORD=secret ./spore-gateway -mqtt tcp://localhost:1883
# Terminal 3: Publish test messages
cd hack
./mqtt-test.sh
```
## Message Format
MQTT messages received by the gateway are forwarded to WebSocket clients with the following JSON structure:
```json
{
"topic": "sensor/temperature/living-room",
"data": "{\"temperature\": 23.5, \"unit\": \"celsius\", \"timestamp\": \"2024-01-15T10:30:00Z\"}",
"timestamp": "2024-01-15T10:30:00Z"
}
```
### Fields
- **topic** (string): The MQTT topic the message was published to
- **data** (string): The raw message payload as a string (can be JSON, text, binary data encoded as string, etc.)
- **timestamp** (string): RFC3339 timestamp when the gateway received the message
### Message Payload Handling
The gateway treats all MQTT message payloads as raw data (byte arrays). When forwarding to WebSocket:
- Binary data is converted to string representation
- Text data is forwarded as-is
- JSON data remains as JSON string (not parsed)
This preserves the original message format while allowing the WebSocket client to parse or display it as needed.
## Configuration
### Command Line Flags
| Flag | Description | Example |
|------|-------------|---------|
| `-mqtt` | MQTT broker URL | `tcp://localhost:1883` |
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `MQTT_USER` | Username for MQTT authentication | No |
| `MQTT_PASSWORD` | Password for MQTT authentication | No |
### Broker URLs
Supported URL formats:
- `tcp://hostname:port` - Standard MQTT (e.g., `tcp://localhost:1883`)
- `tcp://hostname` - Uses default port 1883
- `tls://hostname:8883` - Secure MQTT with TLS
Note: TLS support may require additional configuration in the MQTT client.
## Architecture
### Components
1. **MQTT Client** (`internal/mqtt/mqtt.go`)
- Manages connection to MQTT broker
- Handles subscriptions and message reception
- Implements reconnection logic
2. **WebSocket Server** (`internal/websocket/websocket.go`)
- Broadcasting MQTT messages to connected clients
- Serialization and message formatting
3. **Main Application** (`main.go`)
- Coordinates MQTT client initialization
- Sets up message callback for WebSocket forwarding
### Data Flow
```
MQTT Broker → MQTT Client → Callback → HTTP Server → WebSocket Server → Client
```
1. MQTT broker publishes message to any topic
2. Gateway's MQTT client receives message
3. Message callback triggers
4. HTTP server broadcasts to WebSocket
5. WebSocket server forwards to all connected clients
## Testing
### Local Testing Setup
The `hack/` directory contains scripts for testing MQTT integration:
```bash
# Start a local MQTT broker
./hack/mosquitto.sh
# Run comprehensive test suite
./hack/mqtt-test.sh
```
### Test Messages
The test suite includes 16 different message types:
- Simple text messages
- JSON sensor data (temperature, humidity)
- Device status updates
- System events and alerts
- Configuration updates
- Metrics and telemetry
- Node discovery events
- Firmware updates
- Task status
- Error logs
- Light control (SPORE-specific)
- Binary data
- Edge cases (empty messages, large payloads)
### Manual Testing
You can also publish messages manually using the Mosquitto client:
```bash
# Install mosquitto clients
# Ubuntu/Debian: apt-get install mosquitto-clients
# Or use Docker: docker run --rm -it --network host eclipse-mosquitto:latest mosquitto_pub
# Publish a test message
docker run --rm --network host eclipse-mosquitto:latest \
mosquitto_pub -h localhost -p 1883 -t "test/topic" -m "Hello World"
# Publish JSON message
docker run --rm --network host eclipse-mosquitto:latest \
mosquitto_pub -h localhost -p 1883 -t "sensor/data" \
-m '{"sensor": "temperature", "value": 25.5, "unit": "celsius"}'
```
## Integration with SPORE UI
When the SPORE UI connects to the gateway's WebSocket endpoint, it will automatically receive MQTT messages. The UI can handle these messages similarly to SPORE cluster events.
### WebSocket Event Types
The WebSocket receives different event types:
- **Cluster Events**: `cluster/update`, `node/discovery`, etc. (from SPORE nodes)
- **MQTT Events**: Any topic from MQTT (identified by the topic field)
Example WebSocket message from MQTT:
```json
{
"topic": "sensor/temperature",
"data": "23.5",
"timestamp": "2024-01-15T10:30:00Z"
}
```
Example WebSocket message from SPORE cluster:
```json
{
"topic": "cluster/update",
"members": [...],
"primaryNode": "192.168.1.100",
"totalNodes": 3,
"timestamp": "2024-01-15T10:30:00Z"
}
```
## Troubleshooting
### Connection Issues
**Problem**: Gateway fails to connect to MQTT broker
**Solutions**:
```bash
# Check if broker is running
docker ps | grep mqtt-broker
# Check broker logs
docker logs mqtt-broker
# Test connection manually
docker run --rm -it --network host eclipse-mosquitto:latest \
mosquitto_pub -h localhost -p 1883 -t "test" -m "test"
```
### Messages Not Forwarding
**Problem**: MQTT messages not appearing in WebSocket
**Solutions**:
1. Verify gateway is running with MQTT enabled
2. Check gateway logs for MQTT connection status
3. Verify WebSocket client is connected
4. Check MQTT broker logs for subscription confirmation
### Authentication Errors
**Problem**: "Connection refused" or authentication errors
**Solutions**:
```bash
# Ensure environment variables are set
export MQTT_USER=username
export MQTT_PASSWORD=password
# Verify broker allows connections
# Check mosquitto.conf for allow_anonymous or authentication settings
```
### High Message Volume
If receiving many MQTT messages:
- Gateway handles messages efficiently using Go concurrency
- WebSocket broadcasts are serialized to prevent race conditions
- Consider QoS levels if message delivery is critical
## Best Practices
### Topic Naming
Use hierarchical topic names for better organization:
```
sensor/temperature/living-room
sensor/humidity/bedroom
device/status/esp32-001
cluster/node/discovered
```
### Message Size
- Keep individual messages reasonably sized (< 10KB recommended)
- For large data, consider splitting into multiple messages
- Use compression if transmitting large JSON payloads
### Security
- Use authentication for production deployments
- Consider TLS for encrypted connections
- Use topic filtering if subscribing to specific topics only (modify subscription)
- Implement rate limiting on message processing if needed
### Error Handling
The gateway includes automatic reconnection logic:
- Initial connection failures are logged
- Reconnection attempts every 10 seconds
- Connection state is tracked and logged
- WebSocket clients are notified via disconnect events
## Limitations
- **QoS Levels**: Currently uses QoS 0 (at most once delivery)
- **Topic Filtering**: Subscribes to all topics (`#`); no selective subscription
- **Message Retention**: Does not store messages; forwards only real-time events
- **Duplicate Handling**: Does not deduplicate messages
- **Ordering**: Maintains message order within individual WebSocket broadcasts
## Future Enhancements
Potential improvements:
- Configurable QoS levels per topic
- Selective topic subscription via configuration
- Message persistence and replay
- Metrics and monitoring for MQTT integration
- Support for MQTT 5.0 features
## Related Documentation
- [Main README](../README.md) - Overview of SPORE Gateway
- [Rollout Documentation](./Rollout.md) - Firmware rollout process
- [Hack Directory](../hack/README.md) - Testing tools and scripts
## Examples
### Example: IoT Sensor Integration
Connect temperature sensors to the gateway:
```bash
# Start gateway with MQTT
./spore-gateway -mqtt tcp://iot-broker.example.com:1883
# Sensors publish to topics like:
# - sensor/temperature/room1
# - sensor/humidity/room1
# - sensor/light/room1
```
### Example: Device Control
Control SPORE nodes via MQTT:
```bash
# Publish control commands
mosquitto_pub -h broker.example.com -t "spore/control" \
-m '{"node": "esp32-001", "action": "pattern", "pattern": "rainbow"}'
```
### Example: Monitoring Dashboard
Combine SPORE cluster events with external system events:
```bash
# Gateway receives both:
# 1. SPORE cluster events (from UDP discovery)
# 2. External system events (via MQTT)
# UI displays unified event stream
```
## Support
For issues or questions about MQTT integration:
- Check gateway logs for MQTT connection status
- Review MQTT broker configuration
- Use `hack/mqtt-test.sh` for testing
- See [troubleshooting section](#troubleshooting) above

174
docs/QUICKSTART_MOCK.md Normal file
View File

@@ -0,0 +1,174 @@
# Mock Gateway Quick Start Guide
Get started with the SPORE Mock Gateway in under 1 minute!
## TL;DR
```bash
# Build and run
go build -o mock-gateway cmd/mock-gateway/main.go
./mock-gateway
```
That's it! The mock gateway is now running on http://localhost:3001
## What You Get
**5 mock SPORE nodes** ready to use
**All REST API endpoints** working
**WebSocket support** for real-time updates
**Mock firmware registry** with sample firmware
**Simulated rollouts** with progress tracking
## Test It
### 1. Check Health
```bash
curl http://localhost:3001/api/health | jq
```
### 2. List Nodes
```bash
curl http://localhost:3001/api/discovery/nodes | jq
```
### 3. Get Cluster Members
```bash
curl http://localhost:3001/api/cluster/members | jq
```
### 4. List Firmware
```bash
curl http://localhost:3001/api/registry/firmware | jq
```
### 5. Connect WebSocket
```javascript
const ws = new WebSocket('ws://localhost:3001/ws');
ws.onmessage = (event) => console.log(JSON.parse(event.data));
```
## Customize It
```bash
# More nodes
./mock-gateway -mock-nodes 10
# Different port
./mock-gateway -port 8080
# Debug logging
./mock-gateway -log-level debug
# Faster updates
./mock-gateway -heartbeat-rate 2
# All together
./mock-gateway -port 8080 -mock-nodes 10 -log-level debug -heartbeat-rate 2
```
## Use the Script
```bash
# Easy run with default settings
./scripts/run-mock-gateway.sh
# With custom options
./scripts/run-mock-gateway.sh -p 8080 -n 10 -l debug
# See all options
./scripts/run-mock-gateway.sh --help
```
## Connect Your Frontend
Point your frontend to `http://localhost:3001` instead of the real gateway. All endpoints work the same way!
```javascript
// React example
const API_BASE = 'http://localhost:3001';
const WS_URL = 'ws://localhost:3001/ws';
// Fetch nodes
const nodes = await fetch(`${API_BASE}/api/discovery/nodes`).then(r => r.json());
// WebSocket connection
const ws = new WebSocket(WS_URL);
```
## What's Mocked?
✅ Node discovery (5 default nodes: 192.168.1.100-104)
✅ Cluster management
✅ Firmware uploads (90% success rate, 2s delay)
✅ Rollouts (simulated progress: labels → upload → complete)
✅ Task status
✅ System status
✅ Registry operations
✅ WebSocket broadcasts
## Example Workflow
```bash
# 1. Start mock gateway
./mock-gateway
# 2. In another terminal, test the API
curl http://localhost:3001/api/discovery/nodes | jq '.nodes[0]'
# 3. Start a rollout
curl -X POST http://localhost:3001/api/rollout \
-H "Content-Type: application/json" \
-d '{
"firmware": {"name": "spore-firmware", "version": "1.1.0"},
"nodes": [
{"ip": "192.168.1.100", "version": "1.0.0", "labels": {"env": "mock"}},
{"ip": "192.168.1.101", "version": "1.0.0", "labels": {"env": "mock"}}
]
}' | jq
# 4. Watch WebSocket for progress (in browser console)
const ws = new WebSocket('ws://localhost:3001/ws');
ws.onmessage = e => console.log(JSON.parse(e.data));
```
## Next Steps
- 📖 Read [cmd/mock-gateway/README.md](cmd/mock-gateway/README.md) for detailed documentation
- 📖 Read [MOCK_GATEWAY.md](MOCK_GATEWAY.md) for implementation details
- 🔧 Customize the mock data in `internal/mock/data.go`
- 🚀 Use it for frontend development, testing, or demos
## Troubleshooting
**Port already in use?**
```bash
./mock-gateway -port 8080
```
**Need more/fewer nodes?**
```bash
./mock-gateway -mock-nodes 3
```
**Want to see what's happening?**
```bash
./mock-gateway -log-level debug
```
**Build fails?**
```bash
# Make sure you have Go 1.21 or later
go version
# Update dependencies
go mod download
```
## Questions?
- See [MOCK_GATEWAY.md](MOCK_GATEWAY.md) for complete documentation
- See [cmd/mock-gateway/README.md](cmd/mock-gateway/README.md) for usage details
- Check the real gateway's documentation to understand the API
Happy mocking! 🎭

View File

@@ -1,73 +0,0 @@
# SPORE Gateway Documentation
Welcome to the SPORE Gateway documentation. This directory contains detailed documentation for various features and capabilities of the gateway.
## Available Documentation
### [MQTT Integration](./MQTT.md)
Comprehensive guide to the MQTT integration feature, including:
- Setting up MQTT integration
- Message format and handling
- Testing with local MQTT brokers
- Architecture and data flow
- Troubleshooting and best practices
### [Rollout Process](./Rollout.md)
Detailed documentation for the firmware rollout system:
- Parallel firmware updates across multiple nodes
- WebSocket progress updates
- Integration with spore-registry
- API endpoints and message formats
## Quick Links
- **Main README**: [../README.md](../README.md)
- **Hack Directory**: [../hack/README.md](../hack/README.md)
- **Testing Scripts**: [../hack/](../hack/)
## Feature Overview
### Core Features
- UDP-based node discovery
- Cluster management and primary node selection
- HTTP API server for cluster operations
- WebSocket real-time updates
- Failover logic for automatic primary switching
- Generic proxy calls to SPORE nodes
### Integration Features
- **MQTT Integration**: Subscribe to MQTT topics and forward messages to WebSocket clients
- **Firmware Rollout**: Orchestrated firmware updates across the cluster
- **Registry Proxy**: Proxy for spore-registry firmware management
## Getting Started
1. **Basic Setup**: See [Main README](../README.md) for installation and basic usage
2. **MQTT Integration**: See [MQTT.md](./MQTT.md) for MQTT setup and testing
3. **Testing**: See [Hack README](../hack/README.md) for local testing tools
## Development
The gateway is written in Go and follows modern Go best practices:
- Structured logging using logrus
- Graceful shutdown handling
- Concurrent-safe operations
- HTTP middleware for CORS and logging
- WebSocket support for real-time updates
## Contributing
When adding new features:
1. Update relevant documentation in this directory
2. Add examples to the `hack/` directory
3. Update the main README with feature highlights
4. Follow the existing documentation style and structure
## Support
For questions or issues:
- Check the relevant documentation in this directory
- Review gateway logs for error messages
- Use testing tools in the `hack/` directory
- Check the main README for troubleshooting tips

198
docs/monitoring-example.md Normal file
View File

@@ -0,0 +1,198 @@
# Monitoring Resources Endpoint
## Overview
The `/api/monitoring/resources` endpoint provides comprehensive real-time resource monitoring for all nodes in the cluster.
## Endpoint
```
GET /api/monitoring/resources
```
## Response Format
```json
{
"timestamp": "2025-10-24T10:30:45Z",
"nodes": [
{
"timestamp": 1729763445,
"node_ip": "192.168.1.100",
"hostname": "spore-node-1",
"cpu": {
"frequency_mhz": 160,
"usage_percent": 42.5,
"temperature_c": 58.3
},
"memory": {
"total_bytes": 98304,
"free_bytes": 45632,
"used_bytes": 52672,
"usage_percent": 53.6
},
"network": {
"bytes_sent": 3245678,
"bytes_received": 5678901,
"packets_sent": 32456,
"packets_received": 56789,
"rssi_dbm": -65,
"signal_quality_percent": 75.5
},
"flash": {
"total_bytes": 4194304,
"used_bytes": 2097152,
"free_bytes": 2097152,
"usage_percent": 50.0
},
"labels": {
"version": "1.0.0",
"stable": "true",
"env": "production",
"zone": "zone-1",
"type": "spore-node"
}
}
],
"summary": {
"total_nodes": 5,
"avg_cpu_usage_percent": 38.7,
"avg_memory_usage_percent": 51.2,
"avg_flash_usage_percent": 52.8,
"total_bytes_sent": 16228390,
"total_bytes_received": 28394505
}
}
```
## Data Fields
### CPU Metrics
- **frequency_mhz**: Current CPU frequency in MHz (80-240 MHz typical for ESP32)
- **usage_percent**: CPU utilization percentage (0-100%)
- **temperature_c**: CPU temperature in Celsius (45-65°C typical)
### Memory Metrics
- **total_bytes**: Total RAM available (64-128 KB typical)
- **free_bytes**: Free RAM available
- **used_bytes**: Used RAM
- **usage_percent**: Memory utilization percentage
### Network Metrics
- **bytes_sent**: Total bytes transmitted since boot
- **bytes_received**: Total bytes received since boot
- **packets_sent**: Total packets transmitted
- **packets_received**: Total packets received
- **rssi_dbm**: WiFi signal strength in dBm (-30 to -90 typical)
- **signal_quality_percent**: WiFi signal quality (0-100%)
### Flash Metrics
- **total_bytes**: Total flash storage (typically 4MB)
- **used_bytes**: Used flash storage
- **free_bytes**: Free flash storage
- **usage_percent**: Flash utilization percentage
### Node Labels
Each node includes labels that match firmware versions:
- **version**: Current firmware version (e.g., "1.0.0", "1.1.0", "1.2.0")
- **stable**: Whether this is a stable release ("true" or "false")
- **env**: Environment (e.g., "production", "beta")
- **zone**: Deployment zone (e.g., "zone-1", "zone-2", "zone-3")
- **type**: Node type (e.g., "spore-node")
### Summary Statistics
Aggregate metrics across all nodes:
- **total_nodes**: Total number of nodes monitored
- **avg_cpu_usage_percent**: Average CPU usage across all nodes
- **avg_memory_usage_percent**: Average memory usage across all nodes
- **avg_flash_usage_percent**: Average flash usage across all nodes
- **total_bytes_sent**: Combined network traffic sent
- **total_bytes_received**: Combined network traffic received
## Firmware Version Matching
Node labels are automatically synchronized with the firmware available in the registry:
| Version | Registry Status | Node Distribution | Environment |
|---------|----------------|-------------------|-------------|
| 1.0.0 | Stable | 40% of nodes | production |
| 1.1.0 | Stable | 40% of nodes | production |
| 1.2.0 | Beta | 20% of nodes | beta |
This ensures that monitoring data accurately reflects which firmware versions are deployed across the cluster.
## Use Cases
### 1. Real-time Dashboard
Display live resource usage for all nodes in a monitoring dashboard.
### 2. Alerting
Set up alerts based on thresholds:
- CPU usage > 80%
- Memory usage > 90%
- Flash usage > 95%
- WiFi signal quality < 30%
### 3. Capacity Planning
Track resource trends to plan firmware optimizations or hardware upgrades.
### 4. Firmware Rollout Monitoring
Monitor resource usage before, during, and after firmware rollouts to detect issues.
### 5. Network Health
Track WiFi signal quality and network traffic to identify connectivity issues.
## Example Usage
### cURL
```bash
curl http://localhost:3001/api/monitoring/resources
```
### JavaScript (fetch)
```javascript
const response = await fetch('http://localhost:3001/api/monitoring/resources');
const data = await response.json();
console.log(`Monitoring ${data.summary.total_nodes} nodes`);
console.log(`Average CPU: ${data.summary.avg_cpu_usage_percent.toFixed(1)}%`);
console.log(`Average Memory: ${data.summary.avg_memory_usage_percent.toFixed(1)}%`);
data.nodes.forEach(node => {
console.log(`${node.hostname} (${node.labels.version}): CPU ${node.cpu.usage_percent.toFixed(1)}%`);
});
```
### Python
```python
import requests
response = requests.get('http://localhost:3001/api/monitoring/resources')
data = response.json()
print(f"Monitoring {data['summary']['total_nodes']} nodes")
print(f"Average CPU: {data['summary']['avg_cpu_usage_percent']:.1f}%")
print(f"Average Memory: {data['summary']['avg_memory_usage_percent']:.1f}%")
for node in data['nodes']:
print(f"{node['hostname']} ({node['labels']['version']}): "
f"CPU {node['cpu']['usage_percent']:.1f}%")
```
## Mock Gateway Behavior
The mock gateway generates realistic monitoring data with:
- **Dynamic values**: CPU, memory, and network metrics vary on each request
- **Realistic ranges**: Values stay within typical ESP32 hardware limits
- **Signal quality**: WiFi RSSI converted to quality percentage
- **Consistent labels**: Node labels always match firmware registry versions
- **Aggregate summaries**: Automatic calculation of cluster-wide statistics
## Integration with WebSocket
For real-time updates, consider combining this endpoint with the WebSocket connection at `/ws` which broadcasts:
- Node status changes
- Firmware update progress
- Cluster membership changes
The monitoring endpoint provides detailed point-in-time snapshots, while WebSocket provides real-time event streams.

11
go.mod
View File

@@ -1,18 +1,11 @@
module spore-gateway
go 1.24.0
toolchain go1.24.3
go 1.21
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/sirupsen/logrus v1.9.3
)
require (
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

9
go.sum
View File

@@ -1,8 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -14,13 +12,8 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,130 +0,0 @@
# Hack Directory
This directory contains utility scripts for testing and development of the SPORE Gateway.
## Scripts
### mosquitto.sh
Starts a local Mosquitto MQTT broker using Docker.
**Usage:**
```bash
./mosquitto.sh
```
This will:
- Start a Mosquitto broker on port 1883
- Use the configuration from `mosquitto.conf`
- Allow anonymous connections (no authentication required)
### mqtt-test.sh
Sends various test events to the local MQTT broker to test the gateway's MQTT integration.
**Usage:**
```bash
# Make sure the broker is running first
./mosquitto.sh # In terminal 1
# In another terminal, run the tests
./mqtt-test.sh
```
This script will send 16 different test messages covering:
## Test Message Coverage
The full `mqtt-test.sh` script will send 16 different test messages covering:
- Simple text messages
- JSON sensor data (temperature, humidity)
- Device status updates
- System events and alerts
- Configuration updates
- Metrics
- Cluster/node discovery events
- Firmware updates
- Task status
- Error logs
- Light control (SPORE nodes)
- Binary data
- Edge cases (empty messages, large payloads)
## Testing MQTT Integration
### Complete Test Workflow
1. **Start the MQTT broker:**
```bash
cd hack
./mosquitto.sh
```
2. **In a new terminal, start the SPORE gateway with MQTT enabled:**
```bash
cd /path/to/spore-gateway
./spore-gateway -mqtt tcp://localhost:1883
```
3. **In another terminal, run the test script:**
```bash
cd hack
./mqtt-test.sh
```
4. **Monitor the WebSocket connection** to see the events being forwarded.
You can use a WebSocket client or the SPORE UI to connect to `ws://localhost:3001/ws`.
### Expected Output
All MQTT messages will be forwarded through the WebSocket with this format:
```json
{
"topic": "sensor/temperature/living-room",
"data": "{\"temperature\": 23.5, \"unit\": \"celsius\", \"timestamp\": \"2024-01-15T10:30:00Z\"}",
"timestamp": "2024-01-15T10:30:00Z"
}
```
## Customization
### Using a Different MQTT Broker
You can change the broker URL using the `MQTT_BROKER` environment variable:
```bash
MQTT_BROKER=tcp://broker.example.com:1883 ./mqtt-test.sh
```
### Adding Your Own Test Messages
Edit `mqtt-test.sh` and add your custom test case:
```bash
# Test N: Your custom test
echo -e "${YELLOW}=== Test N: Your Description ===${NC}"
publish_json "your/topic" '{"your": "data"}'
```
## Troubleshooting
### Broker Not Starting
- Make sure Docker is running
- Check if port 1883 is already in use
- Verify the Mosquitto image is available: `docker pull eclipse-mosquitto:latest`
### Messages Not Being Received
- Verify the gateway is running with `-mqtt tcp://localhost:1883`
- Check the gateway logs for connection errors
- Ensure the WebSocket client is connected to `ws://localhost:3001/ws`
### Port Conflicts
If port 1883 is in use, modify `mosquitto.sh` to use a different port:
```bash
-p 1884:1883 # Maps host port 1884 to container port 1883
```
Then update your gateway command:
```bash
./spore-gateway -mqtt tcp://localhost:1884
```

View File

@@ -1,10 +0,0 @@
# -----------------------------
# Basic Mosquitto configuration
# -----------------------------
listener 1883
allow_anonymous true
# (Optional) WebSocket listener if you exposed port 9001 above
# listener 9001
# protocol websockets
# allow_anonymous true

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
docker run --rm -it \
--name mqtt-broker \
-p 1883:1883 \
-v $(pwd)/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro \
eclipse-mosquitto:latest

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# MQTT Test Script for SPORE Gateway
# This script sends various test events to the local MQTT broker
set -e
# Configuration
MQTT_BROKER="${MQTT_BROKER:-tcp://localhost:1883}"
DOCKER_IMAGE="eclipse-mosquitto:latest"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to publish an MQTT message
publish_message() {
local topic="$1"
local payload="$2"
local qos="${3:-0}"
echo -e "${YELLOW}Publishing to topic: ${GREEN}${topic}${NC}"
docker run --rm --network host \
"${DOCKER_IMAGE}" \
mosquitto_pub \
-h localhost \
-p 1883 \
-t "${topic}" \
-m "${payload}" \
-q "${qos}"
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Message sent successfully${NC}"
else
echo -e "${RED}✗ Failed to send message${NC}"
exit 1
fi
echo ""
}
# Function to publish a JSON message
publish_json() {
local topic="$1"
local json="$2"
local qos="${3:-0}"
publish_message "${topic}" "${json}" "${qos}"
}
# Main test execution
echo "============================================"
echo " SPORE Gateway MQTT Test Suite"
echo "============================================"
echo ""
echo "Using MQTT broker: ${MQTT_BROKER}"
echo ""
# Test 1: Simple text message
echo -e "${YELLOW}=== Test 1: Simple Text Message ===${NC}"
publish_message "test/hello" "Hello from MQTT test script!"
# Test 2: Temperature sensor reading
echo -e "${YELLOW}=== Test 2: Temperature Sensor Reading ===${NC}"
publish_json "sensor/temperature/living-room" '{"temperature": 23.5, "unit": "celsius", "timestamp": "2024-01-15T10:30:00Z"}'
# Test 3: Humidity sensor reading
echo -e "${YELLOW}=== Test 3: Humidity Sensor Reading ===${NC}"
publish_json "sensor/humidity/bedroom" '{"humidity": 45.2, "unit": "percent", "timestamp": "2024-01-15T10:30:05Z"}'
# Test 4: Device status
echo -e "${YELLOW}=== Test 4: Device Status Update ===${NC}"
publish_json "device/status/esp32-001" '{"id": "esp32-001", "status": "online", "uptime": 3600, "firmware": "v1.2.3"}'
# Test 5: System event
echo -e "${YELLOW}=== Test 5: System Event ===${NC}"
publish_json "system/event" '{"type": "startup", "message": "Gateway started successfully", "timestamp": "2024-01-15T10:30:10Z"}'
# Test 6: Alert message
echo -e "${YELLOW}=== Test 6: Alert Message ===${NC}"
publish_json "alert/high-temperature" '{"level": "warning", "message": "Temperature exceeded threshold", "value": 35.5, "threshold": 30.0}'
# Test 7: Configuration update
echo -e "${YELLOW}=== Test 7: Configuration Update ===${NC}"
publish_json "config/update" '{"section": "network", "key": "retry_count", "value": 3, "updated": "2024-01-15T10:30:15Z"}'
# Test 8: Metric data
echo -e "${YELLOW}=== Test 8: Metric Data ===${NC}"
publish_json "metrics/system" '{"cpu": 45.2, "memory": 62.5, "disk": 38.7, "timestamp": "2024-01-15T10:30:20Z"}'
# Test 9: Node discovery event
echo -e "${YELLOW}=== Test 9: Node Discovery Event ===${NC}"
publish_json "cluster/node/discovered" '{"ip": "192.168.1.100", "hostname": "node-001", "status": "online", "version": "1.0.0"}'
# Test 10: Firmware update event
echo -e "${YELLOW}=== Test 10: Firmware Update Event ===${NC}"
publish_json "firmware/update/esp32-001" '{"node": "esp32-001", "status": "completed", "version": "v1.3.0", "size": 1234567}'
# Test 11: Task status
echo -e "${YELLOW}=== Test 11: Task Status ===${NC}"
publish_json "task/sync/status" '{"id": "sync-001", "status": "running", "progress": 75, "estimated_completion": "2024-01-15T10:35:00Z"}'
# Test 12: Error log
echo -e "${YELLOW}=== Test 12: Error Log ===${NC}"
publish_json "log/error" '{"severity": "error", "component": "mqtt-client", "message": "Connection timeout", "code": 1001}'
# Test 13: Light control (for SPORE nodes)
echo -e "${YELLOW}=== Test 13: Light Control ===${NC}"
publish_json "light/control" '{"id": "neopixel-001", "brightness": 128, "color": {"r": 255, "g": 0, "b": 0}, "pattern": "solid"}'
# Test 14: Binary data (as hex string)
echo -e "${YELLOW}=== Test 14: Binary Data ===${NC}"
publish_message "data/binary" "48656c6c6f20576f726c64" # "Hello World" in hex
# Test 15: Empty message
echo -e "${YELLOW}=== Test 15: Empty Message ===${NC}"
publish_message "test/empty" ""
# Test 16: Large payload
echo -e "${YELLOW}=== Test 16: Large Payload ===${NC}"
LARGE_PAYLOAD='{"data": "'$(head -c 1000 < /dev/zero | tr '\0' 'A')'"}'
publish_message "test/large" "${LARGE_PAYLOAD}"
echo "============================================"
echo -e "${GREEN}All tests completed successfully!${NC}"
echo "============================================"
echo ""
echo "To monitor these messages, connect to the WebSocket at:"
echo " ws://localhost:3001/ws"
echo ""
echo "You should see all these events forwarded with the format:"
echo ' {"topic": "...", "data": "...", "timestamp": "..."}'
echo ""

View File

@@ -2,7 +2,6 @@ package discovery
import (
"context"
"encoding/json"
"fmt"
"net"
"strconv"
@@ -50,9 +49,6 @@ func (nd *NodeDiscovery) Shutdown(ctx context.Context) error {
return nil
}
// MessageHandler processes a specific UDP message type
type MessageHandler func(payload string, remoteAddr *net.UDPAddr)
// handleUDPMessage processes incoming UDP messages
func (nd *NodeDiscovery) handleUDPMessage(message string, remoteAddr *net.UDPAddr) {
nd.logger.WithFields(log.Fields{
@@ -62,42 +58,13 @@ func (nd *NodeDiscovery) handleUDPMessage(message string, remoteAddr *net.UDPAdd
message = strings.TrimSpace(message)
// Extract topic by splitting on first ":"
parts := strings.SplitN(message, ":", 2)
if len(parts) < 2 {
nd.logger.WithField("message", message).Debug("Invalid message format - missing ':' separator")
return
}
topic := parts[0]
payload := parts[1]
// Handler map for different message types
handlers := map[string]MessageHandler{
"cluster/heartbeat": func(payload string, remoteAddr *net.UDPAddr) {
nd.updateNodeFromHeartbeat(remoteAddr.IP.String(), remoteAddr.Port, payload)
},
"node/update": func(payload string, remoteAddr *net.UDPAddr) {
// Reconstruct full message for handleNodeUpdate which expects "node/update:hostname:{json}"
fullMessage := "node/update:" + payload
nd.handleNodeUpdate(remoteAddr.IP.String(), fullMessage)
},
"RAW": func(payload string, remoteAddr *net.UDPAddr) {
nd.logger.WithField("message", "RAW:"+payload).Debug("Received raw message")
},
"cluster/event": func(payload string, remoteAddr *net.UDPAddr) {
nd.handleClusterEvent(payload, remoteAddr)
},
"cluster/broadcast": func(payload string, remoteAddr *net.UDPAddr) {
nd.handleClusterBroadcast(payload, remoteAddr)
},
}
// Look up and execute handler
if handler, exists := handlers[topic]; exists {
handler(payload, remoteAddr)
} else {
nd.logger.WithField("topic", topic).Debug("Received unknown UDP message type")
if strings.HasPrefix(message, "CLUSTER_HEARTBEAT:") {
hostname := strings.TrimPrefix(message, "CLUSTER_HEARTBEAT:")
nd.updateNodeFromHeartbeat(remoteAddr.IP.String(), remoteAddr.Port, hostname)
} else if strings.HasPrefix(message, "NODE_UPDATE:") {
nd.handleNodeUpdate(remoteAddr.IP.String(), message)
} else if !strings.HasPrefix(message, "RAW:") {
nd.logger.WithField("message", message).Debug("Received unknown UDP message")
}
}
@@ -171,9 +138,9 @@ func (nd *NodeDiscovery) updateNodeFromHeartbeat(sourceIP string, sourcePort int
}
}
// handleNodeUpdate processes NODE_UPDATE and node/update messages
// handleNodeUpdate processes NODE_UPDATE messages
func (nd *NodeDiscovery) handleNodeUpdate(sourceIP, message string) {
// Message format: "NODE_UPDATE:hostname:{json}" or "node/update:hostname:{json}"
// Message format: "NODE_UPDATE:hostname:{json}"
parts := strings.SplitN(message, ":", 3)
if len(parts) < 3 {
nd.logger.WithField("message", message).Warn("Invalid NODE_UPDATE message format")
@@ -377,55 +344,6 @@ func (nd *NodeDiscovery) AddCallback(callback NodeUpdateCallback) {
nd.callbacks = append(nd.callbacks, callback)
}
// SetClusterEventCallback sets the callback for cluster events
func (nd *NodeDiscovery) SetClusterEventCallback(callback ClusterEventBroadcaster) {
nd.mutex.Lock()
defer nd.mutex.Unlock()
nd.clusterEventCallback = callback
}
// handleClusterEvent processes cluster/event messages
func (nd *NodeDiscovery) handleClusterEvent(payload string, remoteAddr *net.UDPAddr) {
nd.logger.WithFields(log.Fields{
"payload": payload,
"from": remoteAddr.String(),
}).Debug("Received cluster/event message")
// Forward to websocket if callback is set
if nd.clusterEventCallback != nil {
nd.clusterEventCallback.BroadcastClusterEvent("cluster/event", payload)
}
}
// handleClusterBroadcast processes cluster/broadcast messages
func (nd *NodeDiscovery) handleClusterBroadcast(payload string, remoteAddr *net.UDPAddr) {
nd.logger.WithFields(log.Fields{
"payload": payload,
"from": remoteAddr.String(),
}).Debug("Received cluster/broadcast message")
// Parse the payload JSON to extract nested event and data
var payloadData struct {
Event string `json:"event"`
Data interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(payload), &payloadData); err != nil {
nd.logger.WithError(err).Error("Failed to parse cluster/broadcast payload")
return
}
nd.logger.WithFields(log.Fields{
"event": payloadData.Event,
"from": remoteAddr.String(),
}).Debug("Parsed cluster/broadcast payload")
// Forward to websocket if callback is set, mapping event to topic and data to data
if nd.clusterEventCallback != nil {
nd.clusterEventCallback.BroadcastClusterEvent(payloadData.Event, payloadData.Data)
}
}
// GetClusterStatus returns current cluster status
func (nd *NodeDiscovery) GetClusterStatus() ClusterStatus {
nd.mutex.RLock()

View File

@@ -41,21 +41,15 @@ type ClusterStatus struct {
// NodeUpdateCallback is called when node information changes
type NodeUpdateCallback func(nodeIP string, action string)
// ClusterEventBroadcaster interface for broadcasting cluster events
type ClusterEventBroadcaster interface {
BroadcastClusterEvent(topic string, data interface{})
}
// NodeDiscovery manages UDP-based node discovery
type NodeDiscovery struct {
udpPort string
discoveredNodes map[string]*NodeInfo
primaryNode string
mutex sync.RWMutex
callbacks []NodeUpdateCallback
clusterEventCallback ClusterEventBroadcaster
staleThreshold time.Duration
logger *log.Logger
udpPort string
discoveredNodes map[string]*NodeInfo
primaryNode string
mutex sync.RWMutex
callbacks []NodeUpdateCallback
staleThreshold time.Duration
logger *log.Logger
}
// NewNodeDiscovery creates a new node discovery instance
@@ -63,7 +57,7 @@ func NewNodeDiscovery(udpPort string) *NodeDiscovery {
return &NodeDiscovery{
udpPort: udpPort,
discoveredNodes: make(map[string]*NodeInfo),
staleThreshold: 10 * time.Second, // Heartbeat timeout - mark nodes inactive after 10 seconds
staleThreshold: 10 * time.Second, // TODO make configurable
logger: log.New(),
}
}

449
internal/mock/data.go Normal file
View File

@@ -0,0 +1,449 @@
package mock
import (
"fmt"
"math/rand"
"time"
"spore-gateway/pkg/client"
"spore-gateway/pkg/registry"
)
// GenerateMockClusterMembers generates mock cluster member data
func GenerateMockClusterMembers(nodes map[string]*NodeInfo) []client.ClusterMember {
members := make([]client.ClusterMember, 0, len(nodes))
for _, node := range nodes {
member := client.ClusterMember{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen.Unix(),
Labels: node.Labels,
Resources: map[string]interface{}{
"freeHeap": 32768 + rand.Intn(32768),
"cpuFreqMHz": 80 + rand.Intn(160),
"flashChipSize": 4194304,
},
}
members = append(members, member)
}
return members
}
// GenerateMockTaskStatus generates mock task status data
func GenerateMockTaskStatus() *client.TaskStatusResponse {
tasks := []client.TaskInfo{
{
Name: "HeartbeatTask",
Interval: 5000,
Enabled: true,
Running: true,
AutoStart: true,
},
{
Name: "SensorReadTask",
Interval: 10000,
Enabled: true,
Running: true,
AutoStart: true,
},
{
Name: "StatusUpdateTask",
Interval: 30000,
Enabled: true,
Running: false,
AutoStart: false,
},
{
Name: "CleanupTask",
Interval: 60000,
Enabled: false,
Running: false,
AutoStart: false,
},
}
activeTasks := 0
for _, task := range tasks {
if task.Running {
activeTasks++
}
}
return &client.TaskStatusResponse{
Summary: client.TaskSummary{
TotalTasks: len(tasks),
ActiveTasks: activeTasks,
},
Tasks: tasks,
System: client.SystemInfo{
FreeHeap: 32768 + int64(rand.Intn(32768)),
Uptime: int64(time.Now().Unix() - 3600*24), // 24 hours uptime
},
}
}
// GenerateMockSystemStatus generates mock system status data
func GenerateMockSystemStatus(labels map[string]string) *client.SystemStatusResponse {
return &client.SystemStatusResponse{
FreeHeap: 32768 + int64(rand.Intn(32768)),
ChipID: int64(rand.Int31()),
SDKVersion: "3.1.0",
CPUFreqMHz: 80 + rand.Intn(160),
FlashChipSize: 4194304,
Labels: labels,
}
}
// GenerateMockCapabilities generates mock API endpoint capabilities
func GenerateMockCapabilities() *client.CapabilitiesResponse {
return &client.CapabilitiesResponse{
Endpoints: []client.EndpointInfo{
{
URI: "/api/node/status",
Method: "GET",
Parameters: []client.ParameterInfo{
{
Name: "detailed",
Type: "boolean",
Required: false,
Description: "Include detailed system information",
Location: "query",
Default: "false",
},
},
},
{
URI: "/api/cluster/members",
Method: "GET",
Parameters: []client.ParameterInfo{},
},
{
URI: "/api/tasks/status",
Method: "GET",
Parameters: []client.ParameterInfo{},
},
{
URI: "/api/node/update",
Method: "POST",
Parameters: []client.ParameterInfo{
{
Name: "firmware",
Type: "file",
Required: true,
Description: "Firmware binary file",
Location: "body",
},
},
},
{
URI: "/api/node/config",
Method: "POST",
Parameters: []client.ParameterInfo{
{
Name: "labels",
Type: "json",
Required: true,
Description: "Node labels in JSON format",
Location: "body",
},
},
},
{
URI: "/api/sensors/read",
Method: "GET",
Parameters: []client.ParameterInfo{
{
Name: "sensor",
Type: "string",
Required: false,
Description: "Specific sensor to read",
Location: "query",
Values: []string{"temperature", "humidity", "pressure"},
},
},
},
},
}
}
// GenerateMockFirmwareList generates mock firmware registry data
func GenerateMockFirmwareList() []registry.GroupedFirmware {
return []registry.GroupedFirmware{
{
Name: "spore-firmware",
Firmware: []registry.FirmwareRecord{
{
Name: "spore-firmware",
Version: "1.0.0",
Size: 524288,
Labels: map[string]string{
"stable": "true",
"env": "production",
},
Path: "/firmware/spore-firmware/1.0.0",
},
{
Name: "spore-firmware",
Version: "1.1.0",
Size: 548864,
Labels: map[string]string{
"stable": "true",
"env": "production",
},
Path: "/firmware/spore-firmware/1.1.0",
},
{
Name: "spore-firmware",
Version: "1.2.0",
Size: 573440,
Labels: map[string]string{
"stable": "false",
"env": "beta",
},
Path: "/firmware/spore-firmware/1.2.0",
},
},
},
{
Name: "sensor-firmware",
Firmware: []registry.FirmwareRecord{
{
Name: "sensor-firmware",
Version: "2.0.0",
Size: 262144,
Labels: map[string]string{
"stable": "true",
"type": "sensor",
},
Path: "/firmware/sensor-firmware/2.0.0",
},
{
Name: "sensor-firmware",
Version: "2.1.0",
Size: 286720,
Labels: map[string]string{
"stable": "true",
"type": "sensor",
},
Path: "/firmware/sensor-firmware/2.1.0",
},
},
},
}
}
// GenerateMockFirmwareBinary generates mock firmware binary data
func GenerateMockFirmwareBinary(size int) []byte {
// Generate some pseudo-random but deterministic binary data
data := make([]byte, size)
for i := range data {
data[i] = byte(i % 256)
}
return data
}
// NodeInfo is an alias to avoid import cycle
type NodeInfo struct {
IP string
Hostname string
Status string
Latency int64
LastSeen time.Time
Labels map[string]string
}
// ResourceMetrics represents resource usage metrics for a node
type ResourceMetrics struct {
Timestamp int64 `json:"timestamp"`
NodeIP string `json:"node_ip"`
Hostname string `json:"hostname"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Network NetworkMetrics `json:"network"`
Flash FlashMetrics `json:"flash"`
Labels map[string]string `json:"labels"`
}
// CPUMetrics represents CPU usage metrics
type CPUMetrics struct {
Frequency int `json:"frequency_mhz"`
UsagePercent float64 `json:"usage_percent"`
Temperature float64 `json:"temperature_c,omitempty"`
}
// MemoryMetrics represents memory usage metrics
type MemoryMetrics struct {
Total int64 `json:"total_bytes"`
Free int64 `json:"free_bytes"`
Used int64 `json:"used_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
// NetworkMetrics represents network usage metrics
type NetworkMetrics struct {
BytesSent int64 `json:"bytes_sent"`
BytesReceived int64 `json:"bytes_received"`
PacketsSent int64 `json:"packets_sent"`
PacketsRecv int64 `json:"packets_received"`
RSSI int `json:"rssi_dbm,omitempty"`
SignalQuality float64 `json:"signal_quality_percent,omitempty"`
}
// FlashMetrics represents flash storage metrics
type FlashMetrics struct {
Total int64 `json:"total_bytes"`
Used int64 `json:"used_bytes"`
Free int64 `json:"free_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
// MonitoringResourcesResponse represents the monitoring resources endpoint response
type MonitoringResourcesResponse struct {
Timestamp string `json:"timestamp"`
Nodes []ResourceMetrics `json:"nodes"`
Summary ResourceSummary `json:"summary"`
}
// ResourceSummary provides aggregate statistics across all nodes
type ResourceSummary struct {
TotalNodes int `json:"total_nodes"`
AvgCPUUsage float64 `json:"avg_cpu_usage_percent"`
AvgMemoryUsage float64 `json:"avg_memory_usage_percent"`
AvgFlashUsage float64 `json:"avg_flash_usage_percent"`
TotalBytesSent int64 `json:"total_bytes_sent"`
TotalBytesRecv int64 `json:"total_bytes_received"`
}
// GenerateMockMonitoringResources generates meaningful mock monitoring data for all nodes
func GenerateMockMonitoringResources(nodes map[string]*NodeInfo) *MonitoringResourcesResponse {
now := time.Now()
metrics := make([]ResourceMetrics, 0, len(nodes))
var totalCPU, totalMemoryPercent, totalFlash float64
var totalBytesSent, totalBytesRecv int64
for _, node := range nodes {
// Generate realistic resource usage based on node characteristics
cpuFreq := 80 + rand.Intn(160)
cpuUsage := 15.0 + rand.Float64()*45.0 // 15-60% usage
// Memory metrics
nodeMemoryTotal := int64(65536 + rand.Intn(65536)) // 64-128KB
freeMemory := int64(32768 + rand.Intn(32768)) // 32-64KB free
usedMemory := nodeMemoryTotal - freeMemory
memoryUsagePercent := float64(usedMemory) / float64(nodeMemoryTotal) * 100
// Flash metrics
flashTotal := int64(4194304) // 4MB
flashUsed := int64(1048576 + rand.Intn(2097152)) // 1-3MB used
flashFree := flashTotal - flashUsed
flashUsagePercent := float64(flashUsed) / float64(flashTotal) * 100
// Network metrics (simulating accumulated traffic)
bytesSent := int64(1000000 + rand.Intn(5000000)) // 1-6MB
bytesRecv := int64(2000000 + rand.Intn(8000000)) // 2-10MB
packetsSent := int64(10000 + rand.Intn(50000))
packetsRecv := int64(15000 + rand.Intn(75000))
// WiFi signal metrics
rssi := -30 - rand.Intn(60) // -30 to -90 dBm
signalQuality := float64(100+rssi+90) / 60.0 * 100 // Convert RSSI to quality percentage
if signalQuality < 0 {
signalQuality = 0
} else if signalQuality > 100 {
signalQuality = 100
}
metric := ResourceMetrics{
Timestamp: now.Unix(),
NodeIP: node.IP,
Hostname: node.Hostname,
CPU: CPUMetrics{
Frequency: cpuFreq,
UsagePercent: cpuUsage,
Temperature: 45.0 + rand.Float64()*20.0, // 45-65°C
},
Memory: MemoryMetrics{
Total: nodeMemoryTotal,
Free: freeMemory,
Used: usedMemory,
UsagePercent: memoryUsagePercent,
},
Network: NetworkMetrics{
BytesSent: bytesSent,
BytesReceived: bytesRecv,
PacketsSent: packetsSent,
PacketsRecv: packetsRecv,
RSSI: rssi,
SignalQuality: signalQuality,
},
Flash: FlashMetrics{
Total: flashTotal,
Used: flashUsed,
Free: flashFree,
UsagePercent: flashUsagePercent,
},
Labels: node.Labels,
}
metrics = append(metrics, metric)
// Accumulate for summary
totalCPU += cpuUsage
totalMemoryPercent += memoryUsagePercent
totalFlash += flashUsagePercent
totalBytesSent += bytesSent
totalBytesRecv += bytesRecv
}
// Calculate averages
nodeCount := len(nodes)
var avgCPU, avgMemory, avgFlash float64
if nodeCount > 0 {
avgCPU = totalCPU / float64(nodeCount)
avgMemory = totalMemoryPercent / float64(nodeCount)
avgFlash = totalFlash / float64(nodeCount)
}
return &MonitoringResourcesResponse{
Timestamp: now.Format(time.RFC3339),
Nodes: metrics,
Summary: ResourceSummary{
TotalNodes: nodeCount,
AvgCPUUsage: avgCPU,
AvgMemoryUsage: avgMemory,
AvgFlashUsage: avgFlash,
TotalBytesSent: totalBytesSent,
TotalBytesRecv: totalBytesRecv,
},
}
}
// GenerateMockProxyResponse generates a mock response for proxy calls
func GenerateMockProxyResponse(method, uri string) map[string]interface{} {
switch uri {
case "/api/sensors/read":
return map[string]interface{}{
"temperature": 22.5 + rand.Float64()*5,
"humidity": 45.0 + rand.Float64()*20,
"pressure": 1013.0 + rand.Float64()*10,
"timestamp": time.Now().Unix(),
}
case "/api/led/control":
return map[string]interface{}{
"status": "success",
"message": "LED state updated",
"state": "on",
}
default:
return map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Mock response for %s %s", method, uri),
"data": map[string]interface{}{"mock": true},
}
}
}

274
internal/mock/discovery.go Normal file
View File

@@ -0,0 +1,274 @@
package mock
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
"spore-gateway/internal/discovery"
log "github.com/sirupsen/logrus"
)
// MockNodeDiscovery simulates node discovery with mock nodes
type MockNodeDiscovery struct {
mockNodes map[string]*discovery.NodeInfo
primaryNode string
mutex sync.RWMutex
callbacks []discovery.NodeUpdateCallback
heartbeatRate time.Duration
shutdownChan chan struct{}
shutdownOnce sync.Once
logger *log.Logger
}
// NewMockNodeDiscovery creates a new mock node discovery instance
func NewMockNodeDiscovery(numNodes int, heartbeatRate time.Duration) *MockNodeDiscovery {
mnd := &MockNodeDiscovery{
mockNodes: make(map[string]*discovery.NodeInfo),
heartbeatRate: heartbeatRate,
shutdownChan: make(chan struct{}),
logger: log.New(),
}
// Generate mock nodes with realistic firmware versions
// These versions should match the firmware available in the registry
now := time.Now()
for i := 0; i < numNodes; i++ {
ip := fmt.Sprintf("192.168.1.%d", 100+i)
hostname := fmt.Sprintf("spore-node-%d", i+1)
// Distribute nodes across different firmware versions
// Most nodes on stable versions, some on beta
var version string
switch i % 5 {
case 0, 1:
version = "1.0.0" // 40% on oldest stable
case 2, 3:
version = "1.1.0" // 40% on newer stable
case 4:
version = "1.2.0" // 20% on beta
}
// Determine stability based on version
stable := "true"
env := "production"
if version == "1.2.0" {
stable = "false"
env = "beta"
}
nodeInfo := &discovery.NodeInfo{
IP: ip,
Port: 80,
Hostname: hostname,
Status: discovery.NodeStatusActive,
DiscoveredAt: now.Add(-time.Duration(i*5) * time.Minute),
LastSeen: now,
Uptime: fmt.Sprintf("%dh%dm", 10+i, rand.Intn(60)),
Labels: map[string]string{
"version": version,
"stable": stable,
"env": env,
"zone": fmt.Sprintf("zone-%d", (i%3)+1),
"type": "spore-node",
},
Latency: int64(10 + rand.Intn(50)),
Resources: map[string]interface{}{
"freeHeap": 32768 + rand.Intn(32768),
"cpuFreqMHz": 80 + rand.Intn(160),
"flashChipSize": 4194304,
},
}
mnd.mockNodes[ip] = nodeInfo
}
// Set first node as primary
if numNodes > 0 {
mnd.primaryNode = fmt.Sprintf("192.168.1.%d", 100)
}
mnd.logger.WithField("nodes", numNodes).Info("Mock discovery initialized with nodes")
return mnd
}
// Start starts the mock discovery (simulates periodic heartbeats)
func (mnd *MockNodeDiscovery) Start() error {
mnd.logger.Info("Starting mock node discovery")
// Simulate periodic node updates
go mnd.simulateHeartbeats()
return nil
}
// Shutdown gracefully shuts down the mock discovery
func (mnd *MockNodeDiscovery) Shutdown(ctx context.Context) error {
mnd.shutdownOnce.Do(func() {
mnd.logger.Info("Shutting down mock node discovery")
close(mnd.shutdownChan)
})
return nil
}
// simulateHeartbeats simulates periodic node heartbeats and updates
func (mnd *MockNodeDiscovery) simulateHeartbeats() {
ticker := time.NewTicker(mnd.heartbeatRate)
defer ticker.Stop()
for {
select {
case <-mnd.shutdownChan:
return
case <-ticker.C:
mnd.updateMockNodes()
}
}
}
// updateMockNodes simulates node updates (version changes, status changes, etc.)
func (mnd *MockNodeDiscovery) updateMockNodes() {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
now := time.Now()
for ip, node := range mnd.mockNodes {
// Update last seen time
node.LastSeen = now
// Randomly update some metrics
if rand.Float32() < 0.3 { // 30% chance
node.Latency = int64(10 + rand.Intn(50))
// Update resources map
node.Resources["freeHeap"] = 32768 + rand.Intn(32768)
}
// Occasionally notify about updates
if rand.Float32() < 0.1 { // 10% chance
mnd.notifyCallbacks(ip, "heartbeat")
}
}
}
// GetNodes returns a copy of all mock nodes
func (mnd *MockNodeDiscovery) GetNodes() map[string]*discovery.NodeInfo {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
nodes := make(map[string]*discovery.NodeInfo)
for ip, node := range mnd.mockNodes {
// Create a copy
nodeCopy := *node
nodes[ip] = &nodeCopy
}
return nodes
}
// GetPrimaryNode returns the current primary node IP
func (mnd *MockNodeDiscovery) GetPrimaryNode() string {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
return mnd.primaryNode
}
// SetPrimaryNode manually sets the primary node
func (mnd *MockNodeDiscovery) SetPrimaryNode(ip string) error {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if _, exists := mnd.mockNodes[ip]; !exists {
return fmt.Errorf("node %s not found", ip)
}
oldPrimary := mnd.primaryNode
mnd.primaryNode = ip
mnd.logger.WithFields(log.Fields{
"old_primary": oldPrimary,
"new_primary": ip,
}).Info("Primary node changed")
mnd.notifyCallbacks(ip, "primary_changed")
return nil
}
// SelectRandomPrimaryNode selects a random active node as primary
func (mnd *MockNodeDiscovery) SelectRandomPrimaryNode() string {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if len(mnd.mockNodes) == 0 {
return ""
}
// Get active nodes
var activeNodes []string
for ip, node := range mnd.mockNodes {
if node.Status == discovery.NodeStatusActive {
activeNodes = append(activeNodes, ip)
}
}
if len(activeNodes) == 0 {
return mnd.primaryNode
}
// Select random node
randomIndex := rand.Intn(len(activeNodes))
randomNode := activeNodes[randomIndex]
oldPrimary := mnd.primaryNode
mnd.primaryNode = randomNode
mnd.logger.WithFields(log.Fields{
"old_primary": oldPrimary,
"new_primary": randomNode,
}).Info("Randomly selected new primary node")
mnd.notifyCallbacks(randomNode, "primary_changed")
return randomNode
}
// AddCallback registers a callback for node updates
func (mnd *MockNodeDiscovery) AddCallback(callback discovery.NodeUpdateCallback) {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
mnd.callbacks = append(mnd.callbacks, callback)
}
// GetClusterStatus returns current cluster status
func (mnd *MockNodeDiscovery) GetClusterStatus() discovery.ClusterStatus {
mnd.mutex.RLock()
defer mnd.mutex.RUnlock()
return discovery.ClusterStatus{
PrimaryNode: mnd.primaryNode,
TotalNodes: len(mnd.mockNodes),
UDPPort: "4210",
ServerRunning: true,
}
}
// notifyCallbacks notifies all registered callbacks about node changes
func (mnd *MockNodeDiscovery) notifyCallbacks(nodeIP, action string) {
for _, callback := range mnd.callbacks {
go callback(nodeIP, action)
}
}
// UpdateNodeVersion simulates updating a node's version (for testing rollouts)
func (mnd *MockNodeDiscovery) UpdateNodeVersion(ip, version string) {
mnd.mutex.Lock()
defer mnd.mutex.Unlock()
if node, exists := mnd.mockNodes[ip]; exists {
node.Labels["version"] = version
mnd.logger.WithFields(log.Fields{
"ip": ip,
"version": version,
}).Info("Updated node version")
mnd.notifyCallbacks(ip, "version_updated")
}
}

752
internal/mock/server.go Normal file
View File

@@ -0,0 +1,752 @@
package mock
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"time"
"spore-gateway/internal/discovery"
"spore-gateway/pkg/client"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
// MockHTTPServer represents the mock HTTP server
type MockHTTPServer struct {
port string
router *mux.Router
discovery *MockNodeDiscovery
wsServer *MockWebSocketServer
server *http.Server
enableWS bool
firmwareStore map[string][]byte // Simple in-memory firmware storage
}
// NewMockHTTPServer creates a new mock HTTP server instance
func NewMockHTTPServer(port string, discovery *MockNodeDiscovery, enableWS bool) *MockHTTPServer {
ms := &MockHTTPServer{
port: port,
router: mux.NewRouter(),
discovery: discovery,
enableWS: enableWS,
firmwareStore: make(map[string][]byte),
}
// Initialize WebSocket server if enabled
if enableWS {
ms.wsServer = NewMockWebSocketServer(discovery)
}
ms.setupRoutes()
ms.setupMiddleware()
ms.server = &http.Server{
Addr: ":" + port,
Handler: ms.router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return ms
}
// setupMiddleware configures middleware for the server
func (ms *MockHTTPServer) setupMiddleware() {
ms.router.Use(ms.corsMiddleware)
ms.router.Use(ms.jsonMiddleware)
ms.router.Use(ms.loggingMiddleware)
}
// corsMiddleware handles CORS headers
func (ms *MockHTTPServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept")
w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// jsonMiddleware sets JSON content type
func (ms *MockHTTPServer) jsonMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
// loggingMiddleware logs HTTP requests
func (ms *MockHTTPServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.WithFields(log.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote_addr": r.RemoteAddr,
"duration": time.Since(start),
}).Debug("HTTP request")
})
}
// setupRoutes configures all the API routes
func (ms *MockHTTPServer) setupRoutes() {
// API routes
api := ms.router.PathPrefix("/api").Subrouter()
api.Use(ms.corsMiddleware)
// Discovery endpoints
api.HandleFunc("/discovery/nodes", ms.getDiscoveryNodes).Methods("GET")
api.HandleFunc("/discovery/refresh", ms.refreshDiscovery).Methods("POST", "OPTIONS")
api.HandleFunc("/discovery/random-primary", ms.selectRandomPrimary).Methods("POST", "OPTIONS")
api.HandleFunc("/discovery/primary/{ip}", ms.setPrimaryNode).Methods("POST", "OPTIONS")
// Cluster endpoints
api.HandleFunc("/cluster/members", ms.getClusterMembers).Methods("GET")
api.HandleFunc("/cluster/refresh", ms.refreshCluster).Methods("POST", "OPTIONS")
api.HandleFunc("/cluster/node/versions", ms.getClusterNodeVersions).Methods("GET")
api.HandleFunc("/rollout", ms.startRollout).Methods("POST", "OPTIONS")
// Task endpoints
api.HandleFunc("/tasks/status", ms.getTaskStatus).Methods("GET")
// Node endpoints
api.HandleFunc("/node/status", ms.getNodeStatus).Methods("GET")
api.HandleFunc("/node/status/{ip}", ms.getNodeStatusByIP).Methods("GET")
api.HandleFunc("/node/endpoints", ms.getNodeEndpoints).Methods("GET")
api.HandleFunc("/node/update", ms.updateNodeFirmware).Methods("POST", "OPTIONS")
// Proxy endpoints
api.HandleFunc("/proxy-call", ms.proxyCall).Methods("POST", "OPTIONS")
// Registry proxy endpoints
api.HandleFunc("/registry/health", ms.getRegistryHealth).Methods("GET")
api.HandleFunc("/registry/firmware", ms.listRegistryFirmware).Methods("GET")
api.HandleFunc("/registry/firmware", ms.uploadRegistryFirmware).Methods("POST", "OPTIONS")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.downloadRegistryFirmware).Methods("GET")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.updateRegistryFirmware).Methods("PUT", "OPTIONS")
api.HandleFunc("/registry/firmware/{name}/{version}", ms.deleteRegistryFirmware).Methods("DELETE", "OPTIONS")
// Monitoring endpoints
api.HandleFunc("/monitoring/resources", ms.getMonitoringResources).Methods("GET")
// Test endpoints
api.HandleFunc("/test/websocket", ms.testWebSocket).Methods("POST", "OPTIONS")
// Health check
api.HandleFunc("/health", ms.healthCheck).Methods("GET")
// WebSocket endpoint
if ms.enableWS {
ms.router.HandleFunc("/ws", ms.corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := ms.wsServer.HandleWebSocket(w, r); err != nil {
log.WithError(err).Error("WebSocket connection failed")
http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest)
}
})).ServeHTTP)
}
}
// Start starts the HTTP server
func (ms *MockHTTPServer) Start() error {
log.WithField("port", ms.port).Info("Starting mock HTTP server")
return ms.server.ListenAndServe()
}
// Shutdown gracefully shuts down the HTTP server
func (ms *MockHTTPServer) Shutdown(ctx context.Context) error {
log.Info("Shutting down mock HTTP server")
// Shutdown WebSocket server if enabled
if ms.enableWS && ms.wsServer != nil {
if err := ms.wsServer.Shutdown(ctx); err != nil {
log.WithError(err).Error("WebSocket server shutdown error")
}
}
return ms.server.Shutdown(ctx)
}
// API endpoint handlers
// GET /api/discovery/nodes
func (ms *MockHTTPServer) getDiscoveryNodes(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
primaryNode := ms.discovery.GetPrimaryNode()
clusterStatus := ms.discovery.GetClusterStatus()
type NodeResponse struct {
*discovery.NodeInfo
IsPrimary bool `json:"isPrimary"`
}
response := struct {
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Nodes []NodeResponse `json:"nodes"`
ClientInitialized bool `json:"clientInitialized"`
ClientBaseURL string `json:"clientBaseUrl"`
ClusterStatus discovery.ClusterStatus `json:"clusterStatus"`
}{
PrimaryNode: primaryNode,
TotalNodes: len(nodes),
Nodes: make([]NodeResponse, 0, len(nodes)),
ClientInitialized: primaryNode != "",
ClientBaseURL: fmt.Sprintf("http://%s", primaryNode),
ClusterStatus: clusterStatus,
}
for _, node := range nodes {
nodeResponse := NodeResponse{
NodeInfo: node,
IsPrimary: node.IP == primaryNode,
}
response.Nodes = append(response.Nodes, nodeResponse)
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/refresh
func (ms *MockHTTPServer) refreshDiscovery(w http.ResponseWriter, r *http.Request) {
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
ClientInitialized bool `json:"clientInitialized"`
}{
Success: true,
Message: "Mock cluster refresh completed",
PrimaryNode: ms.discovery.GetPrimaryNode(),
TotalNodes: len(ms.discovery.GetNodes()),
ClientInitialized: ms.discovery.GetPrimaryNode() != "",
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/random-primary
func (ms *MockHTTPServer) selectRandomPrimary(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
if len(nodes) == 0 {
http.Error(w, `{"error": "No nodes available"}`, http.StatusNotFound)
return
}
newPrimary := ms.discovery.SelectRandomPrimaryNode()
if newPrimary == "" {
http.Error(w, `{"error": "Selection failed"}`, http.StatusInternalServerError)
return
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
ClientInitialized bool `json:"clientInitialized"`
Timestamp string `json:"timestamp"`
}{
Success: true,
Message: fmt.Sprintf("Randomly selected new primary node: %s", newPrimary),
PrimaryNode: newPrimary,
TotalNodes: len(nodes),
ClientInitialized: true,
Timestamp: time.Now().Format(time.RFC3339),
}
json.NewEncoder(w).Encode(response)
}
// POST /api/discovery/primary/{ip}
func (ms *MockHTTPServer) setPrimaryNode(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedIP := vars["ip"]
if err := ms.discovery.SetPrimaryNode(requestedIP); err != nil {
http.Error(w, fmt.Sprintf(`{"error": "Node not found", "message": "Node with IP %s not found"}`, requestedIP), http.StatusNotFound)
return
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
PrimaryNode string `json:"primaryNode"`
ClientInitialized bool `json:"clientInitialized"`
}{
Success: true,
Message: fmt.Sprintf("Primary node set to %s", requestedIP),
PrimaryNode: requestedIP,
ClientInitialized: true,
}
json.NewEncoder(w).Encode(response)
}
// GET /api/cluster/members
func (ms *MockHTTPServer) getClusterMembers(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
response := &client.ClusterStatusResponse{
Members: members,
}
json.NewEncoder(w).Encode(response)
}
// POST /api/cluster/refresh
func (ms *MockHTTPServer) refreshCluster(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil && err.Error() != "EOF" {
requestBody.Reason = "manual_refresh"
}
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
Reason string `json:"reason"`
WSclients int `json:"wsClients"`
}{
Success: true,
Message: "Mock cluster refresh triggered",
Reason: requestBody.Reason,
}
if ms.enableWS && ms.wsServer != nil {
response.WSclients = ms.wsServer.GetClientCount()
}
json.NewEncoder(w).Encode(response)
}
// GET /api/cluster/node/versions
func (ms *MockHTTPServer) getClusterNodeVersions(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
type NodeVersionInfo struct {
IP string `json:"ip"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
var nodeVersions []NodeVersionInfo
for _, node := range nodes {
version := "unknown"
if v, exists := node.Labels["version"]; exists {
version = v
}
nodeVersions = append(nodeVersions, NodeVersionInfo{
IP: node.IP,
Version: version,
Labels: node.Labels,
})
}
response := struct {
Nodes []NodeVersionInfo `json:"nodes"`
}{
Nodes: nodeVersions,
}
json.NewEncoder(w).Encode(response)
}
// RolloutNode represents a node in a rollout request
type RolloutNode struct {
IP string `json:"ip"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
// POST /api/rollout
func (ms *MockHTTPServer) startRollout(w http.ResponseWriter, r *http.Request) {
var request struct {
Firmware struct {
Name string `json:"name"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
} `json:"firmware"`
Nodes []RolloutNode `json:"nodes"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
rolloutID := fmt.Sprintf("rollout_%d", time.Now().Unix())
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
RolloutID string `json:"rolloutId"`
TotalNodes int `json:"totalNodes"`
FirmwareURL string `json:"firmwareUrl"`
}{
Success: true,
Message: fmt.Sprintf("Mock rollout started for %d nodes", len(request.Nodes)),
RolloutID: rolloutID,
TotalNodes: len(request.Nodes),
FirmwareURL: fmt.Sprintf("http://localhost:3002/firmware/%s/%s", request.Firmware.Name, request.Firmware.Version),
}
json.NewEncoder(w).Encode(response)
// Simulate rollout progress in background
if ms.enableWS && ms.wsServer != nil {
go ms.simulateRollout(rolloutID, request.Nodes, request.Firmware.Version)
}
}
// simulateRollout simulates a rollout process with progress updates
func (ms *MockHTTPServer) simulateRollout(rolloutID string, nodes []RolloutNode, newVersion string) {
for i, node := range nodes {
// Simulate updating labels
time.Sleep(500 * time.Millisecond)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "updating_labels", i+1, len(nodes))
}
// Simulate uploading
time.Sleep(1 * time.Second)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "uploading", i+1, len(nodes))
}
// Update node version in discovery
ms.discovery.UpdateNodeVersion(node.IP, strings.TrimPrefix(newVersion, "v"))
// Simulate completion
time.Sleep(500 * time.Millisecond)
if ms.wsServer != nil {
ms.wsServer.BroadcastRolloutProgress(rolloutID, node.IP, "completed", i+1, len(nodes))
}
}
log.WithField("rollout_id", rolloutID).Info("Mock rollout completed")
}
// GET /api/tasks/status
func (ms *MockHTTPServer) getTaskStatus(w http.ResponseWriter, r *http.Request) {
response := GenerateMockTaskStatus()
json.NewEncoder(w).Encode(response)
}
// GET /api/node/status
func (ms *MockHTTPServer) getNodeStatus(w http.ResponseWriter, r *http.Request) {
primaryNode := ms.discovery.GetPrimaryNode()
nodes := ms.discovery.GetNodes()
var labels map[string]string
if primaryNode != "" && nodes[primaryNode] != nil {
labels = nodes[primaryNode].Labels
} else {
labels = make(map[string]string)
}
response := GenerateMockSystemStatus(labels)
json.NewEncoder(w).Encode(response)
}
// GET /api/node/status/{ip}
func (ms *MockHTTPServer) getNodeStatusByIP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
nodeIP := vars["ip"]
nodes := ms.discovery.GetNodes()
node, exists := nodes[nodeIP]
if !exists {
http.Error(w, fmt.Sprintf(`{"error": "Node not found", "message": "Node with IP %s not found"}`, nodeIP), http.StatusNotFound)
return
}
response := GenerateMockSystemStatus(node.Labels)
json.NewEncoder(w).Encode(response)
}
// GET /api/node/endpoints
func (ms *MockHTTPServer) getNodeEndpoints(w http.ResponseWriter, r *http.Request) {
response := GenerateMockCapabilities()
json.NewEncoder(w).Encode(response)
}
// POST /api/node/update
func (ms *MockHTTPServer) updateNodeFirmware(w http.ResponseWriter, r *http.Request) {
nodeIP := r.URL.Query().Get("ip")
if nodeIP == "" {
nodeIP = r.Header.Get("X-Node-IP")
}
if nodeIP == "" {
http.Error(w, `{"error": "Node IP required"}`, http.StatusBadRequest)
return
}
// Parse multipart form
err := r.ParseMultipartForm(50 << 20) // 50MB limit
if err != nil {
http.Error(w, `{"error": "Failed to parse form"}`, http.StatusBadRequest)
return
}
file, fileHeader, err := r.FormFile("file")
if err != nil {
http.Error(w, `{"error": "No file received"}`, http.StatusBadRequest)
return
}
defer file.Close()
filename := fileHeader.Filename
if filename == "" {
filename = "firmware.bin"
}
// Read file data
fileData, err := io.ReadAll(file)
if err != nil {
http.Error(w, `{"error": "Failed to read file"}`, http.StatusInternalServerError)
return
}
log.WithFields(log.Fields{
"node_ip": nodeIP,
"file_size": len(fileData),
"filename": filename,
}).Info("Mock firmware upload received")
// Store firmware in memory
ms.firmwareStore[nodeIP] = fileData
// Broadcast status if WebSocket enabled
if ms.enableWS && ms.wsServer != nil {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "uploading", filename, len(fileData))
}
// Send immediate response
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
NodeIP string `json:"nodeIp"`
FileSize int `json:"fileSize"`
Filename string `json:"filename"`
Status string `json:"status"`
}{
Success: true,
Message: "Mock firmware upload received",
NodeIP: nodeIP,
FileSize: len(fileData),
Filename: filename,
Status: "processing",
}
json.NewEncoder(w).Encode(response)
// Simulate firmware update in background
go func() {
time.Sleep(2 * time.Second)
if ms.enableWS && ms.wsServer != nil {
// Randomly succeed or fail (90% success rate)
if rand.Float32() < 0.9 {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "completed", filename, len(fileData))
} else {
ms.wsServer.BroadcastFirmwareUploadStatus(nodeIP, "failed", filename, len(fileData))
}
}
}()
}
// POST /api/proxy-call
func (ms *MockHTTPServer) proxyCall(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
IP string `json:"ip"`
Method string `json:"method"`
URI string `json:"uri"`
Params []map[string]interface{} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
return
}
// Generate mock response based on URI
mockResponse := GenerateMockProxyResponse(requestBody.Method, requestBody.URI)
// Wrap in data field for consistency
response := map[string]interface{}{
"data": mockResponse,
"status": http.StatusOK,
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/health
func (ms *MockHTTPServer) getRegistryHealth(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
"service": "mock-registry",
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/firmware
func (ms *MockHTTPServer) listRegistryFirmware(w http.ResponseWriter, r *http.Request) {
firmwareList := GenerateMockFirmwareList()
json.NewEncoder(w).Encode(firmwareList)
}
// POST /api/registry/firmware
func (ms *MockHTTPServer) uploadRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware uploaded successfully",
}
json.NewEncoder(w).Encode(response)
}
// GET /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) downloadRegistryFirmware(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
version := vars["version"]
// Generate mock firmware binary
firmwareData := GenerateMockFirmwareBinary(524288) // 512KB
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.bin\"", name, version))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(firmwareData)))
w.Write(firmwareData)
}
// PUT /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) updateRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware metadata updated",
}
json.NewEncoder(w).Encode(response)
}
// DELETE /api/registry/firmware/{name}/{version}
func (ms *MockHTTPServer) deleteRegistryFirmware(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"success": true,
"message": "Mock firmware deleted",
}
json.NewEncoder(w).Encode(response)
}
// POST /api/test/websocket
func (ms *MockHTTPServer) testWebSocket(w http.ResponseWriter, r *http.Request) {
response := struct {
Success bool `json:"success"`
Message string `json:"message"`
WSclients int `json:"websocketClients"`
TotalNodes int `json:"totalNodes"`
}{
Success: true,
Message: "Mock WebSocket test broadcast sent",
TotalNodes: len(ms.discovery.GetNodes()),
}
if ms.enableWS && ms.wsServer != nil {
response.WSclients = ms.wsServer.GetClientCount()
// Trigger a test broadcast
ms.wsServer.BroadcastClusterUpdate()
}
json.NewEncoder(w).Encode(response)
}
// GET /api/monitoring/resources
func (ms *MockHTTPServer) getMonitoringResources(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
response := GenerateMockMonitoringResources(mockNodes)
json.NewEncoder(w).Encode(response)
}
// GET /api/health
func (ms *MockHTTPServer) healthCheck(w http.ResponseWriter, r *http.Request) {
nodes := ms.discovery.GetNodes()
primaryNode := ms.discovery.GetPrimaryNode()
health := struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Services map[string]bool `json:"services"`
Cluster map[string]interface{} `json:"cluster"`
Mock bool `json:"mock"`
}{
Status: "healthy",
Timestamp: time.Now().Format(time.RFC3339),
Services: map[string]bool{
"http": true,
"websocket": ms.enableWS,
"discovery": true,
"mockClient": primaryNode != "",
},
Cluster: map[string]interface{}{
"totalNodes": len(nodes),
"primaryNode": primaryNode,
},
Mock: true,
}
json.NewEncoder(w).Encode(health)
}

475
internal/mock/websocket.go Normal file
View File

@@ -0,0 +1,475 @@
package mock
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow connections from any origin
},
}
// MockWebSocketServer manages WebSocket connections and mock broadcasts
type MockWebSocketServer struct {
discovery *MockNodeDiscovery
clients map[*websocket.Conn]bool
mutex sync.RWMutex
writeMutex sync.Mutex
shutdownChan chan struct{}
shutdownOnce sync.Once
logger *log.Logger
}
// NewMockWebSocketServer creates a new mock WebSocket server
func NewMockWebSocketServer(discovery *MockNodeDiscovery) *MockWebSocketServer {
mws := &MockWebSocketServer{
discovery: discovery,
clients: make(map[*websocket.Conn]bool),
shutdownChan: make(chan struct{}),
logger: log.New(),
}
// Register callback for node updates
discovery.AddCallback(mws.handleNodeUpdate)
// Start periodic broadcasts
go mws.startPeriodicBroadcasts()
return mws
}
// HandleWebSocket handles WebSocket upgrade and connection
func (mws *MockWebSocketServer) HandleWebSocket(w http.ResponseWriter, r *http.Request) error {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
mws.logger.WithError(err).Error("Failed to upgrade WebSocket connection")
return err
}
mws.mutex.Lock()
mws.clients[conn] = true
mws.mutex.Unlock()
mws.logger.Debug("Mock WebSocket client connected")
// Send current cluster state to newly connected client
go mws.sendCurrentClusterState(conn)
// Handle client messages and disconnection
go mws.handleClient(conn)
return nil
}
// handleClient handles messages from a WebSocket client
func (mws *MockWebSocketServer) handleClient(conn *websocket.Conn) {
defer func() {
mws.mutex.Lock()
delete(mws.clients, conn)
mws.mutex.Unlock()
conn.Close()
mws.logger.Debug("Mock WebSocket client disconnected")
}()
// Set read deadline and pong handler
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Start ping routine
go func() {
ticker := time.NewTicker(54 * time.Second)
defer ticker.Stop()
for {
select {
case <-mws.shutdownChan:
return
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}()
// Read messages
for {
_, _, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
mws.logger.WithError(err).Error("WebSocket error")
}
break
}
}
}
// sendCurrentClusterState sends the current cluster state to a newly connected client
func (mws *MockWebSocketServer) sendCurrentClusterState(conn *websocket.Conn) {
nodes := mws.discovery.GetNodes()
if len(nodes) == 0 {
return
}
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
message := struct {
Type string `json:"type"`
Members interface{} `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Type: "cluster_update",
Members: members,
PrimaryNode: mws.discovery.GetPrimaryNode(),
TotalNodes: len(nodes),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal cluster data")
return
}
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send initial cluster state")
}
}
// handleNodeUpdate is called when node information changes
func (mws *MockWebSocketServer) handleNodeUpdate(nodeIP, action string) {
mws.logger.WithFields(log.Fields{
"node_ip": nodeIP,
"action": action,
}).Debug("Mock node update received, broadcasting to WebSocket clients")
// Broadcast cluster update
mws.BroadcastClusterUpdate()
// Also broadcast node discovery event
mws.broadcastNodeDiscovery(nodeIP, action)
}
// startPeriodicBroadcasts sends periodic updates to keep clients informed
func (mws *MockWebSocketServer) startPeriodicBroadcasts() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-mws.shutdownChan:
return
case <-ticker.C:
mws.BroadcastClusterUpdate()
}
}
}
// BroadcastClusterUpdate sends cluster updates to all connected clients
func (mws *MockWebSocketServer) BroadcastClusterUpdate() {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
nodes := mws.discovery.GetNodes()
// Convert to mock format
mockNodes := make(map[string]*NodeInfo)
for ip, node := range nodes {
mockNodes[ip] = &NodeInfo{
IP: node.IP,
Hostname: node.Hostname,
Status: string(node.Status),
Latency: node.Latency,
LastSeen: node.LastSeen,
Labels: node.Labels,
}
}
members := GenerateMockClusterMembers(mockNodes)
message := struct {
Type string `json:"type"`
Members interface{} `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Type: "cluster_update",
Members: members,
PrimaryNode: mws.discovery.GetPrimaryNode(),
TotalNodes: len(nodes),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal cluster update")
return
}
mws.logger.WithField("clients", len(clients)).Debug("Broadcasting mock cluster update")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send cluster update to client")
}
}
}
// broadcastNodeDiscovery sends node discovery events to all clients
func (mws *MockWebSocketServer) broadcastNodeDiscovery(nodeIP, action string) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
Action string `json:"action"`
NodeIP string `json:"nodeIp"`
Timestamp string `json:"timestamp"`
}{
Type: "node_discovery",
Action: action,
NodeIP: nodeIP,
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal node discovery event")
return
}
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send node discovery event to client")
}
}
}
// BroadcastFirmwareUploadStatus sends firmware upload status updates to all clients
func (mws *MockWebSocketServer) BroadcastFirmwareUploadStatus(nodeIP, status, filename string, fileSize int) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
Filename string `json:"filename"`
FileSize int `json:"fileSize"`
Timestamp string `json:"timestamp"`
}{
Type: "firmware_upload_status",
NodeIP: nodeIP,
Status: status,
Filename: filename,
FileSize: fileSize,
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal firmware upload status")
return
}
mws.logger.WithFields(log.Fields{
"node_ip": nodeIP,
"status": status,
"clients": len(clients),
}).Debug("Broadcasting mock firmware upload status")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send firmware upload status to client")
}
}
}
// BroadcastRolloutProgress sends rollout progress updates to all clients
func (mws *MockWebSocketServer) BroadcastRolloutProgress(rolloutID, nodeIP, status string, current, total int) {
mws.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Type string `json:"type"`
RolloutID string `json:"rolloutId"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
Current int `json:"current"`
Total int `json:"total"`
Progress int `json:"progress"`
Timestamp string `json:"timestamp"`
}{
Type: "rollout_progress",
RolloutID: rolloutID,
NodeIP: nodeIP,
Status: status,
Current: current,
Total: total,
Progress: calculateProgress(current, total, status),
Timestamp: time.Now().Format(time.RFC3339),
}
data, err := json.Marshal(message)
if err != nil {
mws.logger.WithError(err).Error("Failed to marshal rollout progress")
return
}
mws.logger.WithFields(log.Fields{
"rollout_id": rolloutID,
"node_ip": nodeIP,
"status": status,
"progress": fmt.Sprintf("%d/%d", current, total),
}).Debug("Broadcasting mock rollout progress")
// Send to all clients with write synchronization
mws.writeMutex.Lock()
defer mws.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, data); err != nil {
mws.logger.WithError(err).Error("Failed to send rollout progress to client")
}
}
}
// calculateProgress calculates the correct progress percentage based on current status
func calculateProgress(current, total int, status string) int {
if total == 0 {
return 0
}
// Base progress is based on completed nodes
completedNodes := current - 1
if status == "completed" {
completedNodes = current
}
// Calculate base progress (completed nodes / total nodes)
baseProgress := float64(completedNodes) / float64(total) * 100
// If currently updating labels or uploading, add partial progress for the current node
if status == "updating_labels" {
nodeProgress := 100.0 / float64(total) * 0.25
baseProgress += nodeProgress
} else if status == "uploading" {
nodeProgress := 100.0 / float64(total) * 0.5
baseProgress += nodeProgress
}
// Ensure we don't exceed 100%
if baseProgress > 100 {
baseProgress = 100
}
return int(baseProgress)
}
// GetClientCount returns the number of connected WebSocket clients
func (mws *MockWebSocketServer) GetClientCount() int {
mws.mutex.RLock()
defer mws.mutex.RUnlock()
return len(mws.clients)
}
// Shutdown gracefully shuts down the WebSocket server
func (mws *MockWebSocketServer) Shutdown(ctx context.Context) error {
mws.shutdownOnce.Do(func() {
mws.logger.Info("Shutting down mock WebSocket server")
close(mws.shutdownChan)
mws.mutex.Lock()
clients := make([]*websocket.Conn, 0, len(mws.clients))
for client := range mws.clients {
clients = append(clients, client)
}
mws.mutex.Unlock()
// Close all client connections
for _, client := range clients {
client.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, "Server shutting down"))
client.Close()
}
})
return nil
}

View File

@@ -1,138 +0,0 @@
package mqtt
import (
"context"
"fmt"
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
log "github.com/sirupsen/logrus"
)
// MQTTClient represents an MQTT client for the gateway
type MQTTClient struct {
client mqtt.Client
serverURL string
username string
password string
connected bool
logger *log.Logger
messageCallback func(topic string, data []byte)
}
// NewMQTTClient creates a new MQTT client instance
func NewMQTTClient(serverURL, username, password string) *MQTTClient {
return &MQTTClient{
serverURL: serverURL,
username: username,
password: password,
logger: log.New(),
}
}
// SetMessageCallback sets the callback function to be called when messages are received
func (mc *MQTTClient) SetMessageCallback(callback func(topic string, data []byte)) {
mc.messageCallback = callback
}
// Connect connects to the MQTT broker
func (mc *MQTTClient) Connect() error {
opts := mqtt.NewClientOptions()
opts.AddBroker(mc.serverURL)
opts.SetClientID(fmt.Sprintf("spore-gateway-%d", time.Now().Unix()))
opts.SetCleanSession(true)
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(10 * time.Second)
opts.SetKeepAlive(30 * time.Second)
opts.SetPingTimeout(10 * time.Second)
// Set credentials if provided
if mc.username != "" {
opts.SetUsername(mc.username)
}
if mc.password != "" {
opts.SetPassword(mc.password)
}
// Set connection callbacks
opts.SetOnConnectHandler(mc.onConnected)
opts.SetConnectionLostHandler(mc.onConnectionLost)
mc.client = mqtt.NewClient(opts)
mc.logger.WithFields(log.Fields{
"server": mc.serverURL,
"username": mc.username,
}).Info("Connecting to MQTT broker")
if token := mc.client.Connect(); token.Wait() && token.Error() != nil {
return fmt.Errorf("failed to connect to MQTT broker: %w", token.Error())
}
return nil
}
// onConnected is called when the client successfully connects to the broker
func (mc *MQTTClient) onConnected(client mqtt.Client) {
mc.logger.Info("Successfully connected to MQTT broker")
mc.connected = true
// Subscribe to all topics
if token := mc.client.Subscribe("#", 0, mc.handleMessage); token.Wait() && token.Error() != nil {
mc.logger.WithError(token.Error()).Error("Failed to subscribe to MQTT topics")
} else {
mc.logger.Info("Subscribed to all MQTT topics (#)")
}
}
// onConnectionLost is called when the connection to the broker is lost
func (mc *MQTTClient) onConnectionLost(client mqtt.Client, err error) {
mc.logger.WithError(err).Error("MQTT connection lost")
mc.connected = false
}
// handleMessage handles incoming MQTT messages
func (mc *MQTTClient) handleMessage(client mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
mc.logger.WithFields(log.Fields{
"topic": topic,
"length": len(payload),
}).Debug("Received MQTT message")
// Call the callback if set
if mc.messageCallback != nil {
mc.messageCallback(topic, payload)
}
}
// Disconnect disconnects from the MQTT broker
func (mc *MQTTClient) Disconnect() {
if mc.client != nil && mc.connected {
mc.logger.Info("Disconnecting from MQTT broker")
mc.client.Disconnect(250)
mc.connected = false
}
}
// Shutdown gracefully shuts down the MQTT client
func (mc *MQTTClient) Shutdown(ctx context.Context) error {
mc.logger.Info("Shutting down MQTT client")
mc.Disconnect()
return nil
}
// IsConnected returns whether the client is currently connected
func (mc *MQTTClient) IsConnected() bool {
return mc.connected
}
// NewMQTTClientFromEnv creates a new MQTT client from environment variables
func NewMQTTClientFromEnv(serverURL string) *MQTTClient {
username := os.Getenv("MQTT_USER")
password := os.Getenv("MQTT_PASSWORD")
return NewMQTTClient(serverURL, username, password)
}

View File

@@ -38,9 +38,6 @@ func NewHTTPServer(port string, nodeDiscovery *discovery.NodeDiscovery) *HTTPSer
// Initialize registry client
registryClient := registry.NewRegistryClient("http://localhost:3002")
// Register WebSocket server as cluster event broadcaster
nodeDiscovery.SetClusterEventCallback(wsServer)
hs := &HTTPServer{
port: port,
router: mux.NewRouter(),
@@ -177,11 +174,6 @@ func (hs *HTTPServer) Start() error {
return hs.server.ListenAndServe()
}
// BroadcastMQTTMessage broadcasts an MQTT message through the WebSocket server
func (hs *HTTPServer) BroadcastMQTTMessage(topic string, data []byte) {
hs.webSocketServer.BroadcastMQTTMessage(topic, data)
}
// Shutdown gracefully shuts down the HTTP server
func (hs *HTTPServer) Shutdown(ctx context.Context) error {
log.Info("Shutting down HTTP server")
@@ -374,19 +366,16 @@ func (hs *HTTPServer) setPrimaryNode(w http.ResponseWriter, r *http.Request) {
// GET /api/cluster/members
func (hs *HTTPServer) getClusterMembers(w http.ResponseWriter, r *http.Request) {
log.Debug("Fetching cluster members via API")
result, err := hs.performWithFailover(func(client *client.SporeClient) (interface{}, error) {
return client.GetClusterStatus()
})
if err != nil {
log.WithError(err).Debug("Failed to fetch cluster members")
log.WithError(err).Error("Error fetching cluster members")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch cluster members", "message": "%s"}`, err.Error()), http.StatusBadGateway)
return
}
log.Debug("Successfully fetched cluster members via API")
json.NewEncoder(w).Encode(result)
}
@@ -428,52 +417,42 @@ func (hs *HTTPServer) getTaskStatus(w http.ResponseWriter, r *http.Request) {
ip := r.URL.Query().Get("ip")
if ip != "" {
log.WithField("node_ip", ip).Debug("Fetching task status from specific node")
client := hs.getSporeClient(ip)
result, err := client.GetTaskStatus()
if err != nil {
log.WithFields(log.Fields{
"node_ip": ip,
"error": err.Error(),
}).Debug("Failed to fetch task status from specific node")
log.WithError(err).Error("Error fetching task status from specific node")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch task status from node", "message": "%s"}`, err.Error()), http.StatusInternalServerError)
return
}
log.WithField("node_ip", ip).Debug("Successfully fetched task status from specific node")
json.NewEncoder(w).Encode(result)
return
}
log.Debug("Fetching task status via failover")
result, err := hs.performWithFailover(func(client *client.SporeClient) (interface{}, error) {
return client.GetTaskStatus()
})
if err != nil {
log.WithError(err).Debug("Failed to fetch task status via failover")
log.WithError(err).Error("Error fetching task status")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch task status", "message": "%s"}`, err.Error()), http.StatusBadGateway)
return
}
log.Debug("Successfully fetched task status via failover")
json.NewEncoder(w).Encode(result)
}
// GET /api/node/status
func (hs *HTTPServer) getNodeStatus(w http.ResponseWriter, r *http.Request) {
log.Debug("Fetching node system status via failover")
result, err := hs.performWithFailover(func(client *client.SporeClient) (interface{}, error) {
return client.GetSystemStatus()
})
if err != nil {
log.WithError(err).Debug("Failed to fetch system status via failover")
log.WithError(err).Error("Error fetching system status")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch system status", "message": "%s"}`, err.Error()), http.StatusBadGateway)
return
}
log.Debug("Successfully fetched system status via failover")
json.NewEncoder(w).Encode(result)
}
@@ -482,20 +461,14 @@ func (hs *HTTPServer) getNodeStatusByIP(w http.ResponseWriter, r *http.Request)
vars := mux.Vars(r)
nodeIP := vars["ip"]
log.WithField("node_ip", nodeIP).Debug("Fetching system status from specific node")
client := hs.getSporeClient(nodeIP)
result, err := client.GetSystemStatus()
if err != nil {
log.WithFields(log.Fields{
"node_ip": nodeIP,
"error": err.Error(),
}).Debug("Failed to fetch status from specific node")
log.WithError(err).Error("Error fetching status from specific node")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch status from node %s", "message": "%s"}`, nodeIP, err.Error()), http.StatusInternalServerError)
return
}
log.WithField("node_ip", nodeIP).Debug("Successfully fetched status from specific node")
json.NewEncoder(w).Encode(result)
}
@@ -504,34 +477,27 @@ func (hs *HTTPServer) getNodeEndpoints(w http.ResponseWriter, r *http.Request) {
ip := r.URL.Query().Get("ip")
if ip != "" {
log.WithField("node_ip", ip).Debug("Fetching endpoints from specific node")
client := hs.getSporeClient(ip)
result, err := client.GetCapabilities()
if err != nil {
log.WithFields(log.Fields{
"node_ip": ip,
"error": err.Error(),
}).Debug("Failed to fetch endpoints from specific node")
log.WithError(err).Error("Error fetching endpoints from specific node")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch endpoints from node", "message": "%s"}`, err.Error()), http.StatusInternalServerError)
return
}
log.WithField("node_ip", ip).Debug("Successfully fetched endpoints from specific node")
json.NewEncoder(w).Encode(result)
return
}
log.Debug("Fetching capabilities via failover")
result, err := hs.performWithFailover(func(client *client.SporeClient) (interface{}, error) {
return client.GetCapabilities()
})
if err != nil {
log.WithError(err).Debug("Failed to fetch capabilities via failover")
log.WithError(err).Error("Error fetching capabilities")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch capabilities", "message": "%s"}`, err.Error()), http.StatusBadGateway)
return
}
log.Debug("Successfully fetched capabilities via failover")
json.NewEncoder(w).Encode(result)
}
@@ -883,21 +849,18 @@ type ClusterNodeVersionsResponse struct {
// GET /api/cluster/node/versions
func (hs *HTTPServer) getClusterNodeVersions(w http.ResponseWriter, r *http.Request) {
log.Debug("Fetching cluster node versions")
result, err := hs.performWithFailover(func(client *client.SporeClient) (interface{}, error) {
return client.GetClusterStatus()
})
if err != nil {
log.WithError(err).Debug("Failed to fetch cluster members for versions")
log.WithError(err).Error("Error fetching cluster members for versions")
http.Error(w, fmt.Sprintf(`{"error": "Failed to fetch cluster members", "message": "%s"}`, err.Error()), http.StatusBadGateway)
return
}
clusterStatus, ok := result.(*client.ClusterStatusResponse)
if !ok {
log.Debug("Invalid cluster status response type")
http.Error(w, `{"error": "Invalid cluster status response"}`, http.StatusInternalServerError)
return
}
@@ -917,8 +880,6 @@ func (hs *HTTPServer) getClusterNodeVersions(w http.ResponseWriter, r *http.Requ
})
}
log.WithField("node_count", len(nodeVersions)).Debug("Successfully fetched cluster node versions")
response := ClusterNodeVersionsResponse{
Nodes: nodeVersions,
}
@@ -995,25 +956,12 @@ func (hs *HTTPServer) nodeMatchesLabels(nodeLabels, rolloutLabels map[string]str
// processRollout handles the actual rollout process in the background
func (hs *HTTPServer) processRollout(rolloutID string, nodes []NodeInfo, firmwareInfo FirmwareInfo) {
log.WithFields(log.Fields{
"rollout_id": rolloutID,
"firmware": fmt.Sprintf("%s/%s", firmwareInfo.Name, firmwareInfo.Version),
"node_count": len(nodes),
}).Debug("Starting background rollout process")
log.WithField("rollout_id", rolloutID).Info("Starting background rollout process")
// Download firmware from registry
log.WithFields(log.Fields{
"rollout_id": rolloutID,
"firmware": fmt.Sprintf("%s/%s", firmwareInfo.Name, firmwareInfo.Version),
}).Debug("Downloading firmware from registry for rollout")
firmwareData, err := hs.registryClient.DownloadFirmware(firmwareInfo.Name, firmwareInfo.Version)
if err != nil {
log.WithFields(log.Fields{
"rollout_id": rolloutID,
"firmware": fmt.Sprintf("%s/%s", firmwareInfo.Name, firmwareInfo.Version),
"error": err.Error(),
}).Error("Failed to download firmware for rollout")
log.WithError(err).Error("Failed to download firmware for rollout")
return
}
@@ -1022,7 +970,7 @@ func (hs *HTTPServer) processRollout(rolloutID string, nodes []NodeInfo, firmwar
"firmware": fmt.Sprintf("%s/%s", firmwareInfo.Name, firmwareInfo.Version),
"size": len(firmwareData),
"total_nodes": len(nodes),
}).Debug("Successfully downloaded firmware for rollout")
}).Info("Downloaded firmware for rollout")
// Process nodes in parallel using goroutines
var wg sync.WaitGroup
@@ -1036,14 +984,9 @@ func (hs *HTTPServer) processRollout(rolloutID string, nodes []NodeInfo, firmwar
"rollout_id": rolloutID,
"node_ip": node.IP,
"progress": fmt.Sprintf("%d/%d", nodeIndex+1, len(nodes)),
}).Debug("Processing node in rollout")
}).Info("Processing node in rollout")
// Update version label on the node before upload
log.WithFields(log.Fields{
"rollout_id": rolloutID,
"node_ip": node.IP,
}).Debug("Getting SPORE client for node")
client := hs.getSporeClient(node.IP)
// Create updated labels with the new version

View File

@@ -23,34 +23,26 @@ var upgrader = websocket.Upgrader{
// WebSocketServer manages WebSocket connections and broadcasts
type WebSocketServer struct {
nodeDiscovery *discovery.NodeDiscovery
sporeClients map[string]*client.SporeClient
clients map[*websocket.Conn]bool
mutex sync.RWMutex
writeMutex sync.Mutex // Mutex to serialize writes to WebSocket connections
logger *log.Logger
clusterInfoTicker *time.Ticker
clusterInfoStopCh chan bool
clusterInfoInterval time.Duration
nodeDiscovery *discovery.NodeDiscovery
sporeClients map[string]*client.SporeClient
clients map[*websocket.Conn]bool
mutex sync.RWMutex
writeMutex sync.Mutex // Mutex to serialize writes to WebSocket connections
logger *log.Logger
}
// NewWebSocketServer creates a new WebSocket server
func NewWebSocketServer(nodeDiscovery *discovery.NodeDiscovery) *WebSocketServer {
wss := &WebSocketServer{
nodeDiscovery: nodeDiscovery,
sporeClients: make(map[string]*client.SporeClient),
clients: make(map[*websocket.Conn]bool),
logger: log.New(),
clusterInfoStopCh: make(chan bool),
clusterInfoInterval: 5 * time.Second, // Fetch cluster info every 5 seconds
nodeDiscovery: nodeDiscovery,
sporeClients: make(map[string]*client.SporeClient),
clients: make(map[*websocket.Conn]bool),
logger: log.New(),
}
// Register callback for node updates
nodeDiscovery.AddCallback(wss.handleNodeUpdate)
// Start periodic cluster info fetching
go wss.startPeriodicClusterInfoFetching()
return wss
}
@@ -134,13 +126,13 @@ func (wss *WebSocketServer) sendCurrentClusterState(conn *websocket.Conn) {
}
message := struct {
Topic string `json:"topic"`
Type string `json:"type"`
Members []client.ClusterMember `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Topic: "cluster/update",
Type: "cluster_update",
Members: clusterData,
PrimaryNode: wss.nodeDiscovery.GetPrimaryNode(),
TotalNodes: len(nodes),
@@ -159,48 +151,17 @@ func (wss *WebSocketServer) sendCurrentClusterState(conn *websocket.Conn) {
}
}
// startPeriodicClusterInfoFetching starts a goroutine that periodically fetches cluster info
func (wss *WebSocketServer) startPeriodicClusterInfoFetching() {
wss.clusterInfoTicker = time.NewTicker(wss.clusterInfoInterval)
defer wss.clusterInfoTicker.Stop()
wss.logger.WithField("interval", wss.clusterInfoInterval).Info("Starting periodic cluster info fetching")
for {
select {
case <-wss.clusterInfoTicker.C:
wss.fetchAndBroadcastClusterInfo()
case <-wss.clusterInfoStopCh:
wss.logger.Info("Stopping periodic cluster info fetching")
return
}
}
}
// fetchAndBroadcastClusterInfo fetches cluster info and broadcasts it to clients
func (wss *WebSocketServer) fetchAndBroadcastClusterInfo() {
// Only fetch if we have clients connected
wss.mutex.RLock()
clientCount := len(wss.clients)
wss.mutex.RUnlock()
if clientCount == 0 {
return
}
wss.logger.Debug("Periodically fetching cluster info")
wss.broadcastClusterUpdate()
}
// handleNodeUpdate is called when node information changes
func (wss *WebSocketServer) handleNodeUpdate(nodeIP, action string) {
wss.logger.WithFields(log.Fields{
"node_ip": nodeIP,
"action": action,
}).Debug("Node update received, broadcasting node discovery event")
}).Debug("Node update received, broadcasting to WebSocket clients")
// Only broadcast node discovery event, not cluster update
// Cluster updates are now handled by periodic fetching
// Broadcast cluster update to all clients
wss.broadcastClusterUpdate()
// Also broadcast node discovery event
wss.broadcastNodeDiscovery(nodeIP, action)
}
@@ -227,13 +188,13 @@ func (wss *WebSocketServer) broadcastClusterUpdate() {
}
message := struct {
Topic string `json:"topic"`
Type string `json:"type"`
Members []client.ClusterMember `json:"members"`
PrimaryNode string `json:"primaryNode"`
TotalNodes int `json:"totalNodes"`
Timestamp string `json:"timestamp"`
}{
Topic: "cluster/update",
Type: "cluster_update",
Members: clusterData,
PrimaryNode: wss.nodeDiscovery.GetPrimaryNode(),
TotalNodes: len(wss.nodeDiscovery.GetNodes()),
@@ -287,12 +248,12 @@ func (wss *WebSocketServer) broadcastNodeDiscovery(nodeIP, action string) {
}
message := struct {
Topic string `json:"topic"`
Type string `json:"type"`
Action string `json:"action"`
NodeIP string `json:"nodeIp"`
Timestamp string `json:"timestamp"`
}{
Topic: "node/discovery",
Type: "node_discovery",
Action: action,
NodeIP: nodeIP,
Timestamp: time.Now().Format(time.RFC3339),
@@ -330,14 +291,14 @@ func (wss *WebSocketServer) BroadcastFirmwareUploadStatus(nodeIP, status, filena
}
message := struct {
Topic string `json:"topic"`
Type string `json:"type"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
Filename string `json:"filename"`
FileSize int `json:"fileSize"`
Timestamp string `json:"timestamp"`
}{
Topic: "firmware/upload/status",
Type: "firmware_upload_status",
NodeIP: nodeIP,
Status: status,
Filename: filename,
@@ -385,7 +346,7 @@ func (wss *WebSocketServer) BroadcastRolloutProgress(rolloutID, nodeIP, status s
}
message := struct {
Topic string `json:"topic"`
Type string `json:"type"`
RolloutID string `json:"rolloutId"`
NodeIP string `json:"nodeIp"`
Status string `json:"status"`
@@ -394,7 +355,7 @@ func (wss *WebSocketServer) BroadcastRolloutProgress(rolloutID, nodeIP, status s
Progress int `json:"progress"`
Timestamp string `json:"timestamp"`
}{
Topic: "rollout/progress",
Type: "rollout_progress",
RolloutID: rolloutID,
NodeIP: nodeIP,
Status: status,
@@ -468,38 +429,20 @@ func (wss *WebSocketServer) calculateProgress(current, total int, status string)
func (wss *WebSocketServer) getCurrentClusterMembers() ([]client.ClusterMember, error) {
nodes := wss.nodeDiscovery.GetNodes()
if len(nodes) == 0 {
wss.logger.Debug("No nodes available for cluster member retrieval")
return []client.ClusterMember{}, nil
}
// Try to get real cluster data from primary node
primaryNode := wss.nodeDiscovery.GetPrimaryNode()
if primaryNode != "" {
wss.logger.WithFields(log.Fields{
"primary_node": primaryNode,
"total_nodes": len(nodes),
}).Debug("Fetching cluster members from primary node")
client := wss.getSporeClient(primaryNode)
clusterStatus, err := client.GetClusterStatus()
if err == nil {
wss.logger.WithFields(log.Fields{
"primary_node": primaryNode,
"member_count": len(clusterStatus.Members),
}).Debug("Successfully fetched cluster members from primary node")
// Update local node data with API information but preserve heartbeat status
// Update local node data with API information
wss.updateLocalNodesWithAPI(clusterStatus.Members)
// Return merged data with heartbeat-based status override
return wss.mergeAPIWithHeartbeatStatus(clusterStatus.Members), nil
return clusterStatus.Members, nil
}
wss.logger.WithFields(log.Fields{
"primary_node": primaryNode,
"error": err.Error(),
}).Debug("Failed to get cluster status from primary node, using fallback")
} else {
wss.logger.Debug("No primary node available, using fallback cluster members")
wss.logger.WithError(err).Error("Failed to get cluster status from primary node")
}
// Fallback to local data if API fails
@@ -508,60 +451,18 @@ func (wss *WebSocketServer) getCurrentClusterMembers() ([]client.ClusterMember,
// updateLocalNodesWithAPI updates local node data with information from API
func (wss *WebSocketServer) updateLocalNodesWithAPI(apiMembers []client.ClusterMember) {
// This would update the local node discovery with fresh API data
// For now, we'll just log that we received the data
wss.logger.WithField("members", len(apiMembers)).Debug("Updating local nodes with API data")
for _, member := range apiMembers {
// Update local node with API data, but preserve heartbeat-based status
wss.updateNodeWithAPIData(member)
}
}
// updateNodeWithAPIData updates a single node with API data while preserving heartbeat status
func (wss *WebSocketServer) updateNodeWithAPIData(apiMember client.ClusterMember) {
nodes := wss.nodeDiscovery.GetNodes()
if localNode, exists := nodes[apiMember.IP]; exists {
// Update additional data from API but preserve heartbeat-based status
localNode.Labels = apiMember.Labels
localNode.Resources = apiMember.Resources
localNode.Latency = apiMember.Latency
// Only update hostname if it's different and not empty
if apiMember.Hostname != "" && apiMember.Hostname != localNode.Hostname {
localNode.Hostname = apiMember.Hostname
}
wss.logger.WithFields(log.Fields{
"ip": apiMember.IP,
"labels": apiMember.Labels,
"status": localNode.Status, // Keep heartbeat-based status
}).Debug("Updated node with API data, preserved heartbeat status")
}
}
// mergeAPIWithHeartbeatStatus merges API member data with heartbeat-based status
func (wss *WebSocketServer) mergeAPIWithHeartbeatStatus(apiMembers []client.ClusterMember) []client.ClusterMember {
localNodes := wss.nodeDiscovery.GetNodes()
mergedMembers := make([]client.ClusterMember, 0, len(apiMembers))
for _, apiMember := range apiMembers {
mergedMember := apiMember
// Override status with heartbeat-based status if we have local data
if localNode, exists := localNodes[apiMember.IP]; exists {
mergedMember.Status = string(localNode.Status)
mergedMember.LastSeen = localNode.LastSeen.Unix()
if len(member.Labels) > 0 {
wss.logger.WithFields(log.Fields{
"ip": apiMember.IP,
"api_status": apiMember.Status,
"heartbeat_status": localNode.Status,
}).Debug("Overriding API status with heartbeat status")
"ip": member.IP,
"labels": member.Labels,
}).Debug("API member labels")
}
mergedMembers = append(mergedMembers, mergedMember)
}
return mergedMembers
}
// getFallbackClusterMembers returns local node data as fallback
@@ -602,106 +503,10 @@ func (wss *WebSocketServer) GetClientCount() int {
return len(wss.clients)
}
// BroadcastClusterEvent sends cluster events to all connected clients
func (wss *WebSocketServer) BroadcastClusterEvent(topic string, data interface{}) {
wss.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(wss.clients))
for client := range wss.clients {
clients = append(clients, client)
}
wss.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Topic string `json:"topic"`
Data interface{} `json:"data"`
Timestamp string `json:"timestamp"`
}{
Topic: topic,
Data: data,
Timestamp: time.Now().Format(time.RFC3339),
}
messageData, err := json.Marshal(message)
if err != nil {
wss.logger.WithError(err).Error("Failed to marshal cluster event")
return
}
wss.logger.WithFields(log.Fields{
"topic": topic,
"clients": len(clients),
}).Debug("Broadcasting cluster event to WebSocket clients")
// Send to all clients with write synchronization
wss.writeMutex.Lock()
defer wss.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, messageData); err != nil {
wss.logger.WithError(err).Error("Failed to send cluster event to client")
}
}
}
// BroadcastMQTTMessage broadcasts an MQTT message to all connected WebSocket clients
func (wss *WebSocketServer) BroadcastMQTTMessage(topic string, data []byte) {
wss.mutex.RLock()
clients := make([]*websocket.Conn, 0, len(wss.clients))
for client := range wss.clients {
clients = append(clients, client)
}
wss.mutex.RUnlock()
if len(clients) == 0 {
return
}
message := struct {
Topic string `json:"topic"`
Data string `json:"data"`
Timestamp string `json:"timestamp"`
}{
Topic: topic,
Data: string(data),
Timestamp: time.Now().Format(time.RFC3339),
}
messageData, err := json.Marshal(message)
if err != nil {
wss.logger.WithError(err).Error("Failed to marshal MQTT message")
return
}
wss.logger.WithFields(log.Fields{
"topic": topic,
"clients": len(clients),
"length": len(data),
}).Debug("Broadcasting MQTT message to WebSocket clients")
// Send to all clients with write synchronization
wss.writeMutex.Lock()
defer wss.writeMutex.Unlock()
for _, client := range clients {
client.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := client.WriteMessage(websocket.TextMessage, messageData); err != nil {
wss.logger.WithError(err).Error("Failed to send MQTT message to client")
}
}
}
// Shutdown gracefully shuts down the WebSocket server
func (wss *WebSocketServer) Shutdown(ctx context.Context) error {
wss.logger.Info("Shutting down WebSocket server")
// Stop periodic cluster info fetching
close(wss.clusterInfoStopCh)
wss.mutex.Lock()
clients := make([]*websocket.Conn, 0, len(wss.clients))
for client := range wss.clients {

31
main.go
View File

@@ -10,7 +10,6 @@ import (
"time"
"spore-gateway/internal/discovery"
"spore-gateway/internal/mqtt"
"spore-gateway/internal/server"
"spore-gateway/pkg/config"
@@ -22,7 +21,6 @@ func main() {
configFile := flag.String("config", "", "Path to configuration file")
port := flag.String("port", "3001", "HTTP server port")
udpPort := flag.String("udp-port", "4210", "UDP discovery port")
mqttServer := flag.String("mqtt", "", "Enable MQTT integration with server URL (e.g., tcp://localhost:1883)")
logLevel := flag.String("log-level", "info", "Log level (debug, info, warn, error)")
flag.Parse()
@@ -63,28 +61,6 @@ func main() {
// Initialize HTTP server
httpServer := server.NewHTTPServer(cfg.HTTPPort, nodeDiscovery)
// Initialize MQTT client if enabled
var mqttClient *mqtt.MQTTClient
// Check for MQTT server from flag or environment variable
mqttServerURL := *mqttServer
if mqttServerURL == "" {
mqttServerURL = os.Getenv("MQTT_SERVER")
}
if mqttServerURL != "" {
log.WithField("server", mqttServerURL).Info("Initializing MQTT client")
mqttClient = mqtt.NewMQTTClientFromEnv(mqttServerURL)
// Set callback to forward MQTT messages to WebSocket
mqttClient.SetMessageCallback(func(topic string, data []byte) {
httpServer.BroadcastMQTTMessage(topic, data)
})
if err := mqttClient.Connect(); err != nil {
log.WithError(err).Fatal("Failed to connect to MQTT broker")
}
}
// Setup graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -114,13 +90,6 @@ func main() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown MQTT client
if mqttClient != nil {
if err := mqttClient.Shutdown(shutdownCtx); err != nil {
log.WithError(err).Error("MQTT client shutdown error")
}
}
// Shutdown HTTP server
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.WithError(err).Error("HTTP server shutdown error")

View File

@@ -117,43 +117,21 @@ type FirmwareUpdateResponse struct {
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
}
@@ -161,44 +139,21 @@ func (c *SporeClient) GetClusterStatus() (*ClusterStatusResponse, error) {
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
}
@@ -206,44 +161,21 @@ func (c *SporeClient) GetTaskStatus() (*TaskStatusResponse, error) {
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
}
@@ -251,43 +183,21 @@ func (c *SporeClient) GetSystemStatus() (*SystemStatusResponse, error) {
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
}
@@ -295,30 +205,16 @@ func (c *SporeClient) GetCapabilities() (*CapabilitiesResponse, error) {
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)
}
@@ -330,10 +226,9 @@ func (c *SporeClient) UpdateFirmware(firmwareData []byte, filename string) (*Fir
}
log.WithFields(log.Fields{
"node_url": c.BaseURL,
"filename": filename,
"data_size": len(firmwareData),
}).Debug("Uploading firmware to SPORE node")
"node_ip": c.BaseURL,
"status": "sending_firmware",
}).Debug("Sending firmware to SPORE device")
resp, err := firmwareClient.Do(req)
if err != nil {
@@ -382,19 +277,9 @@ func (c *SporeClient) UpdateFirmware(firmwareData []byte, filename string) (*Fir
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)
}
@@ -404,10 +289,6 @@ func (c *SporeClient) UpdateNodeLabels(labels map[string]string) error {
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)
}
@@ -415,28 +296,19 @@ func (c *SporeClient) UpdateNodeLabels(labels map[string]string) error {
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")
"node_ip": c.BaseURL,
"labels": labels,
}).Info("Node labels updated successfully")
return nil
}
@@ -446,43 +318,17 @@ func (c *SporeClient) ProxyCall(method, uri string, params map[string]interface{
// 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
}

View File

@@ -69,42 +69,21 @@ func (c *RegistryClient) FindFirmwareByNameAndVersion(name, version string) (*Fi
func (c *RegistryClient) GetHealth() (map[string]interface{}, error) {
url := fmt.Sprintf("%s/health", c.BaseURL)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": "/health",
}).Debug("Checking registry health")
resp, err := c.HTTPClient.Get(url)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to check registry health")
return nil, fmt.Errorf("failed to get registry health: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"status_code": resp.StatusCode,
}).Debug("Registry health check returned non-OK status")
return nil, fmt.Errorf("registry health check failed with status %d", resp.StatusCode)
}
var health map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to decode health response")
return nil, fmt.Errorf("failed to decode health response: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
}).Debug("Successfully checked registry health")
return health, nil
}
@@ -112,13 +91,6 @@ func (c *RegistryClient) GetHealth() (map[string]interface{}, error) {
func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile io.Reader) (map[string]interface{}, error) {
url := fmt.Sprintf("%s/firmware", c.BaseURL)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": "/firmware",
"name": metadata.Name,
"version": metadata.Version,
}).Debug("Uploading firmware to registry")
// Create multipart form data
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
@@ -126,19 +98,11 @@ func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile
// Add metadata
metadataJSON, err := json.Marshal(metadata)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to marshal firmware metadata")
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPart, err := writer.CreateFormField("metadata")
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to create metadata field")
return nil, fmt.Errorf("failed to create metadata field: %w", err)
}
metadataPart.Write(metadataJSON)
@@ -146,18 +110,10 @@ func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile
// Add firmware file
firmwarePart, err := writer.CreateFormFile("firmware", fmt.Sprintf("%s-%s.bin", metadata.Name, metadata.Version))
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to create firmware field")
return nil, fmt.Errorf("failed to create firmware field: %w", err)
}
if _, err := io.Copy(firmwarePart, firmwareFile); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to copy firmware data")
return nil, fmt.Errorf("failed to copy firmware data: %w", err)
}
@@ -165,10 +121,6 @@ func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile
req, err := http.NewRequest("POST", url, body)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to create upload request")
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -176,43 +128,20 @@ func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile
resp, err := c.HTTPClient.Do(req)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": metadata.Name,
"version": metadata.Version,
"error": err.Error(),
}).Debug("Failed to upload firmware to registry")
return nil, fmt.Errorf("failed to upload firmware: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": metadata.Name,
"version": metadata.Version,
"status_code": resp.StatusCode,
"error_body": string(body),
}).Debug("Firmware upload returned non-OK status")
return nil, fmt.Errorf("firmware upload failed with status %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to decode upload response")
return nil, fmt.Errorf("failed to decode upload response: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": metadata.Name,
"version": metadata.Version,
}).Debug("Successfully uploaded firmware to registry")
return result, nil
}
@@ -220,32 +149,13 @@ func (c *RegistryClient) UploadFirmware(metadata FirmwareMetadata, firmwareFile
func (c *RegistryClient) UpdateFirmwareMetadata(name, version string, metadata FirmwareMetadata) (map[string]interface{}, error) {
url := fmt.Sprintf("%s/firmware/%s/%s", c.BaseURL, name, version)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": fmt.Sprintf("/firmware/%s/%s", name, version),
"name": name,
"version": version,
}).Debug("Updating firmware metadata in registry")
metadataJSON, err := json.Marshal(metadata)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to marshal metadata")
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(metadataJSON))
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to create update request")
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -253,45 +163,20 @@ func (c *RegistryClient) UpdateFirmwareMetadata(name, version string, metadata F
resp, err := c.HTTPClient.Do(req)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to update firmware metadata in registry")
return nil, fmt.Errorf("failed to update firmware metadata: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"status_code": resp.StatusCode,
"error_body": string(body),
}).Debug("Firmware metadata update returned non-OK status")
return nil, fmt.Errorf("firmware metadata update failed with status %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to decode update response")
return nil, fmt.Errorf("failed to decode update response: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
}).Debug("Successfully updated firmware metadata in registry")
return result, nil
}
@@ -336,43 +221,21 @@ func (c *RegistryClient) firmwareMatchesLabels(firmwareLabels, rolloutLabels map
func (c *RegistryClient) ListFirmware() ([]GroupedFirmware, error) {
url := fmt.Sprintf("%s/firmware", c.BaseURL)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": "/firmware",
}).Debug("Fetching firmware list from registry")
resp, err := c.HTTPClient.Get(url)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to fetch firmware list from registry")
return nil, fmt.Errorf("failed to get firmware list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"status_code": resp.StatusCode,
}).Debug("Firmware list request returned non-OK status")
return nil, fmt.Errorf("firmware list request failed with status %d", resp.StatusCode)
}
var firmwareList []GroupedFirmware
if err := json.NewDecoder(resp.Body).Decode(&firmwareList); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"error": err.Error(),
}).Debug("Failed to decode firmware list response")
return nil, fmt.Errorf("failed to decode firmware list response: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"firmware_count": len(firmwareList),
}).Debug("Successfully fetched firmware list from registry")
return firmwareList, nil
}
@@ -380,52 +243,26 @@ func (c *RegistryClient) ListFirmware() ([]GroupedFirmware, error) {
func (c *RegistryClient) DownloadFirmware(name, version string) ([]byte, error) {
url := fmt.Sprintf("%s/firmware/%s/%s", c.BaseURL, name, version)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": fmt.Sprintf("/firmware/%s/%s", name, version),
"name": name,
"version": version,
}).Debug("Downloading firmware from registry")
resp, err := c.HTTPClient.Get(url)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to download firmware from registry")
return nil, fmt.Errorf("failed to download firmware: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"status_code": resp.StatusCode,
}).Debug("Firmware download request returned non-OK status")
return nil, fmt.Errorf("firmware download request failed with status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to read firmware data from registry")
return nil, fmt.Errorf("failed to read firmware data: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"size": len(data),
}).Debug("Successfully downloaded firmware from registry")
"name": name,
"version": version,
"size": len(data),
}).Info("Downloaded firmware from registry")
return data, nil
}
@@ -434,64 +271,31 @@ func (c *RegistryClient) DownloadFirmware(name, version string) ([]byte, error)
func (c *RegistryClient) DeleteFirmware(name, version string) (map[string]interface{}, error) {
url := fmt.Sprintf("%s/firmware/%s/%s", c.BaseURL, name, version)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"endpoint": fmt.Sprintf("/firmware/%s/%s", name, version),
"name": name,
"version": version,
}).Debug("Deleting firmware from registry")
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to create delete request")
return nil, fmt.Errorf("failed to create delete request: %w", err)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to delete firmware from registry")
return nil, fmt.Errorf("failed to delete firmware: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"status_code": resp.StatusCode,
"error_body": string(body),
}).Debug("Firmware delete returned non-OK status")
return nil, fmt.Errorf("firmware delete request failed with status %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
"error": err.Error(),
}).Debug("Failed to decode delete response")
return nil, fmt.Errorf("failed to decode delete response: %w", err)
}
log.WithFields(log.Fields{
"registry_url": c.BaseURL,
"name": name,
"version": version,
}).Debug("Successfully deleted firmware from registry")
"name": name,
"version": version,
}).Info("Deleted firmware from registry")
return result, nil
}

103
scripts/run-mock-gateway.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
# SPORE Mock Gateway Runner
# Builds and runs the mock gateway with common configurations
set -e
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Default values
PORT="${PORT:-3001}"
MOCK_NODES="${MOCK_NODES:-5}"
HEARTBEAT_RATE="${HEARTBEAT_RATE:-5}"
LOG_LEVEL="${LOG_LEVEL:-info}"
ENABLE_WS="${ENABLE_WS:-true}"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--port)
PORT="$2"
shift 2
;;
-n|--nodes)
MOCK_NODES="$2"
shift 2
;;
-h|--heartbeat)
HEARTBEAT_RATE="$2"
shift 2
;;
-l|--log-level)
LOG_LEVEL="$2"
shift 2
;;
--no-ws)
ENABLE_WS="false"
shift
;;
--help)
echo "SPORE Mock Gateway Runner"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -p, --port PORT HTTP server port (default: 3001)"
echo " -n, --nodes NUM Number of mock nodes (default: 5)"
echo " -h, --heartbeat SECONDS Heartbeat interval (default: 5)"
echo " -l, --log-level LEVEL Log level: debug, info, warn, error (default: info)"
echo " --no-ws Disable WebSocket support"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 -p 8080 -n 10 # Run on port 8080 with 10 nodes"
echo " $0 -l debug -h 2 # Debug logging with 2s heartbeats"
echo " $0 --no-ws # Run without WebSocket"
echo ""
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo -e "${BLUE}================================================${NC}"
echo -e "${BLUE} SPORE Mock Gateway${NC}"
echo -e "${BLUE}================================================${NC}"
echo ""
echo -e "${GREEN}Configuration:${NC}"
echo -e " Port: ${YELLOW}$PORT${NC}"
echo -e " Mock Nodes: ${YELLOW}$MOCK_NODES${NC}"
echo -e " Heartbeat Rate: ${YELLOW}${HEARTBEAT_RATE}s${NC}"
echo -e " Log Level: ${YELLOW}$LOG_LEVEL${NC}"
echo -e " WebSocket: ${YELLOW}$ENABLE_WS${NC}"
echo ""
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo -e "${YELLOW}Error: Go is not installed${NC}"
exit 1
fi
# Build the mock gateway
echo -e "${GREEN}Building mock gateway...${NC}"
go build -o mock-gateway cmd/mock-gateway/main.go
echo -e "${GREEN}Starting mock gateway...${NC}"
echo ""
# Run the mock gateway
./mock-gateway \
-port "$PORT" \
-mock-nodes "$MOCK_NODES" \
-heartbeat-rate "$HEARTBEAT_RATE" \
-log-level "$LOG_LEVEL" \
-enable-ws="$ENABLE_WS"