feat: basic firmware registry
This commit is contained in:
56
.cursor/rules/cleancode.mdc
Normal file
56
.cursor/rules/cleancode.mdc
Normal 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
111
.cursor/rules/gitflow.mdc
Normal 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
35
.cursor/rules/golang.mdc
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
registry.db
|
||||||
|
spore-registry
|
||||||
|
registry
|
||||||
37
Makefile
Normal file
37
Makefile
Normal 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
129
README.md
Normal 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
275
api/openapi.yaml
Normal 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
85
docs/ARCHITECTURE.md
Normal 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
8
go.mod
Normal 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
4
go.sum
Normal 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
109
internal/app/app.go
Normal 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
47
internal/config/config.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/database/database.go
Normal file
61
internal/database/database.go
Normal 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
|
||||||
|
}
|
||||||
207
internal/handlers/firmware.go
Normal file
207
internal/handlers/firmware.go
Normal 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)
|
||||||
|
}
|
||||||
37
internal/models/firmware.go
Normal file
37
internal/models/firmware.go
Normal 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"`
|
||||||
|
}
|
||||||
82
internal/repository/firmware.go
Normal file
82
internal/repository/firmware.go
Normal 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
|
||||||
|
}
|
||||||
190
internal/service/firmware.go
Normal file
190
internal/service/firmware.go
Normal 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
57
internal/storage/file.go
Normal 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
19
main.go
Normal 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
279
main_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user