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