265 lines
8.1 KiB
Go
265 lines
8.1 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"reanimator/internal/domain"
|
|
"reanimator/internal/history"
|
|
"reanimator/internal/repository/failures"
|
|
"reanimator/internal/repository/registry"
|
|
)
|
|
|
|
type FailureDependencies struct {
|
|
Failures *failures.FailureRepository
|
|
Assets *registry.AssetRepository
|
|
Components *registry.ComponentRepository
|
|
Installations *registry.InstallationRepository
|
|
History *history.Service
|
|
}
|
|
|
|
type failureHandlers struct {
|
|
deps FailureDependencies
|
|
}
|
|
|
|
func RegisterFailureRoutes(mux *http.ServeMux, deps FailureDependencies) {
|
|
h := failureHandlers{deps: deps}
|
|
mux.HandleFunc("/failures", h.handleFailures)
|
|
}
|
|
|
|
func (h failureHandlers) handleFailures(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleFailuresGet(w, r)
|
|
case http.MethodPost:
|
|
h.handleFailuresPost(w, r)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h failureHandlers) handleFailuresGet(w http.ResponseWriter, r *http.Request) {
|
|
if h.deps.Failures == nil {
|
|
writeError(w, http.StatusInternalServerError, "failures unavailable")
|
|
return
|
|
}
|
|
limit := 200
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
parsed, err := parseIntParam(raw)
|
|
if err != nil || parsed <= 0 {
|
|
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
items, err := h.deps.Failures.ListAll(r.Context(), limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "list failures failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (h failureHandlers) handleFailuresPost(w http.ResponseWriter, r *http.Request) {
|
|
if h.deps.Failures == nil || h.deps.Components == nil || h.deps.History == nil {
|
|
writeError(w, http.StatusInternalServerError, "failures unavailable")
|
|
return
|
|
}
|
|
var req struct {
|
|
ComponentSerial string `json:"component_serial"`
|
|
ServerSerial *string `json:"server_serial"`
|
|
FailureDate string `json:"failure_date"`
|
|
Description *string `json:"description"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
componentSerial := strings.TrimSpace(req.ComponentSerial)
|
|
if componentSerial == "" {
|
|
writeError(w, http.StatusBadRequest, "component_serial is required")
|
|
return
|
|
}
|
|
failureDateRaw := strings.TrimSpace(req.FailureDate)
|
|
failureDate, err := time.Parse("2006-01-02", failureDateRaw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failure_date must be YYYY-MM-DD")
|
|
return
|
|
}
|
|
failureAt := time.Date(failureDate.Year(), failureDate.Month(), failureDate.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
components, err := h.deps.Components.List(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "list components failed")
|
|
return
|
|
}
|
|
component, ok := findComponentByVendorSerial(components, componentSerial)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "component serial not found")
|
|
return
|
|
}
|
|
|
|
var machineByID = map[string]registryMachine{}
|
|
var allAssets []domain.Asset
|
|
if h.deps.Assets != nil {
|
|
list, listErr := h.deps.Assets.List(r.Context())
|
|
if listErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "list assets failed")
|
|
return
|
|
}
|
|
allAssets = list
|
|
for _, item := range list {
|
|
machineByID[item.ID] = registryMachine{ID: item.ID, Name: item.Name, VendorSerial: item.VendorSerial}
|
|
}
|
|
}
|
|
|
|
var currentAssetID *string
|
|
var currentSlot *string
|
|
if h.deps.Installations != nil {
|
|
refs, refsErr := h.deps.Installations.ListCurrentInstallationsByComponentIDs(r.Context(), []string{component.ID})
|
|
if refsErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "lookup component installation failed")
|
|
return
|
|
}
|
|
if ref, ok := refs[component.ID]; ok {
|
|
currentAssetID = &ref.AssetID
|
|
currentSlot = ref.SlotName
|
|
}
|
|
}
|
|
|
|
var machineID *string
|
|
if currentAssetID != nil {
|
|
machineID = currentAssetID
|
|
}
|
|
if reqServer := normalizeOptionalString(req.ServerSerial); reqServer != nil {
|
|
if h.deps.Assets == nil {
|
|
writeError(w, http.StatusInternalServerError, "assets unavailable")
|
|
return
|
|
}
|
|
if len(allAssets) == 0 {
|
|
var err error
|
|
allAssets, err = h.deps.Assets.List(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "list assets failed")
|
|
return
|
|
}
|
|
}
|
|
asset, found := findAssetByVendorSerial(allAssets, *reqServer)
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "server serial not found")
|
|
return
|
|
}
|
|
if currentAssetID != nil && *currentAssetID != asset.ID {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "server serial does not match current component installation",
|
|
"code": "server_mismatch",
|
|
})
|
|
return
|
|
}
|
|
machineID = &asset.ID
|
|
machineByID[asset.ID] = registryMachine{ID: asset.ID, Name: asset.Name, VendorSerial: asset.VendorSerial}
|
|
}
|
|
|
|
desc := normalizeOptionalString(req.Description)
|
|
externalID := buildManualFailureExternalIDForAPI(component.ID, failureAt, desc)
|
|
sourceRef := "ui_failures_manual:" + component.ID + ":" + failureAt.Format("2006-01-02")
|
|
var idemPtr *string
|
|
if idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")); idem != "" {
|
|
idemPtr = &idem
|
|
}
|
|
res, err := h.deps.History.RegisterManualComponentFailure(r.Context(), history.RegisterManualComponentFailureCommand{
|
|
ComponentID: component.ID,
|
|
MachineID: machineID,
|
|
FailureTime: failureAt,
|
|
Description: desc,
|
|
FailureType: "component_failed_manual",
|
|
FailureSource: "manual_ui",
|
|
FailureExternal: externalID,
|
|
SourceRef: &sourceRef,
|
|
IdempotencyKey: idemPtr,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, history.ErrInvalidPatch):
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
case errors.Is(err, history.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "component not found")
|
|
case errors.Is(err, history.ErrConflict):
|
|
writeError(w, http.StatusConflict, "history conflict")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "register failure failed")
|
|
}
|
|
return
|
|
}
|
|
|
|
var resolvedServer any
|
|
if machineID != nil {
|
|
if info, ok := machineByID[*machineID]; ok {
|
|
resolvedServer = map[string]any{
|
|
"id": info.ID,
|
|
"vendor_serial": info.VendorSerial,
|
|
"label": strings.TrimSpace(info.Name),
|
|
}
|
|
} else {
|
|
resolvedServer = map[string]any{"id": *machineID}
|
|
}
|
|
}
|
|
componentLabel := component.VendorSerial
|
|
if component.Model != nil && strings.TrimSpace(*component.Model) != "" {
|
|
componentLabel += " · " + strings.TrimSpace(*component.Model)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "registered",
|
|
"failure_event_id": res.FailureEventID,
|
|
"history_event_id": res.HistoryApply.EventID,
|
|
"resolved": map[string]any{
|
|
"component": map[string]any{
|
|
"id": component.ID,
|
|
"vendor_serial": component.VendorSerial,
|
|
"label": componentLabel,
|
|
},
|
|
"server": resolvedServer,
|
|
"slot": currentSlot,
|
|
},
|
|
})
|
|
}
|
|
|
|
type registryMachine struct {
|
|
ID string
|
|
Name string
|
|
VendorSerial string
|
|
}
|
|
|
|
func findComponentByVendorSerial(items []domain.Component, serial string) (domain.Component, bool) {
|
|
needle := strings.ToLower(strings.TrimSpace(serial))
|
|
for _, item := range items {
|
|
if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle {
|
|
return item, true
|
|
}
|
|
}
|
|
return domain.Component{}, false
|
|
}
|
|
|
|
func findAssetByVendorSerial(items []domain.Asset, serial string) (domain.Asset, bool) {
|
|
needle := strings.ToLower(strings.TrimSpace(serial))
|
|
for _, item := range items {
|
|
if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle {
|
|
return item, true
|
|
}
|
|
}
|
|
return domain.Asset{}, false
|
|
}
|
|
|
|
func buildManualFailureExternalIDForAPI(componentID string, failureAt time.Time, description *string) string {
|
|
normDesc := ""
|
|
if description != nil {
|
|
normDesc = strings.TrimSpace(*description)
|
|
}
|
|
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(componentID)) + "|" + failureAt.UTC().Format(time.RFC3339Nano) + "|" + strings.ToLower(normDesc)))
|
|
return "manual_ui:" + strings.TrimSpace(componentID) + ":" + failureAt.UTC().Format(time.RFC3339Nano) + ":" + hex.EncodeToString(sum[:4])
|
|
}
|