New external site management. New external prober heartbeat feature.

This commit is contained in:
ayurishchev committed 2026-08-26 20:47:54 +03:00
1 parent 42f584dd3c
commit ef24cc9858
38 files changed
+1058 -158

No files matched your search

+7 -2
View File
@@ -1,5 +1,7 @@
package httpapi
import "time"
// DTOs for the admin queue-management and dynamic-config endpoints
// (/api/v1/admin/ips, /api/v1/admin/config/*). Unlike the older read-only
// admin endpoints (which marshal internal/db model structs directly, in
@@ -46,8 +48,11 @@ type updateValidatorRequest struct {
}
type siteDTO struct {
Index int `json:"index"`
SiteID string `json:"site_id"`
Index int `json:"index"`
SiteID string `json:"site_id"`
Hostname string `json:"hostname"`
State string `json:"state"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
}
type putSiteRequest struct {
+9 -2
View File
@@ -3,6 +3,8 @@ package httpapi
import (
"net/http"
"strconv"
"cloudipvalidator/internal/db"
)
// --- validators ---
@@ -70,7 +72,10 @@ func (s *Server) handleConfigListSites(w http.ResponseWriter, r *http.Request) {
}
out := make([]siteDTO, len(sites))
for i, site := range sites {
out[i] = siteDTO{Index: site.Index, SiteID: site.SiteID}
out[i] = siteDTO{
Index: site.Index, SiteID: site.SiteID, Hostname: site.Hostname,
State: site.State, LastHeartbeatAt: site.LastHeartbeatAt,
}
}
writeJSON(w, http.StatusOK, out)
}
@@ -90,7 +95,9 @@ func (s *Server) handleConfigPutSite(w http.ResponseWriter, r *http.Request) {
writeDBError(w, err)
return
}
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID})
// 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) {
+107
View File
@@ -623,3 +623,110 @@ func TestInboundChecksReflectedInProberAssignmentsWithoutRestart(t *testing.T) {
t.Fatalf("expected updated {[8080] false} without restart, got %+v", assignments)
}
}
// TestProberRegisterSetsHostnameAndIdleState proves POST
// /api/v1/probers/register persists the calling prober's hostname and
// flips the site's state to idle, visible via GET
// /api/v1/admin/config/sites — the prober-availability analog of
// validator registration.
func TestProberRegisterSetsHostnameAndIdleState(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "site-1"})
resp, body := fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites: status=%d body=%s", resp.StatusCode, body)
}
var sites []siteDTO
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if len(sites) != 1 || sites[0].State != "unregistered" {
t.Fatalf("expected freshly-created slot unregistered, got %+v", sites)
}
resp, body = fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-1", Hostname: "probe-host-1"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("register prober: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites after register: status=%d body=%s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if sites[0].State != "idle" || sites[0].Hostname != "probe-host-1" {
t.Fatalf("expected {state:idle, hostname:probe-host-1}, got %+v", sites[0])
}
if sites[0].LastHeartbeatAt == nil {
t.Fatalf("expected last_heartbeat_at stamped")
}
}
// TestProberHeartbeat404UnknownSite proves the heartbeat endpoint rejects
// an unconfigured site_id, mirroring the validator heartbeat's 404.
func TestProberHeartbeat404UnknownSite(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
resp, body := fc.do(http.MethodPost, "/api/v1/probers/unknown-site/heartbeat", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 for unknown site_id, status=%d body=%s", resp.StatusCode, body)
}
}
// TestSweepStaleSiteHeartbeatsMarksUnreachable is the end-to-end proof that
// a prober that stops heartbeating gets marked unreachable by the sweep,
// visible via the admin API — the prober-side analog of
// TestFIPSettleDelayGatesAssignmentEndpoint's wire-level style.
func TestSweepStaleSiteHeartbeatsMarksUnreachable(t *testing.T) {
fc, d, orch, _ := newConfigTestHarness(t)
ctx := context.Background()
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "site-1"})
resp, body := fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-1", Hostname: "probe-host-1"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("register prober: status=%d body=%s", resp.StatusCode, body)
}
// Backdate last_heartbeat_at past HeartbeatTimeoutSeconds (30s, per
// newConfigTestHarness) directly in the DB, rather than sleeping 30+
// real seconds in the test.
old := db.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
if _, err := d.ExecContext(ctx, `UPDATE sites SET last_heartbeat_at=? WHERE site_id=?`, old, "site-1"); err != nil {
t.Fatalf("backdate heartbeat: %v", err)
}
if err := orch.SweepStaleSiteHeartbeats(ctx); err != nil {
t.Fatalf("sweep stale site heartbeats: %v", err)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites: status=%d body=%s", resp.StatusCode, body)
}
var sites []siteDTO
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if len(sites) != 1 || sites[0].State != "unreachable" {
t.Fatalf("expected site-1 marked unreachable after sweep, got %+v", sites)
}
// A fresh heartbeat brings it back to idle.
resp, body = fc.do(http.MethodPost, "/api/v1/probers/site-1/heartbeat", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("heartbeat: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites after heartbeat: status=%d body=%s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if sites[0].State != "idle" {
t.Fatalf("expected site-1 back to idle after heartbeat, got %+v", sites)
}
}
+15
View File
@@ -22,10 +22,25 @@ func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID)
return
}
if err := s.DB.RegisterSiteProber(r.Context(), req.SiteID, req.Hostname); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.Orch.RecordEvent(r.Context(), "prober", req.SiteID, nil, "registered", "")
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
}
func (s *Server) handleProberHeartbeat(w http.ResponseWriter, r *http.Request) {
siteID := r.PathValue("site_id")
var req heartbeatRequest
_ = readJSON(r, &req) // heartbeat body is informational only; tolerate empty/missing
if err := s.DB.SiteHeartbeat(r.Context(), siteID); err != nil {
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// handleProberAssignments returns every IP currently in the checking
// state — probers work the whole active set each poll, not one IP at a
// time, since multiple validators run in parallel.
+1
View File
@@ -14,6 +14,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
mux.HandleFunc("POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat)
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)