Add hardware ingest flow and UI upload

This commit is contained in:
2026-02-11 21:57:34 +03:00
parent 38ebe8cd2a
commit 66a3166276
14 changed files with 2222 additions and 111 deletions

View File

@@ -25,6 +25,7 @@ type ingestHandlers struct {
func RegisterIngestRoutes(mux *http.ServeMux, deps IngestDependencies) {
h := ingestHandlers{deps: deps}
mux.HandleFunc("/ingest/logbundle", h.handleLogBundle)
mux.HandleFunc("/ingest/hardware", h.handleHardware)
}
func (h ingestHandlers) handleLogBundle(w http.ResponseWriter, r *http.Request) {
@@ -129,6 +130,102 @@ func (h ingestHandlers) handleLogBundle(w http.ResponseWriter, r *http.Request)
writeJSON(w, status, result)
}
func (h ingestHandlers) handleHardware(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxLogBundleSize)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, "payload too large")
return
}
writeError(w, http.StatusBadRequest, "invalid body")
return
}
var req ingest.HardwareIngestRequest
if err := decodeJSONBytes(body, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
targetHost := strings.TrimSpace(req.TargetHost)
if targetHost == "" {
writeValidationError(w, "target_host", "target_host is required")
return
}
if strings.TrimSpace(req.CollectedAt) == "" {
writeValidationError(w, "collected_at", "collected_at is required")
return
}
collectedAt, err := time.Parse(time.RFC3339, req.CollectedAt)
if err != nil {
writeValidationError(w, "collected_at", "collected_at must be RFC3339")
return
}
boardSerial := strings.TrimSpace(req.Hardware.Board.SerialNumber)
if boardSerial == "" {
writeValidationError(w, "hardware.board.serial_number", "serial_number is required")
return
}
components, firmware := ingest.FlattenHardwareComponents(req.Hardware)
canonicalPayload, err := json.Marshal(req)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
input := ingest.HardwareInput{
Filename: req.Filename,
SourceType: req.SourceType,
Protocol: req.Protocol,
TargetHost: targetHost,
CollectedAt: collectedAt,
ContentHash: ingest.HashPayload(canonicalPayload),
Payload: canonicalPayload,
Board: req.Hardware.Board,
Components: components,
Firmware: firmware,
}
result, err := h.deps.Service.IngestHardware(r.Context(), input)
if err != nil {
switch err {
case ingest.ErrConflict:
writeError(w, http.StatusConflict, "ingest conflict")
default:
writeError(w, http.StatusInternalServerError, "hardware ingest failed")
}
return
}
status := http.StatusCreated
if result.Duplicate {
status = http.StatusOK
}
response := map[string]any{
"status": "success",
"bundle_id": result.BundleID,
"asset_id": result.AssetID,
"collected_at": result.CollectedAt.Format(time.RFC3339),
"duplicate": result.Duplicate,
}
if result.Summary != nil {
response["summary"] = result.Summary
}
if result.Message != nil {
response["message"] = *result.Message
}
writeJSON(w, status, response)
}
func decodeJSONBytes(payload []byte, dest any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
@@ -140,3 +237,14 @@ func decodeJSONBytes(payload []byte, dest any) error {
}
return nil
}
func writeValidationError(w http.ResponseWriter, field, message string) {
writeJSON(w, http.StatusBadRequest, map[string]any{
"status": "error",
"error": "validation_failed",
"details": map[string]string{
"field": field,
"message": message,
},
})
}