137 строки
5.1 KiB
Go
137 строки
5.1 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
)
|
|
|
|
// clusterStatusResponse is the JSON the operator-facing
|
|
// /web/api/cluster/status endpoint returns. The shape is stable so the
|
|
// e2e shell script and any future frontend pages can pin against it.
|
|
//
|
|
// FSMConfigVersion / FSMOutboxLen / FSMPartition surface the FSM-side
|
|
// operator signals from plan section 6.1 (config_version, outbox
|
|
// length, partition_state) so a single GET tells the operator what
|
|
// config the cluster has adopted, whether the notification outbox is
|
|
// draining, and whether the cluster sees itself as partitioned.
|
|
type clusterStatusResponse struct {
|
|
SelfID string `json:"self_id"`
|
|
Role string `json:"role"`
|
|
Term uint64 `json:"term"`
|
|
LeaderID string `json:"leader_id"`
|
|
Voters []string `json:"voters"`
|
|
AppliedIndex uint64 `json:"applied_index"`
|
|
CommitIndex uint64 `json:"commit_index"`
|
|
FSMChecks int `json:"fsm_checks"`
|
|
FSMMembers int `json:"fsm_membership"`
|
|
FSMConfigVersion uint64 `json:"fsm_config_version"`
|
|
FSMOutboxLen int `json:"fsm_outbox_len"`
|
|
FSMPartition string `json:"fsm_partition"`
|
|
ClusterID string `json:"cluster_id"`
|
|
LocalAddr string `json:"local_addr"`
|
|
}
|
|
|
|
// handleClusterStatus serializes the current cluster state for the
|
|
// operator. Returns 503 if no cluster is attached; 200 otherwise.
|
|
//
|
|
// Admin-only: the worker webapp is single-tenant so the session
|
|
// middleware (requireSession) is the admin check. Cross-tenant
|
|
// protection is not required at this layer.
|
|
//
|
|
// The `r` parameter is unused but kept so the signature matches
|
|
// http.HandlerFunc (the route is registered via requireSession).
|
|
func (s *Server) handleClusterStatus(w http.ResponseWriter, _ *http.Request) {
|
|
writeNoStore(w)
|
|
if s.cluster == nil {
|
|
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
stats := s.cluster.Stats()
|
|
resp := clusterStatusResponse{
|
|
SelfID: stats.NodeID,
|
|
Role: stats.State,
|
|
Term: stats.Term,
|
|
LeaderID: stats.Leader,
|
|
Voters: stats.Voters,
|
|
AppliedIndex: stats.AppliedIndex,
|
|
CommitIndex: stats.LastIndex,
|
|
FSMChecks: stats.FSMChecks,
|
|
FSMMembers: stats.FSMMembers,
|
|
FSMConfigVersion: stats.FSMConfigVersion,
|
|
FSMOutboxLen: stats.FSMOutboxLen,
|
|
FSMPartition: stats.FSMPartition,
|
|
ClusterID: s.cluster.ClusterID(),
|
|
LocalAddr: s.cluster.LocalAddr(),
|
|
}
|
|
if resp.Voters == nil {
|
|
resp.Voters = []string{}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
s.deps.Logger.Printf("cluster status encode: %v", err)
|
|
}
|
|
}
|
|
|
|
// handleClusterApplyTestConfig applies a hardcoded config.adopt log
|
|
// entry to the cluster. It exists so the e2e script and any operator
|
|
// debugging session can verify FSM replication without having to wire
|
|
// up the real signed-config-adoption producer (which lives in a later
|
|
// phase).
|
|
//
|
|
// DEBUG: this endpoint is a placeholder for the real producer. It must
|
|
// be replaced (or removed) before any production deployment.
|
|
//
|
|
// The handler is gated behind Config.DebugClusterApply (env
|
|
// WORKER_CLUSTER_DEBUG_APPLY=true). When the flag is false the
|
|
// handler returns 404 — the route is still registered so the auth
|
|
// + CSRF paths are exercised in tests, but no real FSM entry is ever
|
|
// appended from a production webapp.
|
|
//
|
|
// TODO(worker-cluster-real-producer): remove the apply-test-config
|
|
// endpoint entirely once the signed-config-adoption producer ships.
|
|
func (s *Server) handleClusterApplyTestConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeNoStore(w)
|
|
if !s.cfg.DebugClusterApply {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if s.cluster == nil {
|
|
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if !s.requireCSRF(sessionFromContextOrFail(w, r), r) {
|
|
http.Error(w, "csrf token required", http.StatusForbidden)
|
|
return
|
|
}
|
|
applied, err := s.cluster.ApplyTestConfig()
|
|
if err != nil {
|
|
s.deps.Logger.Printf("cluster apply test config: %v", err)
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(map[string]uint64{"applied_index": applied}); err != nil {
|
|
s.deps.Logger.Printf("cluster apply encode: %v", err)
|
|
}
|
|
}
|
|
|
|
// sessionFromContextOrFail is a tiny adapter so requireCSRF can be
|
|
// called from this handler without leaking the middleware into the
|
|
// cluster package. If no session is attached (should not happen
|
|
// because requireSession already ran) we return a stub session with
|
|
// no CSRF token, which causes requireCSRF to refuse the request.
|
|
func sessionFromContextOrFail(_ http.ResponseWriter, r *http.Request) *Session {
|
|
sess, _ := sessionFromContext(r.Context())
|
|
if sess != nil {
|
|
return sess
|
|
}
|
|
return &Session{}
|
|
}
|
|
|
|
// ErrClusterNotConfigured is returned when a cluster-admin endpoint is
|
|
// hit on a server without a cluster attached.
|
|
var ErrClusterNotConfigured = errors.New("webapp: cluster not configured")
|