feat: basic firmware registry

This commit is contained in:
2025-10-21 14:27:21 +02:00
commit 986745d8c8
20 changed files with 1831 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
---
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
globs:
alwaysApply: true
---
# Clean Code Guidelines
## Constants Over Magic Numbers
- Replace hard-coded values with named constants
- Use descriptive constant names that explain the value's purpose
- Keep constants at the top of the file or in a dedicated constants file
## Meaningful Names
- Variables, functions, and classes should reveal their purpose
- Names should explain why something exists and how it's used
- Avoid abbreviations unless they're universally understood
## Smart Comments
- Don't comment on what the code does - make the code self-documenting
- Use comments to explain why something is done a certain way
- Document APIs, complex algorithms, and non-obvious side effects
## Single Responsibility
- Each function should do exactly one thing
- Functions should be small and focused
- If a function needs a comment to explain what it does, it should be split
## DRY (Don't Repeat Yourself)
- Extract repeated code into reusable functions
- Share common logic through proper abstraction
- Maintain single sources of truth
## Clean Structure
- Keep related code together
- Organize code in a logical hierarchy
- Use consistent file and folder naming conventions
## Encapsulation
- Hide implementation details
- Expose clear interfaces
- Move nested conditionals into well-named functions
## Code Quality Maintenance
- Refactor continuously
- Fix technical debt early
- Leave code cleaner than you found it
## Testing
- Write tests before fixing bugs
- Keep tests readable and maintainable
- Test edge cases and error conditions
## Version Control
- Write clear commit messages
- Make small, focused commits
- Use meaningful branch names

111
.cursor/rules/gitflow.mdc Normal file
View File

@@ -0,0 +1,111 @@
---
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
---
# Gitflow Workflow Rules
## Main Branches
### main (or master)
- Contains production-ready code
- Never commit directly to main
- Only accepts merges from:
- hotfix/* branches
- release/* branches
- Must be tagged with version number after each merge
### develop
- Main development branch
- Contains latest delivered development changes
- Source branch for feature branches
- Never commit directly to develop
## Supporting Branches
### feature/*
- Branch from: develop
- Merge back into: develop
- Naming convention: feature/[issue-id]-descriptive-name
- Example: feature/123-user-authentication
- Must be up-to-date with develop before creating PR
- Delete after merge
### release/*
- Branch from: develop
- Merge back into:
- main
- develop
- Naming convention: release/vX.Y.Z
- Example: release/v1.2.0
- Only bug fixes, documentation, and release-oriented tasks
- No new features
- Delete after merge
### hotfix/*
- Branch from: main
- Merge back into:
- main
- develop
- Naming convention: hotfix/vX.Y.Z
- Example: hotfix/v1.2.1
- Only for urgent production fixes
- Delete after merge
## Commit Messages
- Format: `type(scope): description`
- Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
## Version Control
### Semantic Versioning
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality
- PATCH version for backwards-compatible bug fixes
## Pull Request Rules
1. All changes must go through Pull Requests
2. Required approvals: minimum 1
3. CI checks must pass
4. No direct commits to protected branches (main, develop)
5. Branch must be up to date before merging
6. Delete branch after merge
## Branch Protection Rules
### main & develop
- Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Include administrators in restrictions
- No force pushes
- No deletions
## Release Process
1. Create release branch from develop
2. Bump version numbers
3. Fix any release-specific issues
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete release branch
## Hotfix Process
1. Create hotfix branch from main
2. Fix the issue
3. Bump patch version
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete hotfix branch

35
.cursor/rules/golang.mdc Normal file
View File

@@ -0,0 +1,35 @@
---
description:
globs: *.go
alwaysApply: true
---
You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22.
Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms.
- Follow the user's requirements carefully & to the letter.
- First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail.
- Confirm the plan, then write code!
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs.
- Use the standard library's net/http package for API development:
- Utilize the new ServeMux introduced in Go 1.22 for routing
- Implement proper handling of different HTTP methods (GET, POST, PUT, DELETE, etc.)
- Use method handlers with appropriate signatures (e.g., func(w http.ResponseWriter, r *http.Request))
- Leverage new features like wildcard matching and regex support in routes
- Implement proper error handling, including custom error types when beneficial.
- Use appropriate status codes and format JSON responses correctly.
- Implement input validation for API endpoints.
- Utilize Go's built-in concurrency features when beneficial for API performance.
- Follow RESTful API design principles and best practices.
- Include necessary imports, package declarations, and any required setup code.
- Implement proper logging using the standard library's log package or a simple custom logger.
- Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication).
- Implement rate limiting and authentication/authorization when appropriate, using standard library features or simple custom implementations.
- Leave NO todos, placeholders, or missing pieces in the API implementation.
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
- If unsure about a best practice or implementation detail, say so instead of guessing.
- Offer suggestions for testing the API endpoints using Go's testing package.
Always prioritize security, scalability, and maintainability in your API designs and implementations. Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs.

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
registry.db
spore-registry
registry

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
.PHONY: build run test clean
# Build the application
build:
go build -o spore-registry main.go
# Run the application
run:
go run main.go
# Run tests
test:
go test -v ./...
# Clean build artifacts
clean:
rm -f spore-registry
rm -rf registry/
rm -f registry.db
# Run tests with coverage
test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Format code
fmt:
go fmt ./...
# Lint code (requires golangci-lint)
lint:
golangci-lint run
# Install dependencies
deps:
go mod download
go mod tidy

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# SPORE Registry
The SPORE registry is a storage backend and update server for managing versioned firmware binaries in the SPORE embedded system ecosystem.
## Overview
The registry provides:
- **Hierarchical Storage**: Firmware binaries stored as `registry/{name}/{version}/firmware.bin`
- **SQLite Database**: Metadata storage with labels for flexible querying
- **REST API**: Simple HTTP endpoints for upload, listing, and download
- **Version Management**: Semantic versioning support
## Quick Start
### Prerequisites
- Go 1.22 or later
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd spore-registry
```
2. Install dependencies:
```bash
go mod download
```
3. Run the server:
```bash
go run main.go
```
The server will start on port 8080 by default (or use `PORT` environment variable to change it).
## API Endpoints
### Upload Firmware
```bash
curl -X POST http://localhost:8080/firmware \
-F "metadata={\"name\":\"base\",\"version\":\"1.0.0\",\"labels\":{\"app\":\"base\"}}" \
-F "firmware=@firmware.bin"
```
### List Firmware
```bash
# Get all firmware
curl http://localhost:8080/firmware
# Filter by name
curl "http://localhost:8080/firmware?name=base"
# Filter by version
curl "http://localhost:8080/firmware?version=1.0.0"
```
### Download Firmware
```bash
curl http://localhost:8080/firmware/base/1.0.0 -o firmware.bin
```
### Health Check
```bash
curl http://localhost:8080/health
```
## Storage Structure
Firmware binaries are stored hierarchically:
```
registry/
├── base/
│ ├── 1.0.0/
│ │ └── firmware.bin
│ └── 1.1.0/
│ └── firmware.bin
└── sensor/
└── 2.0.0/
└── firmware.bin
```
## Configuration
The registry uses the following environment variables:
- `PORT`: Server port (default: 8080)
## Database Schema
The SQLite database contains a `firmware` table:
```sql
CREATE TABLE firmware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version TEXT NOT NULL,
size INTEGER NOT NULL,
labels TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, version)
);
```
## API Documentation
Full OpenAPI specification is available in `api/openapi.yaml`.
## Development
### Building
```bash
go build -o spore-registry main.go
```
### Testing
```bash
go test ./...
```
### Code Structure
- `main.go` - Main application with HTTP server and API handlers
- `api/openapi.yaml` - OpenAPI specification
- `registry/` - Firmware storage directory (created automatically)
- `registry.db` - SQLite database (created automatically)
## License
MIT License - see LICENSE file for details.

275
api/openapi.yaml Normal file
View File

@@ -0,0 +1,275 @@
openapi: 3.0.3
info:
title: SPORE Registry API
description: |
RESTful API for managing firmware binaries in the SPORE embedded system.
The SPORE registry provides storage and distribution of versioned firmware binaries
with metadata management and hierarchical file organization.
## Features
- **Firmware Upload**: Store firmware binaries with metadata
- **Firmware Listing**: Query firmware by name, version, or labels
- **Firmware Download**: Retrieve specific firmware versions
- **Version Management**: Semantic versioning support with hierarchical storage
## Storage Structure
Firmware binaries are stored hierarchically as:
```
registry/{name}/{version}/firmware.bin
```
Metadata is stored in SQLite database with labels for flexible querying.
version: 1.0.0
contact:
name: SPORE Development Team
url: https://git.dcentral.systems/iot/spore
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: http://localhost:8080
description: Local SPORE registry server
paths:
/firmware:
post:
summary: Upload firmware binary
description: |
Upload a firmware binary with metadata. The firmware is stored in the
hierarchical file structure and metadata is saved to the database.
operationId: uploadFirmware
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- metadata
- firmware
properties:
metadata:
type: string
description: JSON metadata for the firmware
example: |
{
"name": "base",
"version": "1.0.0",
"labels": {
"app": "base",
"platform": "esp32"
}
}
firmware:
type: string
format: binary
description: Firmware binary file
responses:
'201':
description: Firmware uploaded successfully
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
name:
type: string
example: "base"
version:
type: string
example: "1.0.0"
size:
type: integer
format: int64
example: 245760
'400':
description: Bad request - missing or invalid parameters
content:
text/plain:
schema:
type: string
'500':
description: Internal server error
content:
text/plain:
schema:
type: string
get:
summary: List firmware records
description: |
Retrieve firmware records with optional filtering by name, version, or labels.
Returns metadata for each firmware including download URLs.
operationId: listFirmware
parameters:
- name: name
in: query
description: Filter by firmware name
required: false
schema:
type: string
example: "base"
- name: version
in: query
description: Filter by firmware version
required: false
schema:
type: string
example: "1.0.0"
responses:
'200':
description: List of firmware records
content:
application/json:
schema:
type: array
items:
type: object
properties:
name:
type: string
example: "base"
version:
type: string
example: "1.0.0"
size:
type: integer
format: int64
example: 245760
labels:
type: object
additionalProperties:
type: string
example:
app: "base"
platform: "esp32"
download_url:
type: string
example: "/firmware/base/1.0.0"
'500':
description: Internal server error
content:
text/plain:
schema:
type: string
/firmware/{name}/{version}:
get:
summary: Download firmware binary
description: |
Download the firmware binary for the specified name and version.
Returns the binary file directly.
operationId: downloadFirmware
parameters:
- name: name
in: path
description: Firmware name
required: true
schema:
type: string
example: "base"
- name: version
in: path
description: Firmware version (semantic versioning)
required: true
schema:
type: string
example: "1.0.0"
responses:
'200':
description: Firmware binary file
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
description: Firmware not found
content:
text/plain:
schema:
type: string
'500':
description: Internal server error
content:
text/plain:
schema:
type: string
/health:
get:
summary: Health check
description: Simple health check endpoint
operationId: healthCheck
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "healthy"
components:
schemas:
FirmwareMetadata:
type: object
required:
- name
- version
properties:
name:
type: string
description: Firmware name
example: "base"
version:
type: string
description: Firmware version (semantic versioning)
example: "1.0.0"
labels:
type: object
additionalProperties:
type: string
description: Key-value labels for categorizing firmware
example:
app: "base"
platform: "esp32"
FirmwareRecord:
type: object
properties:
name:
type: string
example: "base"
version:
type: string
example: "1.0.0"
size:
type: integer
format: int64
example: 245760
labels:
type: object
additionalProperties:
type: string
download_url:
type: string
example: "/firmware/base/1.0.0"
Error:
type: object
properties:
error:
type: string
description: Error message
code:
type: integer
description: HTTP status code

85
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,85 @@
# Architecture
## Overview
The refactored code follows a clean architecture pattern with clear separation of concerns:
### 1. **Models** (`internal/models/`)
- Contains all data structures and types
- `FirmwareMetadata`, `FirmwareRecord`, `GroupedFirmware`, etc.
- Pure data structures with JSON tags
### 2. **Config** (`internal/config/`)
- Configuration management
- Environment variable handling
- Default values and validation
### 3. **Database** (`internal/database/`)
- Database connection management
- Table creation and schema management
- Connection lifecycle handling
### 4. **Repository** (`internal/repository/`)
- Data access layer
- Database operations (CRUD)
- SQL query management
- Data mapping between database and models
### 5. **Storage** (`internal/storage/`)
- File system operations
- Firmware binary storage
- File path management
- File existence checks
### 6. **Service** (`internal/service/`)
- Business logic layer
- Orchestrates repository and storage operations
- Data validation and processing
- Version comparison logic
### 7. **Handlers** (`internal/handlers/`)
- HTTP request handling
- Request/response processing
- Error handling and status codes
- Input validation
### 8. **App** (`internal/app/`)
- Application initialization
- Dependency injection
- Route setup
- Server configuration
### 9. **Main** (`main.go`)
- Entry point
- Minimal and focused
- Error handling and graceful shutdown
## Environment Variables
- `PORT`: Server port (default: 3002)
- `DB_PATH`: Database file path (default: ./registry.db)
- `REGISTRY_PATH`: Firmware storage directory (default: registry)
- `MAX_UPLOAD_SIZE`: Maximum upload size in bytes (default: 32MB)
## API Endpoints
All API endpoints remain the same:
- `POST /firmware` - Upload firmware
- `GET /firmware` - List firmware (with optional filters)
- `GET /firmware/{name}/{version}` - Download firmware
- `PUT /firmware/{name}/{version}` - Update firmware metadata
- `GET /health` - Health check
## Testing
```bash
# Run all tests
go test ./...
# Run tests with coverage
go test -cover ./...
# Run specific test
go test -run TestStoreFirmwareMetadata
```

8
go.mod Normal file
View File

@@ -0,0 +1,8 @@
module spore-registry
go 1.22
require (
github.com/mattn/go-sqlite3 v1.14.22
github.com/rs/cors v1.11.1
)

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=

109
internal/app/app.go Normal file
View File

@@ -0,0 +1,109 @@
package app
import (
"fmt"
"log"
"net/http"
"os"
"github.com/rs/cors"
"spore-registry/internal/config"
"spore-registry/internal/database"
"spore-registry/internal/handlers"
"spore-registry/internal/repository"
"spore-registry/internal/service"
"spore-registry/internal/storage"
)
// App holds the application dependencies
type App struct {
config *config.Config
db *database.DB
handler *handlers.FirmwareHandler
}
// NewApp creates a new application instance
func NewApp() (*App, error) {
// Load configuration
cfg := config.LoadConfig()
// Create registry directory if it doesn't exist
if err := os.MkdirAll(cfg.Registry, 0755); err != nil {
return nil, fmt.Errorf("failed to create registry directory: %w", err)
}
// Initialize database
db, err := database.NewDB(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Create repository
repo := repository.NewFirmwareRepository(db.GetConnection())
// Create storage
fileStorage := storage.NewFileStorage(cfg.Registry)
// Create service
firmwareService := service.NewFirmwareService(repo, fileStorage)
// Create handler
handler := handlers.NewFirmwareHandler(firmwareService)
return &App{
config: cfg,
db: db,
handler: handler,
}, nil
}
// Close closes the database connection
func (a *App) Close() error {
return a.db.Close()
}
// SetupRoutes sets up HTTP routes
func (a *App) SetupRoutes() *http.ServeMux {
mux := http.NewServeMux()
// API endpoints
mux.HandleFunc("POST /firmware", a.handler.UploadFirmware)
mux.HandleFunc("PUT /firmware/{name}/{version}", a.handler.UpdateFirmwareMetadata)
mux.HandleFunc("GET /firmware", a.handler.ListFirmware)
mux.HandleFunc("GET /firmware/{name}/{version}", a.handler.DownloadFirmware)
// Health check endpoint
mux.HandleFunc("GET /health", a.handler.HealthCheck)
return mux
}
// Start starts the HTTP server
func (a *App) Start() error {
mux := a.SetupRoutes()
log.Printf("Starting SPORE registry server on port %s", a.config.Port)
log.Printf("Server will be accessible from any host on port %s", a.config.Port)
log.Printf("CORS enabled for all origins to support mobile access")
// Add CORS middleware with dynamic origin handling
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, // Allow all origins for mobile access
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: false, // Set to false when using wildcard origins
AllowOriginFunc: func(origin string) bool {
// Allow all origins for mobile/remote access
return true
},
})
// Add request logging middleware
loggedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
c.Handler(mux).ServeHTTP(w, r)
})
return http.ListenAndServe(":"+a.config.Port, loggedHandler)
}

47
internal/config/config.go Normal file
View File

@@ -0,0 +1,47 @@
package config
import (
"os"
"strconv"
)
// Config holds the application configuration
type Config struct {
Port string
DBPath string
Registry string
MaxSize int64
}
// LoadConfig loads configuration from environment variables with defaults
func LoadConfig() *Config {
port := os.Getenv("PORT")
if port == "" {
port = "3002"
}
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./registry.db"
}
registry := os.Getenv("REGISTRY_PATH")
if registry == "" {
registry = "registry"
}
maxSizeStr := os.Getenv("MAX_UPLOAD_SIZE")
maxSize := int64(32 << 20) // 32MB default
if maxSizeStr != "" {
if parsed, err := strconv.ParseInt(maxSizeStr, 10, 64); err == nil {
maxSize = parsed
}
}
return &Config{
Port: port,
DBPath: dbPath,
Registry: registry,
MaxSize: maxSize,
}
}

View File

@@ -0,0 +1,61 @@
package database
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps the database connection and provides methods for database operations
type DB struct {
conn *sql.DB
}
// NewDB creates a new database connection
func NewDB(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db := &DB{conn: conn}
// Create tables
if err := db.createTables(); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return db, nil
}
// Close closes the database connection
func (db *DB) Close() error {
return db.conn.Close()
}
// GetConnection returns the underlying database connection
func (db *DB) GetConnection() *sql.DB {
return db.conn
}
// createTables creates the necessary database tables
func (db *DB) createTables() error {
query := `
CREATE TABLE IF NOT EXISTS firmware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version TEXT NOT NULL,
size INTEGER NOT NULL,
labels TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, version)
);`
if _, err := db.conn.Exec(query); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,207 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strings"
"spore-registry/internal/models"
"spore-registry/internal/service"
)
// FirmwareHandler handles HTTP requests for firmware operations
type FirmwareHandler struct {
service *service.FirmwareService
}
// NewFirmwareHandler creates a new firmware handler
func NewFirmwareHandler(service *service.FirmwareService) *FirmwareHandler {
return &FirmwareHandler{service: service}
}
// UploadFirmware handles POST /firmware endpoint
func (h *FirmwareHandler) UploadFirmware(w http.ResponseWriter, r *http.Request) {
log.Printf("POST /firmware - %s", r.RemoteAddr)
if r.Method != http.MethodPost {
log.Printf("Invalid method %s for /firmware", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
log.Printf("Failed to parse multipart form: %v", err)
http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
return
}
// Get metadata from form
metadataJSON := r.FormValue("metadata")
if metadataJSON == "" {
log.Printf("Missing metadata field in firmware upload")
http.Error(w, "Missing metadata field", http.StatusBadRequest)
return
}
var metadata models.FirmwareMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
log.Printf("Invalid metadata JSON: %v", err)
http.Error(w, "Invalid metadata JSON: "+err.Error(), http.StatusBadRequest)
return
}
// Get firmware file
file, _, err := r.FormFile("firmware")
if err != nil {
log.Printf("Missing firmware file in upload: %v", err)
http.Error(w, "Missing firmware file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
log.Printf("Uploading firmware: %s/%s", metadata.Name, metadata.Version)
// Upload firmware using service
response, err := h.service.UploadFirmware(metadata, file)
if err != nil {
log.Printf("Failed to upload firmware: %v", err)
http.Error(w, "Failed to upload firmware: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Successfully uploaded firmware: %s/%s (size: %d bytes)", metadata.Name, metadata.Version, response.Size)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// ListFirmware handles GET /firmware endpoint with optional query parameters
func (h *FirmwareHandler) ListFirmware(w http.ResponseWriter, r *http.Request) {
log.Printf("GET /firmware - %s (name=%s, version=%s)",
r.RemoteAddr, r.URL.Query().Get("name"), r.URL.Query().Get("version"))
if r.Method != http.MethodGet {
log.Printf("Invalid method %s for /firmware", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse query parameters
name := r.URL.Query().Get("name")
version := r.URL.Query().Get("version")
// Get firmware records using service
grouped, err := h.service.ListFirmware(name, version)
if err != nil {
log.Printf("Failed to retrieve firmware list: %v", err)
http.Error(w, "Failed to retrieve firmware list: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Returning %d firmware groups", len(grouped))
// Return grouped firmware list
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(grouped)
}
// UpdateFirmwareMetadata handles PUT /firmware/{name}/{version} endpoint for metadata-only updates
func (h *FirmwareHandler) UpdateFirmwareMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract path parameters
path := strings.TrimPrefix(r.URL.Path, "/firmware/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
log.Printf("Invalid firmware path for update: %s", r.URL.Path)
http.Error(w, "Invalid firmware path", http.StatusBadRequest)
return
}
name := parts[0]
version := parts[1]
log.Printf("PUT /firmware/%s/%s - %s", name, version, r.RemoteAddr)
// Parse JSON body
var metadata models.FirmwareMetadata
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
log.Printf("Invalid JSON in metadata update: %v", err)
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
// Update metadata using service
response, err := h.service.UpdateFirmwareMetadata(name, version, metadata)
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("Firmware not found for update: %s/%s", name, version)
http.Error(w, "Firmware not found", http.StatusNotFound)
} else {
log.Printf("Failed to update firmware metadata: %v", err)
http.Error(w, "Failed to update firmware metadata: "+err.Error(), http.StatusInternalServerError)
}
return
}
log.Printf("Successfully updated firmware metadata: %s/%s", name, version)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
// DownloadFirmware handles GET /firmware/{name}/{version} endpoint
func (h *FirmwareHandler) DownloadFirmware(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract path parameters
path := strings.TrimPrefix(r.URL.Path, "/firmware/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
log.Printf("Invalid firmware path: %s", r.URL.Path)
http.Error(w, "Invalid firmware path", http.StatusBadRequest)
return
}
name := parts[0]
version := parts[1]
log.Printf("GET /firmware/%s/%s - %s", name, version, r.RemoteAddr)
// Get firmware file path using service
filePath, err := h.service.GetFirmwarePath(name, version)
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("Firmware not found: %s/%s", name, version)
http.Error(w, "Firmware not found", http.StatusNotFound)
} else {
log.Printf("Failed to get firmware path: %v", err)
http.Error(w, "Failed to get firmware: "+err.Error(), http.StatusInternalServerError)
}
return
}
// Serve the file
log.Printf("Serving firmware file: %s", filePath)
http.ServeFile(w, r, filePath)
}
// HealthCheck provides a simple health check endpoint
func (h *FirmwareHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
response := models.HealthResponse{Status: "healthy"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,37 @@
package models
// FirmwareMetadata represents the metadata structure for firmware uploads
type FirmwareMetadata struct {
Name string `json:"name"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
// FirmwareRecord represents a firmware record in the database
type FirmwareRecord struct {
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"`
Labels map[string]string `json:"labels"`
Path string `json:"download_url"`
}
// GroupedFirmware represents firmware grouped by name
type GroupedFirmware struct {
Name string `json:"name"`
Firmware []FirmwareRecord `json:"firmware"`
}
// UploadResponse represents the response for firmware upload
type UploadResponse struct {
Success bool `json:"success"`
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"`
Message string `json:"message,omitempty"`
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
}

View File

@@ -0,0 +1,82 @@
package repository
import (
"database/sql"
"encoding/json"
"fmt"
"spore-registry/internal/models"
)
// FirmwareRepository handles database operations for firmware
type FirmwareRepository struct {
db *sql.DB
}
// NewFirmwareRepository creates a new firmware repository
func NewFirmwareRepository(db *sql.DB) *FirmwareRepository {
return &FirmwareRepository{db: db}
}
// StoreFirmwareMetadata stores firmware metadata in the database
func (r *FirmwareRepository) StoreFirmwareMetadata(metadata models.FirmwareMetadata, size int64) error {
labelsJSON, err := json.Marshal(metadata.Labels)
if err != nil {
return fmt.Errorf("failed to marshal labels: %w", err)
}
query := `INSERT OR REPLACE INTO firmware (name, version, size, labels) VALUES (?, ?, ?, ?)`
_, err = r.db.Exec(query, metadata.Name, metadata.Version, size, string(labelsJSON))
if err != nil {
return fmt.Errorf("failed to store firmware metadata: %w", err)
}
return nil
}
// GetFirmwareRecords retrieves firmware records with optional filtering
func (r *FirmwareRepository) GetFirmwareRecords(name, version string) ([]models.FirmwareRecord, error) {
var records []models.FirmwareRecord
// Build query with optional filters
query := `
SELECT name, version, size, labels
FROM firmware
WHERE 1=1`
var args []interface{}
if name != "" {
query += " AND name = ?"
args = append(args, name)
}
if version != "" {
query += " AND version = ?"
args = append(args, version)
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query firmware records: %w", err)
}
defer rows.Close()
for rows.Next() {
var record models.FirmwareRecord
var labelsJSON string
err := rows.Scan(&record.Name, &record.Version, &record.Size, &labelsJSON)
if err != nil {
return nil, fmt.Errorf("failed to scan firmware record: %w", err)
}
if err := json.Unmarshal([]byte(labelsJSON), &record.Labels); err != nil {
return nil, fmt.Errorf("failed to unmarshal labels: %w", err)
}
// Construct download path
record.Path = fmt.Sprintf("/firmware/%s/%s", record.Name, record.Version)
records = append(records, record)
}
return records, nil
}

View File

@@ -0,0 +1,190 @@
package service
import (
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"spore-registry/internal/models"
"spore-registry/internal/repository"
"spore-registry/internal/storage"
)
// FirmwareService handles business logic for firmware operations
type FirmwareService struct {
repo *repository.FirmwareRepository
storage *storage.FileStorage
}
// NewFirmwareService creates a new firmware service
func NewFirmwareService(repo *repository.FirmwareRepository, storage *storage.FileStorage) *FirmwareService {
return &FirmwareService{
repo: repo,
storage: storage,
}
}
// UploadFirmware uploads firmware binary and metadata
func (s *FirmwareService) UploadFirmware(metadata models.FirmwareMetadata, data io.Reader) (*models.UploadResponse, error) {
// Validate required fields
if metadata.Name == "" || metadata.Version == "" {
return nil, fmt.Errorf("name and version are required")
}
// Store firmware binary
filePath, size, err := s.storage.StoreFirmwareBinary(metadata.Name, metadata.Version, data)
if err != nil {
return nil, fmt.Errorf("failed to store firmware binary: %w", err)
}
// Store metadata in database
if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil {
// Try to clean up the file if database operation fails
os.Remove(filePath)
return nil, fmt.Errorf("failed to store firmware metadata: %w", err)
}
return &models.UploadResponse{
Success: true,
Name: metadata.Name,
Version: metadata.Version,
Size: size,
}, nil
}
// ListFirmware retrieves firmware records with optional filtering
func (s *FirmwareService) ListFirmware(name, version string) ([]models.GroupedFirmware, error) {
records, err := s.repo.GetFirmwareRecords(name, version)
if err != nil {
return nil, fmt.Errorf("failed to retrieve firmware records: %w", err)
}
grouped := s.groupFirmwareByName(records)
return grouped, nil
}
// UpdateFirmwareMetadata updates firmware metadata
func (s *FirmwareService) UpdateFirmwareMetadata(name, version string, metadata models.FirmwareMetadata) (*models.UploadResponse, error) {
// Validate that the name and version match the URL
if metadata.Name != name || metadata.Version != version {
return nil, fmt.Errorf("name and version in URL must match metadata")
}
// Check if firmware exists
records, err := s.repo.GetFirmwareRecords(name, version)
if err != nil {
return nil, fmt.Errorf("failed to check if firmware exists: %w", err)
}
if len(records) == 0 {
return nil, fmt.Errorf("firmware not found")
}
// Get existing firmware size (keep the same size since we're not updating the binary)
existingRecord := records[0]
size := existingRecord.Size
// Update metadata in database
if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil {
return nil, fmt.Errorf("failed to update firmware metadata: %w", err)
}
return &models.UploadResponse{
Success: true,
Name: metadata.Name,
Version: metadata.Version,
Size: size,
Message: "Metadata updated successfully",
}, nil
}
// GetFirmwarePath returns the file path for downloading firmware
func (s *FirmwareService) GetFirmwarePath(name, version string) (string, error) {
if !s.storage.FirmwareExists(name, version) {
return "", fmt.Errorf("firmware not found")
}
return s.storage.GetFirmwareBinaryPath(name, version), nil
}
// groupFirmwareByName groups firmware records by name
func (s *FirmwareService) groupFirmwareByName(records []models.FirmwareRecord) []models.GroupedFirmware {
nameMap := make(map[string][]models.FirmwareRecord)
// Group records by name
for _, record := range records {
nameMap[record.Name] = append(nameMap[record.Name], record)
}
// Convert to slice
var grouped []models.GroupedFirmware
for name, firmware := range nameMap {
// Sort firmware versions by version number (highest first)
sort.Slice(firmware, func(i, j int) bool {
return compareVersions(firmware[i].Version, firmware[j].Version) > 0
})
grouped = append(grouped, models.GroupedFirmware{
Name: name,
Firmware: firmware,
})
}
// Sort firmware groups by name (A to Z)
sort.Slice(grouped, func(i, j int) bool {
return grouped[i].Name < grouped[j].Name
})
return grouped
}
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Remove 'v' prefix if present
v1 = strings.TrimPrefix(v1, "v")
v2 = strings.TrimPrefix(v2, "v")
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for len(parts1) < maxLen {
parts1 = append(parts1, "0")
}
for len(parts2) < maxLen {
parts2 = append(parts2, "0")
}
// Compare each part
for i := 0; i < maxLen; i++ {
num1, err1 := strconv.Atoi(parts1[i])
num2, err2 := strconv.Atoi(parts2[i])
// If parsing fails, do string comparison
if err1 != nil || err2 != nil {
if parts1[i] < parts2[i] {
return -1
} else if parts1[i] > parts2[i] {
return 1
}
continue
}
if num1 < num2 {
return -1
} else if num1 > num2 {
return 1
}
}
return 0
}

57
internal/storage/file.go Normal file
View File

@@ -0,0 +1,57 @@
package storage
import (
"fmt"
"io"
"os"
"path/filepath"
)
// FileStorage handles file system operations for firmware binaries
type FileStorage struct {
registryPath string
}
// NewFileStorage creates a new file storage instance
func NewFileStorage(registryPath string) *FileStorage {
return &FileStorage{registryPath: registryPath}
}
// StoreFirmwareBinary stores a firmware binary in the hierarchical file structure
func (fs *FileStorage) StoreFirmwareBinary(name, version string, data io.Reader) (string, int64, error) {
// Create directory path: registry/<name>/<version>/
dirPath := filepath.Join(fs.registryPath, name, version)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return "", 0, fmt.Errorf("failed to create firmware directory: %w", err)
}
// Create firmware file path
filePath := filepath.Join(dirPath, "firmware.bin")
// Create the file
file, err := os.Create(filePath)
if err != nil {
return "", 0, fmt.Errorf("failed to create firmware file: %w", err)
}
defer file.Close()
// Copy data to file and calculate size
size, err := io.Copy(file, data)
if err != nil {
return "", 0, fmt.Errorf("failed to write firmware data: %w", err)
}
return filePath, size, nil
}
// GetFirmwareBinaryPath returns the file path for a firmware binary
func (fs *FileStorage) GetFirmwareBinaryPath(name, version string) string {
return filepath.Join(fs.registryPath, name, version, "firmware.bin")
}
// FirmwareExists checks if a firmware binary exists
func (fs *FileStorage) FirmwareExists(name, version string) bool {
filePath := fs.GetFirmwareBinaryPath(name, version)
_, err := os.Stat(filePath)
return !os.IsNotExist(err)
}

19
main.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"log"
"spore-registry/internal/app"
)
func main() {
application, err := app.NewApp()
if err != nil {
log.Fatal("Failed to initialize application:", err)
}
defer application.Close()
if err := application.Start(); err != nil {
log.Fatal("Server failed:", err)
}
}

279
main_test.go Normal file
View File

@@ -0,0 +1,279 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"spore-registry/internal/database"
"spore-registry/internal/handlers"
"spore-registry/internal/models"
"spore-registry/internal/repository"
"spore-registry/internal/service"
"spore-registry/internal/storage"
_ "github.com/mattn/go-sqlite3"
)
// setupTestDB creates a temporary database for testing
func setupTestDB(t *testing.T) (*database.DB, *storage.FileStorage, *handlers.FirmwareHandler) {
// Create temporary directory for test registry
tmpDir, err := os.MkdirTemp("", "registry_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
// Clean up temporary directory after test
defer func() {
os.RemoveAll(tmpDir)
}()
// Create test database
db, err := database.NewDB(":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create repository
repo := repository.NewFirmwareRepository(db.GetConnection())
// Create storage
fileStorage := storage.NewFileStorage(tmpDir)
// Create service
firmwareService := service.NewFirmwareService(repo, fileStorage)
// Create handler
handler := handlers.NewFirmwareHandler(firmwareService)
return db, fileStorage, handler
}
func TestStoreFirmwareMetadata(t *testing.T) {
db, _, _ := setupTestDB(t)
defer db.Close()
metadata := models.FirmwareMetadata{
Name: "test-firmware",
Version: "1.0.0",
Labels: map[string]string{
"app": "test",
},
}
// Create a test service to test the repository directly
repo := repository.NewFirmwareRepository(db.GetConnection())
fileStorage := storage.NewFileStorage("/tmp")
service := service.NewFirmwareService(repo, fileStorage)
_, err := service.UploadFirmware(metadata, bytes.NewReader([]byte("test data")))
if err != nil {
t.Fatalf("Failed to upload firmware: %v", err)
}
// Verify the record was stored by listing firmware
grouped, err := service.ListFirmware("", "")
if err != nil {
t.Fatalf("Failed to retrieve firmware records: %v", err)
}
if len(grouped) != 1 {
t.Fatalf("Expected 1 group, got %d", len(grouped))
}
if len(grouped[0].Firmware) != 1 {
t.Fatalf("Expected 1 firmware record, got %d", len(grouped[0].Firmware))
}
record := grouped[0].Firmware[0]
if record.Name != metadata.Name {
t.Errorf("Expected name %s, got %s", metadata.Name, record.Name)
}
if record.Version != metadata.Version {
t.Errorf("Expected version %s, got %s", metadata.Version, record.Version)
}
if record.Labels["app"] != "test" {
t.Errorf("Expected label app=test, got %s", record.Labels["app"])
}
}
func TestGetFirmwareRecordsWithFilters(t *testing.T) {
db, _, _ := setupTestDB(t)
defer db.Close()
// Create a test service to test the repository directly
repo := repository.NewFirmwareRepository(db.GetConnection())
fileStorage := storage.NewFileStorage("/tmp")
service := service.NewFirmwareService(repo, fileStorage)
// Insert test data
testRecords := []models.FirmwareMetadata{
{Name: "firmware1", Version: "1.0.0", Labels: map[string]string{"type": "app"}},
{Name: "firmware1", Version: "1.1.0", Labels: map[string]string{"type": "app"}},
{Name: "firmware2", Version: "1.0.0", Labels: map[string]string{"type": "sensor"}},
}
for _, record := range testRecords {
_, err := service.UploadFirmware(record, bytes.NewReader([]byte("test data")))
if err != nil {
t.Fatalf("Failed to store test data: %v", err)
}
}
// Test filtering by name
grouped, err := service.ListFirmware("firmware1", "")
if err != nil {
t.Fatalf("Failed to get records by name: %v", err)
}
if len(grouped) != 1 || len(grouped[0].Firmware) != 2 {
t.Errorf("Expected 1 group with 2 records for firmware1, got %d groups", len(grouped))
}
// Test filtering by version
grouped, err = service.ListFirmware("", "1.0.0")
if err != nil {
t.Fatalf("Failed to get records by version: %v", err)
}
if len(grouped) != 2 {
t.Errorf("Expected 2 groups for version 1.0.0, got %d", len(grouped))
}
// Test filtering by both name and version
grouped, err = service.ListFirmware("firmware1", "1.0.0")
if err != nil {
t.Fatalf("Failed to get records by name and version: %v", err)
}
if len(grouped) != 1 || len(grouped[0].Firmware) != 1 {
t.Errorf("Expected 1 group with 1 record for firmware1/1.0.0, got %d groups", len(grouped))
}
}
func TestStoreFirmwareBinary(t *testing.T) {
db, fileStorage, _ := setupTestDB(t)
defer db.Close()
testData := []byte("test firmware binary data")
reader := bytes.NewReader(testData)
filePath, size, err := fileStorage.StoreFirmwareBinary("test", "1.0.0", reader)
if err != nil {
t.Fatalf("Failed to store firmware binary: %v", err)
}
if size != int64(len(testData)) {
t.Errorf("Expected size %d, got %d", len(testData), size)
}
// Verify file was created
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Errorf("Firmware file was not created at %s", filePath)
}
// Verify file contents
storedData, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read stored file: %v", err)
}
if !bytes.Equal(testData, storedData) {
t.Error("Stored file contents don't match original data")
}
}
func TestListFirmwareAPI(t *testing.T) {
db, _, handler := setupTestDB(t)
defer db.Close()
// Create a test service to test the repository directly
repo := repository.NewFirmwareRepository(db.GetConnection())
fileStorage := storage.NewFileStorage("/tmp")
service := service.NewFirmwareService(repo, fileStorage)
// Insert test data
metadata := models.FirmwareMetadata{
Name: "test-firmware",
Version: "1.0.0",
Labels: map[string]string{"app": "test"},
}
_, err := service.UploadFirmware(metadata, bytes.NewReader([]byte("test data")))
if err != nil {
t.Fatalf("Failed to store test data: %v", err)
}
// Test GET /firmware
req := httptest.NewRequest("GET", "/firmware", nil)
w := httptest.NewRecorder()
handler.ListFirmware(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var grouped []models.GroupedFirmware
err = json.Unmarshal(w.Body.Bytes(), &grouped)
if err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if len(grouped) != 1 {
t.Errorf("Expected 1 group, got %d", len(grouped))
}
}
func TestHealthCheckAPI(t *testing.T) {
db, _, handler := setupTestDB(t)
defer db.Close()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handler.HealthCheck(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response models.HealthResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response.Status != "healthy" {
t.Errorf("Expected status 'healthy', got '%s'", response.Status)
}
}
func TestInvalidFirmwarePath(t *testing.T) {
db, _, handler := setupTestDB(t)
defer db.Close()
// Test invalid path (too many parts)
req := httptest.NewRequest("GET", "/firmware/invalid/path/extra", nil)
w := httptest.NewRecorder()
handler.DownloadFirmware(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
func TestFirmwareNotFound(t *testing.T) {
db, _, handler := setupTestDB(t)
defer db.Close()
// Test firmware not found
req := httptest.NewRequest("GET", "/firmware/nonexistent/1.0.0", nil)
w := httptest.NewRecorder()
handler.DownloadFirmware(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
}