From aa3c82d9ba955b558d492dd8dcf9afc71866cb60 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 4 Feb 2026 09:54:48 +0300 Subject: [PATCH] feat(api): add live collection contract endpoints --- README.md | 50 +++++++++++ internal/server/collect_types.go | 30 +++++++ internal/server/handlers.go | 138 +++++++++++++++++++++++++++++++ internal/server/server.go | 11 ++- 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 internal/server/collect_types.go diff --git a/README.md b/README.md index ea61a79..fd9a742 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ open http://localhost:8080 ``` POST /api/upload # Загрузить архив +POST /api/collect # Создать задачу live-сбора (контракт-заглушка) +GET /api/collect/{id} # Получить статус задачи live-сбора +POST /api/collect/{id}/cancel # Отменить задачу live-сбора GET /api/status # Получить статус парсинга GET /api/parsers # Получить список доступных парсеров GET /api/events # Получить список событий @@ -106,6 +109,53 @@ DELETE /api/clear # Очистить загруженные данны 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-сбора на этом этапе). + ## Структура проекта ``` diff --git a/internal/server/collect_types.go b/internal/server/collect_types.go new file mode 100644 index 0000000..4c0032e --- /dev/null +++ b/internal/server/collect_types.go @@ -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"` +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 1e62b9a..fb86411 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,11 +1,13 @@ package server import ( + "crypto/rand" "encoding/json" "fmt" "html/template" "net/http" "os" + "regexp" "sort" "strings" "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{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) diff --git a/internal/server/server.go b/internal/server/server.go index 718556b..748ab6e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,12 +28,16 @@ type Server struct { mu sync.RWMutex result *models.AnalysisResult detectedVendor string + + collectMu sync.RWMutex + collectJobs map[string]*CollectJobStatusResponse } func New(cfg Config) *Server { s := &Server{ - config: cfg, - mux: http.NewServeMux(), + config: cfg, + mux: http.NewServeMux(), + collectJobs: make(map[string]*CollectJobStatusResponse), } s.setupRoutes() return s @@ -64,6 +68,9 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT) s.mux.HandleFunc("DELETE /api/clear", s.handleClear) 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 {