repo init
This commit is contained in:
commit
7e44db87b2
48 files changed
+5346
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package httpapi
|
||||
|
||||
// Request/response bodies for the /api/v1 surface. Kept in one file since
|
||||
// they're small and mostly 1:1 with a single handler each.
|
||||
|
||||
type registerAgentRequest struct {
|
||||
ValidatorID string `json:"validator_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
}
|
||||
|
||||
type registerAgentResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
PollIntervalSeconds int `json:"poll_interval_seconds"`
|
||||
}
|
||||
|
||||
type heartbeatRequest struct {
|
||||
LocalState string `json:"local_state"`
|
||||
CurrentIPAddress string `json:"current_ip_address,omitempty"`
|
||||
}
|
||||
|
||||
type okResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type checkConfigDTO struct {
|
||||
Type string `json:"type"`
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type assignmentResponse struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Phase string `json:"phase"`
|
||||
CheckConfig []checkConfigDTO `json:"check_config,omitempty"`
|
||||
}
|
||||
|
||||
type selfCheckRequest struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
DetectedEgress string `json:"detected_egress_ip"`
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type agentEventRequest struct {
|
||||
EventType string `json:"event_type"`
|
||||
IPID *int64 `json:"ip_id,omitempty"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type checkResultDTO struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
CheckType string `json:"check_type"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
type agentResultsRequest struct {
|
||||
Results []checkResultDTO `json:"results"`
|
||||
}
|
||||
|
||||
type agentCompleteRequest struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
}
|
||||
|
||||
type registerProberRequest struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
type proberAssignment struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Ports []int `json:"ports"`
|
||||
ICMP bool `json:"icmp"`
|
||||
}
|
||||
|
||||
type proberResultDTO struct {
|
||||
IPID int64 `json:"ip_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
CheckType string `json:"check_type"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type proberResultsRequest struct {
|
||||
Results []proberResultDTO `json:"results"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ips, err := s.DB.ListIPs(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
validators, err := s.DB.ListValidators(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byState := map[string]int{}
|
||||
for _, ip := range ips {
|
||||
byState[ip.State]++
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"total_ips": len(ips),
|
||||
"ips_by_state": byState,
|
||||
"total_validators": len(validators),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIPs(w http.ResponseWriter, r *http.Request) {
|
||||
ips, err := s.DB.ListIPs(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ips)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIPDetail(w http.ResponseWriter, r *http.Request) {
|
||||
address := r.PathValue("ip")
|
||||
item, err := s.DB.GetIPByAddress(r.Context(), address)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown ip: "+address)
|
||||
return
|
||||
}
|
||||
checks, err := s.DB.ListChecksForAttempt(r.Context(), item.ID, item.AttemptNumber)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
events, err := s.DB.ListEventsForIP(r.Context(), item.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
IP *db.IPQueueItem `json:"ip"`
|
||||
Checks []db.Check `json:"checks"`
|
||||
Events []db.Event `json:"events"`
|
||||
}{item, checks, events})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminValidators(w http.ResponseWriter, r *http.Request) {
|
||||
validators, err := s.DB.ListValidators(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, validators)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleAgentRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerAgentRequest
|
||||
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 is required")
|
||||
return
|
||||
}
|
||||
// os_port_id is supplied via control-api's own config (config.ValidatorConfig),
|
||||
// not by the agent, so registration only touches hostname/version here;
|
||||
// RegisterValidator preserves any existing os_port_id row.
|
||||
existing, _ := s.DB.GetValidator(r.Context(), req.ValidatorID)
|
||||
osPortID := ""
|
||||
if existing != nil {
|
||||
osPortID = existing.OSPortID
|
||||
}
|
||||
if err := s.DB.RegisterValidator(r.Context(), req.ValidatorID, req.Hostname, osPortID, req.AgentVersion); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.Orch.RecordEvent(r.Context(), "validator-agent", req.ValidatorID, nil, "registered", "")
|
||||
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req heartbeatRequest
|
||||
_ = readJSON(r, &req) // heartbeat body is informational only; tolerate empty/missing
|
||||
if err := s.DB.Heartbeat(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown validator: "+id)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
item, checks, err := s.Orch.AssignmentForValidator(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown validator: "+id)
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var cfg []checkConfigDTO
|
||||
for _, c := range checks {
|
||||
cfg = append(cfg, checkConfigDTO{Type: c.Type, Targets: c.Targets})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, assignmentResponse{
|
||||
IPID: item.ID, IPAddress: item.IPAddress, Phase: item.State, CheckConfig: cfg,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentSelfCheck(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req selfCheckRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
detail := req.Detail
|
||||
if req.DetectedEgress != "" {
|
||||
detail = "detected_egress_ip=" + req.DetectedEgress + " " + detail
|
||||
}
|
||||
if err := s.Orch.SelfCheckResult(r.Context(), id, req.IPID, req.Success, detail); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentEvent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req agentEventRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.EventType == "" {
|
||||
writeError(w, http.StatusBadRequest, "event_type is required")
|
||||
return
|
||||
}
|
||||
s.Orch.RecordEvent(r.Context(), "validator-agent", id, req.IPID, req.EventType, req.Payload)
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentResults(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req agentResultsRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, res := range req.Results {
|
||||
item, err := s.DB.GetIP(r.Context(), res.IPID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown ip_id")
|
||||
return
|
||||
}
|
||||
checkedAt, err := time.Parse(time.RFC3339Nano, res.CheckedAt)
|
||||
if err != nil {
|
||||
checkedAt = db.Now()
|
||||
}
|
||||
err = s.Orch.RecordCheck(r.Context(), db.Check{
|
||||
IPID: item.ID, IPAddress: item.IPAddress, AttemptNumber: item.AttemptNumber,
|
||||
ValidatorID: id, Source: db.SourceEgress, CheckType: res.CheckType, Target: res.Target,
|
||||
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail, CheckedAt: checkedAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var req agentCompleteRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.Orch.MarkEgressComplete(r.Context(), req.IPID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleWhatsMyIP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ip": remoteIP(r)})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerProberRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.Orch.SiteIndexForID(req.SiteID) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID)
|
||||
return
|
||||
}
|
||||
s.Orch.RecordEvent(r.Context(), "prober", req.SiteID, nil, "registered", "")
|
||||
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Server) handleProberAssignments(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.PathValue("site_id")
|
||||
if s.Orch.SiteIndexForID(siteID) == 0 {
|
||||
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
|
||||
return
|
||||
}
|
||||
items, err := s.DB.ListChecking(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]proberAssignment, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, proberAssignment{
|
||||
IPID: item.ID, IPAddress: item.IPAddress,
|
||||
Ports: s.Orch.Inbound.Ports, ICMP: s.Orch.Inbound.ICMP,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleProberResults(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.PathValue("site_id")
|
||||
siteIndex := s.Orch.SiteIndexForID(siteID)
|
||||
if siteIndex == 0 {
|
||||
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
|
||||
return
|
||||
}
|
||||
var req proberResultsRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
completed := map[int64]bool{}
|
||||
for _, res := range req.Results {
|
||||
item, err := s.DB.GetIP(r.Context(), res.IPID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown ip_id")
|
||||
return
|
||||
}
|
||||
checkedAt, err := time.Parse(time.RFC3339Nano, res.CheckedAt)
|
||||
if err != nil {
|
||||
checkedAt = db.Now()
|
||||
}
|
||||
err = s.Orch.RecordCheck(r.Context(), db.Check{
|
||||
IPID: item.ID, IPAddress: item.IPAddress, AttemptNumber: item.AttemptNumber,
|
||||
Source: db.InboundSource(siteIndex), CheckType: res.CheckType, Target: res.IPAddress,
|
||||
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail, CheckedAt: checkedAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if res.Complete {
|
||||
completed[res.IPID] = true
|
||||
}
|
||||
}
|
||||
for ipID := range completed {
|
||||
if err := s.Orch.MarkSiteComplete(r.Context(), ipID, siteIndex); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/config"
|
||||
"cloudipvalidator/internal/db"
|
||||
"cloudipvalidator/internal/openstack"
|
||||
"cloudipvalidator/internal/orchestrator"
|
||||
)
|
||||
|
||||
// fakeClient plays both a validator-agent and the three probers against a
|
||||
// real httptest server, driving the full protocol exactly as the real
|
||||
// binaries would, to prove the HTTP layer and orchestrator agree on state
|
||||
// transitions end-to-end.
|
||||
type fakeClient struct {
|
||||
t *testing.T
|
||||
base string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (f *fakeClient) do(method, path string, body interface{}) (*http.Response, []byte) {
|
||||
f.t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
f.t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, f.base+path, reader)
|
||||
if err != nil {
|
||||
f.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
f.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return resp, respBody
|
||||
}
|
||||
|
||||
func TestEndToEndHTTPFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
d, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
mock := openstack.NewMockClient()
|
||||
mock.Seed("fip-1", "1.2.3.4", "svc-project")
|
||||
|
||||
cfg := &config.ControlAPI{
|
||||
Orchestrator: config.OrchestratorConfig{
|
||||
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
|
||||
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
|
||||
},
|
||||
Aggregation: config.AggregationConfig{MissingCountsAsFail: true},
|
||||
Sites: []config.SiteConfig{
|
||||
{SiteID: "site-1", Index: 1}, {SiteID: "site-2", Index: 2}, {SiteID: "site-3", Index: 3},
|
||||
},
|
||||
CheckTypes: []config.CheckTypeConfig{{Name: "https", Enabled: true, Targets: []string{"web"}}},
|
||||
Targets: map[string][]string{"web": {"https://example.test"}},
|
||||
Inbound: config.InboundConfig{Ports: []int{22, 80}, ICMP: true},
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
orch := orchestrator.New(d, mock, cfg, log)
|
||||
|
||||
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
|
||||
t.Fatalf("seed queue: %v", err)
|
||||
}
|
||||
|
||||
srv := New(d, orch, log)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
fc := &fakeClient{t: t, base: ts.URL, client: ts.Client()}
|
||||
|
||||
// Register the validator directly via DB (os_port_id comes from
|
||||
// control-api config, not the agent's own registration call).
|
||||
if err := d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1"); err != nil {
|
||||
t.Fatalf("register validator: %v", err)
|
||||
}
|
||||
|
||||
// Agent re-registers over HTTP (as the real binary would at startup).
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{
|
||||
ValidatorID: "validator-1", Hostname: "host-1", AgentVersion: "v0.1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("register: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Orchestrator claims the IP and associates the FIP.
|
||||
orch.Tick(ctx)
|
||||
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/agents/validator-1/assignment", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("assignment: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var assignment assignmentResponse
|
||||
if err := json.Unmarshal(body, &assignment); err != nil {
|
||||
t.Fatalf("unmarshal assignment: %v", err)
|
||||
}
|
||||
if assignment.Phase != db.IPAwaitingSelfCheck {
|
||||
t.Fatalf("expected awaiting_self_check, got %s", assignment.Phase)
|
||||
}
|
||||
if assignment.IPAddress != "1.2.3.4" {
|
||||
t.Fatalf("expected 1.2.3.4, got %s", assignment.IPAddress)
|
||||
}
|
||||
|
||||
// Self-check via /whatsmyip: in the real deployment this would equal
|
||||
// the FIP; here we just exercise the endpoint and always report success.
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/whatsmyip", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("whatsmyip: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{
|
||||
IPID: assignment.IPID, DetectedEgress: "1.2.3.4", Success: true, Detail: "matched",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("self-check: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Agent runs its configured egress check and reports the result.
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{
|
||||
Results: []checkResultDTO{{
|
||||
IPID: assignment.IPID, CheckType: "https", Target: "https://example.test",
|
||||
Success: true, LatencyMS: 12, CheckedAt: time.Now().Format(time.RFC3339Nano),
|
||||
}},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("results: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/complete", agentCompleteRequest{IPID: assignment.IPID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("complete: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Three probers register, poll, and report inbound results.
|
||||
for _, site := range []string{"site-1", "site-2", "site-3"} {
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: site})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("prober register %s: status=%d body=%s", site, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/probers/"+site+"/assignments", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("prober assignments %s: status=%d body=%s", site, resp.StatusCode, body)
|
||||
}
|
||||
var assignments []proberAssignment
|
||||
if err := json.Unmarshal(body, &assignments); err != nil {
|
||||
t.Fatalf("unmarshal assignments: %v", err)
|
||||
}
|
||||
if len(assignments) != 1 || assignments[0].IPAddress != "1.2.3.4" {
|
||||
t.Fatalf("expected 1 assignment for 1.2.3.4, got %+v", assignments)
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339Nano)
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/probers/"+site+"/results", proberResultsRequest{
|
||||
Results: []proberResultDTO{
|
||||
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-22", Success: true, CheckedAt: now},
|
||||
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-80", Success: true, CheckedAt: now},
|
||||
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "icmp", Success: true, CheckedAt: now, Complete: true},
|
||||
},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("prober results %s: status=%d body=%s", site, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Orchestrator sweep should now aggregate and release.
|
||||
orch.Tick(ctx)
|
||||
|
||||
resp, body = fc.do(http.MethodGet, "/api/v1/admin/ips/1.2.3.4", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin ip detail: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var detail struct {
|
||||
IP db.IPQueueItem `json:"ip"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &detail); err != nil {
|
||||
t.Fatalf("unmarshal detail: %v", err)
|
||||
}
|
||||
if detail.IP.State != db.IPDone {
|
||||
t.Fatalf("expected done, got %s", detail.IP.State)
|
||||
}
|
||||
if detail.IP.OverallResult != db.ResultPass {
|
||||
t.Fatalf("expected pass, got %s", detail.IP.OverallResult)
|
||||
}
|
||||
|
||||
if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" {
|
||||
t.Fatalf("expected fip disassociated at end of run")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /api/v1/whatsmyip", s.handleWhatsMyIP)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/agents/register", s.handleAgentRegister)
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment)
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck)
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/events", s.handleAgentEvent)
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/results", s.handleAgentResults)
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
|
||||
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
|
||||
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/status", s.handleAdminStatus)
|
||||
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
|
||||
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail)
|
||||
mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package httpapi exposes the Control API's HTTP surface — the only way
|
||||
// validator-agents, probers, and operators interact with the system. All
|
||||
// business logic lives in internal/orchestrator; handlers here do request
|
||||
// parsing/validation, call into the orchestrator or db package, and shape
|
||||
// the JSON response.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
"cloudipvalidator/internal/orchestrator"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
DB *db.DB
|
||||
Orch *orchestrator.Orchestrator
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
||||
return &Server{DB: d, Orch: o, Log: log}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
return loggingMiddleware(s.Log, mux)
|
||||
}
|
||||
|
||||
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
log.Debug("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v interface{}) error {
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return nil
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// remoteIP returns the caller's source IP with any port stripped. Used by
|
||||
// /whatsmyip — the validator-agent's self-check mechanism relies on this
|
||||
// being the actual TCP peer address (as SNAT'd by the newly associated
|
||||
// FIP), never a client-supplied header.
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in new issue
Block a user