New external site management. New external prober heartbeat feature.
This commit is contained in:
1 parent
42f584dd3c
commit
ef24cc9858
38 files changed
+1058
-158
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user