feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
136
internal/webapp/handlers_cluster.go
Обычный файл
136
internal/webapp/handlers_cluster.go
Обычный файл
@@ -0,0 +1,136 @@
|
||||
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")
|
||||
Ссылка в новой задаче
Block a user