admin control features and admin dashboard

This commit is contained in:
ayurishchev committed 2026-08-23 20:39:22 +03:00
1 parent c630f13c57
commit 37910e410b
69 files changed
+4959 -400

No files matched your search

+62
View File
@@ -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"`
}
+46
View File
@@ -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
}
+183
View File
@@ -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})
}
+291
View File
@@ -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)
}
}
+17 -3
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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)
}
+17
View File
@@ -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())
}
}