diff --git a/checker/server/server.go b/checker/server/server.go index 5505307..5075b04 100644 --- a/checker/server/server.go +++ b/checker/server/server.go @@ -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 { diff --git a/checker/server/server_test.go b/checker/server/server_test.go index 9e0adc4..17bbd12 100644 --- a/checker/server/server_test.go +++ b/checker/server/server_test.go @@ -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) + } +} diff --git a/checker/types.go b/checker/types.go index ea25247..f32d8d8 100644 --- a/checker/types.go +++ b/checker/types.go @@ -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). //