iop/apps/control-plane/cmd/control-plane/http_edge_handlers.go
toki 20b5f1f241 refactor(control-plane): API 경계를 분리한다
Control Plane HTTP API가 fleet polling과 client 상태 확장 전에 더 커지지 않도록 server lifecycle, DTO conversion, Edge/fleet handler 책임을 파일 단위로 나눈다.
2026-06-06 17:02:31 +09:00

149 lines
4.7 KiB
Go

package main
import (
"encoding/json"
"net/http"
"strings"
"time"
iop "iop/proto/gen/iop"
"iop/apps/control-plane/internal/wire"
)
// edgeStatusRequester asks a connected Edge for its Edge-owned node snapshot.
type edgeStatusRequester func(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error)
// edgeCommandSender dispatches a command to a connected Edge and returns its
// typed response.
type edgeCommandSender func(edgeID, operation, targetSelector string, parameters map[string]string, timeout time.Duration) (*iop.EdgeCommandResponse, error)
func registerEdgeRegistryHandlers(mux *http.ServeMux, registry *wire.EdgeRegistry, requestStatus edgeStatusRequester, sendCommand edgeCommandSender) {
mux.HandleFunc("/edges", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/edges" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
states := registry.Snapshots()
views := make([]edgeRegistryView, 0, len(states))
for _, state := range states {
views = append(views, edgeRegistryViewFromState(state))
}
writeJSON(w, http.StatusOK, edgeRegistryResponse{Edges: views})
})
mux.HandleFunc("/edges/", func(w http.ResponseWriter, r *http.Request) {
edgePath := strings.TrimPrefix(r.URL.Path, "/edges/")
edgeID, rest, hasRest := strings.Cut(edgePath, "/")
if edgeID == "" {
http.NotFound(w, r)
return
}
if !hasRest {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
state, ok := registry.Snapshot(edgeID)
if !ok {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, edgeRegistryViewFromState(state))
return
}
switch rest {
case "status":
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if _, ok := registry.Snapshot(edgeID); !ok {
http.NotFound(w, r)
return
}
resp, err := requestStatus(edgeID, 5*time.Second)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, edgeStatusResponseViewFromProto(resp))
case "events":
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if _, ok := registry.Snapshot(edgeID); !ok {
http.NotFound(w, r)
return
}
records := registry.NodeEvents(edgeID, r.URL.Query().Get("node_id"))
views := make([]edgeNodeEventView, 0, len(records))
for _, record := range records {
views = append(views, edgeNodeEventViewFromRecord(record))
}
writeJSON(w, http.StatusOK, edgeNodeEventsResponse{EdgeID: edgeID, Events: views})
case "operations":
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if _, ok := registry.Snapshot(edgeID); !ok {
http.NotFound(w, r)
return
}
records := registry.Commands(edgeID)
views := make([]edgeCommandRecordView, 0, len(records))
for _, record := range records {
views = append(views, edgeCommandRecordViewFromRecord(record))
}
writeJSON(w, http.StatusOK, edgeOperationsResponse{EdgeID: edgeID, Operations: views})
case "commands":
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
handleEdgeCommandPost(w, r, edgeID, sendCommand)
default:
http.NotFound(w, r)
}
})
}
// handleEdgeCommandPost decodes a command request body and dispatches it to a
// single target Edge. A not-connected Edge yields a 502 with the wire error.
func handleEdgeCommandPost(w http.ResponseWriter, r *http.Request, edgeID string, sendCommand edgeCommandSender) {
var body edgeCommandRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Operation == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "operation is required"})
return
}
if sendCommand == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "command dispatch is not configured"})
return
}
resp, err := sendCommand(edgeID, body.Operation, body.TargetSelector, body.Parameters, 10*time.Second)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusAccepted, edgeCommandResponseViewFromProto(resp))
}
func methodNotAllowed(w http.ResponseWriter, allow ...string) {
w.Header().Set("Allow", strings.Join(allow, ", "))
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}