feat(api): add live collection contract endpoints
This commit is contained in:
50
README.md
50
README.md
@@ -92,6 +92,9 @@ open http://localhost:8080
|
|||||||
|
|
||||||
```
|
```
|
||||||
POST /api/upload # Загрузить архив
|
POST /api/upload # Загрузить архив
|
||||||
|
POST /api/collect # Создать задачу live-сбора (контракт-заглушка)
|
||||||
|
GET /api/collect/{id} # Получить статус задачи live-сбора
|
||||||
|
POST /api/collect/{id}/cancel # Отменить задачу live-сбора
|
||||||
GET /api/status # Получить статус парсинга
|
GET /api/status # Получить статус парсинга
|
||||||
GET /api/parsers # Получить список доступных парсеров
|
GET /api/parsers # Получить список доступных парсеров
|
||||||
GET /api/events # Получить список событий
|
GET /api/events # Получить список событий
|
||||||
@@ -106,6 +109,53 @@ DELETE /api/clear # Очистить загруженные данны
|
|||||||
POST /api/shutdown # Завершить работу приложения
|
POST /api/shutdown # Завершить работу приложения
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Контракты live-сбора (`/api/collect`)
|
||||||
|
|
||||||
|
`POST /api/collect` принимает JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "bmc01.example.local",
|
||||||
|
"protocol": "redfish",
|
||||||
|
"port": 443,
|
||||||
|
"username": "admin",
|
||||||
|
"auth_type": "password",
|
||||||
|
"password": "secret",
|
||||||
|
"tls_mode": "strict"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Обязательные поля: `host`, `protocol`, `port`, `username`, `auth_type`, `tls_mode`
|
||||||
|
- `protocol`: `redfish` или `ipmi`
|
||||||
|
- `auth_type`: `password` или `token`
|
||||||
|
- `tls_mode`: `strict` или `insecure`
|
||||||
|
- При `auth_type=password` обязателен `password`, при `auth_type=token` — `token`
|
||||||
|
|
||||||
|
Ответ `202 Accepted`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "job_a1b2c3d4e5f6g7h8",
|
||||||
|
"status": "queued",
|
||||||
|
"message": "Collection job accepted",
|
||||||
|
"created_at": "2026-02-04T10:15:20Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/collect/{id}` возвращает `200 OK` со статусом задачи:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "job_a1b2c3d4e5f6g7h8",
|
||||||
|
"status": "queued",
|
||||||
|
"progress": 0,
|
||||||
|
"logs": ["Job queued"],
|
||||||
|
"updated_at": "2026-02-04T10:15:20Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/collect/{id}/cancel` возвращает `200 OK` и переводит задачу в `canceled` (контрактно, без реального backend-сбора на этом этапе).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
30
internal/server/collect_types.go
Normal file
30
internal/server/collect_types.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CollectRequest struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
AuthType string `json:"auth_type"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
TLSMode string `json:"tls_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectJobResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectJobStatusResponse struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Progress *int `json:"progress,omitempty"`
|
||||||
|
Logs []string `json:"logs,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -556,6 +558,142 @@ func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CollectRequest
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateCollectRequest(req); err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID := generateJobID()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
progress := 0
|
||||||
|
|
||||||
|
s.collectMu.Lock()
|
||||||
|
s.collectJobs[jobID] = &CollectJobStatusResponse{
|
||||||
|
JobID: jobID,
|
||||||
|
Status: "queued",
|
||||||
|
Progress: &progress,
|
||||||
|
Logs: []string{"Job queued"},
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
s.collectMu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(CollectJobResponse{
|
||||||
|
JobID: jobID,
|
||||||
|
Status: "queued",
|
||||||
|
Message: "Collection job accepted",
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if !isValidCollectJobID(jobID) {
|
||||||
|
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.collectMu.RLock()
|
||||||
|
job, ok := s.collectJobs[jobID]
|
||||||
|
if !ok || job == nil {
|
||||||
|
s.collectMu.RUnlock()
|
||||||
|
jsonError(w, "Collect job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := *job
|
||||||
|
s.collectMu.RUnlock()
|
||||||
|
|
||||||
|
jsonResponse(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if !isValidCollectJobID(jobID) {
|
||||||
|
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.collectMu.Lock()
|
||||||
|
job, ok := s.collectJobs[jobID]
|
||||||
|
if !ok || job == nil {
|
||||||
|
s.collectMu.Unlock()
|
||||||
|
jsonError(w, "Collect job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
progress := 0
|
||||||
|
job.Status = "canceled"
|
||||||
|
job.Progress = &progress
|
||||||
|
job.Logs = append(job.Logs, "Job canceled by user")
|
||||||
|
job.Error = ""
|
||||||
|
job.UpdatedAt = now
|
||||||
|
resp := *job
|
||||||
|
s.collectMu.Unlock()
|
||||||
|
|
||||||
|
jsonResponse(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCollectRequest(req CollectRequest) error {
|
||||||
|
if strings.TrimSpace(req.Host) == "" {
|
||||||
|
return fmt.Errorf("field 'host' is required")
|
||||||
|
}
|
||||||
|
switch req.Protocol {
|
||||||
|
case "redfish", "ipmi":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("field 'protocol' must be one of: redfish, ipmi")
|
||||||
|
}
|
||||||
|
if req.Port < 1 || req.Port > 65535 {
|
||||||
|
return fmt.Errorf("field 'port' must be in range 1..65535")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Username) == "" {
|
||||||
|
return fmt.Errorf("field 'username' is required")
|
||||||
|
}
|
||||||
|
switch req.AuthType {
|
||||||
|
case "password":
|
||||||
|
if strings.TrimSpace(req.Password) == "" {
|
||||||
|
return fmt.Errorf("field 'password' is required when auth_type=password")
|
||||||
|
}
|
||||||
|
case "token":
|
||||||
|
if strings.TrimSpace(req.Token) == "" {
|
||||||
|
return fmt.Errorf("field 'token' is required when auth_type=token")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("field 'auth_type' must be one of: password, token")
|
||||||
|
}
|
||||||
|
switch req.TLSMode {
|
||||||
|
case "strict", "insecure":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("field 'tls_mode' must be one of: strict, insecure")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectJobIDPattern = regexp.MustCompile(`^job_[a-zA-Z0-9_-]{8,}$`)
|
||||||
|
|
||||||
|
func isValidCollectJobID(id string) bool {
|
||||||
|
return collectJobIDPattern.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateJobID() string {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("job_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("job_%x", buf)
|
||||||
|
}
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ type Server struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
result *models.AnalysisResult
|
result *models.AnalysisResult
|
||||||
detectedVendor string
|
detectedVendor string
|
||||||
|
|
||||||
|
collectMu sync.RWMutex
|
||||||
|
collectJobs map[string]*CollectJobStatusResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Server {
|
func New(cfg Config) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
|
collectJobs: make(map[string]*CollectJobStatusResponse),
|
||||||
}
|
}
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
return s
|
return s
|
||||||
@@ -64,6 +68,9 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
|
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
|
||||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||||
|
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
|
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||||
|
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
|
|||||||
Reference in New Issue
Block a user