checker: add POST /definition with precheck failures

Introduces an optional RulePrecheck interface so rules can declare
prerequisite checks against the current options (e.g. "missing API
key"). POST /definition mirrors GET /definition and adds a
precheck_failures map keyed by rule name, letting a UI fetch the
definition and precheck results in a single round-trip.
This commit is contained in:
nemunaire 2026-05-20 13:59:44 +08:00
commit f203b2e573
3 changed files with 150 additions and 0 deletions

View file

@ -133,6 +133,7 @@ func New(provider checker.ObservationProvider) *Server {
s.definition = def
s.definition.BuildRulesInfo()
s.mux.HandleFunc("GET /definition", s.handleDefinition)
s.mux.HandleFunc("POST /definition", s.handlePrecheck)
s.mux.Handle("POST /evaluate", s.TrackWork(http.HandlerFunc(s.handleEvaluate)))
}
}
@ -316,6 +317,38 @@ func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.definition)
}
// handlePrecheck answers POST /definition: it returns the same
// definition body as GET /definition, plus a PrecheckFailures map
// listing rules whose prerequisites are unmet for the submitted
// options. Rules that do not implement checker.RulePrecheck, or whose
// Precheck returned nil, are omitted from that map.
func (s *Server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
var req checker.RulePrecheckRequest
if r.ContentLength != 0 {
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("invalid request body: %v", err),
})
return
}
}
failures := map[string]string{}
for _, rule := range s.definition.Rules {
pc, ok := rule.(checker.RulePrecheck)
if !ok {
continue
}
if err := pc.Precheck(r.Context(), req.Options); err != nil {
failures[rule.Name()] = err.Error()
}
}
writeJSON(w, http.StatusOK, checker.RulePrecheckResponse{
CheckerDefinition: s.definition,
PrecheckFailures: failures,
})
}
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
var req checker.ExternalCollectRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {

View file

@ -667,3 +667,92 @@ func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) {
t.Error("POST /evaluate should not be available without CheckerDefinitionProvider")
}
}
// prereqRule implements RulePrecheck, failing when a named option is empty.
type prereqRule struct {
name string
optKey string
msg string
}
func (r *prereqRule) Name() string { return r.name }
func (r *prereqRule) Description() string { return "" }
func (r *prereqRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []checker.CheckState{{Status: checker.StatusOK}}
}
func (r *prereqRule) Precheck(ctx context.Context, opts checker.CheckerOptions) error {
if v, _ := opts[r.optKey].(string); v == "" {
return errors.New(r.msg)
}
return nil
}
func TestServer_Precheck(t *testing.T) {
gated := &prereqRule{name: "gated", optKey: "api_key", msg: "missing API key"}
open := &dummyRule{name: "open", desc: "no prereq"}
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{
ID: "test",
Rules: []checker.CheckRule{gated, open},
},
}
srv := newTestServer(p)
defer srv.Close()
handler := srv.Handler()
// GET /definition stays static — no precheck information surfaces here.
rec := doRequest(handler, "GET", "/definition", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
}
if bytes.Contains(rec.Body.Bytes(), []byte("precheck_failures")) {
t.Errorf("GET /definition leaked precheck_failures field: %s", rec.Body.String())
}
// POST /definition with empty opts: gated rule fails, open rule absent.
rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{Options: checker.CheckerOptions{}}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition (empty opts) = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.RulePrecheckResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode POST /definition: %v", err)
}
if resp.CheckerDefinition == nil {
t.Fatalf("POST /definition response missing embedded CheckerDefinition")
}
if resp.ID != "test" {
t.Errorf("response ID = %q, want %q", resp.ID, "test")
}
if len(resp.RulesInfo) != 2 {
t.Errorf("response RulesInfo len = %d, want 2", len(resp.RulesInfo))
}
if got := resp.PrecheckFailures["gated"]; got != "missing API key" {
t.Errorf("PrecheckFailures[gated] = %q, want %q", got, "missing API key")
}
if _, ok := resp.PrecheckFailures["open"]; ok {
t.Errorf("PrecheckFailures[open] should be absent (no RulePrecheck impl), got %q", resp.PrecheckFailures["open"])
}
if len(resp.PrecheckFailures) != 1 {
t.Errorf("PrecheckFailures = %v, want exactly 1 entry", resp.PrecheckFailures)
}
// POST /definition with sufficient opts: empty failure map.
rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{
Options: checker.CheckerOptions{"api_key": "secret"},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition (with opts) = %d, want %d", rec.Code, http.StatusOK)
}
resp = checker.RulePrecheckResponse{}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode POST /definition: %v", err)
}
if resp.CheckerDefinition == nil || resp.ID != "test" {
t.Errorf("response missing definition: %+v", resp)
}
if len(resp.PrecheckFailures) != 0 {
t.Errorf("PrecheckFailures = %v, want empty when opts satisfy prereqs", resp.PrecheckFailures)
}
}

View file

@ -250,6 +250,34 @@ type CheckRuleWithOptions interface {
Options() CheckerOptionsDocumentation
}
// RulePrecheck is an optional interface a CheckRule can implement to
// declare whether the current options are sufficient for the rule to
// run. Return nil if runnable, or an error describing the missing
// prerequisite (for example "missing API key"). The host calls this via
// POST /definition to surface unavailable rules in the UI; it is never
// invoked from Collect, so rules that need to short-circuit at run time
// must keep their own self-guard.
type RulePrecheck interface {
CheckRule
Precheck(ctx context.Context, opts CheckerOptions) error
}
// RulePrecheckRequest is the body accepted by POST /definition.
type RulePrecheckRequest struct {
Options CheckerOptions `json:"options"`
}
// RulePrecheckResponse is the body returned by POST /definition. The
// embedded *CheckerDefinition mirrors GET /definition so a client can
// fetch the full definition and precheck results in one round-trip.
// Keys in PrecheckFailures are rule names; values are the precheck
// error messages. Rules that do not implement RulePrecheck, or whose
// Precheck returned nil for the given options, are absent from the map.
type RulePrecheckResponse struct {
*CheckerDefinition
PrecheckFailures map[string]string `json:"precheck_failures"`
}
// ObservationGetter provides access to observation data (used by CheckRule).
// Get unmarshals observation data into dest (like json.Unmarshal).
//