diff --git a/README.md b/README.md index 49767f7..e224fdf 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ srv.Handle("POST /webhook", srv.TrackWork(myWebhookHandler)) log.Fatal(srv.ListenAndServe(":8080")) ``` -Patterns that collide with built-in routes panic at registration: +Patterns that collide with built-in routes panic at registration — pick non-overlapping paths. Custom handlers are not wrapped by the load-tracking middleware unless you opt in via `TrackWork`. @@ -74,7 +74,7 @@ type CheckerInteractive interface { pipeline, and returns a consolidated HTML page. `ParseForm` is where the checker replaces what happyDomain would normally -auto-fill (zone records, service payload, …), typically by issuing its +auto-fill (zone records, service payload, …) — typically by issuing its own DNS queries from the human-supplied inputs. ## License diff --git a/checker/server/interactive.go b/checker/interactive.go similarity index 69% rename from checker/server/interactive.go rename to checker/interactive.go index 27cc260..bb7158e 100644 --- a/checker/server/interactive.go +++ b/checker/interactive.go @@ -12,43 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. -package server +package checker import ( "bytes" - "context" "encoding/json" "fmt" "html/template" "log" - "maps" "net/http" "time" - - "git.happydns.org/checker-sdk-go/checker" ) -// Interactive is an optional interface that observation providers +// CheckerInteractive is an optional interface that observation providers // can implement to expose a human-facing web form usable standalone, // outside of a happyDomain host. Detect support with a type assertion: -// _, ok := provider.(server.Interactive). +// _, ok := provider.(CheckerInteractive). // // When the provider implements it, Server binds GET and POST on /check. // GET renders an HTML form built from RenderForm(). POST calls ParseForm -// to obtain the checker.CheckerOptions, then runs the standard pipeline +// to obtain the CheckerOptions, then runs the standard pipeline // (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a // consolidated result page. // // Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed -// options from execution context, an Interactive implementation is +// options from execution context, a CheckerInteractive implementation is // responsible for resolving whatever it needs from the human inputs // (typically via direct DNS queries) before Collect runs. -type Interactive interface { +type CheckerInteractive interface { // RenderForm returns the fields the human must fill in to bootstrap // a check. Typically a minimal set (domain name, nameserver to // query, …) that ParseForm expands into the full CheckerOptions // that Collect expects. - RenderForm() []checker.CheckerOptionField + RenderForm() []CheckerOptionField // ParseForm reads the submitted form and returns the CheckerOptions // ready to feed Collect. It is the checker's responsibility to do @@ -56,29 +52,14 @@ type Interactive interface { // that would normally be auto-filled by happyDomain. Returning an // error causes the SDK to re-render the form with the error // displayed. - ParseForm(r *http.Request) (checker.CheckerOptions, error) -} - -// Siblings is an optional interface an interactive ObservationProvider -// can co-implement to declare sibling providers whose Collect the SDK -// runs in-process during /check. Their results are exposed as -// RelatedObservations on ObservationGetter and ReportContext, mirroring -// the cross-checker lineage a happyDomain host resolves. -// -// For each sibling the SDK seeds options from the primary and, when the -// primary implements DiscoveryPublisher, writes its entries into any -// sibling option tagged AutoFill == checker.AutoFillDiscoveryEntries. -// Sibling errors are logged and skipped so the primary result still -// reaches the user. -type Siblings interface { - RelatedProviders() []checker.ObservationProvider + ParseForm(r *http.Request) (CheckerOptions, error) } // checkResult holds everything the result page needs to render. type checkResult struct { Title string - States []checker.CheckState - Metrics []checker.CheckMetric + States []CheckState + Metrics []CheckMetric ReportHTML string CollectErr string ReportErr string @@ -87,7 +68,7 @@ type checkResult struct { type checkFormPage struct { Title string - Fields []checker.CheckerOptionField + Fields []CheckerOptionField Error string } @@ -96,15 +77,14 @@ func (s *Server) handleCheckForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { - fields := s.interactive.RenderForm() if err := r.ParseForm(); err != nil { - s.renderCheckForm(w, fields, fmt.Sprintf("invalid form: %v", err)) + s.renderCheckForm(w, s.interactive.RenderForm(), fmt.Sprintf("invalid form: %v", err)) return } opts, err := s.interactive.ParseForm(r) if err != nil { - s.renderCheckForm(w, fields, err.Error()) + s.renderCheckForm(w, s.interactive.RenderForm(), err.Error()) return } @@ -124,21 +104,16 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { return } - related := s.collectRelatedObservations(r.Context(), opts, data) - if s.definition != nil { - obs := &mapObservationGetter{ - data: map[checker.ObservationKey]json.RawMessage{ - s.provider.Key(): raw, - }, - related: related, - } + obs := &mapObservationGetter{data: map[ObservationKey]json.RawMessage{ + s.provider.Key(): raw, + }} result.States = s.evaluateRules(r.Context(), obs, opts, nil) } - ctx := checker.NewReportContext(raw, related, result.States) + ctx := NewReportContext(raw, nil) - if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok { + if reporter, ok := s.provider.(CheckerHTMLReporter); ok { html, rerr := reporter.GetHTMLReport(ctx) if rerr != nil { result.ReportErr = rerr.Error() @@ -147,7 +122,7 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { } } - if reporter, ok := s.provider.(checker.CheckerMetricsReporter); ok { + if reporter, ok := s.provider.(CheckerMetricsReporter); ok { metrics, merr := reporter.ExtractMetrics(ctx, time.Now()) if merr != nil { result.MetricsErr = merr.Error() @@ -159,86 +134,6 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { s.renderCheckResult(w, result) } -// collectRelatedObservations runs sibling providers declared via Siblings -// and returns their results keyed by the sibling's observation key. -// Sibling errors are logged and skipped. -func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation { - irp, ok := s.provider.(Siblings) - if !ok { - return nil - } - siblings := irp.RelatedProviders() - if len(siblings) == 0 { - return nil - } - - var entries []checker.DiscoveryEntry - if dp, ok := s.provider.(checker.DiscoveryPublisher); ok { - e, err := dp.DiscoverEntries(data) - if err != nil { - log.Printf("interactive: DiscoverEntries failed: %v", err) - } else { - entries = e - } - } - - related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings)) - for _, sp := range siblings { - sOpts := cloneOptions(opts) - siblingID := "" - if dp, ok := sp.(checker.CheckerDefinitionProvider); ok { - if def := dp.Definition(); def != nil { - siblingID = def.ID - if len(entries) > 0 { - fillDiscoveryEntryOption(sOpts, def, entries) - } - } - } - sData, err := sp.Collect(ctx, sOpts) - if err != nil { - log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err) - continue - } - raw, err := json.Marshal(sData) - if err != nil { - log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err) - continue - } - related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{ - CheckerID: siblingID, - Key: sp.Key(), - Data: raw, - CollectedAt: time.Now(), - }) - } - return related -} - -func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions { - out := make(checker.CheckerOptions, len(opts)) - maps.Copy(out, opts) - return out -} - -// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes -// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries. -func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) { - scopes := [][]checker.CheckerOptionDocumentation{ - def.Options.AdminOpts, - def.Options.UserOpts, - def.Options.DomainOpts, - def.Options.ServiceOpts, - def.Options.RunOpts, - } - for _, scope := range scopes { - for _, f := range scope { - if f.AutoFill == checker.AutoFillDiscoveryEntries { - opts[f.Id] = entries - } - } - } -} - func (s *Server) checkPageTitle() string { if s.definition != nil && s.definition.Name != "" { return s.definition.Name @@ -258,7 +153,7 @@ func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data w.Write(buf.Bytes()) } -func (s *Server) renderCheckForm(w http.ResponseWriter, fields []checker.CheckerOptionField, errMsg string) { +func (s *Server) renderCheckForm(w http.ResponseWriter, fields []CheckerOptionField, errMsg string) { status := http.StatusOK if errMsg != "" { status = http.StatusBadRequest @@ -274,17 +169,17 @@ func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) { renderHTML(w, http.StatusOK, checkResultTemplate, result) } -func statusClass(s checker.Status) string { +func statusClass(s Status) string { switch s { - case checker.StatusOK: + case StatusOK: return "ok" - case checker.StatusInfo: + case StatusInfo: return "info" - case checker.StatusWarn: + case StatusWarn: return "warn" - case checker.StatusCrit: + case StatusCrit: return "crit" - case checker.StatusError: + case StatusError: return "error" default: return "unknown" @@ -316,7 +211,7 @@ func defaultBool(v any) bool { var templateFuncs = template.FuncMap{ "statusClass": statusClass, - "statusString": checker.Status.String, + "statusString": Status.String, "defaultString": defaultString, "defaultBool": defaultBool, } diff --git a/checker/interactive_test.go b/checker/interactive_test.go new file mode 100644 index 0000000..0a48512 --- /dev/null +++ b/checker/interactive_test.go @@ -0,0 +1,245 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// interactiveProvider embeds testProvider and adds CheckerInteractive. +type interactiveProvider struct { + *testProvider + fields []CheckerOptionField + parseFn func(r *http.Request) (CheckerOptions, error) + parseErr error +} + +func (p *interactiveProvider) RenderForm() []CheckerOptionField { + return p.fields +} + +func (p *interactiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { + if p.parseErr != nil { + return nil, p.parseErr + } + if p.parseFn != nil { + return p.parseFn(r) + } + return CheckerOptions{"domain": r.FormValue("domain")}, nil +} + +func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +// minimalProvider implements only ObservationProvider. +type minimalProvider struct{ key ObservationKey } + +func (m *minimalProvider) Key() ObservationKey { return m.key } +func (m *minimalProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { + return nil, nil +} + +func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { + p := &minimalProvider{key: "test"} + srv := NewServer(p) + defer srv.Close() + + rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /check without CheckerInteractive = %d, want 404", rec.Code) + } +} + +func TestCheck_Form_Renders(t *testing.T) { + p := &interactiveProvider{ + testProvider: &testProvider{key: "test"}, + fields: []CheckerOptionField{ + {Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"}, + {Id: "verbose", Type: "bool", Label: "Verbose", Default: true}, + {Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"}, + {Id: "hidden", Type: "string", Hide: true}, + }, + } + srv := NewServer(p) + defer srv.Close() + + rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /check = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + `name="domain"`, + `placeholder="example.com"`, + `Domain name`, + `type="checkbox"`, + `name="verbose"`, + ` checked`, + ``, - } { - if !strings.Contains(body, want) { - t.Errorf("form body missing %q", want) - } - } - if strings.Contains(body, `name="hidden"`) { - t.Errorf("hidden field should not be rendered") - } -} - -func TestCheck_Submit_Success(t *testing.T) { - definition := &checker.CheckerDefinition{ - ID: "test", - Name: "Test Checker", - Rules: []checker.CheckRule{ - &dummyRule{name: "rule1", desc: "first rule"}, - }, - } - p := &interactiveProvider{ - testProvider: &testProvider{key: "test", definition: definition}, - fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}}, - } - srv := New(p) - defer srv.Close() - - rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /check = %d, want 200", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{ - `Test Checker`, - `Check states`, - `rule1`, - `rule1 passed`, - `badge ok`, - `Metrics`, - `m1`, - `Report`, - `Metrics") { - t.Errorf("body should not contain metrics section when no metrics reporter") - } -} - -// bareInteractiveProvider implements only the required interfaces -// (ObservationProvider, CheckerDefinitionProvider, Interactive), -// no reporters. -type bareInteractiveProvider struct { - key checker.ObservationKey - def *checker.CheckerDefinition -} - -func (b *bareInteractiveProvider) Key() checker.ObservationKey { return b.key } -func (b *bareInteractiveProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return map[string]string{"ok": "1"}, nil -} -func (b *bareInteractiveProvider) Definition() *checker.CheckerDefinition { return b.def } -func (b *bareInteractiveProvider) RenderForm() []checker.CheckerOptionField { - return []checker.CheckerOptionField{{Id: "domain", Type: "string"}} -} -func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) { - return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil -} - -type siblingProvider struct { - key checker.ObservationKey - id string - entriesOpt string - gotOpts checker.CheckerOptions - payload any -} - -func (s *siblingProvider) Key() checker.ObservationKey { return s.key } -func (s *siblingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - s.gotOpts = opts - return s.payload, nil -} -func (s *siblingProvider) Definition() *checker.CheckerDefinition { - return &checker.CheckerDefinition{ - ID: s.id, - Options: checker.CheckerOptionsDocumentation{ - RunOpts: []checker.CheckerOptionDocumentation{ - {Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries}, - }, - }, - } -} - -type primaryWithSibling struct { - key checker.ObservationKey - def *checker.CheckerDefinition - entries []checker.DiscoveryEntry - sibling checker.ObservationProvider -} - -func (p *primaryWithSibling) Key() checker.ObservationKey { return p.key } -func (p *primaryWithSibling) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return map[string]string{"primary": "ok"}, nil -} -func (p *primaryWithSibling) Definition() *checker.CheckerDefinition { return p.def } -func (p *primaryWithSibling) RenderForm() []checker.CheckerOptionField { - return []checker.CheckerOptionField{{Id: "domain", Type: "string"}} -} -func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) { - return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil -} -func (p *primaryWithSibling) DiscoverEntries(data any) ([]checker.DiscoveryEntry, error) { - return p.entries, nil -} -func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider { - return []checker.ObservationProvider{p.sibling} -} - -type relatedAssertRule struct { - key checker.ObservationKey -} - -func (r *relatedAssertRule) Name() string { return "related_assert" } -func (r *relatedAssertRule) Description() string { return "" } -func (r *relatedAssertRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState { - related, err := obs.GetRelated(ctx, r.key) - if err != nil { - return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}} - } - if len(related) == 0 { - return []checker.CheckState{{Status: checker.StatusCrit, Message: "no related observation"}} - } - return []checker.CheckState{{Status: checker.StatusOK, Message: "saw related observation"}} -} - -func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) { - sibling := &siblingProvider{ - key: "sibling_key", - id: "sibling", - entriesOpt: "endpoints", - payload: map[string]string{"sibling": "ok"}, - } - entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"} - primary := &primaryWithSibling{ - key: "primary_key", - def: &checker.CheckerDefinition{ - ID: "primary", - Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}}, - }, - entries: []checker.DiscoveryEntry{entry}, - sibling: sibling, - } - - srv := New(primary) - defer srv.Close() - - rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /check = %d, want 200", rec.Code) - } - body := rec.Body.String() - if !strings.Contains(body, "saw related observation") { - t.Errorf("rule did not see related observation; body:\n%s", body) - } - - got, ok := sibling.gotOpts[sibling.entriesOpt].([]checker.DiscoveryEntry) - if !ok { - t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt]) - } - if len(got) != 1 || got[0].Ref != entry.Ref { - t.Errorf("sibling saw entries %v, want [%v]", got, entry) - } - - if v, _ := sibling.gotOpts["domain"].(string); v != "example.com" { - t.Errorf("sibling did not receive primary domain opt, got %q", v) - } -} - -// interactiveStatesPeekingProvider implements Interactive + HTMLReporter -// and captures the ReportContext.States() seen at GetHTMLReport time. -type interactiveStatesPeekingProvider struct { - key checker.ObservationKey - def *checker.CheckerDefinition - seen *[]checker.CheckState -} - -func (p *interactiveStatesPeekingProvider) Key() checker.ObservationKey { return p.key } -func (p *interactiveStatesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return map[string]string{"ok": "1"}, nil -} -func (p *interactiveStatesPeekingProvider) Definition() *checker.CheckerDefinition { return p.def } -func (p *interactiveStatesPeekingProvider) RenderForm() []checker.CheckerOptionField { - return []checker.CheckerOptionField{{Id: "domain", Type: "string"}} -} -func (p *interactiveStatesPeekingProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) { - return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil -} -func (p *interactiveStatesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { - if p.seen != nil { - *p.seen = ctx.States() - } - return "

ok

", nil -} - -// TestCheck_Submit_ThreadsStatesIntoReport verifies that CheckStates -// produced by evaluateRules during POST /check are threaded into the -// ReportContext handed to GetHTMLReport. Without this wiring, the /check -// UI can show states in its own section but the embedded report would -// have to re-derive severity/hints from Data. -func TestCheck_Submit_ThreadsStatesIntoReport(t *testing.T) { - var seen []checker.CheckState - p := &interactiveStatesPeekingProvider{ - key: "test", - def: &checker.CheckerDefinition{ - ID: "test", - Rules: []checker.CheckRule{&dummyRule{name: "rule1", desc: "first"}}, - }, - seen: &seen, - } - srv := New(p) - defer srv.Close() - - rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /check = %d, want 200", rec.Code) - } - if len(seen) != 1 { - t.Fatalf("reporter saw %d states, want 1", len(seen)) - } - if seen[0].RuleName != "rule1" { - t.Errorf("state RuleName = %q, want %q", seen[0].RuleName, "rule1") - } - if seen[0].Status != checker.StatusOK { - t.Errorf("state Status = %v, want %v", seen[0].Status, checker.StatusOK) - } -} - -func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) { - p := &interactiveProvider{ - testProvider: &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ - ID: "test", - Rules: []checker.CheckRule{&relatedAssertRule{key: "other"}}, - }, - }, - fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}}, - } - srv := New(p) - defer srv.Close() - - rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /check = %d, want 200", rec.Code) - } - body := rec.Body.String() - if !strings.Contains(body, "no related observation") { - t.Errorf("rule should have seen no related observation; body:\n%s", body) - } -} diff --git a/checker/server/server_test.go b/checker/server_test.go similarity index 60% rename from checker/server/server_test.go rename to checker/server_test.go index 9e0adc4..7b07e13 100644 --- a/checker/server/server_test.go +++ b/checker/server_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package server +package checker import ( "bytes" @@ -24,39 +24,37 @@ import ( "sync" "testing" "time" - - "git.happydns.org/checker-sdk-go/checker" ) // --- test doubles --- type testProvider struct { - key checker.ObservationKey - collectFn func(ctx context.Context, opts checker.CheckerOptions) (any, error) - definition *checker.CheckerDefinition + key ObservationKey + collectFn func(ctx context.Context, opts CheckerOptions) (any, error) + definition *CheckerDefinition htmlFn func(raw json.RawMessage) (string, error) - metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error) + metricsFn func(raw json.RawMessage, t time.Time) ([]CheckMetric, error) } -func (p *testProvider) Key() checker.ObservationKey { return p.key } -func (p *testProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { +func (p *testProvider) Key() ObservationKey { return p.key } +func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { if p.collectFn != nil { return p.collectFn(ctx, opts) } return map[string]string{"result": "ok"}, nil } -func (p *testProvider) Definition() *checker.CheckerDefinition { return p.definition } -func (p *testProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { +func (p *testProvider) Definition() *CheckerDefinition { return p.definition } +func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) { if p.htmlFn != nil { return p.htmlFn(ctx.Data()) } return "

report

", nil } -func (p *testProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) { +func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) { if p.metricsFn != nil { return p.metricsFn(ctx.Data(), t) } - return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil + return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil } // dummyRule is a minimal CheckRule for testing evaluate. @@ -67,8 +65,8 @@ type dummyRule struct { func (r *dummyRule) Name() string { return r.name } func (r *dummyRule) Description() string { return r.desc } -func (r *dummyRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState { - return []checker.CheckState{{Status: checker.StatusOK, Message: r.name + " passed"}} +func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { + return []CheckState{{Status: StatusOK, Message: r.name + " passed"}} } // codedRule emits a CheckState with a pre-set Code, to verify the server @@ -79,25 +77,14 @@ type codedRule struct { func (r *codedRule) Name() string { return r.name } func (r *codedRule) Description() string { return "" } -func (r *codedRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState { - return []checker.CheckState{{Status: checker.StatusWarn, Code: r.code, Message: "coded finding"}} -} - -// stubProvider is a minimal ObservationProvider that does not implement -// CheckerDefinitionProvider, used to verify conditional endpoint registration. -type stubProvider struct { - key checker.ObservationKey -} - -func (s stubProvider) Key() checker.ObservationKey { return s.key } -func (s stubProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return nil, nil +func (r *codedRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { + return []CheckState{{Status: StatusWarn, Code: r.code, Message: "coded finding"}} } // --- helpers --- func newTestServer(p *testProvider) *Server { - return New(p) + return NewServer(p) } func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder { @@ -120,14 +107,14 @@ func doRequest(handler http.Handler, method, path string, body any, headers map[ // --- tests --- func TestServer_Health(t *testing.T) { - p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}} + p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} srv := newTestServer(p) defer srv.Close() rec := doRequest(srv.Handler(), "GET", "/health", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK) } - var resp checker.HealthResponse + var resp HealthResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode /health: %v", err) } @@ -156,8 +143,8 @@ func TestServer_Health_TracksInFlight(t *testing.T) { var collectEntered sync.WaitGroup p := &testProvider{ key: "test", - definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}, - collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) { + definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, + collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { collectEntered.Done() <-release return map[string]string{"ok": "1"}, nil @@ -174,7 +161,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) { for i := 0; i < n; i++ { go func() { defer clientsDone.Done() - doRequest(handler, "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil) + doRequest(handler, "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil) }() } @@ -183,7 +170,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) { // Record /health mid-flight. Also hammer it to verify /health polls // do not inflate InFlight or TotalRequests. - var mid checker.HealthResponse + var mid HealthResponse for i := 0; i < 5; i++ { rec := doRequest(handler, "GET", "/health", nil, nil) if rec.Code != http.StatusOK { @@ -205,7 +192,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) { clientsDone.Wait() rec := doRequest(handler, "GET", "/health", nil, nil) - var after checker.HealthResponse + var after HealthResponse if err := json.NewDecoder(rec.Body).Decode(&after); err != nil { t.Fatalf("decode /health: %v", err) } @@ -250,7 +237,7 @@ func TestUpdateLoadAvg(t *testing.T) { } func TestServer_Close_Idempotent(t *testing.T) { - p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}} + p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} srv := newTestServer(p) done := make(chan error, 2) go func() { done <- srv.Close() }() @@ -270,20 +257,20 @@ func TestServer_Close_Idempotent(t *testing.T) { func TestServer_Collect_Success(t *testing.T) { p := &testProvider{ key: "test", - definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}, - collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) { + definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, + collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { return map[string]int{"count": 42}, nil }, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{ + rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{ Key: "test", - Options: checker.CheckerOptions{"a": "b"}, + Options: CheckerOptions{"a": "b"}, }, nil) if rec.Code != http.StatusOK { t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK) } - var resp checker.ExternalCollectResponse + var resp ExternalCollectResponse json.NewDecoder(rec.Body).Decode(&resp) if resp.Error != "" { t.Errorf("POST /collect error = %q, want empty", resp.Error) @@ -296,17 +283,17 @@ func TestServer_Collect_Success(t *testing.T) { func TestServer_Collect_ProviderError(t *testing.T) { p := &testProvider{ key: "test", - definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}, - collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) { + definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, + collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { return nil, errors.New("provider failed") }, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil) + rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil) if rec.Code != http.StatusInternalServerError { t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError) } - var resp checker.ExternalCollectResponse + var resp ExternalCollectResponse json.NewDecoder(rec.Body).Decode(&resp) if resp.Error == "" { t.Error("expected error in response, got empty") @@ -314,7 +301,7 @@ func TestServer_Collect_ProviderError(t *testing.T) { } func TestServer_Collect_BadBody(t *testing.T) { - p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}} + p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} srv := newTestServer(p) req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid")) rec := httptest.NewRecorder() @@ -325,10 +312,10 @@ func TestServer_Collect_BadBody(t *testing.T) { } func TestServer_Definition(t *testing.T) { - def := &checker.CheckerDefinition{ + def := &CheckerDefinition{ ID: "test-checker", Name: "Test Checker", - Rules: []checker.CheckRule{ + Rules: []CheckRule{ &dummyRule{name: "rule1", desc: "first rule"}, }, } @@ -338,7 +325,7 @@ func TestServer_Definition(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK) } - var got checker.CheckerDefinition + var got CheckerDefinition json.NewDecoder(rec.Body).Decode(&got) if got.ID != "test-checker" { t.Errorf("definition ID = %q, want \"test-checker\"", got.ID) @@ -349,10 +336,10 @@ func TestServer_Definition(t *testing.T) { } func TestServer_Evaluate(t *testing.T) { - def := &checker.CheckerDefinition{ + def := &CheckerDefinition{ ID: "test-checker", Name: "Test Checker", - Rules: []checker.CheckRule{ + Rules: []CheckRule{ &dummyRule{name: "rule1", desc: "first rule"}, &dummyRule{name: "rule2", desc: "second rule"}, }, @@ -360,16 +347,16 @@ func TestServer_Evaluate(t *testing.T) { p := &testProvider{key: "test", definition: def} srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{ - Observations: map[checker.ObservationKey]json.RawMessage{ + rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ + Observations: map[ObservationKey]json.RawMessage{ "test": json.RawMessage(`{"count":42}`), }, - Options: checker.CheckerOptions{}, + Options: CheckerOptions{}, }, nil) if rec.Code != http.StatusOK { t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) } - var resp checker.ExternalEvaluateResponse + var resp ExternalEvaluateResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.States) != 2 { t.Fatalf("evaluate states = %d, want 2", len(resp.States)) @@ -383,9 +370,9 @@ func TestServer_Evaluate(t *testing.T) { } func TestServer_Evaluate_DisabledRule(t *testing.T) { - def := &checker.CheckerDefinition{ + def := &CheckerDefinition{ ID: "test-checker", - Rules: []checker.CheckRule{ + Rules: []CheckRule{ &dummyRule{name: "rule1", desc: "first"}, &dummyRule{name: "rule2", desc: "second"}, }, @@ -393,8 +380,8 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) { p := &testProvider{key: "test", definition: def} srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{ - Observations: map[checker.ObservationKey]json.RawMessage{ + rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ + Observations: map[ObservationKey]json.RawMessage{ "test": json.RawMessage(`{}`), }, EnabledRules: map[string]bool{"rule1": false}, @@ -402,7 +389,7 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) } - var resp checker.ExternalEvaluateResponse + var resp ExternalEvaluateResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.States) != 1 { t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States)) @@ -413,22 +400,22 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) { } func TestServer_Evaluate_RulePreservesCode(t *testing.T) { - def := &checker.CheckerDefinition{ + def := &CheckerDefinition{ ID: "test-checker", - Rules: []checker.CheckRule{ + Rules: []CheckRule{ &codedRule{name: "ruleA", code: "too_many_lookups"}, }, } p := &testProvider{key: "test", definition: def} srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{ - Observations: map[checker.ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)}, + rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ + Observations: map[ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)}, }, nil) if rec.Code != http.StatusOK { t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) } - var resp checker.ExternalEvaluateResponse + var resp ExternalEvaluateResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.States) != 1 { t.Fatalf("states = %d, want 1", len(resp.States)) @@ -443,14 +430,14 @@ func TestServer_Evaluate_RulePreservesCode(t *testing.T) { func TestServer_Report_HTML(t *testing.T) { p := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + key: "test", + definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, htmlFn: func(raw json.RawMessage) (string, error) { return "

hello

", nil }, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{ + rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), }, map[string]string{"Accept": "text/html"}) @@ -467,18 +454,18 @@ func TestServer_Report_HTML(t *testing.T) { func TestServer_Report_Metrics(t *testing.T) { p := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + key: "test", + definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{ + rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), }, map[string]string{"Accept": "application/json"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK) } - var metrics []checker.CheckMetric + var metrics []CheckMetric json.NewDecoder(rec.Body).Decode(&metrics) if len(metrics) != 1 { t.Errorf("metrics count = %d, want 1", len(metrics)) @@ -489,25 +476,25 @@ func TestServer_Report_Metrics(t *testing.T) { // ExternalReportRequest.Related through to the provider's ReportContext, // the fix for the "remote checkers can't see related observations" gap. func TestServer_Report_Related(t *testing.T) { - var gotRelated []checker.RelatedObservation + var gotRelated []RelatedObservation p := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + key: "test", + definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, } // Replace htmlFn with one that peeks at a related key. We can't do that // directly through testProvider's htmlFn (which only sees raw), so // bind to GetHTMLReport via an inline wrapper: use a per-test provider // that captures the ReportContext before delegating to the template. - srv := New(&relatedPeekingProvider{ + srv := NewServer(&relatedPeekingProvider{ base: p, target: &gotRelated, }) defer srv.Close() - req := checker.ExternalReportRequest{ + req := ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), - Related: map[checker.ObservationKey][]checker.RelatedObservation{ + Related: map[ObservationKey][]RelatedObservation{ "tls_probes": { {CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"}, }, @@ -529,124 +516,23 @@ func TestServer_Report_Related(t *testing.T) { // Related("tls_probes") slice observed at GetHTMLReport time into target. type relatedPeekingProvider struct { base *testProvider - target *[]checker.RelatedObservation + target *[]RelatedObservation } -func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() } -func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { +func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() } +func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { return p.base.Collect(ctx, opts) } -func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition } -func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { +func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition } +func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) { *p.target = ctx.Related("tls_probes") return "

ok

", nil } -// statesPeekingProvider captures the ReportContext's States slice at -// GetHTMLReport / ExtractMetrics time. -type statesPeekingProvider struct { - base *testProvider - htmlSeen *[]checker.CheckState - metricSeen *[]checker.CheckState -} - -func (p *statesPeekingProvider) Key() checker.ObservationKey { return p.base.Key() } -func (p *statesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { - return p.base.Collect(ctx, opts) -} -func (p *statesPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition } -func (p *statesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { - if p.htmlSeen != nil { - *p.htmlSeen = ctx.States() - } - return "

ok

", nil -} -func (p *statesPeekingProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) { - if p.metricSeen != nil { - *p.metricSeen = ctx.States() - } - return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil -} - -// TestServer_Report_States_HTML verifies ExternalReportRequest.States is -// threaded into the ReportContext seen by the HTML reporter. -func TestServer_Report_States_HTML(t *testing.T) { - var seen []checker.CheckState - base := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, - } - srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) - defer srv.Close() - - states := []checker.CheckState{ - {Status: checker.StatusCrit, Message: "broken", RuleName: "r1", Code: "bad", Subject: "host.example"}, - } - req := checker.ExternalReportRequest{ - Key: "test", - Data: json.RawMessage(`{}`), - States: states, - } - rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /report = %d, want 200", rec.Code) - } - if len(seen) != 1 || seen[0].RuleName != "r1" || seen[0].Code != "bad" || seen[0].Subject != "host.example" { - t.Errorf("reporter saw states = %+v, want single state {RuleName:r1, Code:bad, Subject:host.example}", seen) - } -} - -// TestServer_Report_States_Metrics verifies the States passthrough on the -// metrics path as well. -func TestServer_Report_States_Metrics(t *testing.T) { - var seen []checker.CheckState - base := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, - } - srv := New(&statesPeekingProvider{base: base, metricSeen: &seen}) - defer srv.Close() - - req := checker.ExternalReportRequest{ - Key: "test", - Data: json.RawMessage(`{}`), - States: []checker.CheckState{{Status: checker.StatusWarn, RuleName: "r1"}}, - } - rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "application/json"}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /report = %d, want 200", rec.Code) - } - if len(seen) != 1 || seen[0].RuleName != "r1" { - t.Errorf("reporter saw states = %+v, want single state with RuleName=r1", seen) - } -} - -// TestServer_Report_States_Absent verifies that omitting States in the -// request yields a nil States() slice on the reporter side (graceful -// degradation for hosts that don't thread evaluate→report yet). -func TestServer_Report_States_Absent(t *testing.T) { - seen := []checker.CheckState{{Status: checker.StatusOK}} // non-nil sentinel - base := &testProvider{ - key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, - } - srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) - defer srv.Close() - - req := checker.ExternalReportRequest{Key: "test", Data: json.RawMessage(`{}`)} - rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /report = %d, want 200", rec.Code) - } - if seen != nil { - t.Errorf("States() = %+v, want nil when ExternalReportRequest.States is absent", seen) - } -} - func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", - definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, } srv := newTestServer(p) req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad")) @@ -660,7 +546,7 @@ func TestServer_Report_BadBody(t *testing.T) { func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) { // A provider that does NOT implement CheckerDefinitionProvider p := &stubProvider{key: "basic"} - srv := New(p) + srv := NewServer(p) rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil) // Should 404 or 405 since /evaluate is not registered if rec.Code == http.StatusOK { diff --git a/checker/types.go b/checker/types.go index 7256766..c218dbb 100644 --- a/checker/types.go +++ b/checker/types.go @@ -298,40 +298,32 @@ type CheckAggregator interface { Aggregate(states []CheckState) CheckState } -// ReportContext carries the primary observation payload, any observations -// produced by other checkers that cover the same discovery entries, and the -// CheckStates produced by this checker's rules for the same observation. -// Hosts build a ReportContext and hand it to reporter methods. +// ReportContext carries both the primary observation payload and any +// observations produced by other checkers that cover the same discovery +// entries. Hosts build a ReportContext and hand it to reporter methods. // -// Reporters use States() to render rule-driven sections (for example a -// "fix these first" list) without re-deriving severity or hints from the -// raw payload. Hosts that have not yet threaded rule output into the -// report pipeline return nil; reporters must treat a nil or empty slice -// as "not provided" and fall back to a data-only rendering. The same -// nil-tolerance applies to Related(key). +// The method set is deliberately tiny: a single primary payload (Data) and +// a query for related observations by key (Related). Hosts return nil from +// Related when there is nothing to relate; reporters must tolerate that. type ReportContext interface { Data() json.RawMessage Related(key ObservationKey) []RelatedObservation - States() []CheckState } -// NewReportContext returns a ReportContext backed by a primary payload, a -// pre-resolved map of related observations by key, and the CheckStates -// produced by the checker's rules on this observation. The SDK's /report -// HTTP handler uses this to wrap ExternalReportRequest contents; hosts and -// tests can use it whenever they already have that material in memory. +// NewReportContext returns a ReportContext backed by a primary payload and +// a pre-resolved map of related observations by key. The SDK's /report HTTP +// handler uses this to wrap ExternalReportRequest contents; hosts and tests +// can use it whenever they already have the related observations in memory. // -// Passing a nil related map or a nil states slice is fine; Related(key) -// and States() will then return nil respectively. Use StaticReportContext -// as a shorthand when both are absent. -func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation, states []CheckState) ReportContext { - return fixedReportContext{data: data, related: related, states: states} +// Passing a nil or empty related map is fine; Related(key) will then return +// nil, just like StaticReportContext. +func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation) ReportContext { + return fixedReportContext{data: data, related: related} } -// StaticReportContext is a shorthand for NewReportContext(data, nil, nil): -// a ReportContext with a primary payload, no related observations, and no -// rule states. Intended for tests and ad-hoc callers that have no lineage -// or rule output to supply. +// StaticReportContext is a shorthand for NewReportContext(data, nil): a +// ReportContext with a primary payload and no related observations. +// Intended for tests and ad-hoc callers that have no lineage to supply. func StaticReportContext(data json.RawMessage) ReportContext { return fixedReportContext{data: data} } @@ -339,7 +331,6 @@ func StaticReportContext(data json.RawMessage) ReportContext { type fixedReportContext struct { data json.RawMessage related map[ObservationKey][]RelatedObservation - states []CheckState } func (f fixedReportContext) Data() json.RawMessage { return f.data } @@ -349,7 +340,6 @@ func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation { } return f.related[key] } -func (f fixedReportContext) States() []CheckState { return f.states } // CheckerHTMLReporter is an optional interface that observation providers can // implement to render their stored data as a full HTML document (for iframe embedding). @@ -496,20 +486,13 @@ type ExternalEvaluateResponse struct { // Related carries observations produced by other checkers on DiscoveryEntry // records originally published by the target of this report, that is, the // cross-checker lineage that ObservationGetter.GetRelated would expose in -// the in-process path. States carries the CheckStates the host produced by -// evaluating this checker's rules against the same observation, letting -// reporters render rule-driven sections (for example a "fix these first" -// list) without re-deriving severity or hints from Data. -// -// The host composes both fields before making the HTTP request. When both -// are absent, the remote checker receives a context equivalent to -// StaticReportContext (no related observations and no states); the -// reporter then falls back to a data-only rendering. +// the in-process path. The host composes it before making the HTTP request; +// when absent, the remote checker receives a context that reports no +// related observations (equivalent to StaticReportContext). type ExternalReportRequest struct { Key ObservationKey `json:"key"` Data json.RawMessage `json:"data"` Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"` - States []CheckState `json:"states,omitempty"` } // HealthResponse is returned by GET /health on a remote checker endpoint. diff --git a/checker/types_test.go b/checker/types_test.go index 87022ee..74b57d0 100644 --- a/checker/types_test.go +++ b/checker/types_test.go @@ -15,24 +15,10 @@ package checker import ( - "context" "encoding/json" - "reflect" "testing" ) -// dummyRule is a minimal CheckRule used only by tests in this package. -type dummyRule struct { - name string - desc string -} - -func (r *dummyRule) Name() string { return r.name } -func (r *dummyRule) Description() string { return r.desc } -func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { - return []CheckState{{Status: StatusOK, Message: r.name + " passed"}} -} - func TestStatus_MarshalJSON(t *testing.T) { tests := []struct { status Status @@ -157,54 +143,6 @@ func TestCheckerDefinition_BuildRulesInfo(t *testing.T) { } } -// Compile-time check that fixedReportContext implements ReportContext. -var _ ReportContext = fixedReportContext{} - -func TestStaticReportContext_NoExtras(t *testing.T) { - ctx := StaticReportContext(json.RawMessage(`{"k":"v"}`)) - if string(ctx.Data()) != `{"k":"v"}` { - t.Errorf("Data() = %s, want %s", ctx.Data(), `{"k":"v"}`) - } - if ctx.Related("any") != nil { - t.Error("Related(any) should be nil for StaticReportContext") - } - if ctx.States() != nil { - t.Error("States() should be nil for StaticReportContext") - } -} - -func TestNewReportContext_NilStates(t *testing.T) { - ctx := NewReportContext(json.RawMessage(`{}`), nil, nil) - if ctx.States() != nil { - t.Errorf("States() = %v, want nil", ctx.States()) - } -} - -func TestNewReportContext_PassesStates(t *testing.T) { - states := []CheckState{ - {Status: StatusWarn, Message: "heads up", RuleName: "r1"}, - {Status: StatusCrit, Message: "fix me", RuleName: "r2", Subject: "host.example"}, - } - ctx := NewReportContext(json.RawMessage(`{}`), nil, states) - got := ctx.States() - if !reflect.DeepEqual(got, states) { - t.Errorf("States() = %+v, want %+v", got, states) - } -} - -func TestNewReportContext_PassesRelated(t *testing.T) { - rel := map[ObservationKey][]RelatedObservation{ - "other.key": {{CheckerID: "other", Key: "other.key", Ref: "r1"}}, - } - ctx := NewReportContext(json.RawMessage(`{}`), rel, nil) - if got := ctx.Related("other.key"); len(got) != 1 || got[0].CheckerID != "other" { - t.Errorf("Related(other.key) = %+v, want one entry with CheckerID=other", got) - } - if ctx.Related("missing") != nil { - t.Error("Related(missing) should be nil") - } -} - func TestRegisterChecker_EmptyIDRejected(t *testing.T) { resetRegistries() RegisterChecker(&CheckerDefinition{ID: "", Name: "bad"})