admin control features and admin dashboard
This commit is contained in:
1 parent
c630f13c57
commit
37910e410b
69 files changed
+4959
-400
No files matched your search
@@ -0,0 +1,62 @@
|
||||
package httpapi
|
||||
|
||||
// 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
|
||||
// PascalCase), these use explicit snake_case JSON tags to match the
|
||||
// agent/prober DTO convention.
|
||||
|
||||
type submitIPsRequest struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
type submitIPsResponse struct {
|
||||
Added []string `json:"added"`
|
||||
Requeued []string `json:"requeued"`
|
||||
Reordered []string `json:"reordered"`
|
||||
SkippedInProgress []string `json:"skipped_in_progress"`
|
||||
}
|
||||
|
||||
type validatorDTO struct {
|
||||
ValidatorID string `json:"validator_id"`
|
||||
OSPortID string `json:"os_port_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type createValidatorRequest struct {
|
||||
ValidatorID string `json:"validator_id"`
|
||||
OSPortID string `json:"os_port_id"`
|
||||
}
|
||||
|
||||
type updateValidatorRequest struct {
|
||||
OSPortID string `json:"os_port_id"`
|
||||
}
|
||||
|
||||
type siteDTO struct {
|
||||
Index int `json:"index"`
|
||||
SiteID string `json:"site_id"`
|
||||
}
|
||||
|
||||
type putSiteRequest struct {
|
||||
SiteID string `json:"site_id"`
|
||||
}
|
||||
|
||||
type targetGroupDTO struct {
|
||||
Name string `json:"name"`
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type putTargetGroupRequest struct {
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type checkTypeDTO struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type putCheckTypeRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
@@ -73,3 +73,49 @@ func (s *Server) handleAdminValidators(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, validators)
|
||||
}
|
||||
|
||||
// handleAdminSubmitIPs is the single entry point for both adding new
|
||||
// addresses to the queue and forcing a re-check of already-finished ones —
|
||||
// see db.SubmitIPs for the exact per-address rules. It's a direct DB call
|
||||
// (no OpenStack interaction is needed to merely queue work), matching the
|
||||
// existing admin handlers above which also bypass the orchestrator for
|
||||
// reads.
|
||||
func (s *Server) handleAdminSubmitIPs(w http.ResponseWriter, r *http.Request) {
|
||||
var req submitIPsRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
result, err := s.DB.SubmitIPs(r.Context(), req.Addresses)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, submitIPsResponse{
|
||||
Added: emptyIfNil(result.Added),
|
||||
Requeued: emptyIfNil(result.Requeued),
|
||||
Reordered: emptyIfNil(result.Reordered),
|
||||
SkippedInProgress: emptyIfNil(result.SkippedInProgress),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminCancelIP force-stops a check in progress (or still-queued) for
|
||||
// the given address. Requires the orchestrator, since a floating IP may
|
||||
// need to be disassociated in OpenStack.
|
||||
func (s *Server) handleAdminCancelIP(w http.ResponseWriter, r *http.Request) {
|
||||
address := r.PathValue("ip")
|
||||
if err := s.Orch.ForceCancel(r.Context(), address); err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// emptyIfNil turns a nil slice into an empty one so these fields always
|
||||
// marshal as `[]` rather than `null`.
|
||||
func emptyIfNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// --- 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, OSPortID: v.OSPortID, State: v.State}
|
||||
}
|
||||
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}
|
||||
}
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/config"
|
||||
"cloudipvalidator/internal/db"
|
||||
"cloudipvalidator/internal/openstack"
|
||||
"cloudipvalidator/internal/orchestrator"
|
||||
)
|
||||
|
||||
// newConfigTestHarness sets up a control-api stack with an *empty*
|
||||
// control-api.yaml (no validators/sites/targets/check_types) — everything
|
||||
// in this test is created purely through the admin API, to prove the
|
||||
// dynamic-config path works with no YAML at all.
|
||||
func newConfigTestHarness(t *testing.T) (*fakeClient, *db.DB, *orchestrator.Orchestrator, *openstack.MockClient) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
mock := openstack.NewMockClient()
|
||||
|
||||
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},
|
||||
Inbound: config.InboundConfig{Ports: []int{22, 80}, ICMP: true},
|
||||
}
|
||||
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
|
||||
t.Fatalf("bootstrap from (empty) config: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
orch := orchestrator.New(d, mock, cfg, log)
|
||||
srv := New(d, orch, log)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return &fakeClient{t: t, base: ts.URL, client: ts.Client()}, d, orch, mock
|
||||
}
|
||||
|
||||
// TestConfigManagedEntirelyViaAPI proves an operator can stand up a working
|
||||
// validator/site/target-group/check-type configuration using only the
|
||||
// admin API — no YAML at all — and that an IP submitted afterwards passes
|
||||
// through the full checking cycle to `done`.
|
||||
func TestConfigManagedEntirelyViaAPI(t *testing.T) {
|
||||
fc, d, orch, mock := newConfigTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mock.Seed("fip-1", "9.9.9.9", "svc-project")
|
||||
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{
|
||||
ValidatorID: "validator-1", OSPortID: "port-1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create validator: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{
|
||||
Targets: []string{"https://example.test"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put target group: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{
|
||||
Enabled: true, Targets: []string{"web"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put check type: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// No sites configured -> inbound checks stay opt-out; egress alone
|
||||
// should be enough to reach `done`.
|
||||
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("submit ips: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var submitResp submitIPsResponse
|
||||
if err := json.Unmarshal(body, &submitResp); err != nil {
|
||||
t.Fatalf("unmarshal submit response: %v", err)
|
||||
}
|
||||
if len(submitResp.Added) != 1 || submitResp.Added[0] != "9.9.9.9" {
|
||||
t.Fatalf("expected 9.9.9.9 added, got %+v", submitResp)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("register agent: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
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 len(assignment.CheckConfig) != 1 || assignment.CheckConfig[0].Type != "https" {
|
||||
t.Fatalf("expected the API-created https check type in the assignment, got %+v", assignment.CheckConfig)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{
|
||||
IPID: assignment.IPID, DetectedEgress: "9.9.9.9", Success: true,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("self-check: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
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, 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)
|
||||
}
|
||||
|
||||
orch.Tick(ctx)
|
||||
|
||||
item, err := d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip: %v", err)
|
||||
}
|
||||
if item.State != db.IPDone || item.OverallResult != db.ResultPass {
|
||||
t.Fatalf("expected done/pass, got state=%s result=%s", item.State, item.OverallResult)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitIPsForcesRecheckOfFinishedAddress proves that resubmitting an
|
||||
// address that already reached `done` starts a brand-new checking cycle
|
||||
// rather than being ignored.
|
||||
func TestSubmitIPsForcesRecheckOfFinishedAddress(t *testing.T) {
|
||||
fc, d, orch, mock := newConfigTestHarness(t)
|
||||
ctx := context.Background()
|
||||
mock.Seed("fip-1", "9.9.9.9", "svc-project")
|
||||
|
||||
fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"})
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{Targets: []string{"https://example.test"}})
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{Enabled: true, Targets: []string{"web"}})
|
||||
|
||||
runOneCycle := func() {
|
||||
orch.Tick(ctx)
|
||||
_, body := fc.do(http.MethodGet, "/api/v1/agents/validator-1/assignment", nil)
|
||||
var assignment assignmentResponse
|
||||
if err := json.Unmarshal(body, &assignment); err != nil {
|
||||
t.Fatalf("unmarshal assignment: %v", err)
|
||||
}
|
||||
fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{
|
||||
IPID: assignment.IPID, DetectedEgress: "9.9.9.9", Success: true,
|
||||
})
|
||||
fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{
|
||||
Results: []checkResultDTO{{
|
||||
IPID: assignment.IPID, CheckType: "https", Target: "https://example.test",
|
||||
Success: true, CheckedAt: time.Now().Format(time.RFC3339Nano),
|
||||
}},
|
||||
})
|
||||
fc.do(http.MethodPost, "/api/v1/agents/validator-1/complete", agentCompleteRequest{IPID: assignment.IPID})
|
||||
orch.Tick(ctx)
|
||||
}
|
||||
|
||||
fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"})
|
||||
fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}})
|
||||
runOneCycle()
|
||||
|
||||
item, err := d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip: %v", err)
|
||||
}
|
||||
if item.State != db.IPDone || item.AttemptNumber != 1 {
|
||||
t.Fatalf("expected done after first cycle with attempt_number=1, got state=%s attempt=%d", item.State, item.AttemptNumber)
|
||||
}
|
||||
|
||||
// Force a recheck of the same, already-finished address. The mock FIP
|
||||
// was disassociated at the end of the first cycle; associateFIP will
|
||||
// simply re-associate it during the second cycle.
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("submit ips (recheck): status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var submitResp submitIPsResponse
|
||||
if err := json.Unmarshal(body, &submitResp); err != nil {
|
||||
t.Fatalf("unmarshal submit response: %v", err)
|
||||
}
|
||||
if len(submitResp.Requeued) != 1 || submitResp.Requeued[0] != "9.9.9.9" {
|
||||
t.Fatalf("expected 9.9.9.9 to be requeued, got %+v", submitResp)
|
||||
}
|
||||
|
||||
item, err = d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip after resubmit: %v", err)
|
||||
}
|
||||
if item.State != db.IPQueued || item.AttemptNumber != 2 || item.OverallResult != "" {
|
||||
t.Fatalf("expected freshly queued with attempt_number=2, got %+v", item)
|
||||
}
|
||||
|
||||
runOneCycle()
|
||||
item, err = d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip after second cycle: %v", err)
|
||||
}
|
||||
if item.State != db.IPDone || item.OverallResult != db.ResultPass || item.AttemptNumber != 2 {
|
||||
t.Fatalf("expected done/pass on second attempt, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForceCancelMidCheck proves POST /admin/ips/{ip}/cancel stops an
|
||||
// in-progress check, disassociates its floating IP, and frees the
|
||||
// validator, without waiting for the checking window to elapse.
|
||||
func TestForceCancelMidCheck(t *testing.T) {
|
||||
fc, d, orch, mock := newConfigTestHarness(t)
|
||||
ctx := context.Background()
|
||||
mock.Seed("fip-1", "9.9.9.9", "svc-project")
|
||||
|
||||
fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"})
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{Targets: []string{"https://example.test"}})
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{Enabled: true, Targets: []string{"web"}})
|
||||
fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"})
|
||||
fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}})
|
||||
|
||||
orch.Tick(ctx) // claim + associate FIP -> awaiting_self_check
|
||||
|
||||
item, err := d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip: %v", err)
|
||||
}
|
||||
if item.State != db.IPAwaitingSelfCheck {
|
||||
t.Fatalf("expected awaiting_self_check before cancel, got %s", item.State)
|
||||
}
|
||||
if fip, _ := mock.GetFloatingIPByAddress(ctx, "9.9.9.9"); fip.PortID == "" {
|
||||
t.Fatalf("expected fip associated before cancel")
|
||||
}
|
||||
|
||||
resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/9.9.9.9/cancel", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("cancel: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
item, err = d.GetIPByAddress(ctx, "9.9.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("get ip after cancel: %v", err)
|
||||
}
|
||||
if item.State != db.IPFailed || item.OverallResult != db.ResultCancelled {
|
||||
t.Fatalf("expected failed/cancelled, got state=%s result=%s", item.State, item.OverallResult)
|
||||
}
|
||||
if fip, _ := mock.GetFloatingIPByAddress(ctx, "9.9.9.9"); fip.PortID != "" {
|
||||
t.Fatalf("expected fip disassociated after cancel, still on port %q", fip.PortID)
|
||||
}
|
||||
|
||||
v, err := d.GetValidator(ctx, "validator-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get validator: %v", err)
|
||||
}
|
||||
if v.State != db.ValidatorIdle || v.CurrentIPID != nil {
|
||||
t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID)
|
||||
}
|
||||
|
||||
// Cancelling again is rejected — nothing left to cancel.
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/9.9.9.9/cancel", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409 cancelling an already-finished ip, status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Cancelling an unknown address is a 404.
|
||||
resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/1.1.1.1/cancel", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 cancelling unknown ip, status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.Orch.SiteIndexForID(req.SiteID) == 0 {
|
||||
idx, err := s.Orch.SiteIndexForID(r.Context(), req.SiteID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if idx == 0 {
|
||||
writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID)
|
||||
return
|
||||
}
|
||||
@@ -26,7 +31,12 @@ func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
idx, err := s.Orch.SiteIndexForID(r.Context(), siteID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if idx == 0 {
|
||||
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
|
||||
return
|
||||
}
|
||||
@@ -47,7 +57,11 @@ func (s *Server) handleProberAssignments(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleProberResults(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.PathValue("site_id")
|
||||
siteIndex := s.Orch.SiteIndexForID(siteID)
|
||||
siteIndex, err := s.Orch.SiteIndexForID(r.Context(), siteID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if siteIndex == 0 {
|
||||
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
|
||||
return
|
||||
|
||||
@@ -78,6 +78,10 @@ func TestEndToEndHTTPFlow(t *testing.T) {
|
||||
Targets: map[string][]string{"web": {"https://example.test"}},
|
||||
Inbound: config.InboundConfig{Ports: []int{22, 80}, ICMP: true},
|
||||
}
|
||||
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
|
||||
t.Fatalf("bootstrap from config: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
orch := orchestrator.New(d, mock, cfg, log)
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/status", s.handleAdminStatus)
|
||||
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
|
||||
mux.HandleFunc("POST /api/v1/admin/ips", s.handleAdminSubmitIPs)
|
||||
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail)
|
||||
mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP)
|
||||
mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/validators", s.handleConfigListValidators)
|
||||
mux.HandleFunc("POST /api/v1/admin/config/validators", s.handleConfigCreateValidator)
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", s.handleConfigDeleteValidator)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/sites", s.handleConfigListSites)
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", s.handleConfigPutSite)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", s.handleConfigDeleteSite)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/targets", s.handleConfigListTargets)
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", s.handleConfigPutTargetGroup)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", s.handleConfigDeleteTargetGroup)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/check-types", s.handleConfigListCheckTypes)
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", s.handleConfigPutCheckType)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", s.handleConfigDeleteCheckType)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -56,3 +57,19 @@ func readJSON(r *http.Request, v interface{}) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// writeDBError maps the typed sentinel errors returned by internal/db's
|
||||
// admin mutation methods to the appropriate HTTP status, instead of
|
||||
// defaulting everything to 500 like the older read-only admin handlers do.
|
||||
func writeDBError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, db.ErrConflict), errors.Is(err, db.ErrBusy), errors.Is(err, db.ErrInUse), errors.Is(err, db.ErrInvalidState):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, db.ErrValidation):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user