Files
worker/internal/webapp/handlers_cluster.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

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")