Files
cloud-ip-validator/internal/httpapi/handlers_config.go
T
ayurishchevandClaude Sonnet 5.5 e95b5eb7d5 Retry a failed self-check on another validator; add the self-check failure ceiling
A validator that failed the self-check of an address no longer gets that address
again in the current round (ClaimNextQueued skips it); the validator itself stays
in service and takes all other addresses. The verdict fail is set when the number
of failed self-checks of an address reaches settings.self_check_max_attempts
(1..50, default 5, independent of the number of validators); max_retries and
retry_count are no longer used for self-check. If every working validator has
already failed the address, a new round starts and the exclusions lapse.

Migration 0012: ip_self_check_failures (permanent history per registry address),
ip_queue.sc_failures and sc_round_start_cycle (cycle_id is used instead of
attempt_number, which restarts when a queue row is recreated), the setting.
db.FailSelfCheck does it in one transaction; re-submission starts a new series.
API: self_check_max_attempts in GET/PUT /admin/config/orchestrator,
self_check_failed_on in /admin/ips/{ip} and /admin/registry/{ip}. Dashboard: the
field on /settings and the line "Self-check не прошёл на: ..." on the address
pages. Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:50:12 +03:00

280 lines
8.8 KiB
Go

package httpapi
import (
"fmt"
"net/http"
"strconv"
"cloudipvalidator/internal/db"
)
// --- validators ---
func (s *Server) handleConfigListValidators(w http.ResponseWriter, r *http.Request) {
validators, err := s.DB.ListValidators(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]validatorDTO, len(validators))
for i, v := range validators {
out[i] = validatorDTO{
ValidatorID: v.ValidatorID, Hostname: v.Hostname, OSPortID: v.OSPortID,
State: v.State, LastHeartbeatAt: v.LastHeartbeatAt,
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleConfigCreateValidator(w http.ResponseWriter, r *http.Request) {
var req createValidatorRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if req.ValidatorID == "" {
writeError(w, http.StatusBadRequest, "validator_id must not be empty")
return
}
if err := s.DB.AdminCreateValidator(r.Context(), req.ValidatorID, req.OSPortID); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusCreated, validatorDTO{ValidatorID: req.ValidatorID, OSPortID: req.OSPortID, State: "idle"})
}
func (s *Server) handleConfigUpdateValidator(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req updateValidatorRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.DB.AdminUpdateValidatorPort(r.Context(), id, req.OSPortID); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleConfigDeleteValidator(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.DB.DeleteValidator(r.Context(), id); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// --- sites ---
func (s *Server) handleConfigListSites(w http.ResponseWriter, r *http.Request) {
sites, err := s.DB.ListSites(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]siteDTO, len(sites))
for i, site := range sites {
out[i] = siteDTO{
Index: site.Index, SiteID: site.SiteID, Hostname: site.Hostname,
State: site.State, LastHeartbeatAt: site.LastHeartbeatAt,
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleConfigPutSite(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be an integer")
return
}
var req putSiteRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.DB.UpsertSite(r.Context(), idx, req.SiteID); err != nil {
writeDBError(w, err)
return
}
// UpsertSite always resets hostname/state/last_heartbeat_at to their
// "never connected" defaults on write (see its doc comment).
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: db.SiteUnregistered})
}
func (s *Server) handleConfigDeleteSite(w http.ResponseWriter, r *http.Request) {
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be an integer")
return
}
if err := s.DB.DeleteSite(r.Context(), idx); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// --- target groups ---
func (s *Server) handleConfigListTargets(w http.ResponseWriter, r *http.Request) {
groups, err := s.DB.ListTargetGroups(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]targetGroupDTO, len(groups))
for i, g := range groups {
out[i] = targetGroupDTO{Name: g.Name, Targets: g.Targets}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleConfigPutTargetGroup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("group")
var req putTargetGroupRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.DB.UpsertTargetGroup(r.Context(), name, req.Targets); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, targetGroupDTO{Name: name, Targets: req.Targets})
}
func (s *Server) handleConfigDeleteTargetGroup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("group")
if err := s.DB.DeleteTargetGroup(r.Context(), name); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// --- check types ---
func (s *Server) handleConfigListCheckTypes(w http.ResponseWriter, r *http.Request) {
checkTypes, err := s.DB.ListCheckTypes(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]checkTypeDTO, len(checkTypes))
for i, ct := range checkTypes {
out[i] = checkTypeDTO{Name: ct.Name, Enabled: ct.Enabled, Targets: ct.TargetGroups}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleConfigPutCheckType(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var req putCheckTypeRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.DB.UpsertCheckType(r.Context(), name, req.Enabled, req.Targets); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, checkTypeDTO{Name: name, Enabled: req.Enabled, Targets: req.Targets})
}
func (s *Server) handleConfigDeleteCheckType(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := s.DB.DeleteCheckType(r.Context(), name); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// --- orchestrator settings ---
func (s *Server) handleConfigGetOrchestratorSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.DB.GetSettings(r.Context())
if err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
FIPSettleSeconds: settings.FIPSettleSeconds,
HistoryRetentionCycles: settings.HistoryRetentionCycles,
SelfCheckMaxAttempts: settings.SelfCheckMaxAttempts,
})
}
// handleConfigPutOrchestratorSettings goes through the orchestrator (not a
// direct DB call, unlike the read above) because setting fip_settle_seconds
// needs cross-field validation against the static lease_ttl_seconds/
// self_check_timeout_seconds config, which only the orchestrator has.
func (s *Server) handleConfigPutOrchestratorSettings(w http.ResponseWriter, r *http.Request) {
var req putOrchestratorSettingsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
// Checked before anything is saved, so a bad ceiling does not leave the
// other two settings half-applied. An omitted field keeps the current value.
if a := req.SelfCheckMaxAttempts; a != nil && (*a < db.MinSelfCheckMaxAttempts || *a > db.MaxSelfCheckMaxAttempts) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("self_check_max_attempts must be in %d..%d", db.MinSelfCheckMaxAttempts, db.MaxSelfCheckMaxAttempts))
return
}
if err := s.Orch.SetFIPSettleSeconds(r.Context(), req.FIPSettleSeconds); err != nil {
writeDBError(w, err)
return
}
if err := s.DB.SetHistoryRetentionCycles(r.Context(), req.HistoryRetentionCycles); err != nil {
writeDBError(w, err)
return
}
if req.SelfCheckMaxAttempts != nil {
if err := s.DB.SetSelfCheckMaxAttempts(r.Context(), *req.SelfCheckMaxAttempts); err != nil {
writeDBError(w, err)
return
}
}
// Answer with what is stored now, so an omitted ceiling shows its current value.
settings, err := s.DB.GetSettings(r.Context())
if err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
FIPSettleSeconds: settings.FIPSettleSeconds,
HistoryRetentionCycles: settings.HistoryRetentionCycles,
SelfCheckMaxAttempts: settings.SelfCheckMaxAttempts,
})
}
// --- prober inbound checks ---
func (s *Server) handleConfigGetInboundChecks(w http.ResponseWriter, r *http.Request) {
settings, err := s.DB.GetInboundChecks(r.Context())
if err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: settings.Ports, ICMP: settings.ICMP})
}
// handleConfigPutInboundChecks writes directly to the DB (unlike the
// orchestrator-settings PUT above) because port-range/duplicate validation
// is purely local — no cross-field dependency on other orchestrator config.
func (s *Server) handleConfigPutInboundChecks(w http.ResponseWriter, r *http.Request) {
var req inboundChecksDTO
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.DB.SetInboundChecks(r.Context(), req.Ports, req.ICMP); err != nil {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: req.Ports, ICMP: req.ICMP})
}